├── src └── eliottree │ ├── test │ ├── __init__.py │ ├── matchers.py │ ├── test_compat.py │ ├── test_util.py │ ├── tasks.py │ ├── test_filter.py │ ├── test_cli.py │ ├── test_format.py │ └── test_render.py │ ├── newsfragments │ └── .gitignore │ ├── tree_format │ ├── tests │ │ ├── __init__.py │ │ └── test_text.py │ ├── __init__.py │ ├── _text.py │ └── LICENSE │ ├── _compat.py │ ├── _errors.py │ ├── _util.py │ ├── __init__.py │ ├── _parse.py │ ├── _color.py │ ├── filter.py │ ├── _theme.py │ ├── format.py │ ├── tree.py │ ├── _render.py │ ├── _cli.py │ └── _version.py ├── .gitattributes ├── doc └── example_eliot_log.png ├── pyproject.toml ├── MANIFEST.in ├── config.example.json ├── .coveragerc ├── setup.cfg ├── .github └── workflows │ ├── pypi.yml │ └── pythonpackage.yml ├── .gitignore ├── LICENSE ├── setup.py ├── NEWS.rst └── README.rst /src/eliottree/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/eliottree/newsfragments/.gitignore: -------------------------------------------------------------------------------- 1 | !.gitignore -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | src/eliottree/_version.py export-subst 2 | -------------------------------------------------------------------------------- /src/eliottree/tree_format/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for tree_format.""" 2 | -------------------------------------------------------------------------------- /doc/example_eliot_log.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonathanj/eliottree/HEAD/doc/example_eliot_log.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.towncrier] 2 | package = "eliottree" 3 | package_dir = "src" 4 | filename = "NEWS.rst" -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE tox.ini .coveragerc setup.cfg 2 | graft src 3 | include versioneer.py 4 | include src/eliottree/_version.py 5 | -------------------------------------------------------------------------------- /config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "raw": true, 3 | "color": "auto", 4 | "ascii": true, 5 | "colorize_tree": true, 6 | "field_limit": 0, 7 | "utc_timestamps": false, 8 | "theme_overrides": { 9 | "root": ["magenta", ["bold"]] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = eliottree 4 | omit = 5 | src/eliottree/_version.py 6 | 7 | [paths] 8 | source = 9 | src/eliottree 10 | .tox/*/lib/python*/site-packages/eliottree 11 | .tox/*/site-packages/eliottree 12 | 13 | [report] 14 | omit = 15 | src/eliottree/_version.py 16 | -------------------------------------------------------------------------------- /src/eliottree/test/matchers.py: -------------------------------------------------------------------------------- 1 | from testtools.matchers import Equals, IsInstance, MatchesAll 2 | 3 | 4 | def ExactlyEquals(value): 5 | """ 6 | Like `Equals` but also requires that the types match. 7 | """ 8 | return MatchesAll( 9 | IsInstance(type(value)), 10 | Equals(value)) 11 | -------------------------------------------------------------------------------- /src/eliottree/_compat.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from six import text_type 4 | 5 | 6 | def dump_json_bytes(obj, dumps=json.dumps): 7 | """ 8 | Serialize ``obj`` to JSON formatted UTF-8 encoded ``bytes``. 9 | """ 10 | result = dumps(obj) 11 | if isinstance(result, text_type): 12 | return result.encode('utf-8') 13 | return result 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | known_first_party=eliottree 3 | default_section=THIRDPARTY 4 | multi_line_output=4 5 | lines_after_imports=2 6 | balanced_wrapping=True 7 | 8 | [flake8] 9 | exclude=src/eliottree/_version.py 10 | ignore=W503,E123,E124,E128,E201,E203,E241,E261,E265,E301,E303,E501,E711,E712,E731,N 11 | 12 | [versioneer] 13 | VCS=git 14 | style=pep440 15 | versionfile_source=src/eliottree/_version.py 16 | versionfile_build=eliottree/_version.py 17 | tag_prefix= 18 | parentdir_prefix=eliottree- 19 | -------------------------------------------------------------------------------- /src/eliottree/_errors.py: -------------------------------------------------------------------------------- 1 | class EliotParseError(RuntimeError): 2 | """ 3 | An error occurred while parsing a particular Eliot message dictionary. 4 | """ 5 | def __init__(self, message_dict, exc_info): 6 | self.message_dict = message_dict 7 | self.exc_info = exc_info 8 | RuntimeError.__init__(self) 9 | 10 | 11 | class JSONParseError(RuntimeError): 12 | """ 13 | An error occurred while parsing JSON text. 14 | """ 15 | def __init__(self, file_name, line_number, line, exc_info): 16 | self.file_name = file_name 17 | self.line_number = line_number 18 | self.line = line 19 | self.exc_info = exc_info 20 | 21 | 22 | __all__ = ['EliotParseError', 'JSONParseError'] 23 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish package to PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Set up Python 14 | uses: actions/setup-python@v1 15 | with: 16 | python-version: 3.7 17 | - name: Build publishing artifacts 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install .[test] wheel 21 | python setup.py sdist bdist_wheel 22 | - name: Test with pytest 23 | run: | 24 | pip install pytest 25 | pytest 26 | - uses: pypa/gh-action-pypi-publish@master 27 | with: 28 | user: __token__ 29 | password: ${{ secrets.pypi_password }} -------------------------------------------------------------------------------- /src/eliottree/_util.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | 4 | namespace = namedtuple('namespace', ['prefix', 'name']) 5 | 6 | 7 | def namespaced(prefix): 8 | """ 9 | Create a function that creates new names in the ``prefix`` namespace. 10 | 11 | :rtype: Callable[[unicode], `namespace`] 12 | """ 13 | return lambda name: namespace(prefix, name) 14 | 15 | 16 | def format_namespace(ns): 17 | """ 18 | Format a `namespace`. 19 | 20 | :rtype: unicode 21 | """ 22 | if not is_namespace(ns): 23 | raise TypeError('Expected namespace', ns) 24 | return u'{}/{}'.format(ns.prefix, ns.name) 25 | 26 | 27 | def is_namespace(x): 28 | """ 29 | Is this a `namespace` instance? 30 | 31 | :rtype: bool 32 | """ 33 | return isinstance(x, namespace) 34 | 35 | 36 | eliot_ns = namespaced(u'eliot') 37 | -------------------------------------------------------------------------------- /src/eliottree/__init__.py: -------------------------------------------------------------------------------- 1 | from eliottree._errors import EliotParseError, JSONParseError 2 | from eliottree._parse import tasks_from_iterable 3 | from eliottree._render import render_tasks 4 | from eliottree.filter import ( 5 | filter_by_end_date, filter_by_jmespath, filter_by_start_date, 6 | filter_by_uuid, combine_filters_and) 7 | from eliottree._theme import get_theme, apply_theme_overrides, Theme 8 | from eliottree._color import color_factory, colored 9 | 10 | 11 | __all__ = [ 12 | 'filter_by_jmespath', 'filter_by_uuid', 'filter_by_start_date', 13 | 'filter_by_end_date', 'render_tasks', 'tasks_from_iterable', 14 | 'EliotParseError', 'JSONParseError', 'combine_filters_and', 15 | 'get_theme', 'apply_theme_overrides', 'Theme', 'color_factory', 16 | 'colored', 17 | ] 18 | 19 | from . import _version 20 | __version__ = _version.get_versions()['version'] 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | cover/ 43 | 44 | # Translations 45 | *.mo 46 | *.pot 47 | 48 | # Django stuff: 49 | *.log 50 | 51 | # Sphinx documentation 52 | docs/_build/ 53 | 54 | # PyBuilder 55 | target/ 56 | -------------------------------------------------------------------------------- /src/eliottree/_parse.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from eliot.parse import Parser 4 | 5 | from eliottree._errors import EliotParseError 6 | 7 | 8 | def tasks_from_iterable(iterable): 9 | """ 10 | Parse an iterable of Eliot message dictionaries into tasks. 11 | 12 | :type iterable: ``Iterable[Dict]`` 13 | :param iterable: Iterable of serialized Eliot message dictionaries. 14 | :rtype: ``Iterable`` 15 | :return: Iterable of parsed Eliot tasks, suitable for use with 16 | `eliottree.render_tasks`. 17 | """ 18 | parser = Parser() 19 | for message_dict in iterable: 20 | try: 21 | completed, parser = parser.add(message_dict) 22 | for task in completed: 23 | yield task 24 | except Exception: 25 | raise EliotParseError(message_dict, sys.exc_info()) 26 | for task in parser.incomplete_tasks(): 27 | yield task 28 | 29 | 30 | __all__ = ['tasks_from_iterable'] 31 | -------------------------------------------------------------------------------- /src/eliottree/tree_format/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Jonathan M. Lange 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Nicely format trees.""" 16 | 17 | from ._text import ( 18 | format_ascii_tree, 19 | format_tree, 20 | print_tree, 21 | Options, 22 | ASCII_OPTIONS, 23 | ) 24 | 25 | __all__ = [ 26 | 'format_ascii_tree', 'format_tree', 'print_tree', 'Options', 'ASCII_OPTIONS'] 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 4 | 5 | Jonathan Jacobs 6 | Tom Prince 7 | Jonathan Lange 8 | Itamar Turner-Trauring 9 | Tristan Seligmann 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy 12 | of this software and associated documentation files (the "Software"), to deal 13 | in the Software without restriction, including without limitation the rights 14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | copies of the Software, and to permit persons to whom the Software is 16 | furnished to do so, subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in all 19 | copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 27 | SOFTWARE. 28 | 29 | -------------------------------------------------------------------------------- /src/eliottree/_color.py: -------------------------------------------------------------------------------- 1 | import colored as _colored 2 | 3 | 4 | # Disable `colored` TTY awareness, since we handle this ourselves. 5 | _colored.set_tty_aware(awareness=False) 6 | 7 | attr_codes = { 8 | 'bold': 1, 9 | 'dim': 2, 10 | 'underlined': 4, 11 | 'blink': 5, 12 | 'reverse': 7, 13 | 'hidden': 8, 14 | 'reset': 0, 15 | 'res_bold': 21, 16 | 'res_dim': 22, 17 | 'res_underlined': 24, 18 | 'res_blink': 25, 19 | 'res_reverse': 27, 20 | 'res_hidden': 28, 21 | } 22 | 23 | 24 | def colored(text, fg=None, bg=None, attrs=None): 25 | """ 26 | Wrap text in terminal color codes. 27 | 28 | Intended to mimic the API of `termcolor`. 29 | """ 30 | if attrs is None: 31 | attrs = [] 32 | return u'{}{}{}{}{}'.format( 33 | _colored.fg(fg) if fg is not None else u'', 34 | _colored.bg(bg) if bg is not None else u'', 35 | text, 36 | u''.join(_colored.attr(attr_codes.get(attr, attr)) for attr in attrs), 37 | _colored.attr(0)) 38 | 39 | 40 | def color_factory(colored): 41 | """ 42 | Factory for making text color-wrappers. 43 | """ 44 | def _color(fg, bg=None, attrs=[]): 45 | def __color(text): 46 | return colored(text, fg, bg, attrs=attrs) 47 | return __color 48 | return _color 49 | -------------------------------------------------------------------------------- /.github/workflows/pythonpackage.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | max-parallel: 4 14 | matrix: 15 | os: [ubuntu-latest, windows-latest] 16 | python-version: ['3.10', '3.11', '3.12'] 17 | 18 | steps: 19 | - uses: actions/checkout@v1 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v1 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install .[test] 28 | - name: Lint with flake8 29 | run: | 30 | pip install flake8 31 | # stop the build if there are Python syntax errors or undefined names 32 | flake8 src setup.py --show-source --statistics 33 | - name: Test with pytest 34 | env: 35 | PYTHONIOENCODING: utf-8 36 | run: | 37 | pip install pytest 38 | pip install pytest-cov 39 | pytest --cov=./ --cov-report=xml 40 | - uses: codecov/codecov-action@v1.0.5 41 | with: 42 | token: ${{ secrets.CODECOV_TOKEN }} 43 | file: ./coverage.xml 44 | flags: unittests 45 | name: codecov-umbrella 46 | -------------------------------------------------------------------------------- /src/eliottree/test/test_compat.py: -------------------------------------------------------------------------------- 1 | import json 2 | from six import binary_type, text_type 3 | from testtools import TestCase 4 | from testtools.matchers import Equals, IsInstance 5 | 6 | from eliottree._compat import dump_json_bytes 7 | 8 | 9 | class DumpJsonBytesTests(TestCase): 10 | """ 11 | Tests for ``eliottree._compat.dump_json_bytes``. 12 | """ 13 | def test_text(self): 14 | """ 15 | Text results are encoded as UTF-8. 16 | """ 17 | def dump_text(obj): 18 | result = json.dumps(obj) 19 | if isinstance(result, binary_type): 20 | return result.decode('utf-8') 21 | return result 22 | 23 | result = dump_json_bytes( 24 | {'a': 42}, dumps=dump_text) 25 | self.assertThat( 26 | result, 27 | IsInstance(binary_type)) 28 | self.assertThat( 29 | result, 30 | Equals(b'{"a": 42}')) 31 | 32 | def test_binary(self): 33 | """ 34 | Binary results are left as-is. 35 | """ 36 | def dump_binary(obj): 37 | result = json.dumps(obj) 38 | if isinstance(result, text_type): 39 | return result.encode('utf-8') 40 | return result 41 | 42 | result = dump_json_bytes( 43 | {'a': 42}, dumps=dump_binary) 44 | self.assertThat( 45 | result, 46 | IsInstance(binary_type)) 47 | self.assertThat( 48 | result, 49 | Equals(b'{"a": 42}')) 50 | -------------------------------------------------------------------------------- /src/eliottree/filter.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import jmespath 4 | from iso8601.iso8601 import UTC 5 | 6 | 7 | def filter_by_jmespath(query): 8 | """ 9 | Produce a function for filtering a task by a jmespath query expression. 10 | """ 11 | def _filter(task): 12 | return bool(expn.search(task)) 13 | expn = jmespath.compile(query) 14 | return _filter 15 | 16 | 17 | def filter_by_uuid(task_uuid): 18 | """ 19 | Produce a function for filtering tasks by their UUID. 20 | """ 21 | return filter_by_jmespath(u'task_uuid == `{}`'.format(task_uuid)) 22 | 23 | 24 | def _parse_timestamp(timestamp): 25 | """ 26 | Parse a timestamp into a UTC L{datetime}. 27 | """ 28 | return datetime.utcfromtimestamp(timestamp).replace(tzinfo=UTC) 29 | 30 | 31 | def filter_by_start_date(start_date): 32 | """ 33 | Produce a function for filtering by task timestamps after (or on) a certain 34 | date and time. 35 | """ 36 | def _filter(task): 37 | return _parse_timestamp(task[u'timestamp']) >= start_date 38 | return _filter 39 | 40 | 41 | def filter_by_end_date(end_date): 42 | """ 43 | Produce a function for filtering by task timestamps before a certain date 44 | and time. 45 | """ 46 | def _filter(task): 47 | return _parse_timestamp(task[u'timestamp']) < end_date 48 | return _filter 49 | 50 | 51 | def combine_filters_and(*filters): 52 | """ 53 | Combine several filters together in a logical-AND fashion. 54 | """ 55 | return lambda value: all(f(value) for f in filters) 56 | 57 | 58 | __all__ = [ 59 | 'filter_by_jmespath', 'filter_by_uuid', 'filter_by_start_date', 60 | 'filter_by_end_date', 'combine_filters_and', 61 | ] 62 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import codecs 3 | import versioneer 4 | from setuptools import setup, find_packages 5 | 6 | HERE = os.path.abspath(os.path.dirname(__file__)) 7 | 8 | 9 | def read(*parts): 10 | with codecs.open(os.path.join(HERE, *parts), 'rb', 'utf-8') as f: 11 | return f.read() 12 | 13 | 14 | setup( 15 | name='eliot-tree', 16 | version=versioneer.get_version(), 17 | cmdclass=versioneer.get_cmdclass(), 18 | description='Render Eliot logs as an ASCII tree', 19 | license='MIT', 20 | url='https://github.com/jonathanj/eliottree', 21 | author='Jonathan Jacobs', 22 | author_email='jonathan@jsphere.com', 23 | maintainer='Jonathan Jacobs', 24 | maintainer_email='jonathan@jsphere.com', 25 | include_package_data=True, 26 | long_description=read('README.rst'), 27 | packages=find_packages(where='src'), 28 | package_dir={'': 'src'}, 29 | entry_points={ 30 | # These are the command-line programs we want setuptools to install. 31 | 'console_scripts': [ 32 | 'eliot-tree = eliottree._cli:main', 33 | ], 34 | }, 35 | zip_safe=False, 36 | classifiers=[ 37 | 'Development Status :: 3 - Alpha', 38 | 'Topic :: System :: Logging', 39 | 'Intended Audience :: Developers', 40 | 'Intended Audience :: System Administrators', 41 | 'License :: OSI Approved :: MIT License', 42 | 'Operating System :: OS Independent', 43 | 'Programming Language :: Python', 44 | ], 45 | install_requires=[ 46 | 'six>=1.13.0', 47 | 'jmespath>=0.7.1', 48 | 'iso8601>=0.1.10', 49 | 'colored>=1.4.2', 50 | 'toolz>=0.8.2', 51 | 'eliot>=1.6.0', 52 | 'win-unicode-console>=0.5;platform_system=="Windows"', 53 | ], 54 | extras_require={ 55 | 'test': ['testtools>=1.8.0'], 56 | }, 57 | ) 58 | -------------------------------------------------------------------------------- /src/eliottree/test/test_util.py: -------------------------------------------------------------------------------- 1 | from testtools import ExpectedException, TestCase 2 | from testtools.matchers import AfterPreprocessing as After 3 | from testtools.matchers import ( 4 | Equals, Is, MatchesAll, MatchesListwise, MatchesPredicate, 5 | MatchesStructure) 6 | 7 | from eliottree._util import format_namespace, is_namespace, namespaced 8 | 9 | 10 | class NamespaceTests(TestCase): 11 | """ 12 | Tests for `namespaced`, `format_namespace` and `is_namespace`. 13 | """ 14 | def test_namespaced(self): 15 | """ 16 | `namespaced` creates a function that when called produces a namespaced 17 | name. 18 | """ 19 | self.assertThat( 20 | namespaced(u'foo'), 21 | MatchesAll( 22 | MatchesPredicate(callable, '%s is not callable'), 23 | After( 24 | lambda f: f(u'bar'), 25 | MatchesAll( 26 | MatchesListwise([ 27 | Equals(u'foo'), 28 | Equals(u'bar')]), 29 | MatchesStructure( 30 | prefix=Equals(u'foo'), 31 | name=Equals(u'bar')))))) 32 | 33 | def test_format_not_namespace(self): 34 | """ 35 | `format_namespace` raises `TypeError` if its argument is not a 36 | namespaced name. 37 | """ 38 | with ExpectedException(TypeError): 39 | format_namespace(42) 40 | 41 | def test_format_namespace(self): 42 | """ 43 | `format_namespace` creates a text representation of a namespaced name. 44 | """ 45 | self.assertThat( 46 | format_namespace(namespaced(u'foo')(u'bar')), 47 | Equals(u'foo/bar')) 48 | 49 | def test_is_namespace(self): 50 | """ 51 | `is_namespace` returns ``True`` only for namespaced names. 52 | """ 53 | self.assertThat( 54 | is_namespace(42), 55 | Is(False)) 56 | self.assertThat( 57 | is_namespace((u'foo', u'bar')), 58 | Is(False)) 59 | self.assertThat( 60 | is_namespace(namespaced(u'foo')), 61 | Is(False)) 62 | self.assertThat( 63 | is_namespace(namespaced(u'foo')(u'bar')), 64 | Is(True)) 65 | -------------------------------------------------------------------------------- /src/eliottree/test/tasks.py: -------------------------------------------------------------------------------- 1 | unnamed_message = { 2 | u"task_uuid": u"cdeb220d-7605-4d5f-8341-1a170222e308", 3 | u"error": False, 4 | u"timestamp": 1425356700, 5 | u"message": u"Main loop terminated.", 6 | u"task_level": [1]} 7 | 8 | message_task = { 9 | u"task_uuid": u"cdeb220d-7605-4d5f-8341-1a170222e308", 10 | u"error": False, 11 | u"timestamp": 1425356700, 12 | u"message": u"Main loop terminated.", 13 | u"message_type": u"twisted:log", 14 | u"task_level": [1]} 15 | 16 | action_task = { 17 | u"timestamp": 1425356800, 18 | u"action_status": u"started", 19 | u"task_uuid": u"f3a32bb3-ea6b-457c-aa99-08a3d0491ab4", 20 | u"action_type": u"app:action", 21 | u"task_level": [1]} 22 | 23 | nested_action_task = { 24 | u"timestamp": 1425356900, 25 | u"action_status": u"started", 26 | u"task_uuid": u"f3a32bb3-ea6b-457c-aa99-08a3d0491ab4", 27 | u"action_type": u"app:action:nest", 28 | u"task_level": [1, 1]} 29 | 30 | action_task_end = { 31 | u"timestamp": 1425356802, 32 | u"action_status": u"succeeded", 33 | u"task_uuid": u"f3a32bb3-ea6b-457c-aa99-08a3d0491ab4", 34 | u"action_type": u"app:action", 35 | u"task_level": [2]} 36 | 37 | action_task_end_failed = { 38 | u"timestamp": 1425356804, 39 | u"action_status": u"failed", 40 | u"task_uuid": u"f3a32bb3-ea6b-457c-aa99-08a3d0491ab4", 41 | u"action_type": u"app:action", 42 | u"task_level": [2]} 43 | 44 | dict_action_task = { 45 | u"timestamp": 1425356800, 46 | u"action_status": u"started", 47 | u"task_uuid": u"f3a32bb3-ea6b-457c-aa99-08a3d0491ab4", 48 | u"action_type": u"app:action", 49 | u"task_level": [1], 50 | u"some_data": {u"a": 42}} 51 | 52 | list_action_task = { 53 | u"timestamp": 1425356800, 54 | u"action_status": u"started", 55 | u"task_uuid": u"f3a32bb3-ea6b-457c-aa99-08a3d0491ab4", 56 | u"action_type": u"app:action", 57 | u"task_level": [1], 58 | u"some_data": [u"a", u"b"]} 59 | 60 | multiline_action_task = { 61 | u"timestamp": 1425356800, 62 | u"action_status": u"started", 63 | u"task_uuid": u"f3a32bb3-ea6b-457c-aa99-08a3d0491ab4", 64 | u"action_type": u"app:action", 65 | u"task_level": [1], 66 | u"message": u"this is a\nmany line message"} 67 | 68 | janky_action_task = { 69 | u"timestamp": '1425356800\x1b(0', 70 | u"action_status": u"started", 71 | u"task_uuid": u"f3a32bb3-ea6b-457c-\x1b(0aa99-08a3d0491ab4", 72 | u"action_type": u"A\x1b(0", 73 | u"task_level": [1], 74 | u"mes\nsage": u"hello\x1b(0world", 75 | u"\x1b(0": {u"\x1b(0": "nope"}} 76 | 77 | janky_message_task = { 78 | u"task_uuid": u"cdeb220d-7605-4d5f-\x1b(08341-1a170222e308", 79 | u"er\x1bror": False, 80 | u"timestamp": 1425356700, 81 | u"mes\nsage": u"Main loop\x1b(0terminated.", 82 | u"message_type": u"M\x1b(0", 83 | u"task_level": [1]} 84 | 85 | missing_uuid_task = { 86 | u"error": False, 87 | u"timestamp": 1425356700, 88 | u"message": u"Main loop terminated.", 89 | u"message_type": u"twisted:log", 90 | u"action_type": u"nope", 91 | u"task_level": [1]} 92 | -------------------------------------------------------------------------------- /NEWS.rst: -------------------------------------------------------------------------------- 1 | -------------------- 2 | eliot-tree changelog 3 | -------------------- 4 | 5 | .. towncrier release notes start 6 | 7 | Eliottree 24.0.0 (2024-11-19) 8 | ============================= 9 | 10 | Bugfixes 11 | -------- 12 | 13 | - `--color=always` does not always output color (#106) 14 | 15 | 16 | Eliottree 21.0.0 (2021-02-22) 17 | ============================= 18 | 19 | Bugfixes 20 | -------- 21 | 22 | - - Eliot-tree crashes with Unicode encoding errors on non-UTF8 terminals even when specifying `--ascii`. (#95) 23 | 24 | 25 | Eliottree 19.0.1 (2020-01-15) 26 | ============================= 27 | 28 | The public API for `render_tasks` was broken unnecessarily in 19.0.0, this 29 | release replaces the `colorize` keyword argument and deprecates it instead. 30 | 31 | 32 | Eliottree 19.0.0 (2020-01-14) 33 | ============================= 34 | 35 | Features 36 | -------- 37 | 38 | - Tree lines are now colored to help differentiate nested tasks, action tasks that have failed are also colored distinctly; `--no-color-tree` will disable tree line colors. (#76) 39 | - An alternative color theme is now provided for light themed terminals, the `COLORFGBG` environment variable is used to try detect this but can be set explicitly with `--theme light`. (#78) 40 | - Timestamps can now be displayed in local time with `--local-timezone`. (#79) 41 | - Unicode and color output is now supported on Windows. (#82) 42 | - Colorize tree lines by default, use `--no-color-tree` to disable the feature. Tree lines normally cycle through several colors, however the lines of failed actions will be colored in a way that differentiates them. (#87) 43 | - It is now possible to configure eliottree's defaults via a config file, as well as override the color theme. Use `--show-default-config` to create a base config. (#88) 44 | 45 | 46 | Bugfixes 47 | -------- 48 | 49 | - Passing multiple `--select` arguments interacted in a way that always failed. (#37) 50 | 51 | 52 | Improved Documentation 53 | ---------------------- 54 | 55 | - Added some examples of `--select` usage. (#37) 56 | 57 | 58 | Misc 59 | ---- 60 | 61 | - #75 62 | 63 | 64 | Eliottree 18.1.0 (2018-07-30) 65 | ============================= 66 | 67 | Features 68 | -------- 69 | 70 | - Timestamps are now rendered after the action status or message level. A duration is included too when available. (#72) 71 | 72 | 73 | Eliottree 18.0.0 (2018-07-23) 74 | ============================= 75 | 76 | Features 77 | -------- 78 | 79 | - Exceptions that occur during node or value formatting no longer interrupt processing and are displayed after the tree output. (#69) 80 | 81 | 82 | Eliottree 17.1.0 83 | ========== 84 | 85 | Bugfixes 86 | -------- 87 | 88 | - Fixed an incompatibility with iso8601 0.1.12. (#60) 89 | 90 | 91 | Eliottree 17.0.0 92 | ========== 93 | 94 | Bugfixes 95 | -------- 96 | 97 | - Python 3 compatibility was improved. (#35) 98 | - Human-readable values are now only transformed at render time instead of 99 | mutating the log data. (#39) 100 | 101 | Features 102 | -------- 103 | 104 | - The `tree-format` library is now used for rendering the tree and colored 105 | output was added. (#19) 106 | - Command-line options `--start` and `--end` were introduced to allow more 107 | easily specifying a time range of messages. (#38) 108 | - Context is now reported when JSON or Eliot parse errors occur. (#42) 109 | - Terminal control characters in Eliot data are now converted to their 110 | innocuous Unicode control image equivalent. (#44) 111 | - Eliot's robust builtin parser is now used to build the tree data. (#52) 112 | 113 | Misc 114 | ---- 115 | 116 | - #46, #54, #56 117 | 118 | 119 | -------------------------------------------------------------------------------- /src/eliottree/_theme.py: -------------------------------------------------------------------------------- 1 | from eliottree._color import color_factory 2 | 3 | 4 | NO_COLOR = (None,) 5 | DEFAULT_THEME = { 6 | # Task root. 7 | 'root': NO_COLOR, 8 | # Action / message node. 9 | 'parent': NO_COLOR, 10 | # Action / message task level. 11 | 'task_level': NO_COLOR, 12 | # Action success status. 13 | 'status_success': NO_COLOR, 14 | # Action failure status. 15 | 'status_failure': NO_COLOR, 16 | # Task timestamp. 17 | 'timestamp': NO_COLOR, 18 | # Action / message property key. 19 | 'prop_key': NO_COLOR, 20 | # Action / message property value. 21 | 'prop_value': NO_COLOR, 22 | # Task duration. 23 | 'duration': NO_COLOR, 24 | # Tree color for failed tasks. 25 | 'tree_failed': NO_COLOR, 26 | # Cycled tree colors. 27 | 'tree_color0': NO_COLOR, 28 | 'tree_color1': NO_COLOR, 29 | 'tree_color2': NO_COLOR, 30 | # Processing error. 31 | 'error': NO_COLOR, 32 | } 33 | 34 | 35 | class Theme(object): 36 | """ 37 | Theme base class. 38 | """ 39 | __slots__ = DEFAULT_THEME.keys() 40 | 41 | def __init__(self, color, **theme): 42 | super(Theme, self).__init__() 43 | self.color = color 44 | _theme = dict(DEFAULT_THEME) 45 | _theme.update(theme) 46 | for k, v in _theme.items(): 47 | if not isinstance(v, (tuple, list)): 48 | raise TypeError( 49 | 'Theme color must be a tuple or list of values', k, v) 50 | setattr(self, k, color(*v)) 51 | 52 | 53 | class DarkBackgroundTheme(Theme): 54 | """ 55 | Color theme for dark backgrounds. 56 | """ 57 | def __init__(self, colored): 58 | super(DarkBackgroundTheme, self).__init__( 59 | color=color_factory(colored), 60 | root=('white', None, ['bold']), 61 | parent=('magenta',), 62 | status_success=('green',), 63 | status_failure=('red',), 64 | prop_key=('blue',), 65 | error=('red', None, ['bold']), 66 | timestamp=('white', None, ['dim']), 67 | duration=('blue', None, ['dim']), 68 | tree_failed=('red',), 69 | tree_color0=('white', None, ['dim']), 70 | tree_color1=('blue', None, ['dim']), 71 | tree_color2=('magenta', None, ['dim']), 72 | ) 73 | 74 | 75 | class LightBackgroundTheme(Theme): 76 | """ 77 | Color theme for light backgrounds. 78 | """ 79 | def __init__(self, colored): 80 | super(LightBackgroundTheme, self).__init__( 81 | color=color_factory(colored), 82 | root=('dark_gray', None, ['bold']), 83 | parent=('magenta',), 84 | status_success=('green',), 85 | status_failure=('red',), 86 | prop_key=('blue',), 87 | error=('red', None, ['bold']), 88 | timestamp=('dark_gray',), 89 | duration=('blue', None, ['dim']), 90 | tree_failed=('red',), 91 | tree_color0=('dark_gray', None, ['dim']), 92 | tree_color1=('blue', None, ['dim']), 93 | tree_color2=('magenta', None, ['dim']), 94 | ) 95 | 96 | 97 | def _no_color(text, *a, **kw): 98 | """ 99 | Colorizer that does not colorize. 100 | """ 101 | return text 102 | 103 | 104 | def get_theme(dark_background, colored=None): 105 | """ 106 | Create an appropriate theme. 107 | """ 108 | if colored is None: 109 | colored = _no_color 110 | return DarkBackgroundTheme(colored) if dark_background else LightBackgroundTheme(colored) 111 | 112 | 113 | def apply_theme_overrides(theme, overrides): 114 | """ 115 | Apply overrides to a theme. 116 | """ 117 | if overrides is None: 118 | return theme 119 | 120 | for key, args in overrides.items(): 121 | setattr(theme, key, theme.color(*args)) 122 | return theme 123 | -------------------------------------------------------------------------------- /src/eliottree/format.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from six import binary_type, text_type, unichr 4 | from toolz import merge 5 | 6 | 7 | _control_equivalents = dict((i, unichr(0x2400 + i)) for i in range(0x20)) 8 | _control_equivalents[0x7f] = u'\u2421' 9 | 10 | 11 | def escape_control_characters(s, overrides={}): 12 | """ 13 | Replace terminal control characters with their Unicode control character 14 | equivalent. 15 | """ 16 | return text_type(s).translate(merge(_control_equivalents, overrides)) 17 | 18 | 19 | def some(*fs): 20 | """ 21 | Create a function that returns the first non-``None`` result of applying 22 | the arguments to each ``fs``. 23 | """ 24 | def _some(*a, **kw): 25 | for f in fs: 26 | result = f(*a, **kw) 27 | if result is not None: 28 | return result 29 | return None 30 | return _some 31 | 32 | 33 | def binary(encoding): 34 | """ 35 | Create a formatter for ``binary_type`` values. 36 | 37 | :param str encoding: Encoding to assume for values. 38 | """ 39 | def _format_bytes_value(value, field_name=None): 40 | if isinstance(value, binary_type): 41 | return value.decode(encoding, 'replace') 42 | return _format_bytes_value 43 | 44 | 45 | def text(): 46 | """ 47 | Create a formatter for ``text_type`` values. 48 | """ 49 | def _format_text_value(value, field_name=None): 50 | if isinstance(value, text_type): 51 | return value 52 | return _format_text_value 53 | 54 | 55 | def fields(format_mapping): 56 | """ 57 | Create a formatter that performs specific formatting based on field names. 58 | 59 | :type format_mapping: ``Dict[text_type, Callable[[Any, text_type], Any]]`` 60 | """ 61 | def _format_field_value(value, field_name=None): 62 | f = format_mapping.get(field_name, None) 63 | if f is None: 64 | return None 65 | return f(value, field_name) 66 | return _format_field_value 67 | 68 | 69 | def timestamp(include_microsecond=True, utc_timestamps=True): 70 | """ 71 | Create a formatter for POSIX timestamp values. 72 | """ 73 | def _format_timestamp_value(value, field_name=None): 74 | from_timestamp = ( 75 | datetime.utcfromtimestamp 76 | if utc_timestamps else datetime.fromtimestamp) 77 | result = from_timestamp(float(value)) 78 | if not include_microsecond: 79 | result = result.replace(microsecond=0) 80 | result = result.isoformat(' ') 81 | if isinstance(result, binary_type): 82 | result = result.decode('ascii') 83 | return result + (u'Z' if utc_timestamps else u'') 84 | return _format_timestamp_value 85 | 86 | 87 | def duration(): 88 | """ 89 | Create a formatter for duration values specified as seconds. 90 | """ 91 | def _format_duration(value, field_name=None): 92 | return u'{:.3f}s'.format(value) 93 | return _format_duration 94 | 95 | 96 | def anything(encoding): 97 | """ 98 | Create a formatter for any value using `repr`. 99 | 100 | :param str encoding: Encoding to assume for a `binary_type` result. 101 | """ 102 | def _format_other_value(value, field_name=None): 103 | result = repr(value) 104 | if isinstance(result, binary_type): 105 | result = result.decode(encoding, 'replace') 106 | return result 107 | return _format_other_value 108 | 109 | 110 | def truncate_value(limit, value): 111 | """ 112 | Truncate ``value`` to a maximum of ``limit`` characters. 113 | """ 114 | values = value.split(u'\n') 115 | value = values[0] 116 | if len(value) > limit or len(values) > 1: 117 | return u'{}\u2026'.format(value[:limit]) 118 | return value 119 | 120 | 121 | __all__ = [ 122 | 'escape_control_characters', 'some', 'binary', 'text', 'fields', 123 | 'timestamp', 'anything', 'truncate_value'] 124 | -------------------------------------------------------------------------------- /src/eliottree/test/test_filter.py: -------------------------------------------------------------------------------- 1 | from calendar import timegm 2 | from datetime import datetime 3 | 4 | from iso8601.iso8601 import UTC 5 | from testtools import TestCase 6 | from testtools.matchers import Equals 7 | 8 | from eliottree import ( 9 | filter_by_end_date, filter_by_jmespath, filter_by_start_date, 10 | filter_by_uuid) 11 | from eliottree.test.tasks import action_task, message_task 12 | 13 | 14 | class FilterByJmespath(TestCase): 15 | """ 16 | Tests for ``eliottree.filter_by_jmespath``. 17 | """ 18 | def test_no_match(self): 19 | """ 20 | Return ``False`` if the jmespath does not match the input. 21 | """ 22 | self.assertThat( 23 | filter_by_jmespath('action_type == `app:action`')(message_task), 24 | Equals(False)) 25 | 26 | def test_match(self): 27 | """ 28 | Return ``True`` if the jmespath does match the input. 29 | """ 30 | self.assertThat( 31 | filter_by_jmespath('action_type == `app:action`')(action_task), 32 | Equals(True)) 33 | 34 | 35 | class FilterByUUID(TestCase): 36 | """ 37 | Tests for ``eliottree.filter_by_uuid``. 38 | """ 39 | def test_no_match(self): 40 | """ 41 | Return ``False`` if the input is not the specified task UUID. 42 | """ 43 | self.assertThat( 44 | filter_by_uuid('nope')(message_task), 45 | Equals(False)) 46 | 47 | def test_match(self): 48 | """ 49 | Return ``True`` if the input is the specified task UUID. 50 | """ 51 | self.assertThat( 52 | filter_by_uuid('cdeb220d-7605-4d5f-8341-1a170222e308')( 53 | message_task), 54 | Equals(True)) 55 | 56 | 57 | class FilterByStartDate(TestCase): 58 | """ 59 | Tests for ``eliottree.filter_by_start_date``. 60 | """ 61 | def test_no_match(self): 62 | """ 63 | Return ``False`` if the input task's timestamp is before the start 64 | date. 65 | """ 66 | now = datetime(2015, 10, 30, 22, 1, 15).replace(tzinfo=UTC) 67 | task = dict( 68 | message_task, 69 | timestamp=timegm(datetime(2015, 10, 30, 22, 1, 0).utctimetuple())) 70 | self.assertThat( 71 | filter_by_start_date(now)(task), 72 | Equals(False)) 73 | 74 | def test_match(self): 75 | """ 76 | Return ``True`` if the input task's timestamp is after the start date. 77 | """ 78 | now = datetime(2015, 10, 30, 22, 1, 15).replace(tzinfo=UTC) 79 | task = dict( 80 | message_task, 81 | timestamp=timegm(datetime(2015, 10, 30, 22, 2).utctimetuple())) 82 | self.assertThat( 83 | filter_by_start_date(now)(task), 84 | Equals(True)) 85 | 86 | def test_match_moment(self): 87 | """ 88 | Return ``True`` if the input task's timestamp is the same as the start 89 | date. 90 | """ 91 | now = datetime(2015, 10, 30, 22, 1, 15).replace(tzinfo=UTC) 92 | task = dict( 93 | message_task, 94 | timestamp=timegm(datetime(2015, 10, 30, 22, 1, 15).utctimetuple())) 95 | self.assertThat( 96 | filter_by_start_date(now)(task), 97 | Equals(True)) 98 | 99 | 100 | class FilterByEndDate(TestCase): 101 | """ 102 | Tests for ``eliottree.filter_by_end_date``. 103 | """ 104 | def test_no_match(self): 105 | """ 106 | Return ``False`` if the input task's timestamp is after the start 107 | date. 108 | """ 109 | now = datetime(2015, 10, 30, 22, 1, 15).replace(tzinfo=UTC) 110 | task = dict( 111 | message_task, 112 | timestamp=timegm(datetime(2015, 10, 30, 22, 2).utctimetuple())) 113 | self.assertThat( 114 | filter_by_end_date(now)(task), 115 | Equals(False)) 116 | 117 | def test_no_match_moment(self): 118 | """ 119 | Return ``False`` if the input task's timestamp is the same as the start 120 | date. 121 | """ 122 | now = datetime(2015, 10, 30, 22, 1, 15).replace(tzinfo=UTC) 123 | task = dict( 124 | message_task, 125 | timestamp=timegm(datetime(2015, 10, 30, 22, 1, 15).utctimetuple())) 126 | self.assertThat( 127 | filter_by_end_date(now)(task), 128 | Equals(False)) 129 | 130 | def test_match(self): 131 | """ 132 | Return ``True`` if the input task's timestamp is before the start date. 133 | """ 134 | now = datetime(2015, 10, 30, 22, 1, 15).replace(tzinfo=UTC) 135 | task = dict( 136 | message_task, 137 | timestamp=timegm(datetime(2015, 10, 30, 22, 1).utctimetuple())) 138 | self.assertThat( 139 | filter_by_end_date(now)(task), 140 | Equals(True)) 141 | -------------------------------------------------------------------------------- /src/eliottree/tree_format/_text.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2015 Jonathan M. Lange 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | 18 | """Library for formatting trees.""" 19 | 20 | import itertools 21 | 22 | 23 | RIGHT_DOUBLE_ARROW = u'\N{RIGHTWARDS DOUBLE ARROW}' 24 | HOURGLASS = u'\N{WHITE HOURGLASS}' 25 | 26 | 27 | class Options(object): 28 | def __init__(self, 29 | FORK=u'\u251c', 30 | LAST=u'\u2514', 31 | VERTICAL=u'\u2502', 32 | HORIZONTAL=u'\u2500', 33 | NEWLINE=u'\u23ce', 34 | ARROW=RIGHT_DOUBLE_ARROW, 35 | HOURGLASS=HOURGLASS): 36 | self.FORK = FORK 37 | self.LAST = LAST 38 | self.VERTICAL = VERTICAL 39 | self.HORIZONTAL = HORIZONTAL 40 | self.NEWLINE = NEWLINE 41 | self.ARROW = ARROW 42 | self.HOURGLASS = HOURGLASS 43 | 44 | def color(self, node, depth): 45 | return lambda text, *a, **kw: text 46 | 47 | def vertical(self): 48 | return u''.join([self.VERTICAL, u' ']) 49 | 50 | def fork(self): 51 | return u''.join([self.FORK, self.HORIZONTAL, self.HORIZONTAL, u' ']) 52 | 53 | def last(self): 54 | return u''.join([self.LAST, self.HORIZONTAL, self.HORIZONTAL, u' ']) 55 | 56 | 57 | 58 | ASCII_OPTIONS = Options(FORK=u'|', 59 | LAST=u'+', 60 | VERTICAL=u'|', 61 | HORIZONTAL=u'-', 62 | NEWLINE=u'\n', 63 | ARROW=u'=>', 64 | HOURGLASS='|Y|') 65 | 66 | 67 | def _format_newlines(prefix, formatted_node, options): 68 | """ 69 | Convert newlines into U+23EC characters, followed by an actual newline and 70 | then a tree prefix so as to position the remaining text under the previous 71 | line. 72 | """ 73 | replacement = u''.join([ 74 | options.NEWLINE, 75 | u'\n', 76 | prefix]) 77 | return formatted_node.replace(u'\n', replacement) 78 | 79 | 80 | def _format_tree(node, format_node, get_children, options, prefix=u'', depth=0): 81 | children = list(get_children(node)) 82 | color = options.color(node, depth) 83 | #options.set_depth(depth) 84 | next_prefix = prefix + color(options.vertical()) 85 | for child in children[:-1]: 86 | yield u''.join([prefix, 87 | color(options.fork()), 88 | _format_newlines(next_prefix, 89 | format_node(child), 90 | options)]) 91 | for result in _format_tree(child, 92 | format_node, 93 | get_children, 94 | options, 95 | next_prefix, 96 | depth=depth + 1): 97 | yield result 98 | if children: 99 | last_prefix = u''.join([prefix, u' ']) 100 | yield u''.join([prefix, 101 | color(options.last()), 102 | _format_newlines(last_prefix, 103 | format_node(children[-1]), 104 | options)]) 105 | for result in _format_tree(children[-1], 106 | format_node, 107 | get_children, 108 | options, 109 | last_prefix, 110 | depth=depth + 1): 111 | yield result 112 | 113 | 114 | def format_tree(node, format_node, get_children, options=None): 115 | lines = itertools.chain( 116 | [format_node(node)], 117 | _format_tree(node, format_node, get_children, options or Options()), 118 | [u''], 119 | ) 120 | return u'\n'.join(lines) 121 | 122 | 123 | def format_ascii_tree(tree, format_node, get_children): 124 | """ Formats the tree using only ascii characters """ 125 | return format_tree(tree, 126 | format_node, 127 | get_children, 128 | ASCII_OPTIONS) 129 | 130 | 131 | def print_tree(*args, **kwargs): 132 | print(format_tree(*args, **kwargs)) 133 | -------------------------------------------------------------------------------- /src/eliottree/test/test_cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the command-line itself. 3 | """ 4 | import os 5 | import six 6 | import tempfile 7 | from collections import namedtuple 8 | from subprocess import PIPE, CalledProcessError, Popen 9 | from unittest import TestCase 10 | 11 | from eliottree._compat import dump_json_bytes 12 | from eliottree.test.tasks import message_task, missing_uuid_task 13 | 14 | 15 | rendered_message_task = ( 16 | u'cdeb220d-7605-4d5f-8341-1a170222e308\n' 17 | u'\u2514\u2500\u2500 twisted:log/1 2015-03-03 04:25:00Z\n' 18 | u' \u251c\u2500\u2500 error: False\n' 19 | u' \u2514\u2500\u2500 message: Main loop terminated.\n\n' 20 | ).replace('\n', os.linesep).encode('utf-8') 21 | 22 | 23 | def bytes_hex(a): 24 | """ 25 | Encode `bytes` into hex on Python 2 or 3. 26 | """ 27 | if hasattr(a, 'hex'): 28 | return a.hex() 29 | return a.encode('hex') 30 | 31 | 32 | class NamedTemporaryFile(object): 33 | """ 34 | Similar to `tempfile.NamedTemporaryFile` except less buggy and with a 35 | Python context manager interface. 36 | """ 37 | def __init__(self): 38 | # See https://stackoverflow.com/a/58955530/81065 39 | self.name = os.path.join(tempfile.gettempdir(), bytes_hex(os.urandom(24))) 40 | self._fd = open(self.name, 'wb+') 41 | self.write = self._fd.write 42 | self.flush = self._fd.flush 43 | self.seek = self._fd.seek 44 | 45 | def close(self): 46 | self._fd.close() 47 | if os.path.exists(self.name): 48 | os.unlink(self.name) 49 | 50 | def __enter__(self): 51 | return self._fd 52 | 53 | def __exit__(self, type, value, traceback): 54 | self.flush() 55 | self.close() 56 | 57 | 58 | def check_output(args, stdin=None): 59 | """ 60 | Similar to `subprocess.check_output` but include separated stdout and 61 | stderr in the `CalledProcessError` exception. 62 | """ 63 | kwargs = {} 64 | pipes = Popen( 65 | args, 66 | stdin=PIPE if stdin is not None else None, 67 | stdout=PIPE, 68 | stderr=PIPE, 69 | **kwargs) 70 | stdout, stderr = pipes.communicate( 71 | six.ensure_binary(stdin) if stdin is not None else None) 72 | if pipes.returncode != 0: 73 | output = namedtuple('Output', ['stdout', 'stderr'])( 74 | six.ensure_binary(stdout), 75 | six.ensure_binary(stderr)) 76 | raise CalledProcessError(pipes.returncode, args, output) 77 | return six.ensure_binary(stdout) 78 | 79 | 80 | class EndToEndTests(TestCase): 81 | """ 82 | Tests that actually run the command-line tool. 83 | """ 84 | def test_stdin(self): 85 | """ 86 | ``eliot-tree`` can read and render JSON messages from stdin when no 87 | arguments are given. 88 | """ 89 | with NamedTemporaryFile() as f: 90 | f.write(dump_json_bytes(message_task)) 91 | f.flush() 92 | f.seek(0, 0) 93 | self.assertEqual(check_output(["eliot-tree"], stdin=f.read()), 94 | rendered_message_task) 95 | 96 | def test_file(self): 97 | """ 98 | ``eliot-tree`` can read and render JSON messages from a file on the 99 | command line. 100 | """ 101 | with NamedTemporaryFile() as f: 102 | f.write(dump_json_bytes(message_task)) 103 | f.flush() 104 | self.assertEqual(check_output(["eliot-tree", f.name]), 105 | rendered_message_task) 106 | 107 | def test_json_parse_error(self): 108 | """ 109 | ``eliot-tree`` displays an error containing the file name, line number 110 | and offending line in the event that JSON parsing fails. 111 | """ 112 | with NamedTemporaryFile() as f: 113 | f.write(dump_json_bytes(message_task) + b'\n') 114 | f.write(b'totally not valid JSON {') 115 | f.flush() 116 | with self.assertRaises(CalledProcessError) as m: 117 | check_output(['eliot-tree', '--color=never', f.name]) 118 | lines = m.exception.output.stderr.splitlines() 119 | first_line = lines[0].decode('utf-8') 120 | second_line = lines[1].decode('utf-8') 121 | self.assertIn('JSON parse error', first_line) 122 | self.assertIn(f.name, first_line) 123 | self.assertIn('line 2', first_line) 124 | self.assertEqual('totally not valid JSON {', second_line) 125 | 126 | def test_eliot_parse_error(self): 127 | """ 128 | ``eliot-tree`` displays an error containing the original file name, 129 | line number and offending task in the event that parsing the message 130 | dict fails. 131 | """ 132 | with NamedTemporaryFile() as f: 133 | f.write(dump_json_bytes(missing_uuid_task) + b'\n') 134 | f.flush() 135 | with self.assertRaises(CalledProcessError) as m: 136 | check_output(['eliot-tree', f.name]) 137 | lines = m.exception.output.stderr.splitlines() 138 | first_line = lines[0].decode('utf-8') 139 | self.assertIn('Eliot message parse error', first_line) 140 | self.assertIn(f.name, first_line) 141 | self.assertIn('line 1', first_line) 142 | -------------------------------------------------------------------------------- /src/eliottree/test/test_format.py: -------------------------------------------------------------------------------- 1 | import time 2 | from testtools import TestCase 3 | from testtools.matchers import Is 4 | 5 | from eliottree import format 6 | from eliottree.test.matchers import ExactlyEquals 7 | 8 | 9 | class BinaryTests(TestCase): 10 | """ 11 | Tests for `eliottree.format.binary`. 12 | """ 13 | def test_not_binary(self): 14 | """ 15 | Not binary values are ignored. 16 | """ 17 | self.assertThat( 18 | format.binary('utf-8')(u'hello'), 19 | Is(None)) 20 | 21 | def test_encoding(self): 22 | """ 23 | Binary values are decoded with the given encoding. 24 | """ 25 | self.assertThat( 26 | format.binary('utf-8')(u'\N{SNOWMAN}'.encode('utf-8')), 27 | ExactlyEquals(u'\u2603')) 28 | 29 | def test_replace(self): 30 | """ 31 | Replace decoding errors with the Unicode replacement character. 32 | """ 33 | self.assertThat( 34 | format.binary('utf-32')(u'\N{SNOWMAN}'.encode('utf-8')), 35 | ExactlyEquals(u'\ufffd')) 36 | 37 | 38 | class TextTests(TestCase): 39 | """ 40 | Tests for `eliottree.format.text`. 41 | """ 42 | def test_not_text(self): 43 | """ 44 | Not text values are ignored. 45 | """ 46 | self.assertThat( 47 | format.text()(b'hello'), 48 | Is(None)) 49 | 50 | def test_text(self): 51 | """ 52 | Text values are returned as is. 53 | """ 54 | self.assertThat( 55 | format.text()(u'\N{SNOWMAN}'), 56 | ExactlyEquals(u'\N{SNOWMAN}')) 57 | 58 | 59 | class FieldsTests(TestCase): 60 | """ 61 | Tests for `eliottree.format.fields`. 62 | """ 63 | def test_missing_mapping(self): 64 | """ 65 | Values for unknown field names are ignored. 66 | """ 67 | self.assertThat( 68 | format.fields({})(b'hello', u'a'), 69 | Is(None)) 70 | 71 | def test_mapping(self): 72 | """ 73 | Values for known field names are passed through their processor. 74 | """ 75 | fields = { 76 | u'a': format.binary('utf-8'), 77 | } 78 | self.assertThat( 79 | format.fields(fields)(u'\N{SNOWMAN}'.encode('utf-8'), u'a'), 80 | ExactlyEquals(u'\N{SNOWMAN}')) 81 | 82 | 83 | class TimestampTests(TestCase): 84 | """ 85 | Tests for `eliottree.format.timestamp`. 86 | """ 87 | def test_text(self): 88 | """ 89 | Timestamps as text are converted to ISO 8601. 90 | """ 91 | self.assertThat( 92 | format.timestamp()(u'1433631432'), 93 | ExactlyEquals(u'2015-06-06 22:57:12Z')) 94 | 95 | def test_float(self): 96 | """ 97 | Timestamps as floats are converted to ISO 8601. 98 | """ 99 | self.assertThat( 100 | format.timestamp()(1433631432.0), 101 | ExactlyEquals(u'2015-06-06 22:57:12Z')) 102 | 103 | def test_local(self): 104 | """ 105 | Timestamps can be converted to local time. 106 | """ 107 | timestamp = 1433631432.0 108 | utc = format.timestamp(utc_timestamps=True)(timestamp) 109 | local = format.timestamp(utc_timestamps=False)(timestamp + time.timezone) 110 | # Strip the "Z" off the end. 111 | self.assertThat(utc[:-1], ExactlyEquals(local)) 112 | 113 | 114 | class AnythingTests(TestCase): 115 | """ 116 | Tests for `eliottree.format.anything`. 117 | """ 118 | def test_anything(self): 119 | """ 120 | Convert values to Unicode via `repr`. 121 | """ 122 | class _Foo(object): 123 | def __repr__(self): 124 | return 'hello' 125 | self.assertThat( 126 | format.anything('utf-8')(_Foo()), 127 | ExactlyEquals(u'hello')) 128 | self.assertThat( 129 | format.anything('utf-8')(42), 130 | ExactlyEquals(u'42')) 131 | 132 | 133 | class TruncateTests(TestCase): 134 | """ 135 | Tests for `eliottree.format.truncate_value`. 136 | """ 137 | def test_under(self): 138 | """ 139 | No truncation occurs if the length of the value is below the limit. 140 | """ 141 | self.assertThat( 142 | format.truncate_value(10, u'abcdefghijklmnopqrstuvwxyz'[:5]), 143 | ExactlyEquals(u'abcde')) 144 | 145 | def test_exact(self): 146 | """ 147 | No truncation occurs if the length of the value is exactly at the 148 | limit. 149 | """ 150 | self.assertThat( 151 | format.truncate_value(10, u'abcdefghijklmnopqrstuvwxyz'[:10]), 152 | ExactlyEquals(u'abcdefghij')) 153 | 154 | def test_over(self): 155 | """ 156 | Truncate values whose length is above the limit. 157 | """ 158 | self.assertThat( 159 | format.truncate_value(10, u'abcdefghijklmnopqrstuvwxyz'), 160 | ExactlyEquals(u'abcdefghij\u2026')) 161 | 162 | def test_multiple_lines(self): 163 | """ 164 | Truncate values that have more than a single line of text by only 165 | showing the first line. 166 | """ 167 | self.assertThat( 168 | format.truncate_value(10, u'abc\ndef'), 169 | ExactlyEquals(u'abc\u2026')) 170 | -------------------------------------------------------------------------------- /src/eliottree/tree.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from warnings import warn 3 | 4 | from six import text_type as unicode 5 | from six import PY2 6 | 7 | 8 | def task_name(task): 9 | """ 10 | Compute the task name for an Eliot task. 11 | 12 | If we can't find a ``message_type`` or an ``action_type`` field to use to 13 | derive the name, then return ``None``. 14 | """ 15 | if task is None: 16 | raise ValueError('Cannot compute task name', task) 17 | level = u','.join(map(unicode, task[u'task_level'])) 18 | message_type = task.get('message_type', None) 19 | if message_type is not None: 20 | status = u'' 21 | elif message_type is None: 22 | message_type = task.get('action_type', None) 23 | if message_type is None: 24 | return None 25 | status = u'/' + task['action_status'] 26 | return u'{message_type}@{level}{status}'.format( 27 | message_type=message_type, 28 | level=level, 29 | status=status) 30 | 31 | 32 | class _TaskNode(object): 33 | """ 34 | A node representing an Eliot task and its child tasks. 35 | 36 | :type task: ``dict`` 37 | :ivar task: Eliot task. 38 | 39 | :type name: ``unicode`` 40 | :ivar name: Node name; this will be derived from the task if it is not 41 | specified. 42 | 43 | :type _children: ``dict`` of ``_TaskNode`` 44 | :ivar _children: Child nodes, see ``_TaskNode.children`` 45 | """ 46 | 47 | _DEFAULT_TASK_NAME = u'' 48 | 49 | def __init__(self, task, name=None): 50 | if task is None: 51 | raise ValueError('Missing eliot task') 52 | self.task = task 53 | self._children = dict() 54 | if name is None: 55 | name = task_name(task) or self._DEFAULT_TASK_NAME 56 | self.name = name 57 | self.success = None 58 | 59 | def __repr__(self): 60 | """ 61 | Human-readable representation of the node. 62 | """ 63 | task_uuid = self.task[u'task_uuid'] 64 | name = self.name 65 | if PY2: 66 | # XXX: This is probably wrong in a bunch of places. 67 | task_uuid = task_uuid.encode('utf-8') 68 | name = name.encode('utf-8') 69 | return '<{type} {task_uuid!r} {name!r} children={children}>'.format( 70 | type=type(self).__name__, 71 | task_uuid=task_uuid, 72 | name=name, 73 | children=len(self._children)) 74 | 75 | def copy(self): 76 | """ 77 | Make a shallow copy of this node. 78 | """ 79 | return type(self)(self.task, self.name) 80 | 81 | def add_child(self, node): 82 | """ 83 | Add a child node. 84 | 85 | :type node: ``_TaskNode`` 86 | :param node: Child node to add to the tree, if the child has multiple 87 | levels it may be added as a grandchild. 88 | """ 89 | def _add_child(parent, levels): 90 | levels = list(levels) 91 | level = levels.pop(0) 92 | children = parent._children 93 | if level in children: 94 | _add_child(children[level], levels) 95 | else: 96 | children[level] = node 97 | action_status = node.task.get('action_status') 98 | if action_status == u'succeeded': 99 | node.success = parent.success = True 100 | elif action_status == u'failed': 101 | node.success = parent.success = False 102 | _add_child(self, node.task['task_level']) 103 | 104 | def children(self): 105 | """ 106 | Get a ``list`` of child ``_TaskNode``s ordered by task level. 107 | """ 108 | return sorted( 109 | self._children.values(), key=lambda n: n.task[u'task_level']) 110 | 111 | 112 | def missing_start_task(task_missing_parent): 113 | """ 114 | Create a fake start task for an existing task that happens to be missing 115 | one. 116 | """ 117 | return { 118 | u'action_type': u'', 119 | u'action_status': u'started', 120 | u'timestamp': task_missing_parent[u'timestamp'], 121 | u'task_uuid': task_missing_parent[u'task_uuid'], 122 | u'task_level': [1]} 123 | 124 | 125 | class TaskMergeError(RuntimeError): 126 | """ 127 | An exception occured while trying to merge a task into the tree. 128 | """ 129 | def __init__(self, task, exc_info): 130 | self.task = task 131 | self.exc_info = exc_info 132 | RuntimeError.__init__(self) 133 | 134 | 135 | class Tree(object): 136 | """ 137 | Eliot task tree. 138 | 139 | :ivar _nodes: Internal tree storage, use ``Tree.nodes`` or 140 | ``Tree.matching_nodes`` to obtain the tree nodes. 141 | """ 142 | def __init__(self): 143 | warn('Tree is deprecated, use eliottree.tasks_from_iterable instead', 144 | DeprecationWarning, 2) 145 | self._nodes = {} 146 | 147 | def nodes(self, uuids=None): 148 | """ 149 | All top-level nodes in the tree. 150 | 151 | :type uuids: ``set`` of ``unicode`` 152 | :param uuids: Set of task UUIDs to include, or ``None`` for no 153 | filtering. 154 | 155 | :rtype: ``iterable`` of 2-``tuple``s 156 | :return: Iterable of key and node pairs for top-level nodes, sorted by 157 | timestamp. 158 | """ 159 | if uuids is not None: 160 | nodes = ((k, self._nodes[k]) for k in uuids) 161 | else: 162 | nodes = self._nodes.items() 163 | return sorted(nodes, key=lambda x: x[1].task[u'timestamp']) 164 | 165 | def merge_tasks(self, tasks, filter_funcs=None): 166 | """ 167 | Merge tasks into the tree. 168 | 169 | :type tasks: ``iterable`` of ``dict`` 170 | :param tasks: Iterable of task dicts. 171 | 172 | :type filter_funcs: ``iterable`` of 1-argument ``callable``s returning 173 | ``bool`` 174 | :param filter_funcs: Iterable of predicate functions that given a task 175 | determine whether to keep it. 176 | 177 | :return: ``set`` of task UUIDs that match all of the filter functions, 178 | can be passed to ``Tree.matching_nodes``, or ``None`` if no filter 179 | functions were specified. 180 | """ 181 | tasktree = self._nodes 182 | if filter_funcs is None: 183 | filter_funcs = [] 184 | filter_funcs = list(filter_funcs) 185 | matches = dict((i, set()) for i, _ in enumerate(filter_funcs)) 186 | 187 | def _merge_one(task, create_missing_tasks): 188 | key = task[u'task_uuid'] 189 | node = tasktree.get(key) 190 | if node is None: 191 | if task[u'task_level'] != [1]: 192 | if create_missing_tasks: 193 | n = tasktree[key] = _TaskNode( 194 | task=missing_start_task(task)) 195 | n.add_child(_TaskNode(task)) 196 | else: 197 | return task 198 | else: 199 | node = tasktree[key] = _TaskNode(task=task) 200 | else: 201 | node.add_child(_TaskNode(task)) 202 | for i, fn in enumerate(filter_funcs): 203 | if fn(task): 204 | matches[i].add(key) 205 | return None 206 | 207 | def _merge(tasks, create_missing_tasks=False): 208 | pending = [] 209 | for task in tasks: 210 | try: 211 | result = _merge_one(task, create_missing_tasks) 212 | if result is not None: 213 | pending.append(result) 214 | except Exception: 215 | raise TaskMergeError(task, sys.exc_info()) 216 | return pending 217 | 218 | pending = _merge(tasks) 219 | if pending: 220 | pending = _merge(pending, True) 221 | if pending: 222 | raise RuntimeError('Some tasks have no start parent', pending) 223 | if not matches: 224 | return None 225 | return set.intersection(*matches.values()) 226 | 227 | 228 | __all__ = ['Tree'] 229 | -------------------------------------------------------------------------------- /src/eliottree/tree_format/tests/test_text.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2015 Jonathan M. Lange 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import doctest 18 | from operator import itemgetter 19 | from textwrap import dedent 20 | 21 | from testtools import TestCase 22 | from testtools.matchers import DocTestMatches 23 | 24 | from .._text import ( 25 | format_tree, format_ascii_tree, 26 | ) 27 | 28 | 29 | class TestFormatTree(TestCase): 30 | 31 | def format_tree(self, tree): 32 | return format_tree(tree, itemgetter(0), itemgetter(1)) 33 | 34 | def format_ascii_tree(self, tree): 35 | return format_ascii_tree(tree, itemgetter(0), itemgetter(1)) 36 | 37 | def test_single_node_tree(self): 38 | tree = ('foo', []) 39 | output = self.format_tree(tree) 40 | self.assertEqual(dedent(u'''\ 41 | foo 42 | '''), output) 43 | 44 | def test_single_level_tree(self): 45 | tree = ( 46 | 'foo', [ 47 | ('bar', []), 48 | ('baz', []), 49 | ('qux', []), 50 | ], 51 | ) 52 | output = self.format_tree(tree) 53 | self.assertEqual(dedent(u'''\ 54 | foo 55 | ├── bar 56 | ├── baz 57 | └── qux 58 | '''), output) 59 | 60 | def test_multi_level_tree(self): 61 | tree = ( 62 | 'foo', [ 63 | ('bar', [ 64 | ('a', []), 65 | ('b', []), 66 | ]), 67 | ('baz', []), 68 | ('qux', []), 69 | ], 70 | ) 71 | output = self.format_tree(tree) 72 | self.assertEqual(dedent(u'''\ 73 | foo 74 | ├── bar 75 | │ ├── a 76 | │ └── b 77 | ├── baz 78 | └── qux 79 | '''), output) 80 | 81 | def test_multi_level_on_last_node_tree(self): 82 | tree = ( 83 | 'foo', [ 84 | ('bar', []), 85 | ('baz', []), 86 | ('qux', [ 87 | ('a', []), 88 | ('b', []), 89 | ]), 90 | ], 91 | ) 92 | output = self.format_tree(tree) 93 | self.assertEqual(dedent(u'''\ 94 | foo 95 | ├── bar 96 | ├── baz 97 | └── qux 98 | ├── a 99 | └── b 100 | '''), output) 101 | 102 | def test_acceptance(self): 103 | output = self.format_tree(ACCEPTANCE_INPUT) 104 | self.assertThat( 105 | output, 106 | DocTestMatches( 107 | ACCEPTANCE_OUTPUT, 108 | doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE | doctest.REPORT_NDIFF)) 109 | 110 | def test_newlines(self): 111 | tree = ( 112 | 'foo', [ 113 | ('bar\nfrob', [ 114 | ('a', []), 115 | ('b\nc\nd', []), 116 | ]), 117 | ('baz', []), 118 | ('qux\nfrab', []), 119 | ], 120 | ) 121 | output = self.format_tree(tree) 122 | self.assertEqual(dedent(u'''\ 123 | foo 124 | ├── bar⏎ 125 | │ frob 126 | │ ├── a 127 | │ └── b⏎ 128 | │ c⏎ 129 | │ d 130 | ├── baz 131 | └── qux⏎ 132 | frab 133 | '''), output) 134 | 135 | def test_ascii__tree(self): 136 | tree = ( 137 | 'foo', [ 138 | ('bar', [ 139 | ('a', []), 140 | ('b', []), 141 | ]), 142 | ('baz', []), 143 | ('qux', []), 144 | ], 145 | ) 146 | output = self.format_ascii_tree(tree) 147 | self.assertEqual(dedent(u'''\ 148 | foo 149 | |-- bar 150 | | |-- a 151 | | +-- b 152 | |-- baz 153 | +-- qux 154 | '''), output) 155 | 156 | 157 | def d(name, files): 158 | return (name, files) 159 | 160 | 161 | def f(name): 162 | return (name, []) 163 | 164 | 165 | ACCEPTANCE_INPUT = \ 166 | d(u'.', [ 167 | f(u'cabal.sandbox.config'), 168 | f(u'config.yaml'), 169 | d(u'dist', [ 170 | d(u'build', [ 171 | d(u'autogen', [ 172 | f(u'cabal_macros.h'), 173 | f(u'Paths_hodor.hs'), 174 | ]), 175 | d(u'hodor', [ 176 | f(u'hodor'), 177 | d(u'hodor-tmp', [ 178 | d(u'Hodor', map(f, [ 179 | u'Actions.hi', 180 | u'Actions.o', 181 | u'CommandLine.hi', 182 | u'CommandLine.o', 183 | u'Commands.hi', 184 | u'Commands.o', 185 | u'Config.hi', 186 | u'Config.o', 187 | u'File.hi', 188 | u'File.o', 189 | u'Format.hi', 190 | u'Format.o', 191 | u'Functional.hi', 192 | u'Functional.o', 193 | u'Parser.hi', 194 | u'Parser.o', 195 | u'Types.hi', 196 | u'Types.o', 197 | ])), 198 | f(u'Hodor.hi'), 199 | f(u'Hodor.o'), 200 | f(u'Main.hi'), 201 | f(u'Main.o'), 202 | ]), 203 | ]), 204 | ]), 205 | f(u'package.conf.inplace'), 206 | f(u'setup-config'), 207 | ]), 208 | d(u'Hodor', map(f, [ 209 | u'Actions.hs', 210 | u'CommandLine.hs', 211 | u'Commands.hs', 212 | u'Config.hs', 213 | u'File.hs', 214 | u'Format.hs', 215 | u'Functional.hs', 216 | u'Parser.hs', 217 | u'Reports.hs', 218 | u'Types.hs', 219 | ])), 220 | f(u'hodor.cabal'), 221 | f(u'Hodor.hs'), 222 | f(u'LICENSE'), 223 | f(u'Main.hs'), 224 | f(u'notes.md'), 225 | f(u'Setup.hs'), 226 | d(u'Tests', map(f, [ 227 | u'FormatSpec.hs', 228 | u'Generators.hs', 229 | u'ParserSpec.hs', 230 | u'TypesSpec.hs', 231 | ])), 232 | f(u'Tests.hs'), 233 | f(u'todo.txt'), 234 | ]) 235 | 236 | 237 | ACCEPTANCE_OUTPUT = u'''\ 238 | . 239 | ├── cabal.sandbox.config 240 | ├── config.yaml 241 | ├── dist 242 | │ ├── build 243 | │ │ ├── autogen 244 | │ │ │ ├── cabal_macros.h 245 | │ │ │ └── Paths_hodor.hs 246 | │ │ └── hodor 247 | │ │ ├── hodor 248 | │ │ └── hodor-tmp 249 | │ │ ├── Hodor 250 | │ │ │ ├── Actions.hi 251 | │ │ │ ├── Actions.o 252 | │ │ │ ├── CommandLine.hi 253 | │ │ │ ├── CommandLine.o 254 | │ │ │ ├── Commands.hi 255 | │ │ │ ├── Commands.o 256 | │ │ │ ├── Config.hi 257 | │ │ │ ├── Config.o 258 | │ │ │ ├── File.hi 259 | │ │ │ ├── File.o 260 | │ │ │ ├── Format.hi 261 | │ │ │ ├── Format.o 262 | │ │ │ ├── Functional.hi 263 | │ │ │ ├── Functional.o 264 | │ │ │ ├── Parser.hi 265 | │ │ │ ├── Parser.o 266 | │ │ │ ├── Types.hi 267 | │ │ │ └── Types.o 268 | │ │ ├── Hodor.hi 269 | │ │ ├── Hodor.o 270 | │ │ ├── Main.hi 271 | │ │ └── Main.o 272 | │ ├── package.conf.inplace 273 | │ └── setup-config 274 | ├── Hodor 275 | │ ├── Actions.hs 276 | │ ├── CommandLine.hs 277 | │ ├── Commands.hs 278 | │ ├── Config.hs 279 | │ ├── File.hs 280 | │ ├── Format.hs 281 | │ ├── Functional.hs 282 | │ ├── Parser.hs 283 | │ ├── Reports.hs 284 | │ └── Types.hs 285 | ├── hodor.cabal 286 | ├── Hodor.hs 287 | ├── LICENSE 288 | ├── Main.hs 289 | ├── notes.md 290 | ├── Setup.hs 291 | ├── Tests 292 | │ ├── FormatSpec.hs 293 | │ ├── Generators.hs 294 | │ ├── ParserSpec.hs 295 | │ └── TypesSpec.hs 296 | ├── Tests.hs 297 | └── todo.txt 298 | ''' 299 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | eliottree 3 | ========= 4 | 5 | |build|_ |coverage|_ 6 | 7 | Render `Eliot `_ logs as an ASCII tree. 8 | 9 | This output: 10 | 11 | .. image:: https://github.com/jonathanj/eliottree/raw/master/doc/example_eliot_log.png 12 | 13 | (or as text) 14 | 15 | .. code-block:: bash 16 | 17 | $ eliot-tree eliot.log 18 | f3a32bb3-ea6b-457c-aa99-08a3d0491ab4 19 | └── app:soap:client:request/1 ⇒ started 2015-03-03 04:28:56 ⧖ 1.238s 20 | ├── dump: /home/user/dump_files/20150303/1425356936.28_Client_req.xml 21 | ├── soapAction: a_soap_action 22 | ├── uri: http://example.org/soap 23 | ├── app:soap:client:success/2/1 ⇒ started 2015-03-03 04:28:57 ⧖ 0.000s 24 | │ └── app:soap:client:success/2/2 ⇒ succeeded 2015-03-03 04:28:57 25 | │ └── dump: /home/user/dump_files/20150303/1425356937.52_Client_res.xml 26 | └── app:soap:client:request/3 ⇒ succeeded 2015-03-03 04:28:57 27 | └── status: 200 28 | 29 | 89a56df5-d808-4a7c-8526-e603aae2e2f2 30 | └── app:soap:service:request/1 ⇒ started 2015-03-03 04:31:08 ⧖ 3.482s 31 | ├── dump: /home/user/dump_files/20150303/1425357068.03_Service_req.xml 32 | ├── soapAction: method 33 | ├── uri: /endpoints/soap/method 34 | ├── app:soap:service:success/2/1 ⇒ started 2015-03-03 04:31:11 ⧖ 0.001s 35 | │ └── app:soap:service:success/2/2 ⇒ succeeded 2015-03-03 04:31:11 36 | │ └── dump: /home/user/dump_files/20150303/1425357071.51_Service_res.xml 37 | └── app:soap:service:request/3 ⇒ succeeded 2015-03-03 04:31:11 38 | └── status: 200 39 | 40 | was generated from: 41 | 42 | .. code-block:: javascript 43 | 44 | {"dump": "/home/user/dump_files/20150303/1425356936.28_Client_req.xml", "timestamp": 1425356936.278875, "uri": "http://example.org/soap", "action_status": "started", "task_uuid": "f3a32bb3-ea6b-457c-aa99-08a3d0491ab4", "action_type": "app:soap:client:request", "soapAction": "a_soap_action", "task_level": [1]} 45 | {"timestamp": 1425356937.516579, "task_uuid": "f3a32bb3-ea6b-457c-aa99-08a3d0491ab4", "action_type": "app:soap:client:success", "action_status": "started", "task_level": [2, 1]} 46 | {"task_uuid": "f3a32bb3-ea6b-457c-aa99-08a3d0491ab4", "action_type": "app:soap:client:success", "dump": "/home/user/dump_files/20150303/1425356937.52_Client_res.xml", "timestamp": 1425356937.517077, "action_status": "succeeded", "task_level": [2, 2]} 47 | {"status": 200, "task_uuid": "f3a32bb3-ea6b-457c-aa99-08a3d0491ab4", "task_level": [3], "action_type": "app:soap:client:request", "timestamp": 1425356937.517161, "action_status": "succeeded"} 48 | {"dump": "/home/user/dump_files/20150303/1425357068.03_Service_req.xml", "timestamp": 1425357068.032091, "uri": "/endpoints/soap/method", "action_status": "started", "task_uuid": "89a56df5-d808-4a7c-8526-e603aae2e2f2", "action_type": "app:soap:service:request", "soapAction": "method", "task_level": [1]} 49 | {"timestamp": 1425357071.51233, "task_uuid": "89a56df5-d808-4a7c-8526-e603aae2e2f2", "action_type": "app:soap:service:success", "action_status": "started", "task_level": [2, 1]} 50 | {"task_uuid": "89a56df5-d808-4a7c-8526-e603aae2e2f2", "action_type": "app:soap:service:success", "dump": "/home/user/dump_files/20150303/1425357071.51_Service_res.xml", "timestamp": 1425357071.513453, "action_status": "succeeded", "task_level": [2, 2]} 51 | {"status": 200, "task_uuid": "89a56df5-d808-4a7c-8526-e603aae2e2f2", "task_level": [3], "action_type": "app:soap:service:request", "timestamp": 1425357071.513992, "action_status": "succeeded"} 52 | 53 | Command-line options 54 | -------------------- 55 | 56 | Consult the output of ``eliot-tree --help`` to see a complete list of command-line 57 | options. 58 | 59 | Streaming 60 | --------- 61 | 62 | It's possible to pipe data into eliot-tree, from a tailed log for example, and 63 | have it rendered incrementally. There is a caveat though: Trees are only 64 | rendered once an end message—a success or failure status—for the tree's root 65 | action appears in the data. 66 | 67 | Selecting / filtering tasks 68 | --------------------------- 69 | 70 | By task UUID 71 | ~~~~~~~~~~~~ 72 | 73 | Entire task trees can be selected by UUID with the ``--task-uuid`` (``-u``) 74 | command-line option. 75 | 76 | By start / end date 77 | ~~~~~~~~~~~~~~~~~~~ 78 | 79 | Individual tasks can be selected based on their timestamp, use ``--start`` to 80 | select tasks after an ISO8601 date-time, and ``--end`` to select tasks before an 81 | ISO8601 date-time. 82 | 83 | By custom query 84 | ~~~~~~~~~~~~~~~ 85 | 86 | Custom task selection can be done with the ``--select`` command-line option, the 87 | syntax of which is `JMESPath`_, and is applied to the original Eliot JSON 88 | structures. Any data that appears within an Eliot task structure can be queried. 89 | Only the matching tasks (and all of their children) will be displayed, the 90 | parents of the task structure (by ``task_uuid``) will be elided. 91 | 92 | An important thing to note is that the query should be used as a *predicate* (it 93 | should describe a boolean condition), not to narrow a JSON data structure, as 94 | many of the examples on the JMESPath site illustrate. The reason for this is 95 | that Eliot tasks are not stored as one large nested JSON structure, they are 96 | instead many small structures that are linked together by metadata 97 | (``task_uuid``), which is not a structure than JMESPath is ideally equipped to 98 | query. 99 | 100 | The ``--select`` argument can be supplied multiple times to mimic logical AND, 101 | that is all ``--select`` predicates must pass in order for a task or node to be 102 | selected. 103 | 104 | .. _JMESPath: http://jmespath.org/ 105 | 106 | Examples 107 | ^^^^^^^^ 108 | 109 | Select all tasks that contain a ``uri`` key, regardless of its value: 110 | 111 | .. code-block:: bash 112 | 113 | --select 'uri' 114 | 115 | Select all Eliot action tasks of type ``http_client:request``: 116 | 117 | .. code-block:: bash 118 | 119 | --select 'action_type == `"http_client:request"`' 120 | 121 | Backquotes are used to represent raw JSON values in JMESPath, ```500``` is the 122 | number 500, ```"500"``` is the string "500". 123 | 124 | Select all tasks that have an ``http_status`` of 401 or 500: 125 | 126 | .. code-block:: bash 127 | 128 | --select 'contains(`[401, 500]`, status)' 129 | 130 | Select all tasks that have an ``http_status`` of 401 that were also made to a 131 | ``uri`` containing the text ``/criticalEndpoint``: 132 | 133 | .. code-block:: bash 134 | 135 | --select 'http_status == `401`' \ 136 | --select 'uri && contains(uri, `"/criticalEndpoint"`)' 137 | 138 | Here ``--select`` is passed twice to mimic a logical AND condition, it is also 139 | possible to use the JMESPath ``&&`` operator. There is also a test for the 140 | existence of the ``uri`` key to guard against calling the ``contains()`` 141 | function with a null subject. 142 | 143 | See the `JMESPath specification`_ for more information. 144 | 145 | .. _JMESPath specification: http://jmespath.org/specification.html 146 | 147 | 148 | Programmatic usage 149 | ------------------ 150 | 151 | .. code-block:: python 152 | 153 | import json, sys 154 | from eliottree import tasks_from_iterable, render_tasks 155 | # Or `codecs.getwriter('utf-8')(sys.stdout).write` on Python 2. 156 | render_tasks(sys.stdout.write, tasks, colorize=True) 157 | 158 | See :code:`help(render_tasks)` and :code:`help(tasks_from_iterable)` from a 159 | Python REPL for more information. 160 | 161 | Configuration 162 | ------------- 163 | 164 | Command-line options may have custom defaults specified by way of a config file. 165 | The config file can be passed with the ``--config`` argument, or will be read from 166 | ``~/.config/eliot-tree/config.json``. See `config.example.json`_ for an 167 | example. 168 | 169 | Use the ``--show-default-config`` command-line option to display the default 170 | configuration, suitable for redirecting to a file. Use the 171 | ``--show-current-config`` command-line option to display the current effective 172 | configuration. 173 | 174 | .. _\_cli.py: https://github.com/jonathanj/eliottree/blob/master/src/eliottree/_cli.py 175 | .. _config.example.json: https://github.com/jonathanj/eliottree/blob/master/config.example.json 176 | 177 | Theme overrides 178 | ~~~~~~~~~~~~~~~ 179 | 180 | Theme colors can be overridden via the ``theme_overrides`` key in the config file. 181 | The value of this key is itself a JSON object, each key is the name of a theme 182 | color and each value is a JSON list. This list should contain three values: 183 | 184 | 1. Foreground color, terminal color name or code; or ``null`` for the default color. 185 | 2. Background color, terminal color name or code; or ``null`` for the default color. 186 | 3. An optional array of color attribute names or codes; or ``null`` for the 187 | default attribute. 188 | 189 | For example, to override the ``root`` theme color to be bold magenta, and the 190 | ``prop`` theme color to be red: 191 | 192 | .. code-block:: json 193 | 194 | { 195 | "theme_overrides": { 196 | "root": ["magenta", null, ["bold"]], 197 | "prop_key": ["red"] 198 | } 199 | } 200 | 201 | See `_theme.py`_ for theme color names and the `colored`_ Python package for 202 | available color and attribute constants. 203 | 204 | .. _\_theme.py: https://github.com/jonathanj/eliottree/blob/master/src/eliottree/_theme.py 205 | .. _colored: https://pypi.org/project/colored/ 206 | 207 | Contribute 208 | ---------- 209 | 210 | See for details. 211 | 212 | 213 | .. |build| image:: https://travis-ci.org/jonathanj/eliottree.svg?branch=master 214 | .. _build: https://travis-ci.org/jonathanj/eliottree 215 | 216 | .. |coverage| image:: https://coveralls.io/repos/jonathanj/eliottree/badge.svg 217 | .. _coverage: https://coveralls.io/r/jonathanj/eliottree 218 | -------------------------------------------------------------------------------- /src/eliottree/tree_format/LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /src/eliottree/_render.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import traceback 3 | import warnings 4 | from functools import partial 5 | 6 | from eliot.parse import WrittenAction, WrittenMessage, Task 7 | from six import text_type 8 | from toolz import compose, excepts, identity 9 | 10 | from eliottree import format 11 | from eliottree.tree_format import format_tree, Options, ASCII_OPTIONS 12 | from eliottree._color import colored 13 | from eliottree._util import eliot_ns, format_namespace, is_namespace 14 | from eliottree._theme import get_theme 15 | 16 | 17 | DEFAULT_IGNORED_KEYS = set([ 18 | u'action_status', u'action_type', u'task_level', u'task_uuid', 19 | u'message_type']) 20 | 21 | 22 | def _default_value_formatter( 23 | human_readable, 24 | field_limit, 25 | utc_timestamps=True, 26 | encoding='utf-8', 27 | ): 28 | """ 29 | Create a value formatter based on several user-specified options. 30 | """ 31 | fields = {} 32 | if human_readable: 33 | fields = { 34 | eliot_ns(u'timestamp'): format.timestamp( 35 | include_microsecond=False, 36 | utc_timestamps=utc_timestamps), 37 | eliot_ns(u'duration'): format.duration(), 38 | } 39 | return compose( 40 | # We want tree-format to handle newlines. 41 | partial(format.escape_control_characters, overrides={0x0a: u'\n'}), 42 | partial(format.truncate_value, 43 | field_limit) if field_limit else identity, 44 | format.some( 45 | format.fields(fields), 46 | format.text(), 47 | format.binary(encoding), 48 | format.anything(encoding))) 49 | 50 | 51 | def message_name(theme, format_value, message, end_message=None, options=None): 52 | """ 53 | Derive the name for a message. 54 | 55 | If the message is an action type then the ``action_type`` field is used in 56 | conjunction with ``task_level`` and ``action_status``. If the message is a 57 | message type then the ``message_type`` and ``task_level`` fields are used, 58 | otherwise no name will be derived. 59 | """ 60 | if message is not None: 61 | timestamp = theme.timestamp( 62 | format_value( 63 | message.timestamp, field_name=eliot_ns('timestamp'))) 64 | if u'action_type' in message.contents: 65 | action_type = format.escape_control_characters( 66 | message.contents.action_type) 67 | duration = u'' 68 | if end_message: 69 | duration_seconds = end_message.timestamp - message.timestamp 70 | duration = u' {} {}'.format( 71 | options.HOURGLASS, 72 | theme.duration( 73 | format_value( 74 | duration_seconds, 75 | field_name=eliot_ns('duration')))) 76 | action_status = end_message.contents.action_status 77 | else: 78 | action_status = message.contents.action_status 79 | status_color = identity 80 | if action_status == u'succeeded': 81 | status_color = theme.status_success 82 | elif action_status == u'failed': 83 | status_color = theme.status_failure 84 | return u'{}{} {} {} {}{}'.format( 85 | theme.parent(action_type), 86 | theme.task_level(message.task_level.to_string()), 87 | options.ARROW, 88 | status_color(message.contents.action_status), 89 | timestamp, 90 | duration) 91 | elif u'message_type' in message.contents: 92 | message_type = format.escape_control_characters( 93 | message.contents.message_type) 94 | return u'{}{} {}'.format( 95 | theme.parent(message_type), 96 | theme.task_level(message.task_level.to_string()), 97 | timestamp) 98 | return u'' 99 | 100 | 101 | def format_node(format_value, theme, options, node): 102 | """ 103 | Format a node for display purposes. 104 | 105 | Different representations exist for the various types of node: 106 | - `eliot.parse.Task`: A task UUID. 107 | - `eliot.parse.WrittenAction`: An action's type, level and status. 108 | - `eliot.parse.WrittenMessage`: A message's type and level. 109 | - ``tuple``: A field name and value. 110 | """ 111 | if isinstance(node, Task): 112 | return u'{}'.format( 113 | theme.root( 114 | format.escape_control_characters(node.root().task_uuid))) 115 | elif isinstance(node, WrittenAction): 116 | return message_name( 117 | theme, 118 | format_value, 119 | node.start_message, 120 | node.end_message, 121 | options) 122 | elif isinstance(node, WrittenMessage): 123 | return message_name( 124 | theme, 125 | format_value, 126 | node, 127 | options=options) 128 | elif isinstance(node, tuple): 129 | key, value = node 130 | if isinstance(value, (dict, list)): 131 | value = u'' 132 | else: 133 | value = format_value(value, key) 134 | if is_namespace(key): 135 | key = format_namespace(key) 136 | return u'{}: {}'.format( 137 | theme.prop_key(format.escape_control_characters(key)), 138 | theme.prop_value(text_type(value))) 139 | raise NotImplementedError() 140 | 141 | 142 | def message_fields(message, ignored_fields): 143 | """ 144 | Sorted fields for a `WrittenMessage`. 145 | """ 146 | def _items(): 147 | for key, value in message.contents.items(): 148 | if key not in ignored_fields: 149 | yield key, value 150 | 151 | def _sortkey(x): 152 | k = x[0] 153 | return format_namespace(k) if is_namespace(k) else k 154 | return sorted(_items(), key=_sortkey) if message else [] 155 | 156 | 157 | def get_children(ignored_fields, node): 158 | """ 159 | Retrieve the child nodes for a node. 160 | 161 | The various types of node have different concepts of children: 162 | - `eliot.parse.Task`: The root ``WrittenAction``. 163 | - `eliot.parse.WrittenAction`: The start message fields, child 164 | ``WrittenAction`` or ``WrittenMessage``s, and end ``WrittenMessage``. 165 | - `eliot.parse.WrittenMessage`: Message fields. 166 | - ``tuple``: Contained values for `dict` and `list` types. 167 | """ 168 | if isinstance(node, Task): 169 | return [node.root()] 170 | elif isinstance(node, WrittenAction): 171 | return filter(None, 172 | (message_fields(node.start_message, ignored_fields) 173 | + list(node.children) 174 | + [node.end_message])) 175 | elif isinstance(node, WrittenMessage): 176 | return message_fields(node, ignored_fields) 177 | elif isinstance(node, tuple): 178 | value = node[1] 179 | if isinstance(value, dict): 180 | return sorted(value.items()) 181 | elif isinstance(value, list): 182 | return enumerate(value) 183 | return [] 184 | 185 | 186 | def track_exceptions(f, caught, default=None): 187 | """ 188 | Decorate ``f`` with a function that traps exceptions and appends them to 189 | ``caught``, returning ``default`` in their place. 190 | """ 191 | def _catch(_): 192 | caught.append(sys.exc_info()) 193 | return default 194 | return excepts(Exception, f, _catch) 195 | 196 | 197 | class ColorizedOptions(object): 198 | """ 199 | `Options` for `format_tree` that colorizes sub-trees. 200 | """ 201 | def __init__(self, failed_color, depth_colors, options): 202 | self.failed_color = failed_color 203 | self.depth_colors = list(depth_colors) 204 | self.options = options 205 | 206 | def __getattr__(self, name): 207 | return getattr(self.options, name) 208 | 209 | def color(self, node, depth): 210 | if isinstance(node, WrittenAction): 211 | end_message = node.end_message 212 | if end_message and end_message.contents.action_status == u'failed': 213 | return self.failed_color 214 | return self.depth_colors[depth % len(self.depth_colors)] 215 | 216 | 217 | def render_tasks(write, tasks, field_limit=0, ignored_fields=None, 218 | human_readable=False, colorize=None, write_err=None, 219 | format_node=format_node, format_value=None, 220 | utc_timestamps=True, colorize_tree=False, ascii=False, 221 | theme=None): 222 | """ 223 | Render Eliot tasks as an ASCII tree. 224 | 225 | :type write: ``Callable[[text_type], None]`` 226 | :param write: Callable used to write the output. 227 | :type tasks: ``Iterable`` 228 | :param tasks: Iterable of parsed Eliot tasks, as returned by 229 | `eliottree.tasks_from_iterable`. 230 | :param int field_limit: Length at which to begin truncating, ``0`` means no 231 | truncation. 232 | :type ignored_fields: ``Set[text_type]`` 233 | :param ignored_fields: Set of field names to ignore, defaults to ignoring 234 | most Eliot metadata. 235 | :param bool human_readable: Render field values as human-readable? 236 | :param bool colorize: Colorized the output? (Deprecated, use `theme`.) 237 | :type write_err: Callable[[`text_type`], None] 238 | :param write_err: Callable used to write errors. 239 | :param format_node: See `format_node`. 240 | :type format_value: Callable[[Any], `text_type`] 241 | :param format_value: Callable to format a value. 242 | :param bool utc_timestamps: Format timestamps as UTC? 243 | :param int colorize_tree: Colorizing the tree output? 244 | :param bool ascii: Render the tree as plain ASCII instead of Unicode? 245 | :param Theme theme: Theme to use for rendering. 246 | """ 247 | def make_options(): 248 | if ascii: 249 | _options = ASCII_OPTIONS 250 | else: 251 | _options = Options() 252 | if colorize_tree: 253 | return ColorizedOptions( 254 | failed_color=theme.tree_failed, 255 | depth_colors=[theme.tree_color0, theme.tree_color1, theme.tree_color2], 256 | options=_options) 257 | return _options 258 | 259 | options = make_options() 260 | 261 | if ignored_fields is None: 262 | ignored_fields = DEFAULT_IGNORED_KEYS 263 | if colorize is not None: 264 | warnings.warn( 265 | 'Passing `colorize` is deprecated, use `theme` instead.', 266 | DeprecationWarning) 267 | theme = get_theme( 268 | dark_background=True, 269 | colored=colored if colorize else None) 270 | if theme is None: 271 | theme = get_theme(dark_background=True) 272 | caught_exceptions = [] 273 | if format_value is None: 274 | format_value = _default_value_formatter( 275 | human_readable=human_readable, 276 | field_limit=field_limit, 277 | utc_timestamps=utc_timestamps) 278 | _format_value = track_exceptions( 279 | format_value, 280 | caught_exceptions, 281 | u'') 282 | _format_node = track_exceptions( 283 | partial(format_node, _format_value, theme, options), 284 | caught_exceptions, 285 | u'') 286 | _get_children = partial(get_children, ignored_fields) 287 | 288 | for task in tasks: 289 | write(format_tree(task, _format_node, _get_children, options)) 290 | write(u'\n') 291 | 292 | if write_err and caught_exceptions: 293 | write_err( 294 | theme.error( 295 | u'Exceptions ({}) occurred during processing:\n'.format( 296 | len(caught_exceptions)))) 297 | for exc in caught_exceptions: 298 | for line in traceback.format_exception(*exc): 299 | if not isinstance(line, text_type): 300 | line = line.decode('utf-8') 301 | write_err(line) 302 | write_err(u'\n') 303 | 304 | 305 | __all__ = ['render_tasks'] 306 | -------------------------------------------------------------------------------- /src/eliottree/_cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import codecs 3 | import json 4 | import os 5 | import platform 6 | import sys 7 | from pprint import pformat 8 | 9 | import iso8601 10 | from six import PY3, binary_type, reraise 11 | from six.moves import filter 12 | 13 | from eliottree import ( 14 | EliotParseError, JSONParseError, filter_by_end_date, filter_by_jmespath, 15 | filter_by_start_date, filter_by_uuid, render_tasks, tasks_from_iterable, 16 | combine_filters_and) 17 | from eliottree._color import colored 18 | from eliottree._theme import get_theme, apply_theme_overrides 19 | 20 | 21 | def text_writer(fd): 22 | """ 23 | File writer that accepts Unicode to write. 24 | """ 25 | if PY3: 26 | return fd 27 | return codecs.getwriter('utf-8')(fd) 28 | 29 | 30 | def text_reader(fd): 31 | """ 32 | File reader that returns Unicode from reading. 33 | """ 34 | if PY3: 35 | return fd 36 | return codecs.getreader('utf-8')(fd) 37 | 38 | 39 | def parse_messages(files=None, select=None, task_uuid=None, start=None, 40 | end=None): 41 | """ 42 | Parse message dictionaries from inputs into Eliot tasks, filtering by any 43 | provided criteria. 44 | """ 45 | def filter_funcs(): 46 | if task_uuid is not None: 47 | yield filter_by_uuid(task_uuid) 48 | if start: 49 | yield filter_by_start_date(start) 50 | if end: 51 | yield filter_by_end_date(end) 52 | if select is not None: 53 | for query in select: 54 | yield filter_by_jmespath(query) 55 | 56 | def _parse(files, inventory): 57 | for file in files: 58 | file_name = getattr(file, 'name', '') 59 | for line_number, line in enumerate(file, 1): 60 | try: 61 | task = json.loads(line) 62 | inventory[id(task)] = file_name, line_number 63 | yield task 64 | except Exception: 65 | raise JSONParseError( 66 | file_name, 67 | line_number, 68 | line.rstrip(u'\n'), 69 | sys.exc_info()) 70 | 71 | if not files: 72 | files = [text_reader(sys.stdin)] 73 | inventory = {} 74 | return inventory, tasks_from_iterable( 75 | filter(combine_filters_and(*filter_funcs()), _parse(files, inventory))) 76 | 77 | 78 | def setup_platform(colorize): 79 | """ 80 | Set up any platform specifics for console output etc. 81 | """ 82 | if platform.system() == 'Windows': 83 | # Initialise Unicode support for Windows terminals, even if they're not 84 | # using the Unicode codepage. 85 | import win_unicode_console # noqa: E402 86 | import warnings # noqa: E402 87 | with warnings.catch_warnings(): 88 | warnings.filterwarnings('ignore', category=RuntimeWarning) 89 | win_unicode_console.enable() 90 | 91 | 92 | def display_tasks(tasks, color, colorize_tree, ascii, theme_name, ignored_fields, 93 | field_limit, human_readable, utc_timestamps, theme_overrides): 94 | """ 95 | Render Eliot tasks, apply any command-line-specified behaviour and render 96 | the task trees to stdout. 97 | """ 98 | if color == 'auto': 99 | colorize = sys.stdout.isatty() 100 | else: 101 | colorize = color == 'always' 102 | 103 | setup_platform(colorize=colorize) 104 | write = text_writer(sys.stdout).write 105 | write_err = text_writer(sys.stderr).write 106 | if theme_name == 'auto': 107 | dark_background = is_dark_terminal_background(default=True) 108 | else: 109 | dark_background = theme_name == 'dark' 110 | theme = apply_theme_overrides( 111 | get_theme( 112 | dark_background=dark_background, 113 | colored=colored if colorize else None), 114 | theme_overrides) 115 | 116 | render_tasks( 117 | write=write, 118 | write_err=write_err, 119 | tasks=tasks, 120 | ignored_fields=set(ignored_fields) or None, 121 | field_limit=field_limit, 122 | human_readable=human_readable, 123 | colorize_tree=colorize and colorize_tree, 124 | ascii=ascii, 125 | utc_timestamps=utc_timestamps, 126 | theme=theme) 127 | 128 | 129 | def _decode_command_line(value, encoding='utf-8'): 130 | """ 131 | Decode a command-line argument. 132 | """ 133 | if isinstance(value, binary_type): 134 | return value.decode(encoding) 135 | return value 136 | 137 | 138 | def is_dark_terminal_background(default=True): 139 | """ 140 | Does the terminal use a dark background color? 141 | 142 | Currently just checks the `COLORFGBG` environment variable, if it exists, 143 | which some terminals define as `fg:bg`. 144 | 145 | :rtype: bool 146 | """ 147 | colorfgbg = os.environ.get('COLORFGBG', None) 148 | if colorfgbg is not None: 149 | parts = os.environ['COLORFGBG'].split(';') 150 | try: 151 | last_number = int(parts[-1]) 152 | if 0 <= last_number <= 6 or last_number == 8: 153 | return True 154 | else: 155 | return False 156 | except ValueError: 157 | pass 158 | return default 159 | 160 | 161 | CONFIG_PATHS = [ 162 | os.path.expanduser('~/.config/eliot-tree/config.json'), 163 | ] 164 | 165 | 166 | def locate_config(): 167 | """ 168 | Find the first config search path that exists. 169 | """ 170 | return next((path for path in CONFIG_PATHS if os.path.exists(path)), None) 171 | 172 | 173 | def read_config(path): 174 | """ 175 | Read a config file from the specified path. 176 | """ 177 | if path is None: 178 | return {} 179 | with open(path, 'rb') as fd: 180 | return json.load(fd) 181 | 182 | 183 | CONFIG_BLACKLIST = [ 184 | 'files', 'start', 'end', 'print_default_config', 'config', 'select', 185 | 'task_uuid'] 186 | 187 | 188 | def print_namespace(namespace): 189 | """ 190 | Print an argparse namespace to stdout as JSON. 191 | """ 192 | config = {k: v for k, v in vars(namespace).items() 193 | if k not in CONFIG_BLACKLIST} 194 | stdout = text_writer(sys.stdout) 195 | stdout.write(json.dumps(config, indent=2)) 196 | 197 | 198 | def main(): 199 | parser = argparse.ArgumentParser( 200 | description='Display an Eliot log as a tree of tasks.') 201 | parser.add_argument('files', 202 | metavar='FILE', 203 | nargs='*', 204 | type=argparse.FileType('r'), 205 | help='''Files to process. Omit to read from stdin.''') 206 | parser.add_argument('--config', 207 | metavar='FILE', 208 | dest='config', 209 | help='''File to read configuration options from.''') 210 | parser.add_argument('-u', '--task-uuid', 211 | dest='task_uuid', 212 | metavar='UUID', 213 | type=_decode_command_line, 214 | help='''Select a specific task by UUID.''') 215 | parser.add_argument('-i', '--ignore-task-key', 216 | action='append', 217 | default=[], 218 | dest='ignored_fields', 219 | metavar='KEY', 220 | type=_decode_command_line, 221 | help='''Ignore a task key, use multiple times to ignore 222 | multiple keys. Defaults to ignoring most Eliot standard 223 | keys.''') 224 | parser.add_argument('--raw', 225 | action='store_false', 226 | dest='human_readable', 227 | help='''Do not format some task values (such as 228 | UTC timestamps) as human-readable.''') 229 | parser.add_argument('--local-timezone', 230 | action='store_false', 231 | dest='utc_timestamps', 232 | help='''Convert timestamps to the local timezone.''') 233 | parser.add_argument('--color', 234 | default='auto', 235 | choices=['always', 'auto', 'never'], 236 | dest='color', 237 | help='''Color the output. Defaults based on whether 238 | the output is a TTY.''') 239 | parser.add_argument('--ascii', 240 | action='store_true', 241 | default=False, 242 | dest='ascii', 243 | help='''Use ASCII for tree drawing characters.''') 244 | parser.add_argument('--no-color-tree', 245 | action='store_false', 246 | default=True, 247 | dest='colorize_tree', 248 | help='''Do not color the tree lines.''') 249 | parser.add_argument('--theme', 250 | default='auto', 251 | choices=['auto', 'dark', 'light'], 252 | dest='theme_name', 253 | help='''Select a color theme to use.'''), 254 | parser.add_argument('-l', '--field-limit', 255 | metavar='LENGTH', 256 | type=int, 257 | default=100, 258 | dest='field_limit', 259 | help='''Limit the length of field values to LENGTH or a 260 | newline, whichever comes first. Use a length of 0 to 261 | output the complete value.''') 262 | parser.add_argument('--select', 263 | action='append', 264 | metavar='QUERY', 265 | dest='select', 266 | type=_decode_command_line, 267 | help='''Select tasks to be displayed based on a jmespath 268 | query, can be specified multiple times to mimic logical 269 | AND. See ''') 270 | parser.add_argument('--start', 271 | dest='start', 272 | type=iso8601.parse_date, 273 | help='''Select tasks whose timestamp occurs after (or 274 | on) an ISO8601 date.''') 275 | parser.add_argument('--end', 276 | dest='end', 277 | type=iso8601.parse_date, 278 | help='''Select tasks whose timestamp occurs before an 279 | ISO8601 date.''') 280 | parser.add_argument('--show-default-config', 281 | dest='print_default_config', 282 | action='store_true', 283 | help='''Show the default configuration.''') 284 | parser.add_argument('--show-current-config', 285 | dest='print_current_config', 286 | action='store_true', 287 | help='''Show the current effective configuration.''') 288 | args = parser.parse_args() 289 | if args.print_default_config: 290 | print_namespace(parser.parse_args([])) 291 | return 292 | 293 | config = read_config(locate_config() or args.config) 294 | parser.set_defaults(**config) 295 | args = parser.parse_args() 296 | if args.print_current_config: 297 | print_namespace(args) 298 | return 299 | 300 | stderr = text_writer(sys.stderr) 301 | try: 302 | inventory, tasks = parse_messages( 303 | files=args.files, 304 | select=args.select, 305 | task_uuid=args.task_uuid, 306 | start=args.start, 307 | end=args.end) 308 | display_tasks( 309 | tasks=tasks, 310 | color=args.color, 311 | colorize_tree=args.colorize_tree, 312 | theme_name=args.theme_name, 313 | ascii=args.ascii, 314 | ignored_fields=args.ignored_fields, 315 | field_limit=args.field_limit, 316 | human_readable=args.human_readable, 317 | utc_timestamps=args.utc_timestamps, 318 | theme_overrides=config.get('theme_overrides')) 319 | except JSONParseError as e: 320 | stderr.write(u'JSON parse error, file {}, line {}:\n{}\n\n'.format( 321 | e.file_name, 322 | e.line_number, 323 | e.line)) 324 | reraise(*e.exc_info) 325 | except EliotParseError as e: 326 | file_name, line_number = inventory.get( 327 | id(e.message_dict), (u'', u'')) 328 | stderr.write( 329 | u'Eliot message parse error, file {}, line {}:\n{}\n\n'.format( 330 | file_name, 331 | line_number, 332 | pformat(e.message_dict))) 333 | reraise(*e.exc_info) 334 | -------------------------------------------------------------------------------- /src/eliottree/_version.py: -------------------------------------------------------------------------------- 1 | 2 | # This file helps to compute a version number in source trees obtained from 3 | # git-archive tarball (such as those provided by githubs download-from-tag 4 | # feature). Distribution tarballs (built by setup.py sdist) and build 5 | # directories (produced by setup.py build) will contain a much shorter file 6 | # that just contains the computed version number. 7 | 8 | # This file is released into the public domain. 9 | # Generated by versioneer-0.29 10 | # https://github.com/python-versioneer/python-versioneer 11 | 12 | """Git implementation of _version.py.""" 13 | 14 | import errno 15 | import os 16 | import re 17 | import subprocess 18 | import sys 19 | from typing import Any, Callable, Dict, List, Optional, Tuple 20 | import functools 21 | 22 | 23 | def get_keywords() -> Dict[str, str]: 24 | """Get the keywords needed to look up the version information.""" 25 | # these strings will be replaced by git during git-archive. 26 | # setup.py/versioneer.py will grep for the variable names, so they must 27 | # each be defined on a line of their own. _version.py will just call 28 | # get_keywords(). 29 | git_refnames = " (HEAD -> master)" 30 | git_full = "3725f34cbf72ffda92144103d3895e3962ec6006" 31 | git_date = "2025-05-06 16:28:29 +0200" 32 | keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} 33 | return keywords 34 | 35 | 36 | class VersioneerConfig: 37 | """Container for Versioneer configuration parameters.""" 38 | 39 | VCS: str 40 | style: str 41 | tag_prefix: str 42 | parentdir_prefix: str 43 | versionfile_source: str 44 | verbose: bool 45 | 46 | 47 | def get_config() -> VersioneerConfig: 48 | """Create, populate and return the VersioneerConfig() object.""" 49 | # these strings are filled in when 'setup.py versioneer' creates 50 | # _version.py 51 | cfg = VersioneerConfig() 52 | cfg.VCS = "git" 53 | cfg.style = "pep440" 54 | cfg.tag_prefix = "" 55 | cfg.parentdir_prefix = "eliottree-" 56 | cfg.versionfile_source = "src/eliottree/_version.py" 57 | cfg.verbose = False 58 | return cfg 59 | 60 | 61 | class NotThisMethod(Exception): 62 | """Exception raised if a method is not valid for the current scenario.""" 63 | 64 | 65 | LONG_VERSION_PY: Dict[str, str] = {} 66 | HANDLERS: Dict[str, Dict[str, Callable]] = {} 67 | 68 | 69 | def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator 70 | """Create decorator to mark a method as the handler of a VCS.""" 71 | def decorate(f: Callable) -> Callable: 72 | """Store f in HANDLERS[vcs][method].""" 73 | if vcs not in HANDLERS: 74 | HANDLERS[vcs] = {} 75 | HANDLERS[vcs][method] = f 76 | return f 77 | return decorate 78 | 79 | 80 | def run_command( 81 | commands: List[str], 82 | args: List[str], 83 | cwd: Optional[str] = None, 84 | verbose: bool = False, 85 | hide_stderr: bool = False, 86 | env: Optional[Dict[str, str]] = None, 87 | ) -> Tuple[Optional[str], Optional[int]]: 88 | """Call the given command(s).""" 89 | assert isinstance(commands, list) 90 | process = None 91 | 92 | popen_kwargs: Dict[str, Any] = {} 93 | if sys.platform == "win32": 94 | # This hides the console window if pythonw.exe is used 95 | startupinfo = subprocess.STARTUPINFO() 96 | startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW 97 | popen_kwargs["startupinfo"] = startupinfo 98 | 99 | for command in commands: 100 | try: 101 | dispcmd = str([command] + args) 102 | # remember shell=False, so use git.cmd on windows, not just git 103 | process = subprocess.Popen([command] + args, cwd=cwd, env=env, 104 | stdout=subprocess.PIPE, 105 | stderr=(subprocess.PIPE if hide_stderr 106 | else None), **popen_kwargs) 107 | break 108 | except OSError as e: 109 | if e.errno == errno.ENOENT: 110 | continue 111 | if verbose: 112 | print("unable to run %s" % dispcmd) 113 | print(e) 114 | return None, None 115 | else: 116 | if verbose: 117 | print("unable to find command, tried %s" % (commands,)) 118 | return None, None 119 | stdout = process.communicate()[0].strip().decode() 120 | if process.returncode != 0: 121 | if verbose: 122 | print("unable to run %s (error)" % dispcmd) 123 | print("stdout was %s" % stdout) 124 | return None, process.returncode 125 | return stdout, process.returncode 126 | 127 | 128 | def versions_from_parentdir( 129 | parentdir_prefix: str, 130 | root: str, 131 | verbose: bool, 132 | ) -> Dict[str, Any]: 133 | """Try to determine the version from the parent directory name. 134 | 135 | Source tarballs conventionally unpack into a directory that includes both 136 | the project name and a version string. We will also support searching up 137 | two directory levels for an appropriately named parent directory 138 | """ 139 | rootdirs = [] 140 | 141 | for _ in range(3): 142 | dirname = os.path.basename(root) 143 | if dirname.startswith(parentdir_prefix): 144 | return {"version": dirname[len(parentdir_prefix):], 145 | "full-revisionid": None, 146 | "dirty": False, "error": None, "date": None} 147 | rootdirs.append(root) 148 | root = os.path.dirname(root) # up a level 149 | 150 | if verbose: 151 | print("Tried directories %s but none started with prefix %s" % 152 | (str(rootdirs), parentdir_prefix)) 153 | raise NotThisMethod("rootdir doesn't start with parentdir_prefix") 154 | 155 | 156 | @register_vcs_handler("git", "get_keywords") 157 | def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: 158 | """Extract version information from the given file.""" 159 | # the code embedded in _version.py can just fetch the value of these 160 | # keywords. When used from setup.py, we don't want to import _version.py, 161 | # so we do it with a regexp instead. This function is not used from 162 | # _version.py. 163 | keywords: Dict[str, str] = {} 164 | try: 165 | with open(versionfile_abs, "r") as fobj: 166 | for line in fobj: 167 | if line.strip().startswith("git_refnames ="): 168 | mo = re.search(r'=\s*"(.*)"', line) 169 | if mo: 170 | keywords["refnames"] = mo.group(1) 171 | if line.strip().startswith("git_full ="): 172 | mo = re.search(r'=\s*"(.*)"', line) 173 | if mo: 174 | keywords["full"] = mo.group(1) 175 | if line.strip().startswith("git_date ="): 176 | mo = re.search(r'=\s*"(.*)"', line) 177 | if mo: 178 | keywords["date"] = mo.group(1) 179 | except OSError: 180 | pass 181 | return keywords 182 | 183 | 184 | @register_vcs_handler("git", "keywords") 185 | def git_versions_from_keywords( 186 | keywords: Dict[str, str], 187 | tag_prefix: str, 188 | verbose: bool, 189 | ) -> Dict[str, Any]: 190 | """Get version information from git keywords.""" 191 | if "refnames" not in keywords: 192 | raise NotThisMethod("Short version file found") 193 | date = keywords.get("date") 194 | if date is not None: 195 | # Use only the last line. Previous lines may contain GPG signature 196 | # information. 197 | date = date.splitlines()[-1] 198 | 199 | # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant 200 | # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 201 | # -like" string, which we must then edit to make compliant), because 202 | # it's been around since git-1.5.3, and it's too difficult to 203 | # discover which version we're using, or to work around using an 204 | # older one. 205 | date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 206 | refnames = keywords["refnames"].strip() 207 | if refnames.startswith("$Format"): 208 | if verbose: 209 | print("keywords are unexpanded, not using") 210 | raise NotThisMethod("unexpanded keywords, not a git-archive tarball") 211 | refs = {r.strip() for r in refnames.strip("()").split(",")} 212 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 213 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 214 | TAG = "tag: " 215 | tags = {r[len(TAG):] for r in refs if r.startswith(TAG)} 216 | if not tags: 217 | # Either we're using git < 1.8.3, or there really are no tags. We use 218 | # a heuristic: assume all version tags have a digit. The old git %d 219 | # expansion behaves like git log --decorate=short and strips out the 220 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 221 | # between branches and tags. By ignoring refnames without digits, we 222 | # filter out many common branch names like "release" and 223 | # "stabilization", as well as "HEAD" and "master". 224 | tags = {r for r in refs if re.search(r'\d', r)} 225 | if verbose: 226 | print("discarding '%s', no digits" % ",".join(refs - tags)) 227 | if verbose: 228 | print("likely tags: %s" % ",".join(sorted(tags))) 229 | for ref in sorted(tags): 230 | # sorting will prefer e.g. "2.0" over "2.0rc1" 231 | if ref.startswith(tag_prefix): 232 | r = ref[len(tag_prefix):] 233 | # Filter out refs that exactly match prefix or that don't start 234 | # with a number once the prefix is stripped (mostly a concern 235 | # when prefix is '') 236 | if not re.match(r'\d', r): 237 | continue 238 | if verbose: 239 | print("picking %s" % r) 240 | return {"version": r, 241 | "full-revisionid": keywords["full"].strip(), 242 | "dirty": False, "error": None, 243 | "date": date} 244 | # no suitable tags, so version is "0+unknown", but full hex is still there 245 | if verbose: 246 | print("no suitable tags, using unknown + full revision id") 247 | return {"version": "0+unknown", 248 | "full-revisionid": keywords["full"].strip(), 249 | "dirty": False, "error": "no suitable tags", "date": None} 250 | 251 | 252 | @register_vcs_handler("git", "pieces_from_vcs") 253 | def git_pieces_from_vcs( 254 | tag_prefix: str, 255 | root: str, 256 | verbose: bool, 257 | runner: Callable = run_command 258 | ) -> Dict[str, Any]: 259 | """Get version from 'git describe' in the root of the source tree. 260 | 261 | This only gets called if the git-archive 'subst' keywords were *not* 262 | expanded, and _version.py hasn't already been rewritten with a short 263 | version string, meaning we're inside a checked out source tree. 264 | """ 265 | GITS = ["git"] 266 | if sys.platform == "win32": 267 | GITS = ["git.cmd", "git.exe"] 268 | 269 | # GIT_DIR can interfere with correct operation of Versioneer. 270 | # It may be intended to be passed to the Versioneer-versioned project, 271 | # but that should not change where we get our version from. 272 | env = os.environ.copy() 273 | env.pop("GIT_DIR", None) 274 | runner = functools.partial(runner, env=env) 275 | 276 | _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, 277 | hide_stderr=not verbose) 278 | if rc != 0: 279 | if verbose: 280 | print("Directory %s not under git control" % root) 281 | raise NotThisMethod("'git rev-parse --git-dir' returned error") 282 | 283 | # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] 284 | # if there isn't one, this yields HEX[-dirty] (no NUM) 285 | describe_out, rc = runner(GITS, [ 286 | "describe", "--tags", "--dirty", "--always", "--long", 287 | "--match", f"{tag_prefix}[[:digit:]]*" 288 | ], cwd=root) 289 | # --long was added in git-1.5.5 290 | if describe_out is None: 291 | raise NotThisMethod("'git describe' failed") 292 | describe_out = describe_out.strip() 293 | full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root) 294 | if full_out is None: 295 | raise NotThisMethod("'git rev-parse' failed") 296 | full_out = full_out.strip() 297 | 298 | pieces: Dict[str, Any] = {} 299 | pieces["long"] = full_out 300 | pieces["short"] = full_out[:7] # maybe improved later 301 | pieces["error"] = None 302 | 303 | branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], 304 | cwd=root) 305 | # --abbrev-ref was added in git-1.6.3 306 | if rc != 0 or branch_name is None: 307 | raise NotThisMethod("'git rev-parse --abbrev-ref' returned error") 308 | branch_name = branch_name.strip() 309 | 310 | if branch_name == "HEAD": 311 | # If we aren't exactly on a branch, pick a branch which represents 312 | # the current commit. If all else fails, we are on a branchless 313 | # commit. 314 | branches, rc = runner(GITS, ["branch", "--contains"], cwd=root) 315 | # --contains was added in git-1.5.4 316 | if rc != 0 or branches is None: 317 | raise NotThisMethod("'git branch --contains' returned error") 318 | branches = branches.split("\n") 319 | 320 | # Remove the first line if we're running detached 321 | if "(" in branches[0]: 322 | branches.pop(0) 323 | 324 | # Strip off the leading "* " from the list of branches. 325 | branches = [branch[2:] for branch in branches] 326 | if "master" in branches: 327 | branch_name = "master" 328 | elif not branches: 329 | branch_name = None 330 | else: 331 | # Pick the first branch that is returned. Good or bad. 332 | branch_name = branches[0] 333 | 334 | pieces["branch"] = branch_name 335 | 336 | # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] 337 | # TAG might have hyphens. 338 | git_describe = describe_out 339 | 340 | # look for -dirty suffix 341 | dirty = git_describe.endswith("-dirty") 342 | pieces["dirty"] = dirty 343 | if dirty: 344 | git_describe = git_describe[:git_describe.rindex("-dirty")] 345 | 346 | # now we have TAG-NUM-gHEX or HEX 347 | 348 | if "-" in git_describe: 349 | # TAG-NUM-gHEX 350 | mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) 351 | if not mo: 352 | # unparsable. Maybe git-describe is misbehaving? 353 | pieces["error"] = ("unable to parse git-describe output: '%s'" 354 | % describe_out) 355 | return pieces 356 | 357 | # tag 358 | full_tag = mo.group(1) 359 | if not full_tag.startswith(tag_prefix): 360 | if verbose: 361 | fmt = "tag '%s' doesn't start with prefix '%s'" 362 | print(fmt % (full_tag, tag_prefix)) 363 | pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" 364 | % (full_tag, tag_prefix)) 365 | return pieces 366 | pieces["closest-tag"] = full_tag[len(tag_prefix):] 367 | 368 | # distance: number of commits since tag 369 | pieces["distance"] = int(mo.group(2)) 370 | 371 | # commit: short hex revision ID 372 | pieces["short"] = mo.group(3) 373 | 374 | else: 375 | # HEX: no tags 376 | pieces["closest-tag"] = None 377 | out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root) 378 | pieces["distance"] = len(out.split()) # total number of commits 379 | 380 | # commit date: see ISO-8601 comment in git_versions_from_keywords() 381 | date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() 382 | # Use only the last line. Previous lines may contain GPG signature 383 | # information. 384 | date = date.splitlines()[-1] 385 | pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 386 | 387 | return pieces 388 | 389 | 390 | def plus_or_dot(pieces: Dict[str, Any]) -> str: 391 | """Return a + if we don't already have one, else return a .""" 392 | if "+" in pieces.get("closest-tag", ""): 393 | return "." 394 | return "+" 395 | 396 | 397 | def render_pep440(pieces: Dict[str, Any]) -> str: 398 | """Build up version string, with post-release "local version identifier". 399 | 400 | Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you 401 | get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty 402 | 403 | Exceptions: 404 | 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] 405 | """ 406 | if pieces["closest-tag"]: 407 | rendered = pieces["closest-tag"] 408 | if pieces["distance"] or pieces["dirty"]: 409 | rendered += plus_or_dot(pieces) 410 | rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) 411 | if pieces["dirty"]: 412 | rendered += ".dirty" 413 | else: 414 | # exception #1 415 | rendered = "0+untagged.%d.g%s" % (pieces["distance"], 416 | pieces["short"]) 417 | if pieces["dirty"]: 418 | rendered += ".dirty" 419 | return rendered 420 | 421 | 422 | def render_pep440_branch(pieces: Dict[str, Any]) -> str: 423 | """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . 424 | 425 | The ".dev0" means not master branch. Note that .dev0 sorts backwards 426 | (a feature branch will appear "older" than the master branch). 427 | 428 | Exceptions: 429 | 1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty] 430 | """ 431 | if pieces["closest-tag"]: 432 | rendered = pieces["closest-tag"] 433 | if pieces["distance"] or pieces["dirty"]: 434 | if pieces["branch"] != "master": 435 | rendered += ".dev0" 436 | rendered += plus_or_dot(pieces) 437 | rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) 438 | if pieces["dirty"]: 439 | rendered += ".dirty" 440 | else: 441 | # exception #1 442 | rendered = "0" 443 | if pieces["branch"] != "master": 444 | rendered += ".dev0" 445 | rendered += "+untagged.%d.g%s" % (pieces["distance"], 446 | pieces["short"]) 447 | if pieces["dirty"]: 448 | rendered += ".dirty" 449 | return rendered 450 | 451 | 452 | def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: 453 | """Split pep440 version string at the post-release segment. 454 | 455 | Returns the release segments before the post-release and the 456 | post-release version number (or -1 if no post-release segment is present). 457 | """ 458 | vc = str.split(ver, ".post") 459 | return vc[0], int(vc[1] or 0) if len(vc) == 2 else None 460 | 461 | 462 | def render_pep440_pre(pieces: Dict[str, Any]) -> str: 463 | """TAG[.postN.devDISTANCE] -- No -dirty. 464 | 465 | Exceptions: 466 | 1: no tags. 0.post0.devDISTANCE 467 | """ 468 | if pieces["closest-tag"]: 469 | if pieces["distance"]: 470 | # update the post release segment 471 | tag_version, post_version = pep440_split_post(pieces["closest-tag"]) 472 | rendered = tag_version 473 | if post_version is not None: 474 | rendered += ".post%d.dev%d" % (post_version + 1, pieces["distance"]) 475 | else: 476 | rendered += ".post0.dev%d" % (pieces["distance"]) 477 | else: 478 | # no commits, use the tag as the version 479 | rendered = pieces["closest-tag"] 480 | else: 481 | # exception #1 482 | rendered = "0.post0.dev%d" % pieces["distance"] 483 | return rendered 484 | 485 | 486 | def render_pep440_post(pieces: Dict[str, Any]) -> str: 487 | """TAG[.postDISTANCE[.dev0]+gHEX] . 488 | 489 | The ".dev0" means dirty. Note that .dev0 sorts backwards 490 | (a dirty tree will appear "older" than the corresponding clean one), 491 | but you shouldn't be releasing software with -dirty anyways. 492 | 493 | Exceptions: 494 | 1: no tags. 0.postDISTANCE[.dev0] 495 | """ 496 | if pieces["closest-tag"]: 497 | rendered = pieces["closest-tag"] 498 | if pieces["distance"] or pieces["dirty"]: 499 | rendered += ".post%d" % pieces["distance"] 500 | if pieces["dirty"]: 501 | rendered += ".dev0" 502 | rendered += plus_or_dot(pieces) 503 | rendered += "g%s" % pieces["short"] 504 | else: 505 | # exception #1 506 | rendered = "0.post%d" % pieces["distance"] 507 | if pieces["dirty"]: 508 | rendered += ".dev0" 509 | rendered += "+g%s" % pieces["short"] 510 | return rendered 511 | 512 | 513 | def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: 514 | """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . 515 | 516 | The ".dev0" means not master branch. 517 | 518 | Exceptions: 519 | 1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty] 520 | """ 521 | if pieces["closest-tag"]: 522 | rendered = pieces["closest-tag"] 523 | if pieces["distance"] or pieces["dirty"]: 524 | rendered += ".post%d" % pieces["distance"] 525 | if pieces["branch"] != "master": 526 | rendered += ".dev0" 527 | rendered += plus_or_dot(pieces) 528 | rendered += "g%s" % pieces["short"] 529 | if pieces["dirty"]: 530 | rendered += ".dirty" 531 | else: 532 | # exception #1 533 | rendered = "0.post%d" % pieces["distance"] 534 | if pieces["branch"] != "master": 535 | rendered += ".dev0" 536 | rendered += "+g%s" % pieces["short"] 537 | if pieces["dirty"]: 538 | rendered += ".dirty" 539 | return rendered 540 | 541 | 542 | def render_pep440_old(pieces: Dict[str, Any]) -> str: 543 | """TAG[.postDISTANCE[.dev0]] . 544 | 545 | The ".dev0" means dirty. 546 | 547 | Exceptions: 548 | 1: no tags. 0.postDISTANCE[.dev0] 549 | """ 550 | if pieces["closest-tag"]: 551 | rendered = pieces["closest-tag"] 552 | if pieces["distance"] or pieces["dirty"]: 553 | rendered += ".post%d" % pieces["distance"] 554 | if pieces["dirty"]: 555 | rendered += ".dev0" 556 | else: 557 | # exception #1 558 | rendered = "0.post%d" % pieces["distance"] 559 | if pieces["dirty"]: 560 | rendered += ".dev0" 561 | return rendered 562 | 563 | 564 | def render_git_describe(pieces: Dict[str, Any]) -> str: 565 | """TAG[-DISTANCE-gHEX][-dirty]. 566 | 567 | Like 'git describe --tags --dirty --always'. 568 | 569 | Exceptions: 570 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 571 | """ 572 | if pieces["closest-tag"]: 573 | rendered = pieces["closest-tag"] 574 | if pieces["distance"]: 575 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 576 | else: 577 | # exception #1 578 | rendered = pieces["short"] 579 | if pieces["dirty"]: 580 | rendered += "-dirty" 581 | return rendered 582 | 583 | 584 | def render_git_describe_long(pieces: Dict[str, Any]) -> str: 585 | """TAG-DISTANCE-gHEX[-dirty]. 586 | 587 | Like 'git describe --tags --dirty --always -long'. 588 | The distance/hash is unconditional. 589 | 590 | Exceptions: 591 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 592 | """ 593 | if pieces["closest-tag"]: 594 | rendered = pieces["closest-tag"] 595 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 596 | else: 597 | # exception #1 598 | rendered = pieces["short"] 599 | if pieces["dirty"]: 600 | rendered += "-dirty" 601 | return rendered 602 | 603 | 604 | def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: 605 | """Render the given version pieces into the requested style.""" 606 | if pieces["error"]: 607 | return {"version": "unknown", 608 | "full-revisionid": pieces.get("long"), 609 | "dirty": None, 610 | "error": pieces["error"], 611 | "date": None} 612 | 613 | if not style or style == "default": 614 | style = "pep440" # the default 615 | 616 | if style == "pep440": 617 | rendered = render_pep440(pieces) 618 | elif style == "pep440-branch": 619 | rendered = render_pep440_branch(pieces) 620 | elif style == "pep440-pre": 621 | rendered = render_pep440_pre(pieces) 622 | elif style == "pep440-post": 623 | rendered = render_pep440_post(pieces) 624 | elif style == "pep440-post-branch": 625 | rendered = render_pep440_post_branch(pieces) 626 | elif style == "pep440-old": 627 | rendered = render_pep440_old(pieces) 628 | elif style == "git-describe": 629 | rendered = render_git_describe(pieces) 630 | elif style == "git-describe-long": 631 | rendered = render_git_describe_long(pieces) 632 | else: 633 | raise ValueError("unknown style '%s'" % style) 634 | 635 | return {"version": rendered, "full-revisionid": pieces["long"], 636 | "dirty": pieces["dirty"], "error": None, 637 | "date": pieces.get("date")} 638 | 639 | 640 | def get_versions() -> Dict[str, Any]: 641 | """Get version information or return default if unable to do so.""" 642 | # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have 643 | # __file__, we can work backwards from there to the root. Some 644 | # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which 645 | # case we can only use expanded keywords. 646 | 647 | cfg = get_config() 648 | verbose = cfg.verbose 649 | 650 | try: 651 | return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, 652 | verbose) 653 | except NotThisMethod: 654 | pass 655 | 656 | try: 657 | root = os.path.realpath(__file__) 658 | # versionfile_source is the relative path from the top of the source 659 | # tree (where the .git directory might live) to this file. Invert 660 | # this to find the root from __file__. 661 | for _ in cfg.versionfile_source.split('/'): 662 | root = os.path.dirname(root) 663 | except NameError: 664 | return {"version": "0+unknown", "full-revisionid": None, 665 | "dirty": None, 666 | "error": "unable to find root of source tree", 667 | "date": None} 668 | 669 | try: 670 | pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) 671 | return render(pieces, cfg.style) 672 | except NotThisMethod: 673 | pass 674 | 675 | try: 676 | if cfg.parentdir_prefix: 677 | return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) 678 | except NotThisMethod: 679 | pass 680 | 681 | return {"version": "0+unknown", "full-revisionid": None, 682 | "dirty": None, 683 | "error": "unable to compute version", "date": None} 684 | -------------------------------------------------------------------------------- /src/eliottree/test/test_render.py: -------------------------------------------------------------------------------- 1 | import time 2 | from eliot.parse import WrittenMessage 3 | from six import StringIO, text_type 4 | from testtools import ExpectedException, TestCase 5 | from testtools.matchers import AfterPreprocessing as After 6 | from testtools.matchers import ( 7 | Contains, Equals, HasLength, MatchesAll, MatchesListwise, StartsWith) 8 | 9 | from eliottree import ( 10 | render_tasks, tasks_from_iterable) 11 | from eliottree._color import colored 12 | from eliottree._render import ( 13 | _default_value_formatter, format_node, 14 | get_children, message_fields, message_name) 15 | from eliottree.tree_format._text import ( 16 | HOURGLASS, RIGHT_DOUBLE_ARROW, Options) 17 | from eliottree._theme import get_theme 18 | from eliottree._util import eliot_ns 19 | from eliottree.test.matchers import ExactlyEquals 20 | from eliottree.test.tasks import ( 21 | action_task, action_task_end, action_task_end_failed, dict_action_task, 22 | janky_action_task, janky_message_task, list_action_task, message_task, 23 | multiline_action_task, nested_action_task) 24 | 25 | 26 | class DefaultValueFormatterTests(TestCase): 27 | """ 28 | Tests for ``eliottree.render._default_value_formatter``. 29 | """ 30 | def test_unicode(self): 31 | """ 32 | Pass ``unicode`` values straight through. 33 | """ 34 | format_value = _default_value_formatter( 35 | human_readable=False, field_limit=0) 36 | self.assertThat( 37 | format_value(u'\N{SNOWMAN}'), 38 | ExactlyEquals(u'\N{SNOWMAN}')) 39 | 40 | def test_unicode_control_characters(self): 41 | """ 42 | Translate control characters to their Unicode "control picture" 43 | equivalent, instead of destroying a terminal. 44 | """ 45 | format_value = _default_value_formatter( 46 | human_readable=False, field_limit=0) 47 | self.assertThat( 48 | format_value(u'hello\001world'), 49 | ExactlyEquals(u'hello\u2401world')) 50 | 51 | def test_bytes(self): 52 | """ 53 | Decode ``bytes`` values as the specified encoding. 54 | """ 55 | format_value = _default_value_formatter( 56 | human_readable=False, field_limit=0, encoding='utf-8') 57 | self.assertThat( 58 | format_value(b'foo'), 59 | ExactlyEquals(u'foo')) 60 | self.assertThat( 61 | format_value(b'\xe2\x98\x83'), 62 | ExactlyEquals(u'\N{SNOWMAN}')) 63 | 64 | def test_anything(self): 65 | """ 66 | Pass unknown values to ``repr``. 67 | """ 68 | class _Thing(object): 69 | def __repr__(self): 70 | return 'Hello' 71 | format_value = _default_value_formatter( 72 | human_readable=False, field_limit=0) 73 | self.assertThat( 74 | format_value(_Thing()), 75 | ExactlyEquals(u'Hello')) 76 | 77 | def test_timestamp_field(self): 78 | """ 79 | Format Eliot ``timestamp`` fields as human-readable if the feature was 80 | requested. 81 | """ 82 | format_value = _default_value_formatter( 83 | human_readable=True, field_limit=0) 84 | # datetime(2015, 6, 6, 22, 57, 12) 85 | now = 1433631432 86 | self.assertThat( 87 | format_value(now, eliot_ns(u'timestamp')), 88 | ExactlyEquals(u'2015-06-06 22:57:12Z')) 89 | self.assertThat( 90 | format_value(str(now), eliot_ns(u'timestamp')), 91 | ExactlyEquals(u'2015-06-06 22:57:12Z')) 92 | 93 | def test_timestamp_field_local(self): 94 | """ 95 | Format Eliot ``timestamp`` fields as human-readable local time if the 96 | feature was requested. 97 | """ 98 | format_value = _default_value_formatter( 99 | human_readable=True, field_limit=0, utc_timestamps=False) 100 | # datetime(2015, 6, 6, 22, 57, 12) 101 | now = 1433631432 + time.timezone 102 | self.assertThat( 103 | format_value(now, eliot_ns(u'timestamp')), 104 | ExactlyEquals(u'2015-06-06 22:57:12')) 105 | self.assertThat( 106 | format_value(str(now), eliot_ns(u'timestamp')), 107 | ExactlyEquals(u'2015-06-06 22:57:12')) 108 | 109 | def test_not_eliot_timestamp_field(self): 110 | """ 111 | Do not format user fields named ``timestamp``. 112 | """ 113 | format_value = _default_value_formatter( 114 | human_readable=True, field_limit=0) 115 | now = 1433631432 116 | self.assertThat( 117 | format_value(now, u'timestamp'), 118 | ExactlyEquals(text_type(now))) 119 | self.assertThat( 120 | format_value(text_type(now), u'timestamp'), 121 | ExactlyEquals(text_type(now))) 122 | 123 | def test_timestamp_field_not_human(self): 124 | """ 125 | Do not format ``timestamp`` fields as human-readable if the feature was 126 | not requested. 127 | """ 128 | format_value = _default_value_formatter( 129 | human_readable=False, field_limit=0) 130 | # datetime(2015, 6, 6, 22, 57, 12) 131 | now = 1433631432 132 | self.assertThat( 133 | format_value(now, u'timestamp'), 134 | ExactlyEquals(text_type(now))) 135 | 136 | 137 | colors = get_theme(dark_background=True, colored=colored) 138 | no_colors = get_theme(dark_background=True, colored=None) 139 | 140 | 141 | def no_formatting(value, field_name=None): 142 | return text_type(value) 143 | 144 | 145 | class MessageNameTests(TestCase): 146 | """ 147 | Tests for `eliottree.render.message_name`. 148 | """ 149 | def test_action_type(self): 150 | """ 151 | Action types include their type name. 152 | """ 153 | message = next(tasks_from_iterable([action_task])).root().start_message 154 | self.assertThat( 155 | message_name(colors, no_formatting, message, options=Options()), 156 | StartsWith(colors.parent(message.contents.action_type))) 157 | 158 | def test_action_task_level(self): 159 | """ 160 | Action types include their task level. 161 | """ 162 | message = next(tasks_from_iterable([action_task])).root().start_message 163 | self.assertThat( 164 | message_name(colors, no_formatting, message, options=Options()), 165 | Contains(message.task_level.to_string())) 166 | 167 | def test_action_status(self): 168 | """ 169 | Action types include their status. 170 | """ 171 | message = next(tasks_from_iterable([action_task])).root().start_message 172 | self.assertThat( 173 | message_name(colors, no_formatting, message, options=Options()), 174 | Contains(u'started')) 175 | 176 | def test_action_status_success(self): 177 | """ 178 | Successful actions color their status. 179 | """ 180 | message = next(tasks_from_iterable([ 181 | action_task, action_task_end, 182 | ])).root().end_message 183 | self.assertThat( 184 | message_name(colors, no_formatting, message, options=Options()), 185 | Contains(colors.status_success(u'succeeded'))) 186 | 187 | def test_action_status_failed(self): 188 | """ 189 | Failed actions color their status. 190 | """ 191 | message = next(tasks_from_iterable([ 192 | action_task, action_task_end_failed, 193 | ])).root().end_message 194 | self.assertThat( 195 | message_name(colors, no_formatting, message, options=Options()), 196 | Contains(colors.status_failure(u'failed'))) 197 | 198 | def test_message_type(self): 199 | """ 200 | Message types include their type name. 201 | """ 202 | message = WrittenMessage.from_dict(message_task) 203 | self.assertThat( 204 | message_name(colors, no_formatting, message, options=Options()), 205 | StartsWith(colors.parent(message.contents.message_type))) 206 | 207 | def test_message_task_level(self): 208 | """ 209 | Message types include their task level. 210 | """ 211 | message = WrittenMessage.from_dict(message_task) 212 | self.assertThat( 213 | message_name(colors, no_formatting, message, options=Options()), 214 | Contains(message.task_level.to_string())) 215 | 216 | def test_unknown(self): 217 | """ 218 | None or messages with no identifiable information are rendered as 219 | ````. 220 | """ 221 | self.assertThat( 222 | message_name(colors, no_formatting, None, options=Options()), 223 | ExactlyEquals(u'')) 224 | message = WrittenMessage.from_dict({u'timestamp': 0}) 225 | self.assertThat( 226 | message_name(colors, no_formatting, message, options=Options()), 227 | ExactlyEquals(u'')) 228 | 229 | 230 | class FormatNodeTests(TestCase): 231 | """ 232 | Tests for `eliottree.render.format_node`. 233 | """ 234 | def format_node(self, node, format_value=None, colors=no_colors): 235 | if format_value is None: 236 | def format_value(value, field_name=None): 237 | return value 238 | return format_node(format_value, colors, Options(), node) 239 | 240 | def test_task(self): 241 | """ 242 | `Task`'s UUID is rendered. 243 | """ 244 | node = next(tasks_from_iterable([action_task])) 245 | self.assertThat( 246 | self.format_node(node), 247 | ExactlyEquals(node.root().task_uuid)) 248 | 249 | def test_written_action(self): 250 | """ 251 | `WrittenAction`'s start message is rendered. 252 | """ 253 | tasks = tasks_from_iterable([action_task, action_task_end]) 254 | node = next(tasks).root() 255 | self.assertThat( 256 | self.format_node(node), 257 | ExactlyEquals(u'{}{} {} {} {} \u29d6 {}'.format( 258 | node.start_message.contents.action_type, 259 | node.start_message.task_level.to_string(), 260 | RIGHT_DOUBLE_ARROW, 261 | node.start_message.contents.action_status, 262 | node.start_message.timestamp, 263 | node.end_message.timestamp - node.start_message.timestamp))) 264 | 265 | def test_tuple_list(self): 266 | """ 267 | Tuples can be a key and list, in which case the value is not rendered 268 | here. 269 | """ 270 | node = (u'a\nb\x1bc', [u'x\n', u'y\x1b', u'z']) 271 | self.assertThat( 272 | self.format_node(node, colors=colors), 273 | ExactlyEquals(u'{}: {}'.format( 274 | colors.prop_key(u'a\u240ab\u241bc'), 275 | colors.prop_value(u'')))) 276 | 277 | def test_tuple_dict(self): 278 | """ 279 | Tuples can be a key and dict, in which case the value is not rendered 280 | here. 281 | """ 282 | node = (u'a\nb\x1bc', {u'x\n': u'y\x1b', u'z': u'zz'}) 283 | self.assertThat( 284 | self.format_node(node, colors=colors), 285 | ExactlyEquals(u'{}: {}'.format( 286 | colors.prop_key(u'a\u240ab\u241bc'), 287 | colors.prop_value(u'')))) 288 | 289 | def test_tuple_other(self): 290 | """ 291 | Tuples can be a key and string, number, etc. rendered inline. 292 | """ 293 | node = (u'a\nb\x1bc', u'hello') 294 | self.assertThat( 295 | self.format_node(node, colors=colors), 296 | ExactlyEquals(u'{}: {}'.format( 297 | colors.prop_key(u'a\u240ab\u241bc'), 298 | colors.prop_value(u'hello')))) 299 | 300 | node = (u'a\nb\x1bc', 42) 301 | self.assertThat( 302 | self.format_node(node, colors=colors), 303 | ExactlyEquals(u'{}: {}'.format( 304 | colors.prop_key(u'a\u240ab\u241bc'), 305 | colors.prop_value(u'42')))) 306 | 307 | def test_other(self): 308 | """ 309 | Other types raise `NotImplementedError`. 310 | """ 311 | node = object() 312 | with ExpectedException(NotImplementedError): 313 | self.format_node(node, colors=colors) 314 | 315 | 316 | class MessageFieldsTests(TestCase): 317 | """ 318 | Tests for `eliottree.render.message_fields`. 319 | """ 320 | def test_empty(self): 321 | """ 322 | ``None`` or empty messages result in no fields. 323 | """ 324 | self.assertThat( 325 | message_fields(None, set()), 326 | Equals([])) 327 | message = WrittenMessage.from_dict({}) 328 | self.assertThat( 329 | message_fields(message, set()), 330 | Equals([])) 331 | 332 | def test_fields(self): 333 | """ 334 | Include all the message fields but not the timestamp. 335 | """ 336 | message = WrittenMessage.from_dict({u'a': 1}) 337 | self.assertThat( 338 | message_fields(message, set()), 339 | Equals([(u'a', 1)])) 340 | message = WrittenMessage.from_dict({u'a': 1, u'timestamp': 12345678}) 341 | self.assertThat( 342 | message_fields(message, set()), 343 | Equals([ 344 | (u'a', 1)])) 345 | 346 | def test_ignored_fields(self): 347 | """ 348 | Ignore any specified fields. 349 | """ 350 | message = WrittenMessage.from_dict({u'a': 1, u'b': 2}) 351 | self.assertThat( 352 | message_fields(message, {u'b'}), 353 | Equals([(u'a', 1)])) 354 | 355 | 356 | class GetChildrenTests(TestCase): 357 | """ 358 | Tests for `eliottree.render.get_children`. 359 | """ 360 | def test_task_action(self): 361 | """ 362 | The root message is the only child of a `Task`. 363 | """ 364 | node = next(tasks_from_iterable([action_task])) 365 | self.assertThat( 366 | get_children(set(), node), 367 | Equals([node.root()])) 368 | node = next(tasks_from_iterable([message_task])) 369 | self.assertThat( 370 | get_children(set(), node), 371 | Equals([node.root()])) 372 | 373 | def test_written_action_ignored_fields(self): 374 | """ 375 | Action fields can be ignored. 376 | """ 377 | task = dict(action_task) 378 | task.update({u'c': u'3'}) 379 | node = next(tasks_from_iterable([task])).root() 380 | start_message = node.start_message 381 | self.assertThat( 382 | list(get_children({u'c'}, node)), 383 | Equals([ 384 | (u'action_status', start_message.contents.action_status), 385 | (u'action_type', start_message.contents.action_type)])) 386 | 387 | def test_written_action_start(self): 388 | """ 389 | The children of a `WrittenAction` begin with the fields of the start 390 | message. 391 | """ 392 | node = next(tasks_from_iterable([ 393 | action_task, nested_action_task, action_task_end])).root() 394 | start_message = node.start_message 395 | self.assertThat( 396 | list(get_children({u'foo'}, node))[:2], 397 | Equals([ 398 | (u'action_status', start_message.contents.action_status), 399 | (u'action_type', start_message.contents.action_type)])) 400 | 401 | def test_written_action_children(self): 402 | """ 403 | The children of a `WrittenAction` contain child actions/messages. 404 | """ 405 | node = next(tasks_from_iterable([ 406 | action_task, nested_action_task, action_task_end])).root() 407 | self.assertThat( 408 | list(get_children({u'foo'}, node))[2], 409 | Equals(node.children[0])) 410 | 411 | def test_written_action_no_children(self): 412 | """ 413 | The children of a `WrittenAction` does not contain any child 414 | actions/messages if there aren't any. 415 | """ 416 | node = next(tasks_from_iterable([action_task])).root() 417 | self.assertThat( 418 | list(get_children({u'foo'}, node)), 419 | HasLength(2)) 420 | 421 | def test_written_action_no_end(self): 422 | """ 423 | If the `WrittenAction` has no end message, it is excluded. 424 | """ 425 | node = next(tasks_from_iterable([ 426 | action_task, nested_action_task])).root() 427 | self.assertThat( 428 | list(get_children({u'foo'}, node))[4:], 429 | Equals([])) 430 | 431 | def test_written_action_end(self): 432 | """ 433 | The last child of a `WrittenAction` is the end message. 434 | """ 435 | node = next(tasks_from_iterable([ 436 | action_task, nested_action_task, action_task_end])).root() 437 | self.assertThat( 438 | list(get_children({u'foo'}, node))[3:], 439 | Equals([node.end_message])) 440 | 441 | def test_written_message(self): 442 | """ 443 | The fields of `WrittenMessage`\\s are their children. Fields can also be 444 | ignored. 445 | """ 446 | node = WrittenMessage.from_dict({u'a': 1, u'b': 2, u'c': 3}) 447 | self.assertThat( 448 | get_children({u'c'}, node), 449 | Equals([ 450 | (u'a', 1), 451 | (u'b', 2)])) 452 | 453 | def test_tuple_dict(self): 454 | """ 455 | The key/value pairs of dicts are their children. 456 | """ 457 | node = (u'key', {u'a': 1, u'b': 2, u'c': 3}) 458 | self.assertThat( 459 | # The ignores intentionally do nothing. 460 | get_children({u'c'}, node), 461 | Equals([ 462 | (u'a', 1), 463 | (u'b', 2), 464 | (u'c', 3)])) 465 | 466 | def test_tuple_list(self): 467 | """ 468 | The indexed items of lists are their children. 469 | """ 470 | node = (u'key', [u'a', u'b', u'c']) 471 | self.assertThat( 472 | # The ignores intentionally do nothing. 473 | get_children({2, u'c'}, node), 474 | After(list, 475 | Equals([ 476 | (0, u'a'), 477 | (1, u'b'), 478 | (2, u'c')]))) 479 | 480 | def test_other(self): 481 | """ 482 | Other values are considered to have no children. 483 | """ 484 | self.assertThat(get_children({}, None), Equals([])) 485 | self.assertThat(get_children({}, 42), Equals([])) 486 | self.assertThat(get_children({}, u'hello'), Equals([])) 487 | 488 | 489 | class RenderTasksTests(TestCase): 490 | """ 491 | Tests for `eliottree.render.render_tasks`. 492 | """ 493 | def render_tasks(self, iterable, **kw): 494 | fd = StringIO() 495 | err = StringIO(u'') 496 | tasks = tasks_from_iterable(iterable) 497 | render_tasks(write=fd.write, write_err=err.write, tasks=tasks, **kw) 498 | if err.tell(): 499 | return fd.getvalue(), err.getvalue() 500 | return fd.getvalue() 501 | 502 | def test_format_node_failures(self): 503 | """ 504 | Catch exceptions when formatting nodes and display a message without 505 | interrupting the processing of tasks. List all caught exceptions to 506 | stderr. 507 | """ 508 | def bad_format_node(*a, **kw): 509 | raise ValueError('Nope') 510 | self.assertThat( 511 | self.render_tasks([message_task], 512 | format_node=bad_format_node), 513 | MatchesListwise([ 514 | Contains(u''), 515 | MatchesAll( 516 | Contains(u'Traceback (most recent call last):'), 517 | Contains(u'ValueError: Nope'))])) 518 | 519 | def test_format_value_failures(self): 520 | """ 521 | Catch exceptions when formatting node values and display a message 522 | without interrupting the processing of tasks. List all caught 523 | exceptions to stderr. 524 | """ 525 | def bad_format_value(*a, **kw): 526 | raise ValueError('Nope') 527 | self.assertThat( 528 | self.render_tasks([message_task], 529 | format_value=bad_format_value), 530 | MatchesListwise([ 531 | Contains(u'message: '), 532 | MatchesAll( 533 | Contains(u'Traceback (most recent call last):'), 534 | Contains(u'ValueError: Nope'))])) 535 | 536 | def test_tasks(self): 537 | """ 538 | Render two tasks of sequential levels, by default most standard Eliot 539 | task keys are ignored. 540 | """ 541 | self.assertThat( 542 | self.render_tasks([action_task, action_task_end]), 543 | ExactlyEquals( 544 | u'f3a32bb3-ea6b-457c-aa99-08a3d0491ab4\n' 545 | u'\u2514\u2500\u2500 app:action/1 \u21d2 started ' 546 | u'1425356800 \u29d6 2\n' 547 | u' \u2514\u2500\u2500 app:action/2 \u21d2 succeeded ' 548 | u'1425356802\n\n')) 549 | 550 | def test_tasks_human_readable(self): 551 | """ 552 | Render two tasks of sequential levels, by default most standard Eliot 553 | task keys are ignored, values are formatted to be human readable. 554 | """ 555 | self.assertThat( 556 | self.render_tasks([action_task, action_task_end], 557 | human_readable=True), 558 | ExactlyEquals( 559 | u'f3a32bb3-ea6b-457c-aa99-08a3d0491ab4\n' 560 | u'\u2514\u2500\u2500 app:action/1 \u21d2 started ' 561 | u'2015-03-03 04:26:40Z \u29d6 2.000s\n' 562 | u' \u2514\u2500\u2500 app:action/2 \u21d2 succeeded ' 563 | u'2015-03-03 04:26:42Z\n' 564 | u'\n')) 565 | 566 | def test_multiline_field(self): 567 | """ 568 | When no field limit is specified for task values, multiple lines are 569 | output for multiline tasks. 570 | """ 571 | fd = StringIO() 572 | tasks = tasks_from_iterable([multiline_action_task]) 573 | render_tasks( 574 | write=fd.write, 575 | tasks=tasks) 576 | self.assertThat( 577 | fd.getvalue(), 578 | ExactlyEquals( 579 | u'f3a32bb3-ea6b-457c-aa99-08a3d0491ab4\n' 580 | u'\u2514\u2500\u2500 app:action/1 \u21d2 started ' 581 | u'1425356800\n' 582 | u' \u2514\u2500\u2500 message: this is a\u23ce\n' 583 | u' many line message\n\n')) 584 | 585 | def test_multiline_field_limit(self): 586 | """ 587 | When a field limit is specified for task values, only the first of 588 | multiple lines is output. 589 | """ 590 | self.assertThat( 591 | self.render_tasks([multiline_action_task], 592 | field_limit=1000), 593 | ExactlyEquals( 594 | u'f3a32bb3-ea6b-457c-aa99-08a3d0491ab4\n' 595 | u'\u2514\u2500\u2500 app:action/1 \u21d2 started ' 596 | u'1425356800\n' 597 | u' \u2514\u2500\u2500 message: this is a\u2026\n\n')) 598 | 599 | def test_field_limit(self): 600 | """ 601 | Truncate task values that are longer than the field_limit if specified. 602 | """ 603 | self.assertThat( 604 | self.render_tasks([message_task], 605 | field_limit=5), 606 | ExactlyEquals( 607 | u'cdeb220d-7605-4d5f-8341-1a170222e308\n' 608 | u'\u2514\u2500\u2500 twisted:log/1 ' 609 | u'14253\u2026\n' 610 | u' \u251c\u2500\u2500 error: False\n' 611 | u' \u2514\u2500\u2500 message: Main \u2026\n\n')) 612 | 613 | def test_ignored_keys(self): 614 | """ 615 | Task keys can be ignored. 616 | """ 617 | self.assertThat( 618 | self.render_tasks([action_task], 619 | ignored_fields={u'action_type'}), 620 | ExactlyEquals( 621 | u'f3a32bb3-ea6b-457c-aa99-08a3d0491ab4\n' 622 | u'\u2514\u2500\u2500 app:action/1 \u21d2 started ' 623 | u'1425356800\n' 624 | u' \u2514\u2500\u2500 action_status: started\n\n')) 625 | 626 | def test_task_data(self): 627 | """ 628 | Task data is rendered as tree elements. 629 | """ 630 | self.assertThat( 631 | self.render_tasks([message_task]), 632 | ExactlyEquals( 633 | u'cdeb220d-7605-4d5f-8341-1a170222e308\n' 634 | u'\u2514\u2500\u2500 twisted:log/1 ' 635 | u'1425356700\n' 636 | u' \u251c\u2500\u2500 error: False\n' 637 | u' \u2514\u2500\u2500 message: Main loop terminated.\n\n')) 638 | 639 | def test_dict_data(self): 640 | """ 641 | Task values that are ``dict``s are rendered as tree elements. 642 | """ 643 | self.assertThat( 644 | self.render_tasks([dict_action_task]), 645 | ExactlyEquals( 646 | u'f3a32bb3-ea6b-457c-aa99-08a3d0491ab4\n' 647 | u'\u2514\u2500\u2500 app:action/1 \u21d2 started ' 648 | u'1425356800\n' 649 | u' \u2514\u2500\u2500 some_data: \n' 650 | u' \u2514\u2500\u2500 a: 42\n\n')) 651 | 652 | def test_list_data(self): 653 | """ 654 | Task values that are ``list``s are rendered as tree elements. 655 | """ 656 | self.assertThat( 657 | self.render_tasks([list_action_task]), 658 | ExactlyEquals( 659 | u'f3a32bb3-ea6b-457c-aa99-08a3d0491ab4\n' 660 | u'\u2514\u2500\u2500 app:action/1 \u21d2 started ' 661 | u'1425356800\n' 662 | u' \u2514\u2500\u2500 some_data: \n' 663 | u' \u251c\u2500\u2500 0: a\n' 664 | u' \u2514\u2500\u2500 1: b\n\n')) 665 | 666 | def test_nested(self): 667 | """ 668 | Render nested tasks in a way that visually represents that nesting. 669 | """ 670 | self.assertThat( 671 | self.render_tasks([action_task, nested_action_task]), 672 | ExactlyEquals( 673 | u'f3a32bb3-ea6b-457c-aa99-08a3d0491ab4\n' 674 | u'\u2514\u2500\u2500 app:action/1 \u21d2 started ' 675 | u'1425356800\n' 676 | u' \u2514\u2500\u2500 app:action:nest/1/1 \u21d2 started ' 677 | u'1425356900\n\n')) 678 | 679 | def test_janky_message(self): 680 | """ 681 | Task names, UUIDs, keys and values in messages all have control 682 | characters escaped. 683 | """ 684 | self.assertThat( 685 | self.render_tasks([janky_message_task]), 686 | ExactlyEquals( 687 | u'cdeb220d-7605-4d5f-\u241b(08341-1a170222e308\n' 688 | u'\u2514\u2500\u2500 M\u241b(0/1 ' 689 | u'1425356700\n' 690 | u' \u251c\u2500\u2500 er\u241bror: False\n' 691 | u' \u2514\u2500\u2500 mes\u240asage: ' 692 | u'Main loop\u241b(0terminated.\n\n')) 693 | 694 | def test_janky_action(self): 695 | """ 696 | Task names, UUIDs, keys and values in actions all have control 697 | characters escaped. 698 | """ 699 | self.assertThat( 700 | self.render_tasks([janky_action_task]), 701 | ExactlyEquals( 702 | u'f3a32bb3-ea6b-457c-\u241b(0aa99-08a3d0491ab4\n' 703 | u'\u2514\u2500\u2500 A\u241b(0/1 \u21d2 started ' 704 | u'1425356800\u241b(0\n' 705 | u' \u251c\u2500\u2500 \u241b(0: \n' 706 | u' \u2502 \u2514\u2500\u2500 \u241b(0: nope\n' 707 | u' \u2514\u2500\u2500 mes\u240asage: hello\u241b(0world\n\n' 708 | )) 709 | 710 | def test_colorize(self): 711 | """ 712 | Passing ``theme`` will colorize the output. 713 | """ 714 | self.assertThat( 715 | self.render_tasks([action_task, action_task_end], 716 | theme=colors), 717 | ExactlyEquals( 718 | u'\n'.join([ 719 | colors.root(u'f3a32bb3-ea6b-457c-aa99-08a3d0491ab4'), 720 | u'\u2514\u2500\u2500 {}{} \u21d2 {} {} {} {}'.format( 721 | colors.parent(u'app:action'), 722 | colors.task_level(u'/1'), 723 | colors.status_success(u'started'), 724 | colors.timestamp(u'1425356800'), 725 | HOURGLASS, 726 | colors.duration(u'2')), 727 | u' \u2514\u2500\u2500 {}{} \u21d2 {} {}'.format( 728 | colors.parent(u'app:action'), 729 | colors.task_level(u'/2'), 730 | colors.status_success(u'succeeded'), 731 | colors.timestamp(u'1425356802')), 732 | u'\n', 733 | ]))) 734 | --------------------------------------------------------------------------------