Repository: jonathanj/eliottree

Issue:
Handle exceptions during formatting more gracefully
Sometimes timestamps are missing / None for example:

```
  File "/home/mithrandi/code/eliottree/eliottree/format.py", line 47, in _format_timestamp_value
    result = datetime.utcfromtimestamp(float(value)).isoformat(' ')
TypeError: float() argument must be a string or a number
```

`--raw` will work around this but then you get no formatting for _anything_.

Discussion:

Patch:
diff --git a/eliottree/_cli.py b/eliottree/_cli.py
index 0325c73..f10389e 100644
--- a/eliottree/_cli.py
+++ b/eliottree/_cli.py
@@ -77,12 +77,14 @@ def display_tasks(tasks, color, ignored_fields, field_limit, human_readable):
     the task trees to stdout.
     """
     write = text_writer(sys.stdout).write
+    write_err = text_writer(sys.stderr).write
     if color == 'auto':
         colorize = sys.stdout.isatty()
     else:
         colorize = color == 'always'
     render_tasks(
         write=write,
+        write_err=write_err,
         tasks=tasks,
         ignored_fields=set(ignored_fields) or None,
         field_limit=field_limit,
diff --git a/eliottree/_render.py b/eliottree/_render.py
index 59ee982..c8fc3b9 100644
--- a/eliottree/_render.py
+++ b/eliottree/_render.py
@@ -1,13 +1,17 @@
+import sys
+import traceback
 from functools import partial
 
 from eliot._action import WrittenAction
 from eliot._message import WrittenMessage
 from eliot._parse import Task
+from six import text_type
 from termcolor import colored
-from toolz import compose, identity
+from toolz import compose, excepts, identity
 from tree_format import format_tree
 
 from eliottree import format
+from eliottree._util import eliot_ns, format_namespace, is_namespace
 
 
 RIGHT_DOUBLE_ARROW = u'\u21d2'
@@ -33,6 +37,7 @@ class COLORS(object):
     success = Color('green')
     failure = Color('red')
     prop = Color('blue')
+    error = Color('red', ['bold'])
 
     def __init__(self, colored):
         self.colored = colored
@@ -52,7 +57,7 @@ def _default_value_formatter(human_readable, field_limit, encoding='utf-8'):
     fields = {}
     if human_readable:
         fields = {
-            u'timestamp': format.timestamp(),
+            eliot_ns(u'timestamp'): format.timestamp(),
         }
     return compose(
         # We want tree-format to handle newlines.
@@ -126,6 +131,8 @@ def format_node(format_value, colors, node):
             value = u''
         else:
             value = format_value(value, key)
+        if is_namespace(key):
+            key = format_namespace(key)
         return u'{}: {}'.format(
             colors.prop(format.escape_control_characters(key)),
             value)
@@ -138,13 +145,17 @@ def message_fields(message, ignored_fields):
     """
     def _items():
         try:
-            yield u'timestamp', message.timestamp
+            yield eliot_ns('timestamp'), message.timestamp
         except KeyError:
             pass
         for key, value in message.contents.items():
             if key not in ignored_fields:
                 yield key, value
-    return sorted(_items()) if message else []
+
+    def _sortkey(x):
+        k = x[0]
+        return format_namespace(k) if is_namespace(k) else k
+    return sorted(_items(), key=_sortkey) if message else []
 
 
 def get_children(ignored_fields, node):
@@ -176,8 +187,20 @@ def get_children(ignored_fields, node):
     return []
 
 
