diff --git a/algviz/interface/__init__.py b/algviz/interface/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/algviz/interface/high_level.py b/algviz/interface/high_level.py
new file mode 100644
index 0000000..41d78f6
--- /dev/null
+++ b/algviz/interface/high_level.py
@@ -0,0 +1,19 @@
+import sys
+
+from . import output, visitors
+
+__output_manager = None
+
+def do_setup():
+    global __output_manager
+    if __output_manager is None:
+        __output_manager = output.OutputManager(sys.stdout)
+
+def show(obj, var=None, api=None, metadata=None):
+    global __output_manager
+    do_setup()
+    if api is None:
+        api = visitors.DispatchVisitor
+    visitor = api(__output_manager)
+    with __output_manager.start_snapshot():
+        visitor.traverse(obj, var=var, metadata=metadata)
diff --git a/algviz/interface/output.py b/algviz/interface/output.py
new file mode 100644
index 0000000..eb6a05e
--- /dev/null
+++ b/algviz/interface/output.py
@@ -0,0 +1,201 @@
+import contextlib
+import json
+import sys
+from algviz.parser import json_objects
+
+class OutputStateError(Exception):
+    """For output operations that don't make sense given the state of the output"""
+
+class _OutputContext:
+    """Don't work with this class directly.  Prefer to use OutputManager."""
+    def __init__(self, parent=None, outfile=sys.stdout):
+        self.parent = parent
+        self.indent = 2 if parent is None else parent.indent + 2
+        self.comma_needed = False
+        self.outfile = outfile
+        self.closed = False
+        self.cur_child = None
+
+    def write(self, text):
+        # if '\n' in text:
+        #     print("newline in next line: {!r}".format(text), file=sys.stderr)
+        print(text, end="", file=self.outfile)
+
+    def begin(self):
+        self.write(self.open_char)
+
+    def end(self):
+        if self.parent is not None:
+            assert not self.parent.closed, "parent block ended before this one did"
+        self.end_child()
+        if self.comma_needed:  # we're closing a non-empty empty dict/list, so put a newline
+            if self.parent is not None:
+                self.parent.do_indent()
+            else:
+                self.write("\n")
+        self.write(self.close_char)
+        self.closed = True
+
+    def end_child(self):
+        """If there is a child block (a list or dict), make sure it is
+        closed before printing anything else at this level.
+        """
+        if self.cur_child is not None and not self.cur_child.closed:
+            self.cur_child.end()
+            self.cur_child  = None
+
+    def do_indent(self):
+        self.write("\n" + (" " * self.indent))
+
+    def comma_newline(self):
+        self.end_child()
+        if self.comma_needed:
+            # First item in list/dict doesn't need a comma before it
+            self.write(",")
+        else:
+            self.comma_needed = True
+        # Indentation is nice
+        self.do_indent()
+
+    def write_literal(self, lit):
+        """Write a str or int.  Could also do list or dict, I suppose."""
+        self.write(json.dumps(lit).strip("\n"))
+
+    def push_child(self, child_cls):
+        if self.cur_child is not None:
+            assert self.cur_child.closed, "began child block without ending previous child"
+        self.cur_child = child_cls(parent=self, outfile=self.outfile)
+        self.cur_child.begin()
+
+
+class _DictOutputContext(_OutputContext):
+    open_char = "{"
+    close_char = "}"
+    def __init__(self, *args, **kwargs):
+        self.keys_used = set()
+        super().__init__(*args, **kwargs)
+
+    def key_val(self, key, val):
+        if key in self.keys_used:
+            raise OutputStateError("Key {!r} is a duplicate in this mapping"
+                                   .format(key))
+        self.keys_used.add(key)
+        self.comma_newline()
+        self.write_literal(key)
+        self.write(": ")
+        self.write_literal(val)
+
+    def key_push(self, key, *args, **kwargs):
+        self.comma_newline()
+        self.write_literal(key)
+        self.write(": ")
+        self.push_child(*args, **kwargs)
+
+class _ListOutputContext(_OutputContext):
+    open_char = "["
+    close_char = "]"
+    def item(self, val):
+        """Add a literal to the list"""
+        self.comma_newline()
+        self.write_literal(val)
+
+    def item_push(self, *args, **kwargs):
+        """Open a dict or list within this list"""
+        self.comma_newline()
+        self.push_child(*args, **kwargs)
+
+
+class OutputManager:
+    """Useful for outputting valid JSON without maintaining too much state."""
+
+    def __init__(self, outfile=sys.stdout):
+        # self._in_dict = False
+        # self._in_list = True
+        self.outfile = outfile
+        self.snapshot_ctx = _ListOutputContext(parent=None, outfile=outfile)
+        self.context = self.snapshot_ctx
+        self.context.begin()
+        self.uids = set()
+        self._next_key = None
+    # The idea is that the user calls next_item repeatedly if in an array context,
+    # or alternates calls to next_key and next_item if in a dict context.
+    def next_key(self, key):
+        if not isinstance(key, str):
+            raise TypeError("JSON keys must be strings, not {}".format(key))
+        if self._next_key is not None:
+            raise OutputStateError("previous key ({}) not used when new key ({}) added"
+                                   .format(self._next_key, key))
+        elif not isinstance(self.context, _DictOutputContext):
+            raise OutputStateError("cannot set a key ({}) in non-mapping context {}"
+                                   .format(key, self.context))
+        else:
+            self._next_key = key
+
+    def _use_key(self):
+        if self._next_key is None:
+            raise OutputStateError("Must set a key with `next_key` before adding a key-value pair")
+        result = self._next_key
+        self._next_key = None
+        return result
+
+    def next_val(self, val):
+        """Use this to append a literal (or JSON-encodable) value as the next
+        item in the current context.
+        """
+        if isinstance(self.context, _DictOutputContext):
+            # sneakily keep track of uids
+            if (self._next_key == json_objects.Tokens.UID
+                    or json_objects.aliases.get(self._next_key) == json_objects.Tokens.UID):
+                if val in self.uids:
+                    raise OutputStateError("uid {} is already defined in this snapshot"
+                                           .format(val))
+                else:
+                    self.uids.add(val)
+            self.context.key_val(self._use_key(), val)
+        else:
+            self.context.item(val)
+
+    def _push(self, *args, **kwargs):
+        if isinstance(self.context, _DictOutputContext):
+            self.context.key_push(self._use_key(), *args, **kwargs)
+        else:
+            self.context.item_push(*args, **kwargs)
+        self.context = self.context.cur_child
+
+    @contextlib.contextmanager
+    def push(self, mapping=True):
+        """Use this to append a sub-context as the next item in the current
+        context.  (The sub-context is for a dictionary by default.)
+
+        After calling `push`, the `OutputManager.context` field will hold the
+        new child context.
+
+        When you're done with that context, you should call `pop` to cleanly
+        end the context that you pushed and to restore the
+        `OutputManager.context` field to its original value.
+        """
+        if mapping:
+            self._push(_DictOutputContext)
+        else:
+            self._push(_ListOutputContext)
+        try:
+            yield
+        finally:
+            self.context.end()
+            self.context = self.context.parent
+
+    def start_snapshot(self):
+        """Write a snapshot.  Use as a context manager"""
+        # if self.context is not self.snapshot_ctx:
+        #     self.snapshot_ctx.cur_child.
+        self.context = self.snapshot_ctx
+        return self.push(mapping=False)
+
+    def end(self):
+        self.snapshot_ctx.end()
+        self.outfile.flush()
+        # print("", file=self.outfile)
+
+    def current_snapshot(self):
+        return self.snapshot_ctx.cur_child
+
diff --git a/algviz/interface/visitors.py b/algviz/interface/visitors.py
new file mode 100644
index 0000000..2b8dca9
--- /dev/null
+++ b/algviz/interface/visitors.py
@@ -0,0 +1,192 @@
+import abc
+
+from algviz.parser.json_objects import Tokens
+
+class Visitor(metaclass=abc.ABCMeta):
+
+    def __init__(self, output_mngr, data_visitor=None):
+        """Positional parameters:
+        * An `algviz.interface.output.OutputManager` to use for output
+
+        Keyword parameters:
+        * data_visitor -- an instance of visitor to use on any data by default.
+        If this is None, DispatchVisitor will be used.
+        """
+        self.output_mngr = output_mngr
+        # assert hasattr(self, "type_"), "Visitor subclasses need a 'type_' attribute"
+        self.data_visitor = data_visitor
+        if data_visitor is None:
+            self.data_visitor = DispatchVisitor(self.output_mngr)
+
+    def uid(self, obj):
+        """Return a unique identifier for this object.
+
+        The identifier is guaranteed unique until the state of the objects
+        under inspection is altered, i.e. until the objects are mutated,
+        overwritten, or recounted.
+        """
+        return str(id(obj))
+
+    def traverse(self, obj, **kwargs):
+        # To traverse most simple objects, we can just visit them.
+        # More complicated objects like graphs will require actual traversal
+        if self.uid(obj) in self.output_mngr.uids:
+            self.output_mngr.next_val(self.uid(obj))
+        else:
+            with self.output_mngr.push():
+                self.visit(obj, **kwargs)
+
+    @abc.abstractmethod
+    def visit(self, obj, metadata=None, var=None):
+        """
+        Emit the inside of the JSON dictionary representing the given object.
+        I.e. print out all the key-value pairs that represent the object.
+
+        `visit()` shouldn't be called directly but may be called from within
+        `traverse`.  Therefore when you implement `visit()` in subclasses, call
+        `traverse` to visit your attributes.  (Or just use @algviz macros so
+        you don't have to think about it.)
+        """
+        self.output_mngr.next_key(Tokens.TYPE)
+        self.output_mngr.next_val(self.type_)
+        self.output_mngr.next_key(Tokens.UID)
+        self.output_mngr.next_val(self.uid(obj))
+        if metadata is not None:
+            self.output_mngr.next_key(Tokens.METADATA)
+            self.output_mngr.next_val(metadata)
+        if var is not None:
+            self.output_mngr.next_key(Tokens.VARNAME)
+            self.output_mngr.next_val(var)
+
+
+class DispatchVisitor(Visitor):
+    """Handle objects with a default handler.  Useful when data stored is of mixed types.
+
+    Methods are dispatched to instances of an appropriate visitor based on the
+    class of the given object.  The MRO is checked so that, e.g., a subclass of
+    Foo will be handled by the Foo handler unless it has its own handler.
+
+    By default, the handlers are given in `_dispatch_visit_dict`.  The
+    `updates` keyword argument to `__init__` is used to modify the instance's
+    copy of that dictionary for more customized behavior.
+    """
+
+    def __init__(self, output_mngr, updates=None, **kwargs):
+        # If data_visitor is unspecified, a new instance of this class is
+        # created.  So we must use `self` instead to prevent a crash.
+        kwargs.setdefault("data_visitor", self)
+        super().__init__(output_mngr, **kwargs)
+        self.dispatch_dict = _dispatch_visit_dict.copy()
+        if updates is not None:
+            # This lets us do interesting things like choose non-default handlers for some data structure.  E.g. assume a `list` instance represents a heap
+            self.dispatch_dict.update(updates)
+
+    def _dispatch_method(self, methodname, obj, *args, **kwargs):
+        # Call the named method on the appropriate visitor subclass
+        for superclass in type(obj).mro():
+            if superclass in self.dispatch_dict:
+                # Get an appropriate visitor
+                visitor = self.dispatch_dict[superclass](self.output_mngr, data_visitor=self)
+                # Call the desired method on that visitor
+                return getattr(visitor, methodname)(obj, *args, **kwargs)
+
+    def uid(self, obj, **kwargs):
+        return self._dispatch_method("uid", obj, **kwargs)
+
+    def traverse(self, obj, *args, **kwargs):
+        return self._dispatch_method("traverse", obj, *args, **kwargs)
+
+    def visit(self, obj, *args, **kwargs):
+        return self._dispatch_method("visit", obj, *args, **kwargs)
+
+_dispatch_visit_dict = {
+    # list: ArrayVisitor,
+    # int: NumberVisitor,
+}
+def default_for_type(*types):
+    """Decorated class will become the default visitor for the given type(s).
+    See DispatchVisitor.
+
+    Returns a decorator
+    """
+    def _decorator(cls):
+        for type_ in types:
+            assert type_ not in _dispatch_visit_dict, (
+                "Multiple handlers for type {}: {} and {}".format(
+                    type_, cls, _dispatch_visit_dict[type_]))
+            _dispatch_visit_dict[type_] = cls
+        return cls
+    return _decorator
+
+@default_for_type(list)
+class ArrayVisitor(Visitor):
+    """Visit an array, letting `self.data_visitor` traverse each item in the array.
+
+    This visitor handles the array with `self.length` and `self.get_item`.  If
+    your object implements __len__ and __getitem__, then you won't need to
+    change those methods.  (On the other hand, you could override these methods
+    to do something cool, e.g. treat an int as an array of True and False
+    values.)
+    """
+    type_ = Tokens.ARRAY_T
+    # We don't assume the object being treated as an Array is reasonable.
+    # E.g. you could easily have an Array of bits represented by an int.
+
+    def length(self, array):
+        return len(array)
+
+    def get_item(self, array, i):
+        return array[i]
+
+    def visit(self, array, *args, **kwargs):
+        """
+        context is guaranteed to be a dictionary context where the array body should go, or else
+        If we make it here, somebody already checked that the uid hasn't been included in this snapshot yet.
+        """
+        super().visit(array, *args, **kwargs)  # UID and TYPE
+        self.output_mngr.next_key(Tokens.DATA)
+        with self.output_mngr.push(mapping=False):
+            for i in range(self.length(array)):
+                self.data_visitor.traverse(self.get_item(array, i))
+
+# class TreeVisitor
+
+@default_for_type(int, float)
+class NumberVisitor(Visitor):
+
+    def traverse(self, i, **kwargs):
+        # A float or int can be handed straight to the output manager
+        # This is a rare case where it's appropriate to reimplement `traverse`
+        assert isinstance(i, (float, int))
+        self.output_mngr.next_val(i)
+
+    def visit(self, i, *args, **kwargs):
+        raise NotImplementedError("Something has gone wrong if we're visiting an int (since visiting it implies making a JSON dictionary for it)")
+
+
+@default_for_type(str)
+class StringVisitor(Visitor):
+
+    type_ = Tokens.STRING_T
+
+    def to_str(self, obj):
+        """Override this if you have some non-string object that you want to
+        display as a string, and if calling `__str__` on it isn't good enough.
+        (E.g. if you need to do `bytes.to_string(encoding="UTF-8")` instead.)
+
+        `to_str` should return a string.
+        """
+        return str(obj)
+
+    def visit(self, str_, *args, **kwargs):
+        super().visit(str_, *args, **kwargs)
+        self.output_mngr.next_key(Tokens.DATA)
+        self.output_mngr.next_val(self.to_str(str_))
+
+@default_for_type(object)
+class WidgetVisitor(Visitor):
+    """A Widget is a "don't care" object, quite like a `void*`"""
+    type_ = Tokens.WIDGET_T
+
+    def visit(self, *args, **kwargs):
+        return super().visit(*args, **kwargs)
diff --git a/algviz/interface/weird_visitors.py b/algviz/interface/weird_visitors.py
new file mode 100644
index 0000000..45ed59c
--- /dev/null
+++ b/algviz/interface/weird_visitors.py
@@ -0,0 +1,17 @@
+from . import visitors
+import math
+
+class BitmapArrayVisitor(visitors.ArrayVisitor):
+    """Interpret an `int` as an array of 0s and 1s"""
+
+    def __init__(self, output_mngr, *args, data_visitor=None, **kwargs):
+        if data_visitor is None:
+            data_visitor = visitors.NumberVisitor(output_mngr)
+        super().__init__(output_mngr, *args, data_visitor=data_visitor, **kwargs)
+
+    def length(self, x):
+        return math.ceil(math.log(x, 2))
+
+    def get_item(self, x, i):
+        # Return the i'th bit of x
+        return int(bool(x & (2**i)))
diff --git a/algviz/parser/json_objects.py b/algviz/parser/json_objects.py
index cd36f94..ace9048 100644
--- a/algviz/parser/json_objects.py
+++ b/algviz/parser/json_objects.py
@@ -1,5 +1,7 @@
 import json
 from . import structures
