Repository: jonathanj/eliottree

Issue:
On my terminal, prints white-on-white text
On my terminal, eliot-tree output looks like:

![image](https://user-images.githubusercontent.com/609896/56775876-02154780-677e-11e9-98e7-bb233a2cbf1d.png)

I find that UUID somewhat hard to read :-)

Comments:

Patch:
diff --git a/README.rst b/README.rst
index a09aa73..5715dd8 100644
--- a/README.rst
+++ b/README.rst
@@ -78,9 +78,10 @@ Usage from the command-line
 
    $ eliot-tree
    usage: eliot-tree [-h] [-u UUID] [-i KEY] [--raw] [--local-timezone]
-                  [--color {always,auto,never}] [--ascii] [--no-color-tree]
-                  [-l LENGTH] [--select QUERY] [--start START] [--end END]
-                  [FILE [FILE ...]]
+                     [--color {always,auto,never}] [--ascii] [--no-color-tree]
+                     [--theme {auto,dark,light}] [-l LENGTH] [--select QUERY]
+                     [--start START] [--end END]
+                     [FILE [FILE ...]]
 
    Display an Eliot log as a tree of tasks.
 
@@ -103,6 +104,8 @@ Usage from the command-line
                            is a TTY.
      --ascii               Use ASCII for tree drawing characters.
      --no-color-tree       Do not color the tree lines.
+     --theme {auto,dark,light}
+                           Select a color theme to use.
      -l LENGTH, --field-limit LENGTH
                            Limit the length of field values to LENGTH or a
                            newline, whichever comes first. Use a length of 0 to
diff --git a/src/eliottree/_cli.py b/src/eliottree/_cli.py
index da4f27a..3df68a5 100644
--- a/src/eliottree/_cli.py
+++ b/src/eliottree/_cli.py
@@ -1,9 +1,11 @@
 import argparse
 import codecs
 import json
+import os
 import platform
 import sys
 from pprint import pformat
+from termcolor import colored
 
 import iso8601
 from six import PY3, binary_type, reraise
@@ -13,6 +15,7 @@ from toolz import compose
 from eliottree import (
     EliotParseError, JSONParseError, filter_by_end_date, filter_by_jmespath,
     filter_by_start_date, filter_by_uuid, render_tasks, tasks_from_iterable)
+from eliottree._theme import get_theme
 
 
 def text_writer(fd):
@@ -92,7 +95,7 @@ def setup_platform(colorize):
             colorama.init()
 
 