+def track_exceptions(f, caught, default=None):
+    """
+    Decorate ``f`` with a function that traps exceptions and appends them to
+    ``caught``, returning ``default`` in their place.
+    """
+    def _catch(_):
+        caught.append(sys.exc_info())
+        return default
+    return excepts(Exception, f, _catch)
+
+
 def render_tasks(write, tasks, field_limit=0, ignored_fields=None,
-                 human_readable=False, colorize=False):
+                 human_readable=False, colorize=False, write_err=None,
+                 format_node=format_node, format_value=None):
     """
     Render Eliot tasks as an ASCII tree.
 
@@ -193,18 +216,44 @@ def render_tasks(write, tasks, field_limit=0, ignored_fields=None,
     most Eliot metadata.
     :param bool human_readable: Render field values as human-readable?
     :param bool colorize: Colorized the output?
+    :type write_err: Callable[[`text_type`], None]
+    :param write_err: Callable used to write errors.
+    :param format_node: See `format_node`.
+    :type format_value: Callable[[Any], `text_type`]
+    :param format_value: Callable to format a value.
     """
     if ignored_fields is None:
         ignored_fields = DEFAULT_IGNORED_KEYS
-    _format_node = partial(
-        format_node,
-        _default_value_formatter(human_readable=human_readable,
-                                 field_limit=field_limit),
-        COLORS(colored if colorize else _no_color))
+    colors = COLORS(colored if colorize else _no_color)
+    caught_exceptions = []
+    if format_value is None:
+        format_value = _default_value_formatter(
+            human_readable=human_readable,
+            field_limit=field_limit)
+    _format_value = track_exceptions(
+        format_value,
+        caught_exceptions,
+        u'<value formatting exception>')
+    _format_node = track_exceptions(
+        partial(format_node, _format_value, colors),
+        caught_exceptions,
+        u'<node formatting exception>')
     _get_children = partial(get_children, ignored_fields)
     for task in tasks:
         write(format_tree(task, _format_node, _get_children))
         write(u'\n')
 
+    if write_err and caught_exceptions:
+        write_err(
+            colors.error(
+                u'Exceptions ({}) occurred during processing:\n'.format(
+                    len(caught_exceptions))))
+        for exc in caught_exceptions:
+            for line in traceback.format_exception(*exc):
+                if not isinstance(line, text_type):
+                    line = line.decode('utf-8')
+                write_err(line)
+            write_err(u'\n')
+
 
 __all__ = ['render_tasks']
diff --git a/eliottree/_util.py b/eliottree/_util.py
new file mode 100644
index 0000000..3ef7008
--- /dev/null
+++ b/eliottree/_util.py
@@ -0,0 +1,36 @@
+from collections import namedtuple
+
+
+namespace = namedtuple('namespace', ['prefix', 'name'])
+
+
+def namespaced(prefix):
+    """
+    Create a function that creates new names in the ``prefix`` namespace.
+
+    :rtype: Callable[[unicode], `namespace`]
+    """
+    return lambda name: namespace(prefix, name)
+
+
+def format_namespace(ns):
+    """
+    Format a `namespace`.
+
+    :rtype: unicode
+    """
+    if not is_namespace(ns):
+        raise TypeError('Expected namespace', ns)
+    return u'{}/{}'.format(ns.prefix, ns.name)
+
+
+def is_namespace(x):
+    """
+    Is this a `namespace` instance?
+
+    :rtype: bool
+    """
+    return isinstance(x, namespace)
+
+
+eliot_ns = namespaced(u'eliot')
diff --git a/eliottree/render.py b/eliottree/render.py
index 58d2992..e12f3aa 100644
--- a/eliottree/render.py
+++ b/eliottree/render.py
@@ -7,6 +7,7 @@ from tree_format import format_tree
 
 from eliottree._render import (
     COLORS, DEFAULT_IGNORED_KEYS, _default_value_formatter, _no_color)
+from eliottree._util import is_namespace, eliot_ns
 from eliottree.format import escape_control_characters
 
 
@@ -81,7 +82,10 @@ def get_name_factory(colors):
         if isinstance(task, text_type):
             return escape_control_characters(task)
         elif isinstance(task, tuple):
-            name = escape_control_characters(task[0])
+            key = task[0]
+            if is_namespace(key):
+                key = key.name
+            name = escape_control_characters(key)
             if isinstance(task[1], dict):
                 return name
             elif isinstance(task[1], text_type):
