diff --git a/docs/api/hierarchy.rst b/docs/api/hierarchy.rst
index 799657c8d0..5e0a22507c 100644
--- a/docs/api/hierarchy.rst
+++ b/docs/api/hierarchy.rst
@@ -19,6 +19,7 @@ Groups (``zarr.hierarchy``)
.. automethod:: visitkeys
.. automethod:: visitvalues
.. automethod:: visititems
+ .. automethod:: tree
.. automethod:: create_group
.. automethod:: require_group
.. automethod:: create_groups
diff --git a/notebooks/repr_tree.ipynb b/notebooks/repr_tree.ipynb
new file mode 100644
index 0000000000..a45162d720
--- /dev/null
+++ b/notebooks/repr_tree.ipynb
@@ -0,0 +1,204 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import zarr\n",
+ "\n",
+ "# Python 2/3 trick\n",
+ "try:\n",
+ " unicode\n",
+ "except NameError:\n",
+ " unicode = str"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "g1 = zarr.group()\n",
+ "g3 = g1.create_group('bar')\n",
+ "g3.create_group('baz')\n",
+ "g5 = g3.create_group('quux')\n",
+ "g5.create_dataset('baz', shape=100, chunks=10)\n",
+ "g7 = g3.create_group('zoo')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "/\n",
+ " +-- bar\n",
+ " +-- baz\n",
+ " +-- quux\n",
+ " | +-- baz[...]\n",
+ " +-- zoo\n"
+ ]
+ }
+ ],
+ "source": [
+ "print(bytes(g1.tree()).decode())"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "bar\n",
+ " ├── baz\n",
+ " ├── quux\n",
+ " │ └── baz[...]\n",
+ " └── zoo\n"
+ ]
+ }
+ ],
+ "source": [
+ "print(unicode(g3.tree()))"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ "
\n",
+ " /
\n",
+ " \n",
+ " bar
\n",
+ " \n",
+ " baz
\n",
+ " quux
\n",
+ " \n",
+ " baz[...]
\n",
+ "
\n",
+ " \n",
+ " zoo
\n",
+ "
\n",
+ " \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
\n"
+ ],
+ "text/plain": [
+ "/\n",
+ " └── bar\n",
+ " ├── baz\n",
+ " ├── quux\n",
+ " │ └── baz[...]\n",
+ " └── zoo"
+ ]
+ },
+ "execution_count": 5,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "g1.tree()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.6.3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/requirements.txt b/requirements.txt
index 28a7ceece4..10478995ee 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
+asciitree
nose
numpy
fasteners
diff --git a/requirements_dev.txt b/requirements_dev.txt
index d273262177..f54c565d2e 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -1,5 +1,6 @@
appdirs==1.4.3
args==0.1.0
+asciitree==0.3.3
certifi==2017.7.27.1
chardet==3.0.4
clint==0.5.1
diff --git a/requirements_rtfd.txt b/requirements_rtfd.txt
index 86091ed956..55730713f7 100644
--- a/requirements_rtfd.txt
+++ b/requirements_rtfd.txt
@@ -1,3 +1,4 @@
+asciitree
setuptools
setuptools_scm
sphinx
diff --git a/setup.py b/setup.py
index 11523fd229..c420174a75 100644
--- a/setup.py
+++ b/setup.py
@@ -23,6 +23,7 @@
'setuptools-scm>1.5.4'
],
install_requires=[
+ 'asciitree',
'numpy>=1.7',
'fasteners',
'numcodecs>=0.2.0',
diff --git a/zarr/hierarchy.py b/zarr/hierarchy.py
index dafbe4d238..2ead695c30 100644
--- a/zarr/hierarchy.py
+++ b/zarr/hierarchy.py
@@ -3,18 +3,17 @@
from collections import MutableMapping
from itertools import islice
-
import numpy as np
from zarr.compat import PY2
from zarr.attrs import Attributes
from zarr.core import Array
-from zarr.storage import (contains_array, contains_group, init_group, DictStore, group_meta_key,
- attrs_key, listdir, rmdir)
-from zarr.creation import (array, create, empty, zeros, ones, full, empty_like, zeros_like,
- ones_like, full_like, normalize_store_arg)
-from zarr.util import (normalize_storage_path, normalize_shape, InfoReporter,
+from zarr.storage import (contains_array, contains_group, init_group,
+ DictStore, group_meta_key, attrs_key, listdir, rmdir)
+from zarr.creation import (array, create, empty, zeros, ones, full,
+ empty_like, zeros_like, ones_like, full_like, normalize_store_arg)
+from zarr.util import (normalize_storage_path, normalize_shape, InfoReporter, TreeViewer,
is_valid_python_name, instance_dir)
from zarr.errors import err_contains_array, err_contains_group, err_group_not_found, err_read_only
from zarr.meta import decode_group_metadata
@@ -62,6 +61,7 @@ class Group(MutableMapping):
visitkeys
visitvalues
visititems
+ tree
create_group
require_group
create_groups
@@ -546,6 +546,34 @@ def visititems(self, func):
base_len = len(self.name)
return self.visitvalues(lambda o: func(o.name[base_len:].lstrip("/"), o))
+ def tree(self):
+ """Provide a ``print``-able display of the hierarchy.
+
+ Examples
+ --------
+ >>> import zarr
+ >>> g1 = zarr.group()
+ >>> g2 = g1.create_group('foo')
+ >>> g3 = g1.create_group('bar')
+ >>> g4 = g3.create_group('baz')
+ >>> g5 = g3.create_group('quux')
+ >>> d1 = g5.create_dataset('baz', shape=100, chunks=10)
+ >>> print(g1.tree())
+ /
+ ├── bar
+ │ ├── baz
+ │ └── quux
+ │ └── baz[...]
+ └── foo
+ >>> print(g3.tree())
+ bar
+ ├── baz
+ └── quux
+ └── baz[...]
+ """
+
+ return TreeViewer(self)
+
def _write_op(self, f, *args, **kwargs):
# guard condition
diff --git a/zarr/tests/test_hierarchy.py b/zarr/tests/test_hierarchy.py
index 48adc51a89..881767ea6b 100644
--- a/zarr/tests/test_hierarchy.py
+++ b/zarr/tests/test_hierarchy.py
@@ -4,6 +4,7 @@
import tempfile
import atexit
import shutil
+import textwrap
import os
import pickle
import warnings
@@ -18,6 +19,7 @@
from zarr.storage import (DictStore, DirectoryStore, ZipStore, init_group, init_array, attrs_key,
array_meta_key, group_meta_key, atexit_rmtree, NestedDirectoryStore)
from zarr.core import Array
+from zarr.compat import PY2, text_type
from zarr.hierarchy import Group, group, open_group
from zarr.attrs import Attributes
from zarr.errors import PermissionError
@@ -623,6 +625,109 @@ def visitor1(val, *args):
eq(True, g1.visitvalues(visitor1))
eq(True, g1.visititems(visitor1))
+ def test_tree(self):
+ # setup
+ g1 = self.create_group()
+ g2 = g1.create_group('foo')
+ g3 = g1.create_group('bar')
+ g3.create_group('baz')
+ g5 = g3.create_group('quux')
+ g5.create_dataset('baz', shape=100, chunks=10)
+
+ # test
+ bg1 = textwrap.dedent(u"""\
+ /
+ +-- bar
+ | +-- baz
+ | +-- quux
+ | +-- baz[...]
+ +-- foo""").encode()
+ eq(bg1, bytes(g1.tree()))
+ ug1 = textwrap.dedent(u"""\
+ /
+ ├── bar
+ │ ├── baz
+ │ └── quux
+ │ └── baz[...]
+ └── foo""")
+ eq(ug1, text_type(g1.tree()))
+ sg1 = ug1
+ if PY2:
+ sg1 = bg1
+ eq(sg1, repr(g1.tree()))
+ hg1 = textwrap.dedent(u"""\
+ """)
+ eq(hg1, g1.tree()._repr_html_().split("")[1].strip())
+
+ bg2 = textwrap.dedent(u"""\
+ foo""").encode()
+ eq(bg2, bytes(g2.tree()))
+ ug2 = textwrap.dedent(u"""\
+ foo""")
+ eq(ug2, text_type(g2.tree()))
+ sg2 = ug2
+ if PY2:
+ sg2 = bg2
+ eq(sg2, repr(g2.tree()))
+ hg2 = textwrap.dedent(u"""\
+ """)
+ eq(hg2, g2.tree()._repr_html_().split("")[1].strip())
+
+ bg3 = textwrap.dedent(u"""\
+ bar
+ +-- baz
+ +-- quux
+ +-- baz[...]""").encode()
+ eq(bg3, bytes(g3.tree()))
+ ug3 = textwrap.dedent(u"""\
+ bar
+ ├── baz
+ └── quux
+ └── baz[...]""")
+ eq(ug3, text_type(g3.tree()))
+ sg3 = ug3
+ if PY2:
+ sg3 = bg3
+ eq(sg3, repr(g3.tree()))
+ hg3 = textwrap.dedent(u"""\
+ """)
+ eq(hg3, g3.tree()._repr_html_().split("")[1].strip())
+
def test_empty_getitem_contains_iterators(self):
# setup
g = self.create_group()
diff --git a/zarr/util.py b/zarr/util.py
index 0333210575..a0b888d885 100644
--- a/zarr/util.py
+++ b/zarr/util.py
@@ -1,9 +1,11 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, print_function, division
import operator
-from textwrap import TextWrapper
+from textwrap import TextWrapper, dedent
import numbers
+from asciitree import BoxStyle, LeftAligned
+from asciitree.traversal import Traversal
import numpy as np
@@ -306,6 +308,171 @@ def _repr_html_(self):
return info_html_report(items)
+class ZarrGroupTraversal(Traversal):
+
+ def get_children(self, node):
+ return getattr(node, "values", lambda: [])()
+
+ def get_root(self, tree):
+ return tree
+
+ def get_text(self, node):
+ name = node.name.split("/")[-1] or "/"
+ name += "[...]" if hasattr(node, "dtype") else ""
+ return name
+
+
+def custom_html_sublist(group, indent):
+ traverser = ZarrGroupTraversal(tree=group)
+ result = ""
+
+ result += (
+ """{0}{1}
""".format(
+ indent, traverser.get_text(group)
+ )
+ )
+
+ children = traverser.get_children(group)
+ if children:
+ result += """\n{0}{0}\n""".format(indent)
+ for c in children:
+ for l in custom_html_sublist(c, indent).splitlines():
+ result += "{0}{0}{1}\n".format(indent, l)
+ result += "{0}{0}
\n{0}".format(indent)
+
+ result += "\n"
+
+ return result
+
+
+def custom_html_list(group, indent=" "):
+ result = ""
+
+ # Add custom CSS style for our HTML list
+ result += """\n\n"
+
+ # Insert the HTML list
+ result += """\n"""
+ result += "
\n"
+ result += custom_html_sublist(group, indent=indent)
+ result += "
\n"
+ result += "
\n"
+
+ return result
+
+
+class TreeViewer(object):
+
+ def __init__(self, group):
+ self.group = group
+
+ self.text_kwargs = dict(
+ horiz_len=2,
+ label_space=1,
+ indent=1
+ )
+
+ self.bytes_kwargs = dict(
+ UP_AND_RIGHT="+",
+ HORIZONTAL="-",
+ VERTICAL="|",
+ VERTICAL_AND_RIGHT="+"
+ )
+
+ self.unicode_kwargs = dict(
+ UP_AND_RIGHT=u"\u2514",
+ HORIZONTAL=u"\u2500",
+ VERTICAL=u"\u2502",
+ VERTICAL_AND_RIGHT=u"\u251C"
+ )
+
+ def __bytes__(self):
+ drawer = LeftAligned(
+ traverse=ZarrGroupTraversal(),
+ draw=BoxStyle(gfx=self.bytes_kwargs, **self.text_kwargs)
+ )
+
+ result = drawer(self.group)
+
+ # Unicode characters slip in on Python 3.
+ # So we need to straighten that out first.
+ if not PY2:
+ result = result.encode()
+
+ return result
+
+ def __unicode__(self):
+ drawer = LeftAligned(
+ traverse=ZarrGroupTraversal(),
+ draw=BoxStyle(gfx=self.unicode_kwargs, **self.text_kwargs)
+ )
+
+ return drawer(self.group)
+
+ def __repr__(self):
+ if PY2:
+ return self.__bytes__()
+ else:
+ return self.__unicode__()
+
+ def _repr_html_(self):
+ return custom_html_list(self.group)
+
+
def check_array_shape(param, array, shape):
if not hasattr(array, 'shape'):
raise TypeError('parameter {!r}: expected an array-like object, got {!r}'