+import logging
+logger = logging.getLogger(__name__)
 
 class Tokens:
     """Tokens we expect to see in the JSON"""
@@ -204,6 +206,18 @@ def decode_snapshot(*objects):
                          skip=json_keys_to_skip)
     return sd.finalize()
 
+def reads(text):
+    """This smoothly handles the case where we never printed the closing "]",
+    since that's hard to do."""
+    try:
+        return decode_json(text)
+    except json.JSONDecodeError:
+        logger.info("decoding again with extra ']' added on the end")
+        return decode_json(text + "]")
+
+def read(file_obj):
+    return reads(file_obj.read())
+
 def validate(json_stuff):
     # We will want to check stuff here, but obviously we don't yet.
     # TODO open an issue for this.
diff --git a/algviz/tools/quicksort_tree.py b/algviz/tools/quicksort_tree.py
new file mode 100644
index 0000000..18efc23
--- /dev/null
+++ b/algviz/tools/quicksort_tree.py
@@ -0,0 +1,59 @@
+"""
+Demonstrates some low-level hacking on our own APIs, by printing a call tree
+for a recursive quicksort function.
+
+This whole thing could be done very differently by using our tree-visitor API,
+once we have one.  (The two branches of "quicksort" would be performed by the
+`get_child()` method of the tree API.)
+"""
+
+import sys
+import argparse
+
+from algviz.interface import output
+
+def quicksort(items, uid_str, do_a_thing):
+    do_a_thing(uid_str, items)
+    if len(items) <= 1:
+        return items
+    [pivot, *rest] = items
+    return (quicksort([x for x in rest if x <= pivot], uid_str + "L", do_a_thing)
+            + [pivot]
+            + quicksort([x for x in rest if x > pivot], uid_str + "R", do_a_thing))
+
+def mk_qs_node_visitor(output_manager):
+    def visit_qs_node(uid_str, items):
+        nonlocal output_manager
+        result = {"uid": uid_str,
+                  "type": "btnode",
+                  "data": {"type": "array",
+                           "data": items}}
+
+        if len(items) > 1:
+            # This node will have children
+            result["children"] = [uid_str + "L", uid_str + "R"]
+        # `next_val` can print anything that `json.dumps()` accepts:
+        output_manager.next_val(result)
+    return visit_qs_node
+
+def read_numbers(infile):
+    return [float(num) for num in infile.read().split()]
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="""
+        Quick-sort some numbers and print the tree of calls made
+        Example usage: `echo 1 8 4 5 6 2 9 | %(prog)s -`
+        """)
+    parser.add_argument("infile", type=argparse.FileType("r"),
+                        help="File with whitespace-separated numbers to sort")
+    args = parser.parse_args()
+    numbers = read_numbers(args.infile)
+    out = output.OutputManager()
+    visitor = mk_qs_node_visitor(out)
+    with out.start_snapshot():
+        quicksort(numbers, "mycalltree", visitor)
+    out.end()
+
+if __name__ == "__main__":
+    main()
diff --git a/setup.py b/setup.py
index e6337ee..ad6c5e8 100644
--- a/setup.py
+++ b/setup.py
@@ -10,6 +10,7 @@ setup(name='algviz',
       entry_points={
           "console_scripts": [
               "algviz_graph_mockup=algviz.tools.graph_drawing_mockup:main",
+              "algviz_quicksort_example=algviz.tools.quicksort_tree:main",
           ]},
       install_requires=['pygraphviz'],
 )