@@ -129,6 +133,8 @@ def get_children_factory(ignored_task_keys, format_value):
     def items_children(items):
         for key, value in sorted(items):
             if key not in ignored_task_keys:
+                if key == u'timestamp':
+                    key = eliot_ns(key)
                 if isinstance(value, dict):
                     yield key, value
                 else:
@@ -150,7 +156,10 @@ def get_children_factory(ignored_task_keys, format_value):
             return
         else:
             for child in items_children(task.task.items()):
-                yield child
+                if child[0] == u'timestamp':
+                    yield eliot_ns(child[0]), child[1]
+                else:
+                    yield child
             for child in task.children():
                 yield child
     return get_children
diff --git a/setup.py b/setup.py
index e47a7ef..85950e5 100644
--- a/setup.py
+++ b/setup.py
@@ -30,7 +30,7 @@ setup(
         "six>=1.9.0",
         "jmespath>=0.7.1",
         "iso8601>=0.1.10",
-        "tree-format>=0.1.2",
+        "tree-format>=0.1.1",
         "termcolor>=1.1.0",
         "toolz>=0.8.2",
         "eliot>=0.12.0",


Test patch:
diff --git a/eliottree/test/test_cli.py b/eliottree/test/test_cli.py
index 0d7d9bd..1612f1b 100644
--- a/eliottree/test/test_cli.py
+++ b/eliottree/test/test_cli.py
@@ -13,9 +13,9 @@ from eliottree.test.tasks import message_task, missing_uuid_task
 rendered_message_task = (
     u'cdeb220d-7605-4d5f-8341-1a170222e308\n'
     u'\u2514\u2500\u2500 twisted:log/1\n'
+    u'    \u251c\u2500\u2500 eliot/timestamp: 2015-03-03 04:25:00\n'
     u'    \u251c\u2500\u2500 error: False\n'
-    u'    \u251c\u2500\u2500 message: Main loop terminated.\n'
-    u'    \u2514\u2500\u2500 timestamp: 2015-03-03 04:25:00\n\n'
+    u'    \u2514\u2500\u2500 message: Main loop terminated.\n\n'
 ).encode('utf-8')
 
 
diff --git a/eliottree/test/test_render.py b/eliottree/test/test_render.py
index 3946f7b..0d612d7 100644
--- a/eliottree/test/test_render.py
+++ b/eliottree/test/test_render.py
@@ -4,13 +4,15 @@ from termcolor import colored
 from testtools import ExpectedException, TestCase
 from testtools.matchers import AfterPreprocessing as After
 from testtools.matchers import (
-    Contains, EndsWith, Equals, HasLength, IsDeprecated, StartsWith)
+    Contains, EndsWith, Equals, HasLength, IsDeprecated, MatchesAll,
+    MatchesListwise, StartsWith)
 
 from eliottree import (
     Tree, render_task_nodes, render_tasks, tasks_from_iterable)
 from eliottree._render import (
     COLORS, RIGHT_DOUBLE_ARROW, _default_value_formatter, _no_color,
     format_node, get_children, message_fields, message_name)
