Repository: glotzerlab/signac
Pull request #132 (2019-02-20 22:08:30)

Fix/document view order
Document required order for `signac view` CLI arguments.


Patch:
diff --git a/changelog.txt b/changelog.txt
index 87f0e3d7..3d706c26 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -30,6 +30,7 @@ Added
  - Enable `Collection` class to manage collections stored in gzip files.
  - Enable deleting of `JSONDict` keys through the attribute interface, e.g., `del job.doc.foo`.
  - The `Job.init()` method returns the job to allow one-line job creation and initialization.
+ - Add HTML representation of instances of `Project` and `JobsCursor` targeted at Jupyter Notebooks (requires pandas).
 
 Fixed
 +++++
diff --git a/signac/__main__.py b/signac/__main__.py
index dcf5fa02..07473907 100644
--- a/signac/__main__.py
+++ b/signac/__main__.py
@@ -1230,20 +1230,34 @@ def main():
         help="Print output in JSON and on one line.")
     parser_find.set_defaults(func=main_find)
 
-    parser_view = subparsers.add_parser('view')
+    parser_view = subparsers.add_parser(
+            'view',
+            description="""Generate a human readable set of paths representing
+                           state points in the workspace, e.g.
+                           view/param_name_1/param_value_1/param_name_2/param_value_2/job.
+                           The leaf nodes of this directory structure are
+                           symlinks (named "job") into the workspace directory
+                           for that parameter combination. Note that all
+                           positional arguments must be provided before any
+                           keyword arguments. In particular, the prefix and
+                           path must be specified before arguments such as the
+                           filters, e.g.  signac view $PREFIX $VIEW_PATH -f
+                           FILTERS -d DOC_FILTERS."""  # noqa:E501
+            )
     parser_view.add_argument(
         'prefix',
         type=str,
         nargs='?',
         default='view',
-        help="The path where the view is to be created.")
+        help="The path where the view is to be created. Defaults to view.")
     parser_view.add_argument(
         'path',
         type=str,
         nargs='?',
         default='{{auto}}',
         help="The path used for the generation of the linked view hierarchy, "
-             "defaults to '{{auto}}'.")
+             "defaults to '{{auto}}' (see Project.export_to for information "
+             "on how this is expanded).")
     selection_group = parser_view.add_argument_group('select')
     selection_group.add_argument(
         '-f', '--filter',
diff --git a/signac/contrib/project.py b/signac/contrib/project.py
index df9c1ea0..50460ad2 100644
--- a/signac/contrib/project.py
+++ b/signac/contrib/project.py
@@ -133,6 +133,8 @@ class Project(object):
     FN_CACHE = '.signac_sp_cache.json.gz'
     "The default filename for the state point cache file."
 
+    _use_pandas_for_html_repr = True  # toggle use of pandas for html repr
+
     def __init__(self, config=None):
         if config is None:
             config = load_config()
@@ -167,6 +169,9 @@ class Project(object):
                    type=self.__class__.__module__ + '.' + self.__class__.__name__,
                    id=self.get_id(), rd=self.root_directory(), wd=self.workspace())
 
+    def _repr_html_(self):
+        return repr(self) + self.find_jobs()._repr_html_jobs()
+
     def __eq__(self, other):
         return repr(self) == repr(other)
 
@@ -1637,6 +1642,7 @@ class JobsCursor(object):
     """An iterator over a search query result, enabling simple iteration and
     grouping operations.
     """
+    _use_pandas_for_html_repr = True  # toggle use of pandas for html repr
 
     def __init__(self, project, filter, doc_filter):
         self._project = project
@@ -1838,6 +1844,38 @@ class JobsCursor(object):
             data={job._id: dict(_export_sp_and_doc(job)) for job in self},
             orient='index').infer_objects()
 
+    def __repr__(self):
+        return "{type}({{'project': '{project}', 'filter': '{filter}',"\
+               " 'docfilter': '{doc_filter}'}})".format(
+                   type=self.__class__.__module__ + '.' + self.__class__.__name__,
+                   project=self._project,
+                   filter=self._filter,
+                   doc_filter=self._doc_filter)
+
+    def _repr_html_jobs(self):
+        html = ''
+        len_self = len(self)
+        try:
+            if len_self > 100:
+                raise RuntimeError  # too large
+            if self._use_pandas_for_html_repr:
+                import pandas
+            else:
+                raise RuntimeError
+        except ImportError:
+            warnings.warn('Install pandas for a pretty representation of jobs.')
+            html += '<br/><strong>{}</strong> job(s) found'.format(len_self)
+        except RuntimeError:
+            html += '<br/><strong>{}</strong> job(s) found'.format(len_self)
+        else:
+            with pandas.option_context("display.max_rows", 20):
+                html += self.to_dataframe()._repr_html_()
+        return html
+
+    def _repr_html_(self):
+        """Returns an HTML representation of JobsCursor."""
+        return repr(self) + self._repr_html_jobs()
+
 
 def init_project(name, root=None, workspace=None, make_dir=True):
     """Initialize a project with the given name.
diff --git a/signac/core/h5store.py b/signac/core/h5store.py
index b97b2d68..7f8fcbb0 100644
--- a/signac/core/h5store.py
+++ b/signac/core/h5store.py
@@ -269,24 +269,19 @@ class H5Store(MutableMapping):
 
     :param filename:
         The filename of the underlying HDF5-file.
-    :param mode:
-        The file open mode to use. Defaults to 'a' (append).
     :param kwargs:
         Additional keyword arguments to be forward to the h5py.File constructor
         See documentation for :class:`h5py.File` for more information.
     """