APIs to print JSON from Python
This is the "API" part of #4 .
diff --git a/algviz/interface/test_high_level.py b/algviz/interface/test_high_level.py
new file mode 100644
index 0000000..4da7401
--- /dev/null
+++ b/algviz/interface/test_high_level.py
@@ -0,0 +1,30 @@
+#!/usr/bin/env python3
+
+import unittest
+from unittest import mock
+
+from . import visitors
+from .testutil import TempFileTestMixin
+from algviz.parser import json_objects
+
+class HighLevelTestCase(TempFileTestMixin, unittest.TestCase):
+
+    def setUp(self):
+        self.setup_tempfile()
+
+    def tearDown(self):
+        self.teardown_tempfile()
+
+    def test_functional_show_interface(self):
+        mylist = [1, 2, 3, 4, 5]
+        with self.patch_stdout():  # replace stdout with self.tempfile
+            from . import high_level as hl
+            hl.show(mylist, "myvarname", visitors.ArrayVisitor)
+            hl.show("mystring", "stringname")
+        self.assertEqual(mylist, [1, 2, 3, 4, 5], msg="We broke the list while printing it")
+        text = self.read_tempfile()
+        [list_snapshot, str_snapshot] = json_objects.reads(text)
+        self.assertEqual(list(list_snapshot.names["myvarname"]),
+                         [1, 2, 3, 4, 5])
+        self.assertEqual(str(str_snapshot.names["stringname"]),
+                         "mystring")
diff --git a/algviz/interface/test_output.py b/algviz/interface/test_output.py
new file mode 100644
index 0000000..8121be7
--- /dev/null
+++ b/algviz/interface/test_output.py
@@ -0,0 +1,107 @@
+import unittest
+import tempfile
+import contextlib
+
+from . import output
+
+class OutputManagerTestCase(unittest.TestCase):
+
+    def setUp(self):
+        self.tmpfile = tempfile.TemporaryFile("r+")
+        self.outman = output.OutputManager(outfile=self.tmpfile)
+
+    def tearDown(self):
+        self.tmpfile.close()
+
+    def test_basic_usage(self):
+        with self.outman.start_snapshot():  # start a snapshot
+            with self.outman.push():  # start an object
+                self.outman.next_key("mykey")
+                with self.outman.push(mapping=False):
+                    for i in range(5):
+                        self.outman.next_val(i)
+                self.outman.next_key("other")
+                self.outman.next_val("thingy")
+        self.outman.end()  # close the list of snapshots
+        result = self._get_text()
+        self.assertEqual(result.strip(),
+                         """
+[
+  [
+    {
+      "mykey": [
+        0,
+        1,
+        2,
+        3,
+        4
+      ],
+      "other": "thingy"
+    }
+  ]
+]
+                         """.strip())
+
+    def test_error_for_duplicate_key(self):
+        with self.assertRaisesRegex(output.OutputStateError,
+                                     "Key .data. is a duplicate.*"):
+            with self.outman.start_snapshot():
+                with self.outman.push():  # start an object
+                    self.outman.next_key("data")
+                    self.outman.next_val(1)
+                    self.outman.next_key("data")
+                    self.outman.next_val(2)
+
+    def test_error_for_invalid_key(self):
+        with self.assertRaisesRegex(TypeError,
+                                    "JSON keys must be string.*"):
+            with self.outman.start_snapshot():
+                with self.outman.push():
+                    self.outman.next_key(12)
+
+    def test_error_for_setting_next_key_without_using_prev_key(self):
+        with self.assertRaisesRegex(output.OutputStateError,
+                                    "previous key .*foo.* not used .*bar.*"):
+            with self.outman.start_snapshot():
+                with self.outman.push():
+                    self.outman.next_key("foo")
+                    self.outman.next_key("bar")
+
+    def test_error_for_key_value_pair_in_a_list(self):
+        with self.assertRaisesRegex(output.OutputStateError,
+                                    "cannot set a key .* in non-mapping context .*"):
+            with self.outman.start_snapshot():
+                self.outman.next_key("asdf")
+
+    def test_error_for_adding_value_with_no_key_in_mapping(self):
+        with self.assertRaisesRegex(output.OutputStateError,
+                                   "Must set a key .*"):
+            with self.outman.start_snapshot():
+                with self.outman.push():
+                    # Do the first key normally
+                    self.outman.next_key("llama")
+                    self.outman.next_val("elephant")
+                    # Now mess up
+                    self.outman.next_val("aardvark")
+
+
+    def test_error_for_defining_same_uid_twice_in_snapshot(self):
+        with self.assertRaisesRegex(output.OutputStateError,
+                                    "uid .*asdf.* already defined in this snapshot"):
+            with self.outman.start_snapshot():
+                with self.outman.push():
+                    # First widget
+                    self.outman.next_key("uid")
+                    self.outman.next_val("asdf")
+                    self.outman.next_key("type")
+                    self.outman.next_val("widget")
+                with self.outman.push():
+                    self.outman.next_key("uid")
+                    self.outman.next_val("asdf")
+
+    def _get_text(self):
+        self.tmpfile.seek(0)
+        return self.tmpfile.read()
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/algviz/interface/test_visitors.py b/algviz/interface/test_visitors.py
new file mode 100644
index 0000000..164c96f
--- /dev/null
+++ b/algviz/interface/test_visitors.py
@@ -0,0 +1,95 @@
+import unittest
+
+from algviz.parser import json_objects, structures
+from . import output
+from . import visitors
+from .testutil import TempFileMixin
+
+class VisitorTestCaseMixin(TempFileMixin):
+
+    def setUp(self):
+        self.setup_tempfile()
+        self.output_mngr = output.OutputManager(outfile=self.tempfile)
+        self.visitor = self.visitor_cls(self.output_mngr)
+
+    def tearDown(self):
+        self.teardown_tempfile()
+
+    def read_result(self):
+        self.output_mngr.end()
+        text = self.read_tempfile()
+        return text, json_objects.decode_json(text)
+
+    def to_hell_and_back_full_result(self, instance, **kwargs):
+        """Convenience for test cases where you only need to encode and decode
+        one instance.  Returns (json_text, decoded_object)
+        """
+        with self.output_mngr.start_snapshot():
+            self.visitor.traverse(instance, **kwargs)
+        return self.read_result()
+
+    def to_hell_and_back(self, instance, **kwargs):
+        """Visit the object, print it out, decode it, and return the resulting object"""
+        _, snapshots = self.to_hell_and_back_full_result(instance, **kwargs)
+        return snapshots[-1].obj_table.getuid(self.visitor.uid(instance))
+
+    def test_metadata(self):
+        """Make sure metadata makes it through the process the way it should"""
+        def mk_metadata():
+            return {"I": {"AM": ["metadataaaaaaaaaaa", 1]},
+                    "the number eight": 8, "note": "keys must be strings"}
+        self.assertIsNot(mk_metadata(), mk_metadata(),
+                         msg="""This test doesn't work.  We want different
+                         instances of identical dictionaries, or else the test
+                         can be passed by calling `metadata.clear()`.""")
+        result = self.to_hell_and_back(self.sample_instance(),
+                                       metadata=mk_metadata())
+        self.assertEqual(mk_metadata(), result.metadata)
+
+    def test_varnames(self):
+        """Ensure the correct object has the correct variable name"""
+        inst1 = self.sample_instance()
+        inst2 = self.sample_instance()
+        with self.output_mngr.start_snapshot():
+            self.visitor.traverse(inst1, var="inst1")
+            self.visitor.traverse(inst2, var="inst2")
+        _, [snapshot] = self.read_result()
+        for inst, name in [(inst1, "inst1"), (inst2, "inst2")]:
+            self.assertEqual(snapshot.names[name],
+                             snapshot.obj_table.getuid(self.visitor.uid(inst)))
+
+    def sample_instance(self):
+        """Should return an object suitable for `self.visitor` to traverse.
+
+        Successive calls should return distinct objects.
+        """
+        raise NotImplementedError("Implement in each subclass.  See docstring")
+
+
+class WidgetVisitorTestCase(VisitorTestCaseMixin, unittest.TestCase):
+    visitor_cls = visitors.WidgetVisitor
+
+    def test_widget_export_and_import(self):
+        with self.output_mngr.start_snapshot():
+            self.visitor.traverse("Some string", var="first")
+            self.visitor.traverse(7, var="second", metadata={"hello": "world"})
+        _, snapshots = self.read_result()
+        first = snapshots[0].names["first"]
+        self.assertIsInstance(first, structures.Widget)
+        snd = snapshots[0].names["second"]
+        self.assertIsInstance(snd, structures.Widget)
+        self.assertEqual(snd.metadata, {"hello": "world"})
+
+    def sample_instance(self):
+        return object()
+
+class ArrayVisitorTestCase(VisitorTestCaseMixin, unittest.TestCase):
+    visitor_cls = visitors.ArrayVisitor
+
+    def sample_instance(self):
+        return [1, 2, 3]
+
+    def test_array_export_and_import(self):
+        arr = self.to_hell_and_back([1, 2, 3])
+        self.assertIsInstance(arr, structures.Array)
+        self.assertEqual(list(arr), [1, 2, 3])
diff --git a/algviz/interface/test_weird_visitors.py b/algviz/interface/test_weird_visitors.py
new file mode 100644
index 0000000..11e9f57
--- /dev/null
+++ b/algviz/interface/test_weird_visitors.py
@@ -0,0 +1,28 @@
+import unittest
+import tempfile
+
+from algviz.parser import json_objects
+from . import weird_visitors
+from . import output
+
+from .test_visitors import VisitorTestCaseMixin
+
+class BitmapVisitorTestCase(VisitorTestCaseMixin, unittest.TestCase):
+    visitor_cls = weird_visitors.BitmapArrayVisitor
+    def setUp(self):
+        super().setUp()
+        self._next_sample_bool = True
+
+    def test_bitmap_visit(self):
+        with self.output_mngr.start_snapshot():
+            self.visitor.traverse(123, var="mybits")  # traverse 123 == 0b1111011 as a bitmap
+        _, snapshots = self.read_result()
+        array = snapshots[0].names["mybits"]
+        self.assertEqual(list(array), [1, 1, 0, 1, 1, 1, 1])
+
+    def sample_instance(self):
+        # This is a hack to make the number returned always be the same
+        # but the UID of two consecutive instances be different.
+        # (True == 1 but id(True) != id(1))
+        self._next_sample_bool ^= True
+        return True if self._next_sample_bool else 1
diff --git a/algviz/interface/testutil.py b/algviz/interface/testutil.py
new file mode 100644
index 0000000..b024db2
--- /dev/null
+++ b/algviz/interface/testutil.py
@@ -0,0 +1,64 @@
+import contextlib
+import tempfile
+import unittest
+from unittest import mock
+
+class TempFileMixin(object):
+    def setup_tempfile(self):
+        self.tempfile = tempfile.TemporaryFile("r+")
+
+    def read_tempfile(self):
+        self.tempfile.seek(0)
+        return self.tempfile.read()
+
+    def teardown_tempfile(self):
+        self.tempfile.close()
+
+    @contextlib.contextmanager
+    def patch_stdout(self):
+        try:
+            with mock.patch("sys.stdout", new=self.tempfile):
+                yield
+        finally:
+            pass
+
+class TempFileTestMixin(TempFileMixin):
+    """Must precede `unittest.TestCase` in the method resolution order (`mro`).
+
+    (This means it must be listed before `unittest.TestCase` in the subclass
+    definition.)
+    """
+    def setUp(self):
+        self.setup_tempfile()
+        super().setUp()
+
+    def tearDown(self):
+        self.teardown_tempfile()
+        super().tearDown()
+
+class TempFileMixinTestCase(TempFileTestMixin, unittest.TestCase):
+
+    def test_patching_stdout(self):
+
+        with self.patch_stdout():
+            print("I am a potato")
+        self.assertEqual(self.read_tempfile(),
+                         "I am a potato\n")
+
+    def test_patching_stdout_error_condition(self):
+        class MySillyException(Exception):
+            pass
+        try:
+            with self.patch_stdout():
+                print("foo")
+                raise MySillyException()
+        except MySillyException:
+            pass
+        # Now make sure stdout is normal again...
+        # This means our test may have to print stuff to stdout
+        print("excuse me")
+        self.assertEqual(self.read_tempfile(),
+                         "foo\n")
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/algviz/parser/test_json_objects.py b/algviz/parser/test_json_objects.py
index 04c14fc..1f0eac0 100644
--- a/algviz/parser/test_json_objects.py
+++ b/algviz/parser/test_json_objects.py
@@ -46,6 +46,14 @@ class JSONObjectsTestCase(unittest.TestCase):
                          structures.Array([1, 2, 3, snapshot.names["my_widget"]],
                                           uid="testuid"))
 