+from eliottree._util import eliot_ns
 from eliottree.render import get_name_factory
 from eliottree.test.matchers import ExactlyEquals
 from eliottree.test.tasks import (
@@ -72,7 +74,7 @@ class DefaultValueFormatterTests(TestCase):
 
     def test_timestamp_field(self):
         """
-        Format ``timestamp`` fields as human-readable if the feature was
+        Format Eliot ``timestamp`` fields as human-readable if the feature was
         requested.
         """
         format_value = _default_value_formatter(
@@ -80,12 +82,26 @@ class DefaultValueFormatterTests(TestCase):
         # datetime(2015, 6, 6, 22, 57, 12)
         now = 1433631432
         self.assertThat(
-            format_value(now, u'timestamp'),
+            format_value(now, eliot_ns(u'timestamp')),
             ExactlyEquals(u'2015-06-06 22:57:12'))
         self.assertThat(
-            format_value(str(now), u'timestamp'),
+            format_value(str(now), eliot_ns(u'timestamp')),
             ExactlyEquals(u'2015-06-06 22:57:12'))
 
+    def test_not_eliot_timestamp_field(self):
+        """
+        Do not format user fields named ``timestamp``.
+        """
+        format_value = _default_value_formatter(
+            human_readable=True, field_limit=0)
+        now = 1433631432
+        self.assertThat(
+            format_value(now, u'timestamp'),
+            ExactlyEquals(text_type(now)))
+        self.assertThat(
+            format_value(text_type(now), u'timestamp'),
+            ExactlyEquals(text_type(now)))
+
     def test_timestamp_field_not_human(self):
         """
         Do not format ``timestamp`` fields as human-readable if the feature was
@@ -668,8 +684,9 @@ class MessageFieldsTests(TestCase):
         message = WrittenMessage.from_dict({u'a': 1, u'timestamp': 12345678})
         self.assertThat(
             message_fields(message, set()),
-            Equals([(u'a', 1),
-                    (u'timestamp', 12345678)]))
+            Equals([
+                (u'a', 1),
+                (eliot_ns(u'timestamp'), 12345678)]))
 
     def test_ignored_fields(self):
         """
@@ -711,7 +728,7 @@ class GetChildrenTests(TestCase):
             Equals([
                 (u'action_status', start_message.contents.action_status),
                 (u'action_type', start_message.contents.action_type),
-                (u'timestamp', start_message.timestamp)]))
+                (eliot_ns(u'timestamp'), start_message.timestamp)]))
 
     def test_written_action_start(self):
         """
@@ -726,7 +743,7 @@ class GetChildrenTests(TestCase):
             Equals([
                 (u'action_status', start_message.contents.action_status),
                 (u'action_type', start_message.contents.action_type),
-                (u'timestamp', start_message.timestamp)]))
+                (eliot_ns(u'timestamp'), start_message.timestamp)]))
 
     def test_written_action_children(self):
         """
@@ -822,10 +839,47 @@ class RenderTasksTests(TestCase):
     """
     def render_tasks(self, iterable, **kw):
         fd = StringIO()
+        err = StringIO(u'')
         tasks = tasks_from_iterable(iterable)
-        render_tasks(write=fd.write, tasks=tasks, **kw)
+        render_tasks(write=fd.write, write_err=err.write, tasks=tasks, **kw)
+        if err.tell():
+            return fd.getvalue(), err.getvalue()
         return fd.getvalue()
 
+    def test_format_node_failures(self):
+        """
+        Catch exceptions when formatting nodes and display a message without
+        interrupting the processing of tasks. List all caught exceptions to
+        stderr.
+        """
+        def bad_format_node(*a, **kw):
+            raise ValueError('Nope')
+        self.assertThat(
+            self.render_tasks([message_task],
+                              format_node=bad_format_node),
+            MatchesListwise([
+                Contains(u'<node formatting exception>'),
+                MatchesAll(
+                    Contains(u'Traceback (most recent call last):'),
+                    Contains(u'ValueError: Nope'))]))
+
+    def test_format_value_failures(self):
+        """
+        Catch exceptions when formatting node values and display a message
+        without interrupting the processing of tasks. List all caught
+        exceptions to stderr.
+        """
+        def bad_format_value(*a, **kw):
+            raise ValueError('Nope')
+        self.assertThat(
+            self.render_tasks([message_task],
+                              format_value=bad_format_value),
+            MatchesListwise([
+                Contains(u'message: <value formatting exception>'),
+                MatchesAll(
+                    Contains(u'Traceback (most recent call last):'),
+                    Contains(u'ValueError: Nope'))]))
+
     def test_tasks(self):
         """
         Render two tasks of sequential levels, by default most standard Eliot
