diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 8d52557a5c6..a830876a1c7 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -37,6 +37,12 @@ Breaking changes restore the prior defaults (:issue:`10657`). By `Stephan Hoyer `_. +- The HTML reprs for :py:class:`DataArray`, :py:class:`Dataset` and + :py:class:`DataTree` have been tweaked to hide empty sections, consistent + with the text reprs. The ``DataTree`` HTML repr also now automatically expands + sub-groups (:pull:`10785`). + By `Stephan Hoyer `_. + Deprecations ~~~~~~~~~~~~ diff --git a/xarray/core/formatting.py b/xarray/core/formatting.py index 3a06cf18542..a10b112d002 100644 --- a/xarray/core/formatting.py +++ b/xarray/core/formatting.py @@ -1092,7 +1092,7 @@ def inherited_vars(mapping: ChainMap) -> dict: return {k: v for k, v in mapping.parents.items() if k not in mapping.maps[0]} -def _datatree_node_repr(node: DataTree, show_inherited: bool) -> str: +def _datatree_node_repr(node: DataTree, root: bool) -> str: summary = [f"Group: {node.path}"] col_width = _calculate_col_width(node.variables) @@ -1103,11 +1103,11 @@ def _datatree_node_repr(node: DataTree, show_inherited: bool) -> str: # Only show dimensions if also showing a variable or coordinates section. show_dims = ( node._node_coord_variables - or (show_inherited and inherited_coords) + or (root and inherited_coords) or node._data_variables ) - dim_sizes = node.sizes if show_inherited else node._node_dims + dim_sizes = node.sizes if root else node._node_dims if show_dims: # Includes inherited dimensions. @@ -1121,7 +1121,7 @@ def _datatree_node_repr(node: DataTree, show_inherited: bool) -> str: node_coords = node.to_dataset(inherit=False).coords summary.append(coords_repr(node_coords, col_width=col_width, max_rows=max_rows)) - if show_inherited and inherited_coords: + if root and inherited_coords: summary.append( inherited_coords_repr(node, col_width=col_width, max_rows=max_rows) ) @@ -1139,7 +1139,7 @@ def _datatree_node_repr(node: DataTree, show_inherited: bool) -> str: ) # TODO: only show indexes defined at this node, with a separate section for - # inherited indexes (if show_inherited=True) + # inherited indexes (if root=True) display_default_indexes = _get_boolean_with_default( "display_default_indexes", False ) @@ -1165,16 +1165,19 @@ def datatree_repr(dt: DataTree) -> str: header = f"" lines = [header] - show_inherited = True + root = True for pre, fill, node in renderer: if isinstance(node, str): lines.append(f"{fill}{node}") continue - node_repr = _datatree_node_repr(node, show_inherited=show_inherited) - show_inherited = False # only show inherited coords on the root + node_repr = _datatree_node_repr(node, root=root) + root = False # only the first node is the root + # TODO: figure out if we can restructure this logic to move child groups + # up higher in the repr, directly below the header. + # This would be more consistent with the HTML repr. raw_repr_lines = node_repr.splitlines() node_line = f"{pre}{raw_repr_lines[0]}" diff --git a/xarray/core/formatting_html.py b/xarray/core/formatting_html.py index 46c6709d118..77842751681 100644 --- a/xarray/core/formatting_html.py +++ b/xarray/core/formatting_html.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Literal from xarray.core.formatting import ( + filter_nondefault_indexes, inherited_vars, inline_index_repr, inline_variable_array_repr, @@ -323,24 +324,33 @@ def array_repr(arr) -> str: indexed_dims = {} obj_type = f"xarray.{type(arr).__name__}" - arr_name = f"'{arr.name}'" if getattr(arr, "name", None) else "" + arr_name = escape(repr(arr.name)) if getattr(arr, "name", None) else "" header_components = [ f"
{obj_type}
", - f"
{arr_name}
", + f"
{arr_name}
", format_dims(dims, indexed_dims), ] sections = [array_section(arr)] if hasattr(arr, "coords"): - sections.append(coord_section(arr.coords)) + if arr.coords: + sections.append(coord_section(arr.coords)) if hasattr(arr, "xindexes"): - indexes = _get_indexes_dict(arr.xindexes) - sections.append(index_section(indexes)) - - sections.append(attr_section(arr.attrs)) + display_default_indexes = _get_boolean_with_default( + "display_default_indexes", False + ) + xindexes = filter_nondefault_indexes( + _get_indexes_dict(arr.xindexes), not display_default_indexes + ) + if xindexes: + indexes = _get_indexes_dict(arr.xindexes) + sections.append(index_section(indexes)) + + if arr.attrs: + sections.append(attr_section(arr.attrs)) return _obj_repr(arr, header_components, sections) @@ -350,28 +360,85 @@ def dataset_repr(ds) -> str: header_components = [f"
{escape(obj_type)}
"] - sections = [ - dim_section(ds), - coord_section(ds.coords), - datavar_section(ds.data_vars), - index_section(_get_indexes_dict(ds.xindexes)), - attr_section(ds.attrs), - ] + sections = [] + + sections.append(dim_section(ds)) + + if ds.coords: + sections.append(coord_section(ds.coords)) + + sections.append(datavar_section(ds.data_vars)) + + display_default_indexes = _get_boolean_with_default( + "display_default_indexes", False + ) + xindexes = filter_nondefault_indexes( + _get_indexes_dict(ds.xindexes), not display_default_indexes + ) + if xindexes: + sections.append(index_section(xindexes)) + + if ds.attrs: + sections.append(attr_section(ds.attrs)) return _obj_repr(ds, header_components, sections) +def datatree_node_sections(node: DataTree, root: bool = False) -> list[str]: + from xarray.core.coordinates import Coordinates + + ds = node._to_dataset_view(rebuild_dims=False, inherit=True) + node_coords = node.to_dataset(inherit=False).coords + + # use this class to get access to .xindexes property + inherited_coords = Coordinates( + coords=inherited_vars(node._coord_variables), + indexes=inherited_vars(node._indexes), + ) + + # Only show dimensions if also showing a variable or coordinates section. + show_dims = ( + node._node_coord_variables + or (root and inherited_coords) + or node._data_variables + ) + + sections = [] + + if node.children: + children_max_items = 1 if ds.data_vars else 6 + sections.append( + children_section(node.children, max_items_collapse=children_max_items) + ) + + if show_dims: + sections.append(dim_section(ds)) + + if node_coords: + sections.append(coord_section(node_coords)) + + # only show inherited coordinates on the root + if root and inherited_coords: + sections.append(inherited_coord_section(inherited_coords)) + + if ds.data_vars: + sections.append(datavar_section(ds.data_vars)) + + if ds.attrs: + sections.append(attr_section(ds.attrs)) + + return sections + + def summarize_datatree_children(children: Mapping[str, DataTree]) -> str: MAX_CHILDREN = OPTIONS["display_max_children"] n_children = len(children) children_html = [] - for i, (n, c) in enumerate(children.items()): + for i, child in enumerate(children.values()): if i < ceil(MAX_CHILDREN / 2) or i >= ceil(n_children - MAX_CHILDREN / 2): is_last = i == (n_children - 1) - children_html.append( - _wrap_datatree_repr(datatree_node_repr(n, c), end=is_last) - ) + children_html.append(datatree_child_repr(child, end=is_last)) elif n_children > MAX_CHILDREN and i == ceil(MAX_CHILDREN / 2): children_html.append("
...
") @@ -388,7 +455,6 @@ def summarize_datatree_children(children: Mapping[str, DataTree]) -> str: _mapping_section, name="Groups", details_func=summarize_datatree_children, - max_items_collapse=1, max_option_name="display_max_children", expand_option_name="display_expand_groups", ) @@ -402,108 +468,58 @@ def summarize_datatree_children(children: Mapping[str, DataTree]) -> str: ) -def datatree_node_repr(group_title: str, node: DataTree, show_inherited=False) -> str: - from xarray.core.coordinates import Coordinates - - header_components = [f"
{escape(group_title)}
"] - - ds = node._to_dataset_view(rebuild_dims=False, inherit=True) - node_coords = node.to_dataset(inherit=False).coords - - # use this class to get access to .xindexes property - inherited_coords = Coordinates( - coords=inherited_vars(node._coord_variables), - indexes=inherited_vars(node._indexes), - ) - - sections = [ - children_section(node.children), - dim_section(ds), - coord_section(node_coords), - ] - - # only show inherited coordinates on the root - if show_inherited: - sections.append(inherited_coord_section(inherited_coords)) - - sections += [ - datavar_section(ds.data_vars), - attr_section(ds.attrs), - ] - - return _obj_repr(ds, header_components, sections) - - -def _wrap_datatree_repr(r: str, end: bool = False) -> str: +def datatree_child_repr(node: DataTree, end: bool = False) -> str: + # Wrap DataTree HTML representation with a tee to the left of it. + # + # Enclosing HTML tag is a
with :code:`display: inline-grid` style. + # + # Turns: + # [ title ] + # | details | + # |_____________| + # + # into (A): + # |─ [ title ] + # | | details | + # | |_____________| + # + # or (B): + # └─ [ title ] + # | details | + # |_____________| + end = bool(end) + height = "100%" if end is False else "1.2em" # height of line + + path = escape(node.path) + sections = datatree_node_sections(node, root=False) + section_items = "".join(f"
  • {s}
  • " for s in sections) + + # TODO: Can we make the group name clickable to toggle the sections below? + # This looks like it would require the input/label pattern used above. + html = f""" +
    +
    +
    +
    +
    +
    {path}
    +
    +
      + {section_items} +
    +
    +
    """ - Wrap HTML representation with a tee to the left of it. - - Enclosing HTML tag is a
    with :code:`display: inline-grid` style. + return "".join(t.strip() for t in html.split("\n")) - Turns: - [ title ] - | details | - |_____________| - - into (A): - |─ [ title ] - | | details | - | |_____________| - - or (B): - └─ [ title ] - | details | - |_____________| - - Parameters - ---------- - r: str - HTML representation to wrap. - end: bool - Specify if the line on the left should continue or end. - - Default is True. - - Returns - ------- - str - Wrapped HTML representation. - - Tee color is set to the variable :code:`--xr-border-color`. - """ - # height of line - end = bool(end) - height = "100%" if end is False else "1.2em" - return "".join( - [ - "
    ", - "
    ", - "
    ", - "
    ", - "
    ", - "
    ", - r, - "
    ", - "
    ", - ] - ) +def datatree_repr(node: DataTree) -> str: + header_components = [ + f"
    xarray.{type(node).__name__}
    ", + ] + if node.name is not None: + name = escape(repr(node.name)) + header_components.append(f"
    {name}
    ") -def datatree_repr(dt: DataTree) -> str: - obj_type = f"xarray.{type(dt).__name__}" - return datatree_node_repr(obj_type, dt, show_inherited=True) + sections = datatree_node_sections(node, root=True) + return _obj_repr(node, header_components, sections) diff --git a/xarray/static/css/style.css b/xarray/static/css/style.css index 10c41cfc6d2..78f7c35d9cb 100644 --- a/xarray/static/css/style.css +++ b/xarray/static/css/style.css @@ -101,11 +101,18 @@ body.vscode-dark { } .xr-obj-type, -.xr-array-name { +.xr-obj-name, +.xr-group-name { margin-left: 2px; margin-right: 10px; } +.xr-group-name::before { + content: "📁"; + padding-right: 0.3em; +} + +.xr-group-name, .xr-obj-type { color: var(--xr-font-color2); } @@ -199,6 +206,32 @@ body.vscode-dark { display: contents; } +.xr-group-box { + display: inline-grid; + grid-template-columns: 0px 20px auto; + width: 100%; +} + +.xr-group-box-vline { + grid-column-start: 1; + border-right: 0.2em solid; + border-color: var(--xr-border-color); + width: 0px; +} + +.xr-group-box-hline { + grid-column-start: 2; + grid-row-start: 1; + height: 1em; + width: 20px; + border-bottom: 0.2em solid; + border-color: var(--xr-border-color); +} + +.xr-group-box-contents { + grid-column-start: 3; +} + .xr-array-wrap { grid-column: 1 / -1; display: grid; diff --git a/xarray/tests/test_formatting_html.py b/xarray/tests/test_formatting_html.py index 4af9c69a908..2f0177f3181 100644 --- a/xarray/tests/test_formatting_html.py +++ b/xarray/tests/test_formatting_html.py @@ -1,5 +1,8 @@ from __future__ import annotations +import re +from functools import partial + import numpy as np import pandas as pd import pytest @@ -9,6 +12,66 @@ from xarray.core.coordinates import Coordinates +def drop_fallback_text_repr(html: str) -> str: + pattern = ( + re.escape("
    ") + "[^<]*" + re.escape("
    ") + ) + return re.sub(pattern, "", html) + + +XarrayTypes = xr.DataTree | xr.Dataset | xr.DataArray | xr.Variable + + +def xarray_html_only_repr(obj: XarrayTypes) -> str: + return drop_fallback_text_repr(obj._repr_html_()) + + +def assert_consistent_text_and_html( + obj: XarrayTypes, section_headers: list[str] +) -> None: + actual_html = xarray_html_only_repr(obj) + actual_text = repr(obj) + for section_header in section_headers: + assert actual_html.count(section_header) == actual_text.count(section_header), ( + section_header + ) + + +assert_consistent_text_and_html_dataarray = partial( + assert_consistent_text_and_html, + section_headers=[ + "Coordinates", + "Indexes", + "Attributes", + ], +) + + +assert_consistent_text_and_html_dataset = partial( + assert_consistent_text_and_html, + section_headers=[ + "Dimensions", + "Coordinates", + "Data variables", + "Indexes", + "Attributes", + ], +) + + +assert_consistent_text_and_html_datatree = partial( + assert_consistent_text_and_html, + section_headers=[ + "Dimensions", + "Coordinates", + "Inherited coordinates", + "Data variables", + "Indexes", + "Attributes", + ], +) + + @pytest.fixture def dataarray() -> xr.DataArray: return xr.DataArray(np.random.default_rng(0).random((4, 6))) @@ -99,56 +162,64 @@ def test_summarize_attrs_with_unsafe_attr_name_and_value() -> None: assert "
    <pd.DataFrame>
    " in formatted -def test_repr_of_dataarray(dataarray: xr.DataArray) -> None: - formatted = fh.array_repr(dataarray) +def test_repr_of_dataarray() -> None: + dataarray = xr.DataArray(np.random.default_rng(0).random((4, 6))) + formatted = xarray_html_only_repr(dataarray) assert "dim_0" in formatted # has an expanded data section assert formatted.count("class='xr-array-in' type='checkbox' checked>") == 1 - # coords, indexes and attrs don't have an items so they'll be be disabled and collapsed - assert ( - formatted.count("class='xr-section-summary-in' type='checkbox' disabled >") == 3 - ) + # coords, indexes and attrs don't have an items so they'll be omitted + assert "Coordinates" not in formatted + assert "Indexes" not in formatted + assert "Attributes" not in formatted + + assert_consistent_text_and_html_dataarray(dataarray) with xr.set_options(display_expand_data=False): - formatted = fh.array_repr(dataarray) + formatted = xarray_html_only_repr(dataarray) assert "dim_0" in formatted # has a collapsed data section assert formatted.count("class='xr-array-in' type='checkbox' checked>") == 0 - # coords, indexes and attrs don't have an items so they'll be be disabled and collapsed - assert ( - formatted.count("class='xr-section-summary-in' type='checkbox' disabled >") - == 3 - ) + # coords, indexes and attrs don't have an items so they'll be omitted + assert "Coordinates" not in formatted + assert "Indexes" not in formatted + assert "Attributes" not in formatted def test_repr_of_multiindex(multiindex: xr.Dataset) -> None: formatted = fh.dataset_repr(multiindex) assert "(x)" in formatted + assert_consistent_text_and_html_dataset(multiindex) + def test_repr_of_dataset(dataset: xr.Dataset) -> None: - formatted = fh.dataset_repr(dataset) + formatted = xarray_html_only_repr(dataset) # coords, attrs, and data_vars are expanded assert ( formatted.count("class='xr-section-summary-in' type='checkbox' checked>") == 3 ) - # indexes is collapsed - assert formatted.count("class='xr-section-summary-in' type='checkbox' >") == 1 + # indexes is omitted + assert "Indexes" not in formatted assert "<U4" in formatted or ">U4" in formatted assert "<IA>" in formatted + assert_consistent_text_and_html_dataset(dataset) + with xr.set_options( display_expand_coords=False, display_expand_data_vars=False, display_expand_attrs=False, display_expand_indexes=True, + display_default_indexes=True, ): - formatted = fh.dataset_repr(dataset) - # coords, attrs, and data_vars are collapsed, indexes is expanded + formatted = xarray_html_only_repr(dataset) + # coords, attrs, and data_vars are collapsed, indexes is shown & expanded assert ( formatted.count("class='xr-section-summary-in' type='checkbox' checked>") == 1 ) + assert "Indexes" in formatted assert "<U4" in formatted or ">U4" in formatted assert "<IA>" in formatted @@ -198,128 +269,6 @@ def test_nonstr_variable_repr_html() -> None: assert "
  • 10: 3
  • " in html -@pytest.fixture(scope="module", params=["some html", "some other html"]) -def repr(request): - return request.param - - -class Test_summarize_datatree_children: - """ - Unit tests for summarize_datatree_children. - """ - - func = staticmethod(fh.summarize_datatree_children) - - @pytest.fixture(scope="class") - def childfree_tree_factory(self): - """ - Fixture for a child-free DataTree factory. - """ - from random import randint - - def _childfree_tree_factory(): - return xr.DataTree( - dataset=xr.Dataset({"z": ("y", [randint(1, 100) for _ in range(3)])}) - ) - - return _childfree_tree_factory - - @pytest.fixture(scope="class") - def childfree_tree(self, childfree_tree_factory): - """ - Fixture for a child-free DataTree. - """ - return childfree_tree_factory() - - @pytest.fixture - def mock_datatree_node_repr(self, monkeypatch): - """ - Apply mocking for datatree_node_repr. - """ - - def mock(group_title, dt): - """ - Mock with a simple result - """ - return group_title + " " + str(id(dt)) - - monkeypatch.setattr(fh, "datatree_node_repr", mock) - - @pytest.fixture - def mock_wrap_datatree_repr(self, monkeypatch): - """ - Apply mocking for _wrap_datatree_repr. - """ - - def mock(r, *, end, **kwargs): - """ - Mock by appending "end" or "not end". - """ - return r + " " + ("end" if end else "not end") + "//" - - monkeypatch.setattr(fh, "_wrap_datatree_repr", mock) - - def test_empty_mapping(self): - """ - Test with an empty mapping of children. - """ - children: dict[str, xr.DataTree] = {} - assert self.func(children) == ( - "
    " - "
    " - ) - - def test_one_child( - self, childfree_tree, mock_wrap_datatree_repr, mock_datatree_node_repr - ): - """ - Test with one child. - - Uses a mock of _wrap_datatree_repr and _datatree_node_repr to essentially mock - the inline lambda function "lines_callback". - """ - # Create mapping of children - children = {"a": childfree_tree} - - # Expect first line to be produced from the first child, and - # wrapped as the last child - first_line = f"a {id(children['a'])} end//" - - assert self.func(children) == ( - "
    " - f"{first_line}" - "
    " - ) - - def test_two_children( - self, childfree_tree_factory, mock_wrap_datatree_repr, mock_datatree_node_repr - ): - """ - Test with two level deep children. - - Uses a mock of _wrap_datatree_repr and datatree_node_repr to essentially mock - the inline lambda function "lines_callback". - """ - - # Create mapping of children - children = {"a": childfree_tree_factory(), "b": childfree_tree_factory()} - - # Expect first line to be produced from the first child, and - # wrapped as _not_ the last child - first_line = f"a {id(children['a'])} not end//" - - # Expect second line to be produced from the second child, and - # wrapped as the last child - second_line = f"b {id(children['b'])} end//" - - assert self.func(children) == ( - "
    " - f"{first_line}" - f"{second_line}" - "
    " - ) - - class TestDataTreeTruncatesNodes: def test_many_nodes(self) -> None: # construct a datatree with 500 nodes @@ -368,90 +317,23 @@ def test_many_nodes(self) -> None: class TestDataTreeInheritance: def test_inherited_section_present(self) -> None: - dt = xr.DataTree.from_dict( - { - "/": None, - "a": None, - } - ) - with xr.set_options(display_style="html"): - html = dt._repr_html_().strip() - # checks that the section appears somewhere - assert "Inherited coordinates" in html - - # TODO how can we assert that the Inherited coordinates section does not appear in the child group? - # with xr.set_options(display_style="html"): - # child_html = dt["a"]._repr_html_().strip() - # assert "Inherited coordinates" not in child_html - - -class Test__wrap_datatree_repr: - """ - Unit tests for _wrap_datatree_repr. - """ - - func = staticmethod(fh._wrap_datatree_repr) - - def test_end(self, repr): - """ - Test with end=True. - """ - r = self.func(repr, end=True) - assert r == ( - "
    " - "
    " - "
    " - "
    " - "
    " - "
    " - f"{repr}" - "
    " - "
    " - ) - - def test_not_end(self, repr): - """ - Test with end=False. - """ - r = self.func(repr, end=False) - assert r == ( - "
    " - "
    " - "
    " - "
    " - "
    " - "
    " - f"{repr}" - "
    " - "
    " - ) + dt = xr.DataTree.from_dict(data={"a/b/c": None}, coords={"x": [1]}) + + root_html = dt._repr_html_() + assert "Inherited coordinates" not in root_html + + child_html = xarray_html_only_repr(dt["a"]) + assert child_html.count("Inherited coordinates") == 1 + + def test_repr_consistency(self) -> None: + dt = xr.DataTree.from_dict({"/a/b/c": None}) + assert_consistent_text_and_html_datatree(dt) + assert_consistent_text_and_html_datatree(dt["a"]) + assert_consistent_text_and_html_datatree(dt["a/b"]) + assert_consistent_text_and_html_datatree(dt["a/b/c"]) + + def test_no_repeated_style_or_fallback_text(self) -> None: + dt = xr.DataTree.from_dict({"/a/b/c": None}) + html = dt._repr_html_() + assert html.count("