From 03f8bbf893d58b29e5c141a193574b43856e3c00 Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Wed, 24 Sep 2025 00:01:30 -0700 Subject: [PATCH 1/5] Improve display of DataTree HTML repr --- xarray/core/formatting_html.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/xarray/core/formatting_html.py b/xarray/core/formatting_html.py index 46c6709d118..7a6dc89799d 100644 --- a/xarray/core/formatting_html.py +++ b/xarray/core/formatting_html.py @@ -388,7 +388,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", ) @@ -416,20 +415,29 @@ def datatree_node_repr(group_title: str, node: DataTree, show_inherited=False) - indexes=inherited_vars(node._indexes), ) - sections = [ - children_section(node.children), - dim_section(ds), - coord_section(node_coords), - ] + 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 ds.dims: + sections.append(dim_section(ds)) + + if node_coords: + sections.append(coord_section(node_coords)) # only show inherited coordinates on the root - if show_inherited: + if show_inherited and inherited_coords: sections.append(inherited_coord_section(inherited_coords)) - sections += [ - datavar_section(ds.data_vars), - attr_section(ds.attrs), - ] + if ds.data_vars: + sections.append(datavar_section(ds.data_vars)) + + if ds.attrs: + sections.append(attr_section(ds.attrs)) return _obj_repr(ds, header_components, sections) From 246957e1655ca696b80901e0c1f3c03d4499bb1b Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Wed, 24 Sep 2025 11:47:38 -0700 Subject: [PATCH 2/5] Fix test failure --- xarray/tests/test_formatting_html.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/xarray/tests/test_formatting_html.py b/xarray/tests/test_formatting_html.py index 4af9c69a908..8ede4e6d89d 100644 --- a/xarray/tests/test_formatting_html.py +++ b/xarray/tests/test_formatting_html.py @@ -370,12 +370,12 @@ class TestDataTreeInheritance: def test_inherited_section_present(self) -> None: dt = xr.DataTree.from_dict( { - "/": None, - "a": None, + "/": xr.Dataset(coords={"x": [1]}), + "child": None, } ) with xr.set_options(display_style="html"): - html = dt._repr_html_().strip() + html = dt["child"]._repr_html_().strip() # checks that the section appears somewhere assert "Inherited coordinates" in html From 9b53a72bef2198daf5b8652f77e292e97b846f72 Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Wed, 24 Sep 2025 11:53:18 -0700 Subject: [PATCH 3/5] add whats new --- doc/whats-new.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 8d52557a5c6..3d1be7f1412 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -37,6 +37,10 @@ Breaking changes restore the prior defaults (:issue:`10657`). By `Stephan Hoyer `_. +- The HTML repr for :py:class:`DataTree` has been tweaked to hide empty + sections, and automatically expand sub-groups (:pull:`10785`). + By `Stephan Hoyer `_. + Deprecations ~~~~~~~~~~~~ From de03310cb635ffdec7f6ce07b8bde52001235d9c Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Thu, 25 Sep 2025 16:29:48 -0700 Subject: [PATCH 4/5] Clean up nested HTML and add folder icon --- xarray/core/formatting_html.py | 198 ++++++++++------------- xarray/static/css/style.css | 35 +++- xarray/tests/test_formatting_html.py | 230 +++------------------------ 3 files changed, 143 insertions(+), 320 deletions(-) diff --git a/xarray/core/formatting_html.py b/xarray/core/formatting_html.py index 7a6dc89799d..80ccd388dbb 100644 --- a/xarray/core/formatting_html.py +++ b/xarray/core/formatting_html.py @@ -323,11 +323,11 @@ 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), ] @@ -361,51 +361,9 @@ def dataset_repr(ds) -> str: return _obj_repr(ds, header_components, 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()): - 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) - ) - elif n_children > MAX_CHILDREN and i == ceil(MAX_CHILDREN / 2): - children_html.append("
...
") - - return "".join( - [ - "
", - "".join(children_html), - "
", - ] - ) - - -children_section = partial( - _mapping_section, - name="Groups", - details_func=summarize_datatree_children, - max_option_name="display_max_children", - expand_option_name="display_expand_groups", -) - -inherited_coord_section = partial( - _mapping_section, - name="Inherited coordinates", - details_func=summarize_coords, - max_items_collapse=25, - expand_option_name="display_expand_coords", -) - - -def datatree_node_repr(group_title: str, node: DataTree, show_inherited=False) -> str: +def datatree_node_sections(node: DataTree, root: bool = False) -> list[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 @@ -430,7 +388,7 @@ def datatree_node_repr(group_title: str, node: DataTree, show_inherited=False) - sections.append(coord_section(node_coords)) # only show inherited coordinates on the root - if show_inherited and inherited_coords: + if root and inherited_coords: sections.append(inherited_coord_section(inherited_coords)) if ds.data_vars: @@ -439,79 +397,99 @@ def datatree_node_repr(group_title: str, node: DataTree, show_inherited=False) - if ds.attrs: sections.append(attr_section(ds.attrs)) - return _obj_repr(ds, header_components, sections) + return sections -def _wrap_datatree_repr(r: str, end: bool = False) -> str: - """ - Wrap HTML representation with a tee to the left of it. - - Enclosing HTML tag is a
with :code:`display: inline-grid` style. +def summarize_datatree_children(children: Mapping[str, DataTree]) -> str: + MAX_CHILDREN = OPTIONS["display_max_children"] + n_children = len(children) - Turns: - [ title ] - | details | - |_____________| + children_html = [] + 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(datatree_child_repr(child, end=is_last)) + elif n_children > MAX_CHILDREN and i == ceil(MAX_CHILDREN / 2): + children_html.append("
...
") - into (A): - |─ [ title ] - | | details | - | |_____________| + return "".join( + [ + "
", + "".join(children_html), + "
", + ] + ) - or (B): - └─ [ title ] - | details | - |_____________| - Parameters - ---------- - r: str - HTML representation to wrap. - end: bool - Specify if the line on the left should continue or end. +children_section = partial( + _mapping_section, + name="Groups", + details_func=summarize_datatree_children, + max_option_name="display_max_children", + expand_option_name="display_expand_groups", +) - Default is True. +inherited_coord_section = partial( + _mapping_section, + name="Inherited coordinates", + details_func=summarize_coords, + max_items_collapse=25, + expand_option_name="display_expand_coords", +) - Returns - ------- - str - Wrapped HTML representation. - Tee color is set to the variable :code:`--xr-border-color`. - """ - # height of line +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" - return "".join( - [ - "
", - "
", - "
", - "
", - "
", - "
", - r, - "
", - "
", - ] - ) + 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} +
    +
    +
    + """ + return "".join(t.strip() for t in html.split("\n")) + +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 8ede4e6d89d..7b19022191d 100644 --- a/xarray/tests/test_formatting_html.py +++ b/xarray/tests/test_formatting_html.py @@ -1,5 +1,7 @@ from __future__ import annotations +import re + import numpy as np import pandas as pd import pytest @@ -198,128 +200,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 @@ -366,92 +246,24 @@ def test_many_nodes(self) -> None: assert f"group_{i}
    " not in result +def _drop_fallback_text_repr(html: str) -> str: + pattern = ( + re.escape("
    ") + "[^<]*" + re.escape("
    ") + ) + return re.sub(pattern, "", html) + + class TestDataTreeInheritance: def test_inherited_section_present(self) -> None: - dt = xr.DataTree.from_dict( - { - "/": xr.Dataset(coords={"x": [1]}), - "child": None, - } - ) - with xr.set_options(display_style="html"): - html = dt["child"]._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 = _drop_fallback_text_repr(dt["a"]._repr_html_()) + assert child_html.count("Inherited coordinates") == 1 + + 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("