@@ -836,9 +890,9 @@ class RenderTasksTests(TestCase):
             ExactlyEquals(
                 u'f3a32bb3-ea6b-457c-aa99-08a3d0491ab4\n'
                 u'\u2514\u2500\u2500 app:action/1 \u21d2 started\n'
-                u'    \u251c\u2500\u2500 timestamp: 1425356800\n'
+                u'    \u251c\u2500\u2500 eliot/timestamp: 1425356800\n'
                 u'    \u2514\u2500\u2500 app:action/2 \u21d2 succeeded\n'
-                u'        \u2514\u2500\u2500 timestamp: 1425356800\n\n'))
+                u'        \u2514\u2500\u2500 eliot/timestamp: 1425356800\n\n'))
 
     def test_tasks_human_readable(self):
         """
@@ -851,9 +905,11 @@ class RenderTasksTests(TestCase):
             ExactlyEquals(
                 u'f3a32bb3-ea6b-457c-aa99-08a3d0491ab4\n'
                 u'\u2514\u2500\u2500 app:action/1 \u21d2 started\n'
-                u'    \u251c\u2500\u2500 timestamp: 2015-03-03 04:26:40\n'
+                u'    \u251c\u2500\u2500 eliot/timestamp: '
+                u'2015-03-03 04:26:40\n'
                 u'    \u2514\u2500\u2500 app:action/2 \u21d2 succeeded\n'
-                u'        \u2514\u2500\u2500 timestamp: 2015-03-03 04:26:40\n'
+                u'        \u2514\u2500\u2500 eliot/timestamp: '
+                u'2015-03-03 04:26:40\n'
                 u'\n'))
 
     def test_multiline_field(self):
@@ -871,9 +927,9 @@ class RenderTasksTests(TestCase):
             ExactlyEquals(
                 u'f3a32bb3-ea6b-457c-aa99-08a3d0491ab4\n'
                 u'\u2514\u2500\u2500 app:action/1 \u21d2 started\n'
-                u'    \u251c\u2500\u2500 message: this is a\u23ce\n'
-                u'    \u2502   many line message\n'
-                u'    \u2514\u2500\u2500 timestamp: 1425356800\n\n'))
+                u'    \u251c\u2500\u2500 eliot/timestamp: 1425356800\n'
+                u'    \u2514\u2500\u2500 message: this is a\u23ce\n'
+                u'        many line message\n\n'))
 
     def test_multiline_field_limit(self):
         """
@@ -886,8 +942,8 @@ class RenderTasksTests(TestCase):
             ExactlyEquals(
                 u'f3a32bb3-ea6b-457c-aa99-08a3d0491ab4\n'
                 u'\u2514\u2500\u2500 app:action/1 \u21d2 started\n'
-                u'    \u251c\u2500\u2500 message: this is a\u2026\n'
-                u'    \u2514\u2500\u2500 timestamp: 1425356800\n\n'))
+                u'    \u251c\u2500\u2500 eliot/timestamp: 1425356800\n'
+                u'    \u2514\u2500\u2500 message: this is a\u2026\n\n'))
 
     def test_field_limit(self):
         """
@@ -899,9 +955,9 @@ class RenderTasksTests(TestCase):
             ExactlyEquals(
                 u'cdeb220d-7605-4d5f-8341-1a170222e308\n'
                 u'\u2514\u2500\u2500 twisted:log/1\n'
+                u'    \u251c\u2500\u2500 eliot/timestamp: 14253\u2026\n'
                 u'    \u251c\u2500\u2500 error: False\n'
-                u'    \u251c\u2500\u2500 message: Main \u2026\n'
-                u'    \u2514\u2500\u2500 timestamp: 14253\u2026\n\n'))
+                u'    \u2514\u2500\u2500 message: Main \u2026\n\n'))
 
     def test_ignored_keys(self):
         """
@@ -914,7 +970,7 @@ class RenderTasksTests(TestCase):
                 u'f3a32bb3-ea6b-457c-aa99-08a3d0491ab4\n'
                 u'\u2514\u2500\u2500 app:action/1 \u21d2 started\n'
                 u'    \u251c\u2500\u2500 action_status: started\n'