+    def test_can_handle_missing_outermost_close_bracket(self):
+        """Sometimes it's more trouble than it's worth to print the last
+        closing brace, since that amounts to saying "I'm confident there will
+        be no more printing after this!"
+        """
+        self.assertEqual(json_objects.reads('[[{"T": "widget"}]]'),
+                         json_objects.reads('[[{"T": "widget"}]'))
+
 class GenericDecodingTestCase(unittest.TestCase):
     """Make a subclass of this to test decoding of a specific type of object.
 

pip install -e .
parse_log_pytest
pygraphviz
pytest
apt-get update apt-get install -y gcc
3.9
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
-e git+https://github.com/Agizin/Algorithm-Visualization.git@f0641b860b384bf2760819b4f1bd5548261718c3#egg=algviz
exceptiongroup==1.2.2
iniconfig==2.1.0
packaging==24.2
pluggy==1.5.0
pygraphviz @ file:///croot/pygraphviz_1671045577740/work
pytest==8.3.5
tomli==2.2.1

name: Algorithm-Visualization
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - atk-1.0=2.36.0=ha1a6a79_0
  - boost-cpp=1.82.0=hdb19cb5_2
  - bzip2=1.0.8=h5eee18b_6
  - c-ares=1.19.1=h5eee18b_0
  - ca-certificates=2025.2.25=h06a4308_0
  - cairo=1.16.0=hb05425b_5
  - expat=2.6.4=h6a678d5_0
  - font-ttf-dejavu-sans-mono=2.37=hd3eb1b0_0
  - font-ttf-inconsolata=2.001=hcb22688_0
  - font-ttf-source-code-pro=2.030=hd3eb1b0_0
  - font-ttf-ubuntu=0.83=h8b1ccd4_0
  - fontconfig=2.14.1=h55d465d_3
  - fonts-anaconda=1=h8fa9717_0
  - fonts-conda-ecosystem=1=hd3eb1b0_0
  - freetype=2.12.1=h4a9f257_0
  - fribidi=1.0.10=h7b6447c_0
  - gdk-pixbuf=2.42.10=h5eee18b_1
  - giflib=5.2.2=h5eee18b_0
  - glib=2.78.4=h6a678d5_0
  - glib-tools=2.78.4=h6a678d5_0
  - gobject-introspection=1.78.1=py39h42194e9_2
  - graphite2=1.3.14=h295c915_1
  - graphviz=2.50.0=h78213b7_2
  - gtk2=2.24.33=h27e1c3a_3
  - gts=0.7.6=hb67d8dd_3
  - harfbuzz=10.2.0=hf296adc_0
  - icu=73.1=h6a678d5_0
  - jpeg=9e=h5eee18b_3
  - krb5=1.20.1=h143b758_1
  - lcms2=2.16=hb9589c4_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - lerc=4.0.0=h6a678d5_0
  - libboost=1.82.0=h109eef0_2
  - libcurl=8.12.1=hc9e6f67_0
  - libdeflate=1.22=h5eee18b_0
  - libedit=3.1.20230828=h5eee18b_0
  - libev=4.33=h7f8727e_1
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgd=2.3.3=h6a678d5_3
  - libglib=2.78.4=hdc74915_0
  - libgomp=11.2.0=h1234567_1
  - libiconv=1.16=h5eee18b_3
  - libnghttp2=1.57.0=h2d74bed_0
  - libpng=1.6.39=h5eee18b_0
  - librsvg=2.56.3=hf6914bd_1
  - libssh2=1.11.1=h251f7ec_0
  - libstdcxx-ng=11.2.0=h1234567_1
  - libtiff=4.5.1=hffd6297_1
  - libtool=2.4.7=h6a678d5_0
  - libuuid=1.41.5=h5eee18b_0
  - libwebp=1.2.4=h11a3e52_1
  - libwebp-base=1.2.4=h5eee18b_1
  - libxcb=1.15=h7f8727e_0
  - libxml2=2.13.5=hfdd30dd_0
  - lz4-c=1.9.4=h6a678d5_1
  - ncurses=6.4=h6a678d5_0
  - ninja=1.12.1=h06a4308_0
  - ninja-base=1.12.1=hdb19cb5_0
  - nspr=4.35=h6a678d5_0
  - nss=3.89.1=h6a678d5_0
  - openjpeg=2.5.2=he7f1fd0_0
  - openssl=3.0.16=h5eee18b_0
  - pango=1.50.7=h0fee60c_1
  - pcre2=10.42=hebb0a14_1
  - pip=25.0=py39h06a4308_0
  - pixman=0.40.0=h7f8727e_1
  - poppler=24.09.0=hcf11d46_1
  - poppler-data=0.4.11=h06a4308_1
  - pygraphviz=1.9=py39h5eee18b_1
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - zstd=1.5.6=hc292b87_0
  - pip:
      - exceptiongroup==1.2.2
      - iniconfig==2.1.0
      - packaging==24.2
      - pluggy==1.5.0
      - pytest==8.3.5
      - tomli==2.2.1
prefix: /opt/conda/envs/Algorithm-Visualization

algviz/interface/test_high_level.py::HighLevelTestCase::test_functional_show_interface algviz/interface/test_output.py::OutputManagerTestCase::test_basic_usage algviz/interface/test_output.py::OutputManagerTestCase::test_error_for_adding_value_with_no_key_in_mapping algviz/interface/test_output.py::OutputManagerTestCase::test_error_for_defining_same_uid_twice_in_snapshot algviz/interface/test_output.py::OutputManagerTestCase::test_error_for_duplicate_key algviz/interface/test_output.py::OutputManagerTestCase::test_error_for_invalid_key algviz/interface/test_output.py::OutputManagerTestCase::test_error_for_key_value_pair_in_a_list algviz/interface/test_output.py::OutputManagerTestCase::test_error_for_setting_next_key_without_using_prev_key algviz/interface/test_visitors.py::WidgetVisitorTestCase::test_metadata algviz/interface/test_visitors.py::WidgetVisitorTestCase::test_varnames algviz/interface/test_visitors.py::WidgetVisitorTestCase::test_widget_export_and_import algviz/interface/test_visitors.py::ArrayVisitorTestCase::test_array_export_and_import algviz/interface/test_visitors.py::ArrayVisitorTestCase::test_metadata algviz/interface/test_visitors.py::ArrayVisitorTestCase::test_varnames algviz/interface/test_weird_visitors.py::BitmapVisitorTestCase::test_bitmap_visit algviz/interface/test_weird_visitors.py::BitmapVisitorTestCase::test_metadata algviz/interface/test_weird_visitors.py::BitmapVisitorTestCase::test_varnames algviz/interface/testutil.py::TempFileMixinTestCase::test_patching_stdout algviz/interface/testutil.py::TempFileMixinTestCase::test_patching_stdout_error_condition algviz/parser/test_json_objects.py::JSONObjectsTestCase::test_aliases_are_not_already_tokens algviz/parser/test_json_objects.py::JSONObjectsTestCase::test_can_handle_missing_outermost_close_bracket algviz/parser/test_json_objects.py::JSONObjectsTestCase::test_fix_aliases algviz/parser/test_json_objects.py::JSONObjectsTestCase::test_literal_decoding algviz/parser/test_json_objects.py::JSONObjectsTestCase::test_var_key_shows_up_in_namespace algviz/parser/test_json_objects.py::GenericDecodingTestCase::test_equality_depends_on_uid algviz/parser/test_json_objects.py::GenericDecodingTestCase::test_has_proper_metadata algviz/parser/test_json_objects.py::GenericDecodingTestCase::test_has_proper_type algviz/parser/test_json_objects.py::GenericDecodingTestCase::test_has_proper_uid algviz/parser/test_json_objects.py::GenericDecodingTestCase::test_hash_matches_hash_of_placeholder algviz/parser/test_json_objects.py::GenericDecodingTestCase::test_matches_expected_object algviz/parser/test_json_objects.py::GenericDecodingTestCase::test_same_object_method_works_based_on_uid algviz/parser/test_json_objects.py::ArrayDecodingTestCase::test_equality_depends_on_uid algviz/parser/test_json_objects.py::ArrayDecodingTestCase::test_has_proper_metadata algviz/parser/test_json_objects.py::ArrayDecodingTestCase::test_has_proper_type algviz/parser/test_json_objects.py::ArrayDecodingTestCase::test_has_proper_uid algviz/parser/test_json_objects.py::ArrayDecodingTestCase::test_hash_matches_hash_of_placeholder algviz/parser/test_json_objects.py::ArrayDecodingTestCase::test_matches_expected_object algviz/parser/test_json_objects.py::ArrayDecodingTestCase::test_same_object_method_works_based_on_uid algviz/parser/test_json_objects.py::TreeNodeDecodingTestCase::test_equality_depends_on_uid algviz/parser/test_json_objects.py::TreeNodeDecodingTestCase::test_has_proper_metadata algviz/parser/test_json_objects.py::TreeNodeDecodingTestCase::test_has_proper_type algviz/parser/test_json_objects.py::TreeNodeDecodingTestCase::test_has_proper_uid algviz/parser/test_json_objects.py::TreeNodeDecodingTestCase::test_hash_matches_hash_of_placeholder algviz/parser/test_json_objects.py::TreeNodeDecodingTestCase::test_matches_expected_object algviz/parser/test_json_objects.py::TreeNodeDecodingTestCase::test_same_object_method_works_based_on_uid algviz/parser/test_json_objects.py::NullDecodingTestCase::test_equality_depends_on_uid algviz/parser/test_json_objects.py::NullDecodingTestCase::test_has_proper_metadata algviz/parser/test_json_objects.py::NullDecodingTestCase::test_has_proper_type algviz/parser/test_json_objects.py::NullDecodingTestCase::test_has_proper_uid algviz/parser/test_json_objects.py::NullDecodingTestCase::test_hash_matches_hash_of_placeholder algviz/parser/test_json_objects.py::NullDecodingTestCase::test_matches_expected_object algviz/parser/test_json_objects.py::NullDecodingTestCase::test_same_object_method_works_based_on_uid algviz/parser/test_json_objects.py::OtherNullDecodingTestCase::test_equality_depends_on_uid algviz/parser/test_json_objects.py::OtherNullDecodingTestCase::test_has_proper_metadata algviz/parser/test_json_objects.py::OtherNullDecodingTestCase::test_has_proper_type algviz/parser/test_json_objects.py::OtherNullDecodingTestCase::test_has_proper_uid algviz/parser/test_json_objects.py::OtherNullDecodingTestCase::test_hash_matches_hash_of_placeholder algviz/parser/test_json_objects.py::OtherNullDecodingTestCase::test_matches_expected_object algviz/parser/test_json_objects.py::OtherNullDecodingTestCase::test_same_object_method_works_based_on_uid algviz/parser/test_json_objects.py::StringDecodingTestCase::test_equality_depends_on_uid algviz/parser/test_json_objects.py::StringDecodingTestCase::test_has_proper_metadata algviz/parser/test_json_objects.py::StringDecodingTestCase::test_has_proper_type algviz/parser/test_json_objects.py::StringDecodingTestCase::test_has_proper_uid algviz/parser/test_json_objects.py::StringDecodingTestCase::test_hash_matches_hash_of_placeholder algviz/parser/test_json_objects.py::StringDecodingTestCase::test_matches_expected_object algviz/parser/test_json_objects.py::StringDecodingTestCase::test_same_object_method_works_based_on_uid algviz/parser/test_json_objects.py::PointerDecodingTestCase::test_equality_depends_on_uid algviz/parser/test_json_objects.py::PointerDecodingTestCase::test_has_proper_metadata algviz/parser/test_json_objects.py::PointerDecodingTestCase::test_has_proper_type algviz/parser/test_json_objects.py::PointerDecodingTestCase::test_has_proper_uid algviz/parser/test_json_objects.py::PointerDecodingTestCase::test_hash_matches_hash_of_placeholder algviz/parser/test_json_objects.py::PointerDecodingTestCase::test_matches_expected_object algviz/parser/test_json_objects.py::PointerDecodingTestCase::test_same_object_method_works_based_on_uid algviz/parser/test_json_objects.py::GraphDecodingTestCase::test_equality_depends_on_uid algviz/parser/test_json_objects.py::GraphDecodingTestCase::test_has_proper_metadata algviz/parser/test_json_objects.py::GraphDecodingTestCase::test_has_proper_type algviz/parser/test_json_objects.py::GraphDecodingTestCase::test_has_proper_uid algviz/parser/test_json_objects.py::GraphDecodingTestCase::test_hash_matches_hash_of_placeholder algviz/parser/test_json_objects.py::GraphDecodingTestCase::test_matches_expected_object algviz/parser/test_json_objects.py::GraphDecodingTestCase::test_same_object_method_works_based_on_uid algviz/parser/test_json_objects.py::NodeDecodingTestCase::test_equality_depends_on_uid algviz/parser/test_json_objects.py::NodeDecodingTestCase::test_has_proper_metadata algviz/parser/test_json_objects.py::NodeDecodingTestCase::test_has_proper_type algviz/parser/test_json_objects.py::NodeDecodingTestCase::test_has_proper_uid algviz/parser/test_json_objects.py::NodeDecodingTestCase::test_hash_matches_hash_of_placeholder algviz/parser/test_json_objects.py::NodeDecodingTestCase::test_matches_expected_object algviz/parser/test_json_objects.py::NodeDecodingTestCase::test_same_object_method_works_based_on_uid algviz/parser/test_json_objects.py::EdgeDecodingTestCase::test_equality_depends_on_uid algviz/parser/test_json_objects.py::EdgeDecodingTestCase::test_has_proper_metadata algviz/parser/test_json_objects.py::EdgeDecodingTestCase::test_has_proper_type algviz/parser/test_json_objects.py::EdgeDecodingTestCase::test_has_proper_uid algviz/parser/test_json_objects.py::EdgeDecodingTestCase::test_hash_matches_hash_of_placeholder algviz/parser/test_json_objects.py::EdgeDecodingTestCase::test_matches_expected_object algviz/parser/test_json_objects.py::EdgeDecodingTestCase::test_same_object_method_works_based_on_uid
setup.py algviz/interface/visitors.py algviz/interface/weird_visitors.py algviz/interface/output.py algviz/interface/high_level.py algviz/interface/__init__.py algviz/tools/quicksort_tree.py algviz/parser/json_objects.py
setup.py algviz/interface/visitors.py algviz/interface/weird_visitors.py algviz/interface/output.py algviz/interface/high_level.py algviz/interface/__init__.py algviz/tools/quicksort_tree.py algviz/parser/json_objects.py