-    __slots__ = ['_filename', '_file', '_mode', '_kwargs']
+    __slots__ = ['_filename', '_file', '_kwargs']
 
     _thread_lock = RLock()
 
-    def __init__(self, filename, mode=None, **kwargs):
+    def __init__(self, filename, **kwargs):
         if not (isinstance(filename, six.string_types) and len(filename) > 0):
             raise ValueError('H5Store filename must be a non-empty string.')
-        if mode is None:
-            mode = 'a'
         self._filename = os.path.realpath(filename)
         self._file = None
-        self._mode = mode
         self._kwargs = kwargs
 
     def __repr__(self):
@@ -312,13 +307,12 @@ class H5Store(MutableMapping):
         if self._file is not None:
             raise H5StoreAlreadyOpenError(self)
         import h5py
-
         # We use the default file parameters, which can optionally be overriden
         # by additional keyword arguments (kwargs). This option is intentionally
         # not exposed to the public API.
         parameters = dict(self._kwargs)
-        parameters['mode'] = self.mode
         parameters.update(kwargs)
+        parameters.setdefault('mode', 'a')
 
         self._thread_lock.acquire()
         try:
@@ -328,9 +322,17 @@ class H5Store(MutableMapping):
             raise
         return self
 
-    def open(self):
-        """Open the underlying HDF5-file."""
-        return self._open()
+    def open(self, mode=None):
+        """Open the underlying HDF5-file.
+
+        :param mode:
+            The file open mode to use. Defaults to 'a' (append).
+        :returns:
+            This H5Store instance.
+        """
+        if mode is None:
+            mode = self._kwargs.get('mode', 'a')
+        return self._open(mode=mode)
 
     def close(self):
         """Close the underlying HDF5-file."""


Test patch:
diff --git a/tests/test_project.py b/tests/test_project.py
index 352f0218..2b609aa7 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -28,6 +28,13 @@ if six.PY2:
 else:
     from tempfile import TemporaryDirectory
 
+try:
+    import pandas  # noqa
+except ImportError:
+    PANDAS = False
+else:
+    PANDAS = True
+
 
 # Make sure the jobs created for this test are unique.
 test_token = {'test_token': str(uuid.uuid4())}
@@ -1370,6 +1377,78 @@ class ProjectExportImportTest(BaseProjectTest):
             self.assertEqual(len(tmp_project), len(self.project))
 
 
+@unittest.skipIf(six.PY2, 'requires python 3')
+class ProjectRepresentationTest(BaseProjectTest):
+
+    valid_sp_values = [None, 0, 1, 0.0, 1.0, True, False, [0, 1, 2], [0, 1.0, False]]
+
+    num_few_jobs = 10
+    num_many_jobs = 200
+
+    def call_repr_methods(self):
+
+        with self.subTest(of='project'):
+            with self.subTest(type='str'):
+                str(self.project)
+            with self.subTest(type='repr'):
+                repr(self.project)
+            with self.subTest(type='html'):
+                for use_pandas in (True, False):
+                    type(self.project)._use_use_pandas_for_html_repr = use_pandas
+                    with self.subTest(use_pandas=use_pandas):
+                        if use_pandas and not PANDAS:
+                            raise unittest.SkipTest('requires use_pandas')
+                        self.project._repr_html_()
+
+        with self.subTest(of='JobsCursor'):
+            for filter_ in (None, ):
+                with self.subTest(filter=filter_):
+                    with self.subTest(type='str'):
+                        str(self.project.find_jobs(filter_))
+                    with self.subTest(type='repr'):
+                        repr(self.project.find_jobs(filter_))
+                    with self.subTest(type='html'):
+                        for use_pandas in (True, False):
+                            type(self.project)._use_use_pandas_for_html_repr = use_pandas
+                            with self.subTest(use_pandas=use_pandas):
+                                if use_pandas and not PANDAS:
+                                    raise unittest.SkipTest('requires use_pandas')
+                                self.project.find_jobs(filter_)._repr_html_()
+
+    def test_repr_no_jobs(self):
+        self.call_repr_methods()
+
+    def test_repr_few_jobs_homogeneous(self):
+        # Many jobs with many different state points
+        for i in range(self.num_few_jobs):
+            self.project.open_job(
+                {'{}_{}'.format(i, j): v
+                 for j, v in enumerate(self.valid_sp_values)}).init()
+        self.call_repr_methods()
+
+    def test_repr_many_jobs_homogeneous(self):
+        # Many jobs with many different state points
+        for i in range(self.num_many_jobs):
+            self.project.open_job(
+                {'{}_{}'.format(i, j): v
+                 for j, v in enumerate(self.valid_sp_values)}).init()
+        self.call_repr_methods()
+
+    def test_repr_few_jobs_heterogeneous(self):
+        # Many jobs with many different state points
+        for i in range(self.num_few_jobs):
+            for v in self.valid_sp_values:
+                self.project.open_job(dict(a=v)).init()
+        self.call_repr_methods()
+
+    def test_repr_many_jobs_heterogeneous(self):
+        # Many jobs with many different state points
+        for i in range(self.num_many_jobs):
+            for v in self.valid_sp_values:
+                self.project.open_job(dict(a=v)).init()
+        self.call_repr_methods()
+
+
 class LinkedViewProjectTest(BaseProjectTest):
 
     def test_create_linked_view(self):