-                u'    \u2514\u2500\u2500 timestamp: 1425356800\n\n'))
+                u'    \u2514\u2500\u2500 eliot/timestamp: 1425356800\n\n'))
 
     def test_task_data(self):
         """
@@ -925,9 +981,9 @@ class RenderTasksTests(TestCase):
             ExactlyEquals(
                 u'cdeb220d-7605-4d5f-8341-1a170222e308\n'
                 u'\u2514\u2500\u2500 twisted:log/1\n'
+                u'    \u251c\u2500\u2500 eliot/timestamp: 1425356700\n'
                 u'    \u251c\u2500\u2500 error: False\n'
-                u'    \u251c\u2500\u2500 message: Main loop terminated.\n'
-                u'    \u2514\u2500\u2500 timestamp: 1425356700\n\n'))
+                u'    \u2514\u2500\u2500 message: Main loop terminated.\n\n'))
 
     def test_dict_data(self):
         """
@@ -938,9 +994,9 @@ class RenderTasksTests(TestCase):
             ExactlyEquals(
                 u'f3a32bb3-ea6b-457c-aa99-08a3d0491ab4\n'
                 u'\u2514\u2500\u2500 app:action/1 \u21d2 started\n'
-                u'    \u251c\u2500\u2500 some_data: \n'
-                u'    \u2502   \u2514\u2500\u2500 a: 42\n'
-                u'    \u2514\u2500\u2500 timestamp: 1425356800\n\n'))
+                u'    \u251c\u2500\u2500 eliot/timestamp: 1425356800\n'
+                u'    \u2514\u2500\u2500 some_data: \n'
+                u'        \u2514\u2500\u2500 a: 42\n\n'))
 
     def test_list_data(self):
         """
@@ -951,10 +1007,10 @@ class RenderTasksTests(TestCase):
             ExactlyEquals(
                 u'f3a32bb3-ea6b-457c-aa99-08a3d0491ab4\n'
                 u'\u2514\u2500\u2500 app:action/1 \u21d2 started\n'
-                u'    \u251c\u2500\u2500 some_data: \n'
-                u'    \u2502   \u251c\u2500\u2500 0: a\n'
-                u'    \u2502   \u2514\u2500\u2500 1: b\n'
-                u'    \u2514\u2500\u2500 timestamp: 1425356800\n\n'))
+                u'    \u251c\u2500\u2500 eliot/timestamp: 1425356800\n'
+                u'    \u2514\u2500\u2500 some_data: \n'
+                u'        \u251c\u2500\u2500 0: a\n'
+                u'        \u2514\u2500\u2500 1: b\n\n'))
 
     def test_nested(self):
         """
@@ -965,9 +1021,9 @@ class RenderTasksTests(TestCase):
             ExactlyEquals(
                 u'f3a32bb3-ea6b-457c-aa99-08a3d0491ab4\n'
                 u'\u2514\u2500\u2500 app:action/1 \u21d2 started\n'
-                u'    \u251c\u2500\u2500 timestamp: 1425356800\n'
+                u'    \u251c\u2500\u2500 eliot/timestamp: 1425356800\n'
                 u'    \u2514\u2500\u2500 app:action:nest/1/1 \u21d2 started\n'
-                u'        \u2514\u2500\u2500 timestamp: 1425356900\n\n'))
+                u'        \u2514\u2500\u2500 eliot/timestamp: 1425356900\n\n'))
 
     def test_janky_message(self):
         """
@@ -979,10 +1035,10 @@ class RenderTasksTests(TestCase):
             ExactlyEquals(
                 u'cdeb220d-7605-4d5f-\u241b(08341-1a170222e308\n'
                 u'\u2514\u2500\u2500 M\u241b(0/1\n'
+                u'    \u251c\u2500\u2500 eliot/timestamp: 1425356700\n'
                 u'    \u251c\u2500\u2500 er\u241bror: False\n'
-                u'    \u251c\u2500\u2500 mes\u240asage: '
-                u'Main loop\u241b(0terminated.\n'
-                u'    \u2514\u2500\u2500 timestamp: 1425356700\n\n'))
+                u'    \u2514\u2500\u2500 mes\u240asage: '
+                u'Main loop\u241b(0terminated.\n\n'))
 
     def test_janky_action(self):
         """