-def display_tasks(tasks, color, colorize_tree, ascii, ignored_fields,
+def display_tasks(tasks, color, colorize_tree, ascii, theme_name, ignored_fields,
                   field_limit, human_readable, utc_timestamps):
     """
     Render Eliot tasks, apply any command-line-specified behaviour and render
@@ -106,6 +109,13 @@ def display_tasks(tasks, color, colorize_tree, ascii, ignored_fields,
     setup_platform(colorize=colorize)
     write = text_writer(sys.stdout).write
     write_err = text_writer(sys.stderr).write
+    if theme_name == 'auto':
+        dark_background = is_dark_terminal_background(default=True)
+    else:
+        dark_background = theme_name == 'dark'
+    theme = get_theme(
+        dark_background=dark_background,
+        colored=colored if colorize else None)
 
     render_tasks(
         write=write,
@@ -114,10 +124,10 @@ def display_tasks(tasks, color, colorize_tree, ascii, ignored_fields,
         ignored_fields=set(ignored_fields) or None,
         field_limit=field_limit,
         human_readable=human_readable,
-        colorize=colorize,
         colorize_tree=colorize and colorize_tree,
         ascii=ascii,
-        utc_timestamps=utc_timestamps)
+        utc_timestamps=utc_timestamps,
+        theme=theme)
 
 
 def _decode_command_line(value, encoding='utf-8'):
@@ -129,6 +139,29 @@ def _decode_command_line(value, encoding='utf-8'):
     return value
 
 
+def is_dark_terminal_background(default=True):
+    """
+    Does the terminal use a dark background color?
+
+    Currently just checks the `COLORFGBG` environment variable, if it exists,
+    which some terminals define as `fg:bg`.
+
+    :rtype: bool
+    """
+    colorfgbg = os.environ.get('COLORFGBG', None)
+    if colorfgbg is not None:
+        parts = os.environ['COLORFGBG'].split(';')
+        try:
+            last_number = int(parts[-1])
+            if 0 <= last_number <= 6 or last_number == 8:
+                return True
+            else:
+                return False
+        except ValueError:
+            pass
+    return default
+
+
 def main():
     parser = argparse.ArgumentParser(
         description='Display an Eliot log as a tree of tasks.')
@@ -176,6 +209,11 @@ def main():
                         default=True,
                         dest='colorize_tree',
                         help='''Do not color the tree lines.''')
+    parser.add_argument('--theme',
+                        default='auto',
+                        choices=['auto', 'dark', 'light'],
+                        dest='theme_name',
+                        help='''Select a color theme to use.'''),
     parser.add_argument('-l', '--field-limit',
                         metavar='LENGTH',
                         type=int,
@@ -216,6 +254,7 @@ def main():
             tasks=tasks,
             color=args.color,
             colorize_tree=args.colorize_tree,
+            theme_name=args.theme_name,
             ascii=args.ascii,
             ignored_fields=args.ignored_fields,
             field_limit=args.field_limit,
diff --git a/src/eliottree/_render.py b/src/eliottree/_render.py
index 4e660cf..17bdb83 100644
--- a/src/eliottree/_render.py
+++ b/src/eliottree/_render.py
@@ -4,12 +4,12 @@ from functools import partial
 
 from eliot.parse import WrittenAction, WrittenMessage, Task
 from six import text_type
-from termcolor import colored
 from toolz import compose, excepts, identity
 
 from eliottree import format
 from eliottree.tree_format import format_tree, Options, ASCII_OPTIONS
 from eliottree._util import eliot_ns, format_namespace, is_namespace
+from eliottree._theme import get_theme
 
 
 RIGHT_DOUBLE_ARROW = u'\N{RIGHTWARDS DOUBLE ARROW}'
@@ -20,41 +20,6 @@ DEFAULT_IGNORED_KEYS = set([
     u'message_type'])
 
 
-class Color(object):
-    def __init__(self, color, attrs=[]):
-        self.color = color
-        self.attrs = attrs
-
-    def __get__(self, instance, owner):
-        return lambda text: instance.colored(
-            text, self.color, attrs=self.attrs)
-
-
-class COLORS(object):
-    root = Color('white', ['bold'])
-    parent = Color('magenta')
-    success = Color('green')
-    failure = Color('red')
-    prop = Color('blue')
-    error = Color('red', ['bold'])
-    timestamp = Color('white', ['dark'])
-    duration = Color('blue', ['dark'])
-    tree_failed = Color('red', ['dark'])
-    tree_color0 = Color('white', ['dark'])
-    tree_color1 = Color('blue', ['dark'])
-    tree_color2 = Color('magenta', ['dark'])
-
-    def __init__(self, colored):
-        self.colored = colored
-
-
-def _no_color(text, *a, **kw):
-    """
-    Colorizer that does not colorize.
-    """
-    return text
-
-
 def _default_value_formatter(
         human_readable,
         field_limit,
@@ -84,7 +49,7 @@ def _default_value_formatter(
             format.anything(encoding)))
 
 
-def message_name(colors, format_value, message, end_message=None):
+def message_name(theme, format_value, message, end_message=None):
     """
     Derive the name for a message.
 
@@ -94,7 +59,7 @@ def message_name(colors, format_value, message, end_message=None):
     otherwise no name will be derived.
     """
     if message is not None:
-        timestamp = colors.timestamp(
+        timestamp = theme.timestamp(
             format_value(
                 message.timestamp, field_name=eliot_ns('timestamp')))
         if u'action_type' in message.contents:
@@ -105,7 +70,7 @@ def message_name(colors, format_value, message, end_message=None):
                 duration_seconds = end_message.timestamp - message.timestamp
                 duration = u' {} {}'.format(
                     HOURGLASS,
-                    colors.duration(
+                    theme.duration(
                         format_value(
                             duration_seconds,
                             field_name=eliot_ns('duration'))))
@@ -114,11 +79,11 @@ def message_name(colors, format_value, message, end_message=None):
                 action_status = message.contents.action_status
             status_color = identity
             if action_status == u'succeeded':
-                status_color = colors.success
+                status_color = theme.success
             elif action_status == u'failed':
-                status_color = colors.failure
+                status_color = theme.failure
             return u'{}{} {} {} {}{}'.format(
-                colors.parent(action_type),
+                theme.parent(action_type),
                 message.task_level.to_string(),
                 RIGHT_DOUBLE_ARROW,
                 status_color(message.contents.action_status),
@@ -128,13 +93,13 @@ def message_name(colors, format_value, message, end_message=None):
             message_type = format.escape_control_characters(
                 message.contents.message_type)
             return u'{}{} {}'.format(
-                colors.parent(message_type),
+                theme.parent(message_type),
                 message.task_level.to_string(),
                 timestamp)
     return u'<unnamed>'
 
 
-def format_node(format_value, colors, node):
+def format_node(format_value, theme, node):
     """
     Format a node for display purposes.
 
@@ -146,17 +111,17 @@ def format_node(format_value, colors, node):
     """
     if isinstance(node, Task):
         return u'{}'.format(
-            colors.root(
+            theme.root(
                 format.escape_control_characters(node.root().task_uuid)))
     elif isinstance(node, WrittenAction):
         return message_name(
-            colors,
+            theme,
             format_value,
             node.start_message,
             node.end_message)
     elif isinstance(node, WrittenMessage):
         return message_name(
-            colors,
+            theme,
             format_value,
             node)
     elif isinstance(node, tuple):
@@ -168,7 +133,7 @@ def format_node(format_value, colors, node):
         if is_namespace(key):
             key = format_namespace(key)
         return u'{}: {}'.format(
-            colors.prop(format.escape_control_characters(key)),
+            theme.prop(format.escape_control_characters(key)),
             value)
     raise NotImplementedError()
 
@@ -228,7 +193,7 @@ def track_exceptions(f, caught, default=None):
     return excepts(Exception, f, _catch)
 
 
-class ColorizedOptions():
+class ColorizedOptions(object):
     """
     `Options` for `format_tree` that colorizes sub-trees.
     """
@@ -249,9 +214,10 @@ class ColorizedOptions():
 
 
 def render_tasks(write, tasks, field_limit=0, ignored_fields=None,
-                 human_readable=False, colorize=False, write_err=None,
+                 human_readable=False, write_err=None,
                  format_node=format_node, format_value=None,
-                 utc_timestamps=True, colorize_tree=False, ascii=False):
+                 utc_timestamps=True, colorize_tree=False, ascii=False,
+                 theme=None):
     """
     Render Eliot tasks as an ASCII tree.
 
@@ -275,10 +241,12 @@ def render_tasks(write, tasks, field_limit=0, ignored_fields=None,
     :param bool utc_timestamps: Format timestamps as UTC?
     :param int colorize_tree: Colorizing the tree output?
     :param bool ascii: Render the tree as plain ASCII instead of Unicode?
+    :param Theme theme: Theme to use for rendering.
     """
     if ignored_fields is None:
         ignored_fields = DEFAULT_IGNORED_KEYS
-    colors = COLORS(colored if colorize else _no_color)
+    if theme is None:
+        theme = get_theme(dark_background=True)
     caught_exceptions = []
     if format_value is None:
         format_value = _default_value_formatter(
@@ -290,7 +258,7 @@ def render_tasks(write, tasks, field_limit=0, ignored_fields=None,
         caught_exceptions,
         u'<value formatting exception>')
     _format_node = track_exceptions(
-        partial(format_node, _format_value, colors),
+        partial(format_node, _format_value, theme),
         caught_exceptions,
         u'<node formatting exception>')
     _get_children = partial(get_children, ignored_fields)
@@ -302,8 +270,8 @@ def render_tasks(write, tasks, field_limit=0, ignored_fields=None,
             options = Options()
         if colorize_tree:
             return ColorizedOptions(
-                failed_color=colors.tree_failed,
-                depth_colors=[colors.tree_color0, colors.tree_color1, colors.tree_color2],
+                failed_color=theme.tree_failed,
+                depth_colors=[theme.tree_color0, theme.tree_color1, theme.tree_color2],
                 options=options)
         return options
 
@@ -313,7 +281,7 @@ def render_tasks(write, tasks, field_limit=0, ignored_fields=None,
 
     if write_err and caught_exceptions:
         write_err(
-            colors.error(
+            theme.error(
                 u'Exceptions ({}) occurred during processing:\n'.format(
                     len(caught_exceptions))))
         for exc in caught_exceptions:
diff --git a/src/eliottree/_theme.py b/src/eliottree/_theme.py
new file mode 100644
index 0000000..d73a69b
--- /dev/null
+++ b/src/eliottree/_theme.py
@@ -0,0 +1,93 @@
+def color_factory(colored):
+    """
+    Factory for making text color-wrappers.
+    """
+    def _color(color, attrs=[]):
+        def __color(text):
+            return colored(text, color, attrs=attrs)
+        return __color
+    return _color
+
+
+class Theme(object):
+    """
+    Theme base class.
+    """
+    __slots__ = [
+        'root',
+        'parent',
+        'success',
+        'failure',
+        'prop',
+        'error',
+        'timestamp',
+        'duration',
+        'tree_failed',
+        'tree_color0',
+        'tree_color1',
+        'tree_color2',
+    ]
+    def __init__(self, **theme):
+        super(Theme, self).__init__()
+        for k, v in theme.items():
+            setattr(self, k, v)
+
+
+class DarkBackgroundTheme(Theme):
+    """
+    Color theme for dark backgrounds.
+    """
+    def __init__(self, colored):
+        color = color_factory(colored)
+        super(DarkBackgroundTheme, self).__init__(
+            root=color('white', ['bold']),
+            parent=color('magenta'),
+            success=color('green'),
+            failure=color('red'),
+            prop=color('blue'),
+            error=color('red', ['bold']),
+            timestamp=color('white', ['dark']),
+            duration=color('blue', ['dark']),
+            tree_failed=color('red'),
+            tree_color0=color('white', ['dark']),
+            tree_color1=color('blue', ['dark']),
+            tree_color2=color('magenta', ['dark']),
+        )
+
+
+class LightBackgroundTheme(Theme):
+    """
+    Color theme for light backgrounds.
+    """
+    def __init__(self, colored):
+        color = color_factory(colored)
+        super(LightBackgroundTheme, self).__init__(
+            root=color('grey', ['bold']),
+            parent=color('magenta'),
+            success=color('green'),
+            failure=color('red'),
+            prop=color('blue'),
+            error=color('red', ['bold']),
+            timestamp=color('grey'),
+            duration=color('blue', ['dark']),
+            tree_failed=color('red'),
+            tree_color0=color('grey', ['dark']),
+            tree_color1=color('blue', ['dark']),
+            tree_color2=color('magenta', ['dark']),
+        )
+
+
+def _no_color(text, *a, **kw):
+    """
+    Colorizer that does not colorize.
+    """
+    return text
+
+
+def get_theme(dark_background, colored=None):
+    """
+    Create an appropriate theme.
+    """
+    if colored is None:
+        colored = _no_color
+    return DarkBackgroundTheme(colored) if dark_background else LightBackgroundTheme(colored)


Test patch:
diff --git a/src/eliottree/test/test_render.py b/src/eliottree/test/test_render.py
index 8547ed9..e0002b0 100644
--- a/src/eliottree/test/test_render.py
+++ b/src/eliottree/test/test_render.py
@@ -10,8 +10,9 @@ from testtools.matchers import (
 from eliottree import (
     render_tasks, tasks_from_iterable)
 from eliottree._render import (
-    COLORS, HOURGLASS, RIGHT_DOUBLE_ARROW, _default_value_formatter, _no_color,
-    format_node, get_children, message_fields, message_name)
+    HOURGLASS, RIGHT_DOUBLE_ARROW, _default_value_formatter, format_node,
+    get_children, message_fields, message_name)
+from eliottree._theme import get_theme
 from eliottree._util import eliot_ns
 from eliottree.test.matchers import ExactlyEquals
 from eliottree.test.tasks import (
@@ -131,8 +132,8 @@ class DefaultValueFormatterTests(TestCase):
             ExactlyEquals(text_type(now)))
 
 
-colors = COLORS(colored)
-no_colors = COLORS(_no_color)
+colors = get_theme(dark_background=True, colored=colored)
+no_colors = get_theme(dark_background=True, colored=None)
 
 
 def no_formatting(value, field_name=None):
@@ -704,11 +705,11 @@ class RenderTasksTests(TestCase):
 
     def test_colorize(self):
         """
-        Passing ``colorize=True`` will colorize the output.
+        Passing ``theme`` will colorize the output.
         """
         self.assertThat(
             self.render_tasks([action_task, action_task_end],
-                              colorize=True),
+                              theme=colors),
             ExactlyEquals(
                 u'\n'.join([
                     colors.root(u'f3a32bb3-ea6b-457c-aa99-08a3d0491ab4'),