@@ -996,8 +1052,9 @@ class RenderTasksTests(TestCase):
                 u'\u2514\u2500\u2500 A\u241b(0/1 \u21d2 started\n'
                 u'    \u251c\u2500\u2500 \u241b(0: \n'
                 u'    \u2502   \u2514\u2500\u2500 \u241b(0: nope\n'
-                u'    \u251c\u2500\u2500 mes\u240asage: hello\u241b(0world\n'
-                u'    \u2514\u2500\u2500 timestamp: 1425356800\u241b(0\n\n'))
+                u'    \u251c\u2500\u2500 eliot/timestamp: 1425356800\u241b(0\n'
+                u'    \u2514\u2500\u2500 mes\u240asage: hello\u241b(0world\n\n'
+            ))
 
     def test_colorize(self):
         """
@@ -1013,11 +1070,11 @@ class RenderTasksTests(TestCase):
                         colors.parent(u'app:action'),
                         colors.success(u'started')),
                     u'    \u251c\u2500\u2500 {}: {}'.format(
-                        colors.prop(u'timestamp'), u'1425356800'),
+                        colors.prop(u'eliot/timestamp'), u'1425356800'),
                     u'    \u2514\u2500\u2500 {}/2 \u21d2 {}'.format(
                         colors.parent(u'app:action'),
                         colors.success(u'succeeded')),
                     u'        \u2514\u2500\u2500 {}: {}'.format(
-                        colors.prop('timestamp'), u'1425356800'),
+                        colors.prop('eliot/timestamp'), u'1425356800'),
                     u'\n',
                 ])))
diff --git a/eliottree/test/test_util.py b/eliottree/test/test_util.py
new file mode 100644
index 0000000..c75cdde
--- /dev/null
+++ b/eliottree/test/test_util.py
@@ -0,0 +1,64 @@
+from testtools import ExpectedException, TestCase
+from testtools.matchers import AfterPreprocessing as After
+from testtools.matchers import (
+    Equals, Is, MatchesAll, MatchesListwise, MatchesPredicate,
+    MatchesStructure)
+
+from eliottree._util import format_namespace, is_namespace, namespaced
+
+
+class NamespaceTests(TestCase):
+    """
+    Tests for `namespaced`, `format_namespace` and `is_namespace`.
+    """
+    def test_namespaced(self):
+        """
+        `namespaced` creates a function that when called produces a namespaced
+        name.
+        """
+        self.assertThat(
+            namespaced(u'foo'),
+            MatchesAll(
+                MatchesPredicate(callable, '%s is not callable'),
+                After(
+                    lambda f: f(u'bar'),
+                    MatchesAll(
+                        MatchesListwise([
+                            Equals(u'foo'),
+                            Equals(u'bar')]),
+                        MatchesStructure(
+                            prefix=Equals(u'foo'),
+                            name=Equals(u'bar'))))))
+
+    def test_format_not_namespace(self):
+        """
+        `format_namespace` raises `TypeError` if its argument is not a
+        namespaced name.
+        """
+        with ExpectedException(TypeError):
+            format_namespace(42)
+
+    def test_format_namespace(self):
+        """
+        `format_namespace` creates a text representation of a namespaced name.
+        """
+        self.assertThat(
+            format_namespace(namespaced(u'foo')(u'bar')),
+            Equals(u'foo/bar'))
+
+    def test_is_namespace(self):
+        """
+        `is_namespace` returns ``True`` only for namespaced names.
+        """
+        self.assertThat(
+            is_namespace(42),
+            Is(False))
+        self.assertThat(
+            is_namespace((u'foo', u'bar')),
+            Is(False))
+        self.assertThat(
+            is_namespace(namespaced(u'foo')),
+            Is(False))
+        self.assertThat(
+            is_namespace(namespaced(u'foo')(u'bar')),
+            Is(True))