From c2e13845882b420001961feff0d4dbcb124558e6 Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Sun, 28 Sep 2025 13:15:45 -0700 Subject: [PATCH 01/11] Less vertical whitespace in HTML reprs --- xarray/static/css/style.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/xarray/static/css/style.css b/xarray/static/css/style.css index 78f7c35d9cb..0a48514dec3 100644 --- a/xarray/static/css/style.css +++ b/xarray/static/css/style.css @@ -121,6 +121,8 @@ body.vscode-dark { padding-left: 0 !important; display: grid; grid-template-columns: 150px auto auto 1fr 0 20px 0 20px; + margin-block-start: 0; + margin-block-end: 0; } .xr-section-item { @@ -131,6 +133,7 @@ body.vscode-dark { display: inline-block; opacity: 0; height: 0; + margin: 0; } .xr-section-item input + label { @@ -189,7 +192,6 @@ body.vscode-dark { .xr-section-summary, .xr-section-inline-details { padding-top: 4px; - padding-bottom: 4px; } .xr-section-inline-details { @@ -199,6 +201,7 @@ body.vscode-dark { .xr-section-details { display: none; grid-column: 1 / -1; + margin-top: 4px; margin-bottom: 5px; } From b3bb3861f803ab6c8a0c2266bf786a8bbe19cea4 Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Sun, 28 Sep 2025 13:34:09 -0700 Subject: [PATCH 02/11] ensure consistent line-height in google colab --- xarray/static/css/style.css | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/xarray/static/css/style.css b/xarray/static/css/style.css index 0a48514dec3..8b8e854b62a 100644 --- a/xarray/static/css/style.css +++ b/xarray/static/css/style.css @@ -1,6 +1,4 @@ -/* CSS stylesheet for displaying xarray objects in jupyterlab. - * - */ +/* CSS stylesheet for displaying xarray objects in notebooks */ :root { --xr-font-color0: var( @@ -79,6 +77,7 @@ body.vscode-dark { display: block !important; min-width: 300px; max-width: 700px; + line-height: 1.6; } .xr-text-repr-fallback { From ca16214feb8b60efc90128f4d8d9dab47df34439 Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Mon, 29 Sep 2025 09:25:53 -0700 Subject: [PATCH 03/11] Refactor DataTree HTML repr --- xarray/core/formatting_html.py | 142 +++++++++++++++++++++++---------- xarray/core/options.py | 10 ++- xarray/static/css/style.css | 77 +++++++++++++----- 3 files changed, 166 insertions(+), 63 deletions(-) diff --git a/xarray/core/formatting_html.py b/xarray/core/formatting_html.py index 77842751681..1b1fabaae0d 100644 --- a/xarray/core/formatting_html.py +++ b/xarray/core/formatting_html.py @@ -3,6 +3,7 @@ import uuid from collections import OrderedDict from collections.abc import Mapping +from dataclasses import dataclass, field from functools import lru_cache, partial from html import escape from importlib.resources import files @@ -172,7 +173,12 @@ def summarize_indexes(indexes) -> str: def collapsible_section( - name, inline_details="", details="", n_items=None, enabled=True, collapsed=False + name: str | None, + inline_details="", + details="", + n_items=None, + enabled=True, + collapsed=False, ) -> str: # "unique" id to expand/collapse the section data_id = "section-" + str(uuid.uuid4()) @@ -183,14 +189,17 @@ def collapsible_section( collapsed = "" if collapsed or not has_items else "checked" tip = " title='Expand/collapse section'" if enabled else "" - return ( - f"" - f"" - f"
{inline_details}
" - f"
{details}
" - ) + if name is None: + # uncollapsable (no header) + return f"
{details}
" + else: + html = f""" + + +
{inline_details}
+
{details}
+ """ + return "".join(t.strip() for t in html.split("\n")) def _mapping_section( @@ -201,9 +210,10 @@ def _mapping_section( expand_option_name, enabled=True, max_option_name: Literal["display_max_children"] | None = None, + **kwargs, ) -> str: n_items = len(mapping) - expanded = _get_boolean_with_default( + expanded = max_items_collapse is None or _get_boolean_with_default( expand_option_name, n_items < max_items_collapse ) collapsed = not expanded @@ -217,7 +227,7 @@ def _mapping_section( return collapsible_section( name, inline_details=inline_details, - details=details_func(mapping), + details=details_func(mapping, **kwargs), n_items=n_items, enabled=enabled, collapsed=collapsed, @@ -384,7 +394,18 @@ def dataset_repr(ds) -> str: return _obj_repr(ds, header_components, sections) -def datatree_node_sections(node: DataTree, root: bool = False) -> list[str]: +@dataclass +class _DataTreeDisplayContext: + items_shown: int = 0 + node_count_cache: dict[int, int] = field(default_factory=dict) + + +def datatree_node_sections( + node: DataTree, + *, + root: bool, + display_context: _DataTreeDisplayContext, +) -> tuple[list[str], int]: from xarray.core.coordinates import Coordinates ds = node._to_dataset_view(rebuild_dims=False, inherit=True) @@ -403,13 +424,19 @@ def datatree_node_sections(node: DataTree, root: bool = False) -> list[str]: or node._data_variables ) - sections = [] + n_items = ( + +len(node.children) + + int(bool(show_dims)) + + int(bool(node_coords)) + + len(node_coords) + + int(root) * (int(bool(inherited_coords)) + len(inherited_coords)) + + int(bool(ds.data_vars)) + + len(ds.data_vars) + + int(bool(ds.attrs)) + + len(ds.attrs) + ) - 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) - ) + sections = [] if show_dims: sections.append(dim_section(ds)) @@ -427,10 +454,10 @@ def datatree_node_sections(node: DataTree, root: bool = False) -> list[str]: if ds.attrs: sections.append(attr_section(ds.attrs)) - return sections + return sections, n_items -def summarize_datatree_children(children: Mapping[str, DataTree]) -> str: +def summarize_datatree_children(children: Mapping[str, DataTree], **kwargs) -> str: MAX_CHILDREN = OPTIONS["display_max_children"] n_children = len(children) @@ -438,22 +465,17 @@ def summarize_datatree_children(children: Mapping[str, DataTree]) -> str: 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)) + children_html.append(datatree_child_repr(child, end=is_last, **kwargs)) elif n_children > MAX_CHILDREN and i == ceil(MAX_CHILDREN / 2): - children_html.append("
...
") - - return "".join( - [ - "
", - "".join(children_html), - "
", - ] - ) + n_hidden = MAX_CHILDREN - n_children + children_html.append(f"
... ({n_hidden} items hidden)
") + + return "
" + "".join(children_html) + "
" children_section = partial( _mapping_section, - name="Groups", + name=None, details_func=summarize_datatree_children, max_option_name="display_max_children", expand_option_name="display_expand_groups", @@ -468,7 +490,23 @@ def summarize_datatree_children(children: Mapping[str, DataTree]) -> str: ) -def datatree_child_repr(node: DataTree, end: bool = False) -> str: +def _tree_item_count(node: DataTree, cache: dict[int, int]) -> int: + if id(node) in cache: + return cache[id(node)] + + node_ds = node.to_dataset(inherit=False) + node_count = len(node_ds.variables) + len(node_ds.attrs) + child_count = sum( + _tree_item_count(child, cache) for child in node.children.values() + ) + total = node_count + child_count + cache[id(node)] = total + return total + + +def datatree_child_repr( + node: DataTree, *, end: bool, display_context: _DataTreeDisplayContext +) -> str: # Wrap DataTree HTML representation with a tee to the left of it. # # Enclosing HTML tag is a
with :code:`display: inline-grid` style. @@ -491,19 +529,41 @@ def datatree_child_repr(node: DataTree, end: bool = False) -> str: 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) + sections, n_display_items = datatree_node_sections( + node, root=False, display_context=display_context + ) + + n_items = _tree_item_count(node, display_context.node_count_cache) + n_items_span = f"({n_items})" + + group_id = "group-" + str(uuid.uuid4()) + enabled = "" if sections else "disabled" + tip = " title='Expand/collapse group'" if enabled else "" - # 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. + if display_context.items_shown + n_display_items > OPTIONS["display_max_items"]: + collapsed = "checked" + else: + display_context.items_shown += n_display_items + collapsed = "" + + if node.children: + sections.insert( + 0, + children_section( + node.children, + max_items_collapse=None, + display_context=display_context, + ), + ) + + section_items = "".join(f"
  • {s}
  • " for s in sections) html = f"""
    -
    -
    {path}
    -
    + +
      {section_items}
    @@ -521,5 +581,7 @@ def datatree_repr(node: DataTree) -> str: name = escape(repr(node.name)) header_components.append(f"
    {name}
    ") - sections = datatree_node_sections(node, root=True) + sections, _ = datatree_node_sections( + node, root=True, display_context=_DataTreeDisplayContext() + ) return _obj_repr(node, header_components, sections) diff --git a/xarray/core/options.py b/xarray/core/options.py index c8d00eea5d2..4f753bd0043 100644 --- a/xarray/core/options.py +++ b/xarray/core/options.py @@ -16,6 +16,7 @@ "cmap_sequential", "display_max_children", "display_max_rows", + "display_max_items", "display_values_threshold", "display_style", "display_width", @@ -46,6 +47,7 @@ class T_Options(TypedDict): cmap_sequential: str | Colormap display_max_children: int display_max_rows: int + display_max_items: int display_values_threshold: int display_style: Literal["text", "html"] display_width: int @@ -74,8 +76,9 @@ class T_Options(TypedDict): "chunk_manager": "dask", "cmap_divergent": "RdBu_r", "cmap_sequential": "viridis", - "display_max_children": 6, + "display_max_children": 12, "display_max_rows": 12, + "display_max_items": 30, "display_values_threshold": 200, "display_style": "html", "display_width": 80, @@ -112,6 +115,7 @@ def _positive_integer(value: Any) -> bool: "arithmetic_join": _JOIN_OPTIONS.__contains__, "display_max_children": _positive_integer, "display_max_rows": _positive_integer, + "display_max_items": _positive_integer, "display_values_threshold": _positive_integer, "display_style": _DISPLAY_OPTIONS.__contains__, "display_width": _positive_integer, @@ -236,10 +240,12 @@ class set_options: * ``True`` : to always expand indexes * ``False`` : to always collapse indexes * ``default`` : to expand unless over a pre-defined limit (always collapse for html style) - display_max_children : int, default: 6 + display_max_children : int, default: 12 Maximum number of children to display for each node in a DataTree. display_max_rows : int, default: 12 Maximum display rows. + display_max_items : int, default 30 + Maximum number of items to display for a DataTree, across all levels. display_values_threshold : int, default: 200 Total number of array elements which trigger summarization rather than full repr for variable data views (numpy arrays). diff --git a/xarray/static/css/style.css b/xarray/static/css/style.css index 8b8e854b62a..f4eb7d45ae9 100644 --- a/xarray/static/css/style.css +++ b/xarray/static/css/style.css @@ -88,8 +88,11 @@ body.vscode-dark { .xr-header { padding-top: 6px; padding-bottom: 6px; - margin-bottom: 4px; +} + +.xr-header { border-bottom: solid 1px var(--xr-border-color); + margin-bottom: 4px; } .xr-header > div, @@ -100,20 +103,15 @@ body.vscode-dark { } .xr-obj-type, -.xr-obj-name, -.xr-group-name { +.xr-obj-name { margin-left: 2px; margin-right: 10px; } -.xr-group-name::before { - content: "📁"; - padding-right: 0.3em; -} - -.xr-group-name, -.xr-obj-type { +.xr-obj-type, +.xr-group-box-contents > label { color: var(--xr-font-color2); + display: block; } .xr-sections { @@ -128,28 +126,31 @@ body.vscode-dark { display: contents; } -.xr-section-item input { - display: inline-block; +.xr-section-item > input, +.xr-group-box-contents > input { + display: block; opacity: 0; height: 0; margin: 0; } -.xr-section-item input + label { +.xr-section-item > input + label { color: var(--xr-disabled-color); border: 2px solid transparent !important; } -.xr-section-item input:enabled + label { +.xr-section-item > input:enabled + label, +.xr-group-box-contents > input + label { cursor: pointer; color: var(--xr-font-color2); } -.xr-section-item input:focus + label { +.xr-section-item > input:focus + label { border: 2px solid var(--xr-font-color0) !important; } -.xr-section-item input:enabled + label:hover { +.xr-section-item > input:enabled + label:hover, +.xr-group-box-contents > input:enabled + label:hover { color: var(--xr-font-color0); } @@ -159,7 +160,8 @@ body.vscode-dark { font-weight: 500; } -.xr-section-summary > span { +.xr-section-summary > span, +.xr-group-box-contents > input:checked + label > span { display: inline-block; padding-left: 0.5em; } @@ -189,7 +191,8 @@ body.vscode-dark { } .xr-section-summary, -.xr-section-inline-details { +.xr-section-inline-details, +.xr-group-box-contents > label { padding-top: 4px; } @@ -198,19 +201,29 @@ body.vscode-dark { } .xr-section-details { - display: none; grid-column: 1 / -1; margin-top: 4px; margin-bottom: 5px; } +.xr-section-summary-in ~ .xr-section-details { + display: none; +} + .xr-section-summary-in:checked ~ .xr-section-details { display: contents; } +.xr-children { + display: inline-grid; + grid-template-columns: 100%; + grid-column: 1 / -1; + width: 100%; +} + .xr-group-box { display: inline-grid; - grid-template-columns: 0px 20px auto; + grid-template-columns: 0px 30px auto; width: 100%; } @@ -225,13 +238,35 @@ body.vscode-dark { grid-column-start: 2; grid-row-start: 1; height: 1em; - width: 20px; + width: 26px; border-bottom: 0.2em solid; border-color: var(--xr-border-color); } .xr-group-box-contents { grid-column-start: 3; + width: 100%; +} + +.xr-group-box-contents > label::before { + content: "📂"; + padding-right: 0.3em; +} + +.xr-group-box-contents > input:checked + label::before { + content: "📁"; +} + +.xr-group-box-contents > input:checked + label { + padding-bottom: 0px; +} + +.xr-group-box-contents > input:checked ~ .xr-sections { + display: none; +} + +.xr-group-box-contents > input + label > span { + display: none; } .xr-array-wrap { From 1842a39164cd1c130e2d1b443dd595142454981d Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Tue, 30 Sep 2025 00:12:05 -0700 Subject: [PATCH 04/11] Collapsable DataTree nodes --- xarray/core/formatting_html.py | 254 ++++++++++++++------------- xarray/core/options.py | 14 +- xarray/static/css/style.css | 18 +- xarray/tests/test_formatting_html.py | 4 +- 4 files changed, 157 insertions(+), 133 deletions(-) diff --git a/xarray/core/formatting_html.py b/xarray/core/formatting_html.py index 1b1fabaae0d..9075a065f7d 100644 --- a/xarray/core/formatting_html.py +++ b/xarray/core/formatting_html.py @@ -3,11 +3,10 @@ import uuid from collections import OrderedDict from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass from functools import lru_cache, partial from html import escape from importlib.resources import files -from math import ceil from typing import TYPE_CHECKING, Literal from xarray.core.formatting import ( @@ -173,7 +172,7 @@ def summarize_indexes(indexes) -> str: def collapsible_section( - name: str | None, + name: str, inline_details="", details="", n_items=None, @@ -185,20 +184,16 @@ def collapsible_section( has_items = n_items is not None and n_items n_items_span = "" if n_items is None else f" ({n_items})" - enabled = "" if enabled and has_items else "disabled" - collapsed = "" if collapsed or not has_items else "checked" + enabled = "" if enabled and has_items else " disabled" + collapsed = "" if collapsed or not has_items else " checked" tip = " title='Expand/collapse section'" if enabled else "" - if name is None: - # uncollapsable (no header) - return f"
    {details}
    " - else: - html = f""" - - -
    {inline_details}
    -
    {details}
    - """ + html = f""" + + +
    {inline_details}
    +
    {details}
    + """ return "".join(t.strip() for t in html.split("\n")) @@ -304,6 +299,11 @@ def _get_indexes_dict(indexes): } +def _sections_repr(sections: list[str]) -> str: + section_items = "".join(f"
  • {s}
  • " for s in sections) + return f"
      {section_items}
    " + + def _obj_repr(obj, header_components, sections): """Return HTML repr of an xarray object. @@ -311,7 +311,6 @@ def _obj_repr(obj, header_components, sections): """ header = f"
    {''.join(h for h in header_components)}
    " - sections = "".join(f"
  • {s}
  • " for s in sections) icons_svg, css_style = _load_static_files() return ( @@ -320,7 +319,7 @@ def _obj_repr(obj, header_components, sections): f"
    {escape(repr(obj))}
    " "" "
    " ) @@ -394,18 +393,16 @@ def dataset_repr(ds) -> str: return _obj_repr(ds, header_components, sections) -@dataclass -class _DataTreeDisplayContext: - items_shown: int = 0 - node_count_cache: dict[int, int] = field(default_factory=dict) +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_sections( - node: DataTree, - *, - root: bool, - display_context: _DataTreeDisplayContext, -) -> tuple[list[str], int]: +def _datatree_node_sections(node: DataTree, root: bool) -> tuple[list[str], int]: from xarray.core.coordinates import Coordinates ds = node._to_dataset_view(rebuild_dims=False, inherit=True) @@ -418,76 +415,33 @@ def datatree_node_sections( ) # 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 - ) - - n_items = ( - +len(node.children) - + int(bool(show_dims)) - + int(bool(node_coords)) - + len(node_coords) - + int(root) * (int(bool(inherited_coords)) + len(inherited_coords)) - + int(bool(ds.data_vars)) - + len(ds.data_vars) - + int(bool(ds.attrs)) - + len(ds.attrs) - ) + show_dims = node_coords or (root and inherited_coords) or ds.data_vars sections = [] - 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, n_items - - -def summarize_datatree_children(children: Mapping[str, DataTree], **kwargs) -> str: - MAX_CHILDREN = OPTIONS["display_max_children"] - n_children = len(children) - - 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, **kwargs)) - elif n_children > MAX_CHILDREN and i == ceil(MAX_CHILDREN / 2): - n_hidden = MAX_CHILDREN - n_children - children_html.append(f"
    ... ({n_hidden} items hidden)
    ") - - return "
    " + "".join(children_html) + "
    " - - -children_section = partial( - _mapping_section, - name=None, - details_func=summarize_datatree_children, - max_option_name="display_max_children", - expand_option_name="display_expand_groups", -) + displayed_line_count = ( + len(node.children) + + int(bool(show_dims)) + + int(bool(node_coords)) + + len(node_coords) + + int(root) * (int(bool(inherited_coords)) + len(inherited_coords)) + + int(bool(ds.data_vars)) + + len(ds.data_vars) + + int(bool(ds.attrs)) + + len(ds.attrs) + ) -inherited_coord_section = partial( - _mapping_section, - name="Inherited coordinates", - details_func=summarize_coords, - max_items_collapse=25, - expand_option_name="display_expand_coords", -) + return sections, displayed_line_count def _tree_item_count(node: DataTree, cache: dict[int, int]) -> int: @@ -504,8 +458,85 @@ def _tree_item_count(node: DataTree, cache: dict[int, int]) -> int: return total +@dataclass +class _DataTreeDisplay: + node: DataTree + sections: list[str] + item_count: int + collapsed: bool + disabled: bool + + +def _build_datatree_displays(tree: DataTree) -> dict[str, _DataTreeDisplay]: + displayed_line_count = 0 + displays: dict[str, _DataTreeDisplay] = {} + item_count_cache: dict[int, int] = {} + root = True + collapsed = False + + for node in tree.subtree: # breadth-first + parent = node.parent + if parent is not None: + parent_display = displays.get(parent.path, None) + if parent_display is not None and parent_display.disabled: + break # no need to build display + + item_count = _tree_item_count(node, item_count_cache) + + sections, node_line_count = _datatree_node_sections(node, root) + new_count = displayed_line_count + node_line_count + + disabled = not root and new_count > OPTIONS["display_max_html_elements"] + + if disabled: + sections = [] + collapsed = True + else: + if not root: + collapsed = collapsed or new_count > OPTIONS["display_max_items"] + if not collapsed: + displayed_line_count = new_count + + displays[node.path] = _DataTreeDisplay( + node, sections, item_count, collapsed, disabled + ) + root = False + + return displays + + +def children_section( + children: Mapping[str, DataTree], displays: dict[str, _DataTreeDisplay] +) -> str: + child_elements = [] + for i, child in enumerate(children.values()): + is_last = i == (len(children) - 1) + child_elements.append(datatree_child_repr(child, displays, end=is_last)) + + children_html = "".join(child_elements) + html = f""" +
    +
    {children_html}
    +
    + """ + return "".join(t.strip() for t in html.split("\n")) + + +def datatree_sections( + node: DataTree, displays: dict[str, _DataTreeDisplay] +) -> list[str]: + display = displays[node.path] + sections = [] + if node.children and not display.disabled: + sections.append(children_section(node.children, displays)) + sections.extend(display.sections) + return sections + + def datatree_child_repr( - node: DataTree, *, end: bool, display_context: _DataTreeDisplayContext + node: DataTree, + displays: dict[str, _DataTreeDisplay], + end: bool, ) -> str: # Wrap DataTree HTML representation with a tee to the left of it. # @@ -525,48 +556,33 @@ def datatree_child_repr( # └─ [ title ] # | details | # |_____________| - end = bool(end) - height = "100%" if end is False else "1.2em" # height of line - path = escape(node.path) - sections, n_display_items = datatree_node_sections( - node, root=False, display_context=display_context - ) + vline_height = "1.2em" if end else "100%" - n_items = _tree_item_count(node, display_context.node_count_cache) - n_items_span = f"({n_items})" + path = escape(node.path) + display = displays[node.path] group_id = "group-" + str(uuid.uuid4()) - enabled = "" if sections else "disabled" - tip = " title='Expand/collapse group'" if enabled else "" + collapsed = " checked" if display.collapsed else "" + disabled = " disabled" if display.disabled else "" + tip = " title='Expand/collapse group'" if not display.disabled else "" - if display_context.items_shown + n_display_items > OPTIONS["display_max_items"]: - collapsed = "checked" - else: - display_context.items_shown += n_display_items - collapsed = "" - - if node.children: - sections.insert( - 0, - children_section( - node.children, - max_items_collapse=None, - display_context=display_context, - ), - ) + sections = datatree_sections(node, displays) + sections_html = _sections_repr(sections) if sections else "" + + item_count = f"{display.item_count}" + (" truncated" if disabled else "") - section_items = "".join(f"
  • {s}
  • " for s in sections) html = f"""
    -
    +
    - - -
      - {section_items} -
    + + + {sections_html}
    """ @@ -574,14 +590,12 @@ def datatree_child_repr( def datatree_repr(node: DataTree) -> str: + displays = _build_datatree_displays(node) header_components = [ f"
    xarray.{type(node).__name__}
    ", ] if node.name is not None: name = escape(repr(node.name)) header_components.append(f"
    {name}
    ") - - sections, _ = datatree_node_sections( - node, root=True, display_context=_DataTreeDisplayContext() - ) + sections = datatree_sections(node, displays) return _obj_repr(node, header_components, sections) diff --git a/xarray/core/options.py b/xarray/core/options.py index 4f753bd0043..20da9a39ca1 100644 --- a/xarray/core/options.py +++ b/xarray/core/options.py @@ -15,6 +15,7 @@ "cmap_divergent", "cmap_sequential", "display_max_children", + "display_max_html_elements", "display_max_rows", "display_max_items", "display_values_threshold", @@ -46,6 +47,7 @@ class T_Options(TypedDict): cmap_divergent: str | Colormap cmap_sequential: str | Colormap display_max_children: int + display_max_html_elements: int display_max_rows: int display_max_items: int display_values_threshold: int @@ -77,8 +79,9 @@ class T_Options(TypedDict): "cmap_divergent": "RdBu_r", "cmap_sequential": "viridis", "display_max_children": 12, + "display_max_html_elements": 500, "display_max_rows": 12, - "display_max_items": 30, + "display_max_items": 20, "display_values_threshold": 200, "display_style": "html", "display_width": 80, @@ -114,6 +117,7 @@ def _positive_integer(value: Any) -> bool: "arithmetic_broadcast": lambda value: isinstance(value, bool), "arithmetic_join": _JOIN_OPTIONS.__contains__, "display_max_children": _positive_integer, + "display_max_html_elements": _positive_integer, "display_max_rows": _positive_integer, "display_max_items": _positive_integer, "display_values_threshold": _positive_integer, @@ -242,10 +246,14 @@ class set_options: * ``default`` : to expand unless over a pre-defined limit (always collapse for html style) display_max_children : int, default: 12 Maximum number of children to display for each node in a DataTree. + display_max_html_elements : int, default: 500 + Maximum number of HTML elements to include in DataTree HTML displays. + Additional items are truncated. display_max_rows : int, default: 12 Maximum display rows. - display_max_items : int, default 30 - Maximum number of items to display for a DataTree, across all levels. + display_max_items : int, default 20 + Maximum number of items to display for a DataTree before collapsing + child nodes, across all levels. display_values_threshold : int, default: 200 Total number of array elements which trigger summarization rather than full repr for variable data views (numpy arrays). diff --git a/xarray/static/css/style.css b/xarray/static/css/style.css index f4eb7d45ae9..26d8b48c0e3 100644 --- a/xarray/static/css/style.css +++ b/xarray/static/css/style.css @@ -136,19 +136,19 @@ body.vscode-dark { .xr-section-item > input + label { color: var(--xr-disabled-color); - border: 2px solid transparent !important; + /* border: 2px solid transparent !important; */ } +/* .xr-section-item > input:active + label { + border: 2px solid var(--xr-font-color0) !important; +} */ + .xr-section-item > input:enabled + label, -.xr-group-box-contents > input + label { +.xr-group-box-contents > input:enabled + label { cursor: pointer; color: var(--xr-font-color2); } -.xr-section-item > input:focus + label { - border: 2px solid var(--xr-font-color0) !important; -} - .xr-section-item > input:enabled + label:hover, .xr-group-box-contents > input:enabled + label:hover { color: var(--xr-font-color0); @@ -166,7 +166,8 @@ body.vscode-dark { padding-left: 0.5em; } -.xr-section-summary-in:disabled + label { +.xr-section-summary-in:disabled + label, +.xr-group-box-contents > input:disabled + label { color: var(--xr-font-color2); } @@ -178,7 +179,8 @@ body.vscode-dark { text-align: center; } -.xr-section-summary-in:disabled + label:before { +.xr-section-summary-in:disabled + label:before, +.xr-group-box-contents > input:disabled + label:before { color: var(--xr-disabled-color); } diff --git a/xarray/tests/test_formatting_html.py b/xarray/tests/test_formatting_html.py index 2f0177f3181..f58e8aee49e 100644 --- a/xarray/tests/test_formatting_html.py +++ b/xarray/tests/test_formatting_html.py @@ -197,7 +197,7 @@ def test_repr_of_dataset(dataset: xr.Dataset) -> None: 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 + formatted.count("class='xr-section-summary-in' type='checkbox' checked />") == 3 ) # indexes is omitted assert "Indexes" not in formatted @@ -216,7 +216,7 @@ def test_repr_of_dataset(dataset: xr.Dataset) -> None: 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>") + formatted.count("class='xr-section-summary-in' type='checkbox' checked />") == 1 ) assert "Indexes" in formatted From 73e46ec0a762c99fc779525854cbf6e304058899 Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Thu, 2 Oct 2025 21:58:48 -0700 Subject: [PATCH 05/11] more formatting --- xarray/core/formatting_html.py | 56 +++++++++++++++++++++------------- xarray/static/css/style.css | 33 +++++++++++--------- 2 files changed, 54 insertions(+), 35 deletions(-) diff --git a/xarray/core/formatting_html.py b/xarray/core/formatting_html.py index 9075a065f7d..0a6ab7bd5f6 100644 --- a/xarray/core/formatting_html.py +++ b/xarray/core/formatting_html.py @@ -172,7 +172,7 @@ def summarize_indexes(indexes) -> str: def collapsible_section( - name: str, + header: str, inline_details="", details="", n_items=None, @@ -187,14 +187,17 @@ def collapsible_section( enabled = "" if enabled and has_items else " disabled" collapsed = "" if collapsed or not has_items else " checked" tip = " title='Expand/collapse section'" if enabled else "" + span_grid = " xr-span-grid" if not inline_details else "" - html = f""" - - -
    {inline_details}
    -
    {details}
    - """ - return "".join(t.strip() for t in html.split("\n")) + html = ( + f"" + f"" + ) + if inline_details: + html += f"
    {inline_details}
    " + if details: + html += f"
    {details}
    " + return html def _mapping_section( @@ -220,7 +223,7 @@ def _mapping_section( inline_details = f"({max_items}/{n_items})" return collapsible_section( - name, + f"{name}:", inline_details=inline_details, details=details_func(mapping, **kwargs), n_items=n_items, @@ -233,7 +236,7 @@ def dim_section(obj) -> str: dim_list = format_dims(obj.sizes, obj.xindexes.dims) return collapsible_section( - "Dimensions", inline_details=dim_list, enabled=False, collapsed=True + "Dimensions:", inline_details=dim_list, enabled=False, collapsed=True ) @@ -502,6 +505,16 @@ def _build_datatree_displays(tree: DataTree) -> dict[str, _DataTreeDisplay]: ) root = False + # If any node is collapsed, ensure its immediate siblings are also collapsed + for display in displays.values(): + if not display.disabled: + if any( + displays[child.path].collapsed + for child in display.node.children.values() + ): + for child in display.node.children.values(): + displays[child.path].collapsed = True + return displays @@ -514,12 +527,7 @@ def children_section( child_elements.append(datatree_child_repr(child, displays, end=is_last)) children_html = "".join(child_elements) - html = f""" -
    -
    {children_html}
    -
    - """ - return "".join(t.strip() for t in html.split("\n")) + return f"
    {children_html}
    " def datatree_sections( @@ -564,23 +572,29 @@ def datatree_child_repr( group_id = "group-" + str(uuid.uuid4()) collapsed = " checked" if display.collapsed else "" - disabled = " disabled" if display.disabled else "" tip = " title='Expand/collapse group'" if not display.disabled else "" sections = datatree_sections(node, displays) - sections_html = _sections_repr(sections) if sections else "" + if display.disabled: + sections.append( + collapsible_section( + f"Too many items ({display.item_count}) to display", + enabled=False, + collapsed=True, + ) + ) - item_count = f"{display.item_count}" + (" truncated" if disabled else "") + sections_html = _sections_repr(sections) if sections else "" html = f"""
    - + {sections_html}
    diff --git a/xarray/static/css/style.css b/xarray/static/css/style.css index 26d8b48c0e3..af569f8f248 100644 --- a/xarray/static/css/style.css +++ b/xarray/static/css/style.css @@ -78,6 +78,7 @@ body.vscode-dark { min-width: 300px; max-width: 700px; line-height: 1.6; + padding-bottom: 4px; } .xr-text-repr-fallback { @@ -136,13 +137,8 @@ body.vscode-dark { .xr-section-item > input + label { color: var(--xr-disabled-color); - /* border: 2px solid transparent !important; */ } -/* .xr-section-item > input:active + label { - border: 2px solid var(--xr-font-color0) !important; -} */ - .xr-section-item > input:enabled + label, .xr-group-box-contents > input:enabled + label { cursor: pointer; @@ -160,14 +156,25 @@ body.vscode-dark { font-weight: 500; } -.xr-section-summary > span, +.xr-section-summary > em { + font-weight: normal; +} + +.xr-span-grid { + grid-column-end: -1; +} + +.xr-section-summary > span { + display: inline-block; + padding-left: 0.3em; +} + .xr-group-box-contents > input:checked + label > span { display: inline-block; - padding-left: 0.5em; + padding-left: 0.6em; } -.xr-section-summary-in:disabled + label, -.xr-group-box-contents > input:disabled + label { +.xr-section-summary-in:disabled + label { color: var(--xr-font-color2); } @@ -179,8 +186,7 @@ body.vscode-dark { text-align: center; } -.xr-section-summary-in:disabled + label:before, -.xr-group-box-contents > input:disabled + label:before { +.xr-section-summary-in:disabled + label:before { color: var(--xr-disabled-color); } @@ -220,13 +226,12 @@ body.vscode-dark { display: inline-grid; grid-template-columns: 100%; grid-column: 1 / -1; - width: 100%; + padding-top: 4px; } .xr-group-box { display: inline-grid; grid-template-columns: 0px 30px auto; - width: 100%; } .xr-group-box-vline { @@ -247,7 +252,7 @@ body.vscode-dark { .xr-group-box-contents { grid-column-start: 3; - width: 100%; + padding-bottom: 4px; } .xr-group-box-contents > label::before { From 1958bc9c2f888ab167a2b3668672d2e0a8f68d53 Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Fri, 3 Oct 2025 17:38:16 -0700 Subject: [PATCH 06/11] Tweaks --- xarray/core/formatting_html.py | 37 +++++++++++---------- xarray/core/options.py | 4 +-- xarray/tests/test_formatting_html.py | 48 +++++++--------------------- 3 files changed, 34 insertions(+), 55 deletions(-) diff --git a/xarray/core/formatting_html.py b/xarray/core/formatting_html.py index 0a6ab7bd5f6..e1f2ae64ab4 100644 --- a/xarray/core/formatting_html.py +++ b/xarray/core/formatting_html.py @@ -472,10 +472,21 @@ class _DataTreeDisplay: def _build_datatree_displays(tree: DataTree) -> dict[str, _DataTreeDisplay]: displayed_line_count = 0 + html_line_count = 0 displays: dict[str, _DataTreeDisplay] = {} item_count_cache: dict[int, int] = {} root = True collapsed = False + disabled = False + + html_limit = OPTIONS["display_max_html_elements"] + uncollapsed_limit = OPTIONS["display_max_items"] + + too_many_items_section = collapsible_section( + "Too many items to display (display_max_html_elements exceeded)", + enabled=False, + collapsed=True, + ) for node in tree.subtree: # breadth-first parent = node.parent @@ -487,18 +498,19 @@ def _build_datatree_displays(tree: DataTree) -> dict[str, _DataTreeDisplay]: item_count = _tree_item_count(node, item_count_cache) sections, node_line_count = _datatree_node_sections(node, root) - new_count = displayed_line_count + node_line_count - - disabled = not root and new_count > OPTIONS["display_max_html_elements"] + new_displayed_count = displayed_line_count + node_line_count + new_html_count = html_line_count + node_line_count + disabled = not root and (disabled or new_html_count > html_limit) if disabled: - sections = [] + sections = [too_many_items_section] collapsed = True else: - if not root: - collapsed = collapsed or new_count > OPTIONS["display_max_items"] - if not collapsed: - displayed_line_count = new_count + html_line_count = new_html_count + + collapsed = not root and (collapsed or new_displayed_count > uncollapsed_limit) + if not collapsed: + displayed_line_count = new_displayed_count displays[node.path] = _DataTreeDisplay( node, sections, item_count, collapsed, disabled @@ -575,15 +587,6 @@ def datatree_child_repr( tip = " title='Expand/collapse group'" if not display.disabled else "" sections = datatree_sections(node, displays) - if display.disabled: - sections.append( - collapsible_section( - f"Too many items ({display.item_count}) to display", - enabled=False, - collapsed=True, - ) - ) - sections_html = _sections_repr(sections) if sections else "" html = f""" diff --git a/xarray/core/options.py b/xarray/core/options.py index 20da9a39ca1..ba0b71d159e 100644 --- a/xarray/core/options.py +++ b/xarray/core/options.py @@ -79,7 +79,7 @@ class T_Options(TypedDict): "cmap_divergent": "RdBu_r", "cmap_sequential": "viridis", "display_max_children": 12, - "display_max_html_elements": 500, + "display_max_html_elements": 300, "display_max_rows": 12, "display_max_items": 20, "display_values_threshold": 200, @@ -246,7 +246,7 @@ class set_options: * ``default`` : to expand unless over a pre-defined limit (always collapse for html style) display_max_children : int, default: 12 Maximum number of children to display for each node in a DataTree. - display_max_html_elements : int, default: 500 + display_max_html_elements : int, default: 300 Maximum number of HTML elements to include in DataTree HTML displays. Additional items are truncated. display_max_rows : int, default: 12 diff --git a/xarray/tests/test_formatting_html.py b/xarray/tests/test_formatting_html.py index f58e8aee49e..a4c169f6b04 100644 --- a/xarray/tests/test_formatting_html.py +++ b/xarray/tests/test_formatting_html.py @@ -271,48 +271,24 @@ def test_nonstr_variable_repr_html() -> None: class TestDataTreeTruncatesNodes: def test_many_nodes(self) -> None: - # construct a datatree with 500 nodes - number_of_files = 20 - number_of_groups = 25 + number_of_files = 10 + number_of_groups = 10 tree_dict = {} for f in range(number_of_files): for g in range(number_of_groups): tree_dict[f"file_{f}/group_{g}"] = xr.Dataset({"g": f * g}) tree = xr.DataTree.from_dict(tree_dict) - with xr.set_options(display_style="html"): - result = tree._repr_html_() - - assert "6/20" in result - for i in range(number_of_files): - if i < 3 or i >= (number_of_files - 3): - assert f"file_{i}
    " in result - else: - assert f"file_{i}
    " not in result - - assert "6/25" in result - for i in range(number_of_groups): - if i < 3 or i >= (number_of_groups - 3): - assert f"group_{i}
    " in result - else: - assert f"group_{i}" not in result - - with xr.set_options(display_style="html", display_max_children=3): - result = tree._repr_html_() - - assert "3/20" in result - for i in range(number_of_files): - if i < 2 or i >= (number_of_files - 1): - assert f"file_{i}" in result - else: - assert f"file_{i}" not in result - - assert "3/25" in result - for i in range(number_of_groups): - if i < 2 or i >= (number_of_groups - 1): - assert f"group_{i}" in result - else: - assert f"group_{i}" not in result + + with xr.set_options(display_max_html_elements=25): + result = xarray_html_only_repr(tree) + assert result.count("file_0/group_9") == 1 + assert result.count("file_1/group_0") == 0 # disabled + assert result.count("Too many items to display") == 9 + 10 + + with xr.set_options(display_max_html_elements=1000): + result = xarray_html_only_repr(tree) + assert result.count("Too many items to display") == 0 class TestDataTreeInheritance: From 96efaee98a7c2ef9d174024f401ec319ca89c1bd Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Mon, 15 Dec 2025 15:10:29 -0800 Subject: [PATCH 07/11] Add tests and truncation for many child nodes --- xarray/core/formatting_html.py | 64 +++++++++++++------ xarray/tests/test_formatting_html.py | 91 ++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 19 deletions(-) diff --git a/xarray/core/formatting_html.py b/xarray/core/formatting_html.py index f923ca7a04b..56afb398302 100644 --- a/xarray/core/formatting_html.py +++ b/xarray/core/formatting_html.py @@ -7,6 +7,7 @@ from functools import lru_cache, partial from html import escape from importlib.resources import files +from math import ceil from typing import TYPE_CHECKING, Literal from xarray.core.formatting import ( @@ -188,17 +189,15 @@ def collapsible_section( has_items = n_items is not None and n_items n_items_span = "" if n_items is None else f" ({n_items})" - enabled = "" if enabled and has_items else " disabled" - collapsed = "" if collapsed or not has_items else " checked" - tip = " title='Expand/collapse section'" if enabled else "" - span_grid = " xr-span-grid" if not inline_details else "" + enabled_attr = "" if enabled and has_items else " disabled" + collapsed_attr = "" if collapsed or not has_items else " checked" + tip = " title='Expand/collapse section'" if enabled_attr == "" else "" html = ( - f"" - f"" + f"" + f"" + f"
    {inline_details}
    " ) - if inline_details: - html += f"
    {inline_details}
    " if details: html += f"
    {details}
    " return html @@ -211,7 +210,6 @@ def _mapping_section( max_items_collapse, expand_option_name, enabled=True, - max_option_name: Literal["display_max_children"] | None = None, **kwargs, ) -> str: n_items = len(mapping) @@ -220,15 +218,8 @@ def _mapping_section( ) collapsed = not expanded - inline_details = "" - if max_option_name and max_option_name in OPTIONS: - max_items = int(OPTIONS[max_option_name]) - if n_items > max_items: - inline_details = f"({max_items}/{n_items})" - return collapsible_section( f"{name}:", - inline_details=inline_details, details=details_func(mapping, **kwargs), n_items=n_items, enabled=enabled, @@ -534,13 +525,48 @@ def _build_datatree_displays(tree: DataTree) -> dict[str, _DataTreeDisplay]: return displays +def _ellipsis_element() -> str: + """Create an ellipsis element for truncated children.""" + return ( + "
    " + "
    " + "
    " + "" + "
    " + "
    " + ) + + def children_section( children: Mapping[str, DataTree], displays: dict[str, _DataTreeDisplay] ) -> str: child_elements = [] - for i, child in enumerate(children.values()): - is_last = i == (len(children) - 1) - child_elements.append(datatree_child_repr(child, displays, end=is_last)) + children_list = list(children.values()) + nchildren = len(children_list) + max_children = int(OPTIONS["display_max_children"]) + + if nchildren <= max_children: + # Render all children + for i, child in enumerate(children_list): + is_last = i == nchildren - 1 + child_elements.append(datatree_child_repr(child, displays, end=is_last)) + else: + # Truncate: show first ceil(max/2), ellipsis, last floor(max/2) + first_n = ceil(max_children / 2) + last_n = max_children - first_n + + for i in range(first_n): + child_elements.append( + datatree_child_repr(children_list[i], displays, end=False) + ) + + child_elements.append(_ellipsis_element()) + + for i in range(nchildren - last_n, nchildren): + is_last = i == nchildren - 1 + child_elements.append( + datatree_child_repr(children_list[i], displays, end=is_last) + ) children_html = "".join(child_elements) return f"
    {children_html}
    " diff --git a/xarray/tests/test_formatting_html.py b/xarray/tests/test_formatting_html.py index ccb67d0bdc2..2783b607b97 100644 --- a/xarray/tests/test_formatting_html.py +++ b/xarray/tests/test_formatting_html.py @@ -328,6 +328,97 @@ def test_many_nodes(self) -> None: result = xarray_html_only_repr(tree) assert result.count("Too many items to display") == 0 + def test_many_children_truncated(self) -> None: + # Create tree with 20 children at root level + tree_dict = {f"child_{i:02d}": xr.Dataset({"x": i}) for i in range(20)} + tree = xr.DataTree.from_dict(tree_dict) + + # With max_children=5: show first 3, ellipsis, last 2 + with xr.set_options(display_max_children=5, display_max_html_elements=1000): + result = xarray_html_only_repr(tree) + + # First 3 children should appear + assert "/child_00" in result + assert "/child_01" in result + assert "/child_02" in result + + # Middle children should NOT appear + assert "/child_03" not in result + assert "/child_10" not in result + assert "/child_17" not in result + + # Last 2 children should appear + assert "/child_18" in result + assert "/child_19" in result + + # Vertical ellipsis should appear + assert "⋮" in result + + def test_few_children_not_truncated(self) -> None: + # Create tree with 5 children (at the limit) + tree_dict = {f"child_{i}": xr.Dataset({"x": i}) for i in range(5)} + tree = xr.DataTree.from_dict(tree_dict) + + with xr.set_options(display_max_children=5, display_max_html_elements=1000): + result = xarray_html_only_repr(tree) + + # All children should appear + for i in range(5): + assert f"/child_{i}" in result + + # No ellipsis + assert "⋮" not in result + + def test_nested_children_truncated(self) -> None: + # Create tree with nested children: root → 10 children → each with 2 grandchildren + tree_dict = {} + for i in range(10): + for j in range(2): + tree_dict[f"child_{i:02d}/grandchild_{j}"] = xr.Dataset({"x": i * j}) + tree = xr.DataTree.from_dict(tree_dict) + + with xr.set_options(display_max_children=5, display_max_html_elements=1000): + result = xarray_html_only_repr(tree) + + # Root level: first 3 and last 2 of 10 children should appear + assert "/child_00" in result + assert "/child_01" in result + assert "/child_02" in result + assert "/child_05" not in result # truncated + assert "/child_08" in result + assert "/child_09" in result + + # Ellipsis should appear for truncated children + assert "⋮" in result + + def test_node_item_count_displayed(self) -> None: + # Create tree with known item counts + tree = xr.DataTree.from_dict({ + "node_a": xr.Dataset({"var1": 1, "var2": 2}), # 2 vars + "node_b": xr.Dataset({"var1": 1}, attrs={"attr1": "x", "attr2": "y"}), # 1 var + 2 attrs + }) + + with xr.set_options(display_max_html_elements=1000): + result = xarray_html_only_repr(tree) + + # Item counts should appear in parentheses + assert "(2)" in result # node_a: 2 variables + assert "(3)" in result # node_b: 1 variable + 2 attrs + + def test_collapsible_group_checkbox(self) -> None: + # Create simple tree with children + tree = xr.DataTree.from_dict({ + "child_a": xr.Dataset({"x": 1}), + "child_b": xr.Dataset({"y": 2}), + }) + + with xr.set_options(display_max_html_elements=1000): + result = xarray_html_only_repr(tree) + + # Group nodes should have checkbox inputs for collapsing + assert " None: From 0e0b60ab5d13ce2cd5388c6b1dc1e52a22d3e206 Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Mon, 15 Dec 2025 15:24:10 -0800 Subject: [PATCH 08/11] add missing DataTree.xindexes into repr --- xarray/core/formatting_html.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/xarray/core/formatting_html.py b/xarray/core/formatting_html.py index 56afb398302..f4373402da3 100644 --- a/xarray/core/formatting_html.py +++ b/xarray/core/formatting_html.py @@ -415,6 +415,13 @@ def _datatree_node_sections(node: DataTree, root: bool) -> tuple[list[str], int] # Only show dimensions if also showing a variable or coordinates section. show_dims = node_coords or (root and inherited_coords) or 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 + ) + sections = [] if show_dims: sections.append(dim_section(ds)) @@ -424,6 +431,8 @@ def _datatree_node_sections(node: DataTree, root: bool) -> tuple[list[str], int] sections.append(inherited_coord_section(inherited_coords)) if ds.data_vars: sections.append(datavar_section(ds.data_vars)) + if xindexes: + sections.append(index_section(xindexes)) if ds.attrs: sections.append(attr_section(ds.attrs)) @@ -435,6 +444,8 @@ def _datatree_node_sections(node: DataTree, root: bool) -> tuple[list[str], int] + int(root) * (int(bool(inherited_coords)) + len(inherited_coords)) + int(bool(ds.data_vars)) + len(ds.data_vars) + + int(bool(xindexes)) + + len(xindexes) + int(bool(ds.attrs)) + len(ds.attrs) ) From 2a98a7899908dc536fc9e72a59e0349a88cc3d53 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:24:59 +0000 Subject: [PATCH 09/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- xarray/core/formatting_html.py | 2 +- xarray/tests/test_formatting_html.py | 22 ++++++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/xarray/core/formatting_html.py b/xarray/core/formatting_html.py index f4373402da3..008ba88d8d2 100644 --- a/xarray/core/formatting_html.py +++ b/xarray/core/formatting_html.py @@ -8,7 +8,7 @@ from html import escape from importlib.resources import files from math import ceil -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING from xarray.core.formatting import ( filter_nondefault_indexes, diff --git a/xarray/tests/test_formatting_html.py b/xarray/tests/test_formatting_html.py index 2783b607b97..8f1358b755f 100644 --- a/xarray/tests/test_formatting_html.py +++ b/xarray/tests/test_formatting_html.py @@ -393,10 +393,14 @@ def test_nested_children_truncated(self) -> None: def test_node_item_count_displayed(self) -> None: # Create tree with known item counts - tree = xr.DataTree.from_dict({ - "node_a": xr.Dataset({"var1": 1, "var2": 2}), # 2 vars - "node_b": xr.Dataset({"var1": 1}, attrs={"attr1": "x", "attr2": "y"}), # 1 var + 2 attrs - }) + tree = xr.DataTree.from_dict( + { + "node_a": xr.Dataset({"var1": 1, "var2": 2}), # 2 vars + "node_b": xr.Dataset( + {"var1": 1}, attrs={"attr1": "x", "attr2": "y"} + ), # 1 var + 2 attrs + } + ) with xr.set_options(display_max_html_elements=1000): result = xarray_html_only_repr(tree) @@ -407,10 +411,12 @@ def test_node_item_count_displayed(self) -> None: def test_collapsible_group_checkbox(self) -> None: # Create simple tree with children - tree = xr.DataTree.from_dict({ - "child_a": xr.Dataset({"x": 1}), - "child_b": xr.Dataset({"y": 2}), - }) + tree = xr.DataTree.from_dict( + { + "child_a": xr.Dataset({"x": 1}), + "child_b": xr.Dataset({"y": 2}), + } + ) with xr.set_options(display_max_html_elements=1000): result = xarray_html_only_repr(tree) From 180b4d38e1c07d06bd81a03a89f0a83f7afbb710 Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Mon, 15 Dec 2025 15:26:44 -0800 Subject: [PATCH 10/11] Add whats-new entry for DataTree HTML repr improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- doc/whats-new.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 7e3badc7143..cf623b8721e 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -14,6 +14,11 @@ v2025.12.1 (unreleased) New Features ~~~~~~~~~~~~ +- Improved :py:class:`DataTree` HTML representation: groups are now collapsible + with item counts shown in labels, large trees are automatically truncated + using ``display_max_children`` and ``display_max_html_elements`` options, + and the Indexes section is now displayed (matching the text repr) (:pull:`10816`). + By `Stephan Hoyer `_. Breaking Changes ~~~~~~~~~~~~~~~~ From 8451eff693b8707be891a9768a701b755d9386e3 Mon Sep 17 00:00:00 2001 From: Stephan Hoyer Date: Mon, 15 Dec 2025 15:32:31 -0800 Subject: [PATCH 11/11] Fix ruff PERF401: use list.extend instead of append in loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- xarray/core/formatting_html.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/xarray/core/formatting_html.py b/xarray/core/formatting_html.py index 008ba88d8d2..dfbefff36f9 100644 --- a/xarray/core/formatting_html.py +++ b/xarray/core/formatting_html.py @@ -566,18 +566,17 @@ def children_section( first_n = ceil(max_children / 2) last_n = max_children - first_n - for i in range(first_n): - child_elements.append( - datatree_child_repr(children_list[i], displays, end=False) - ) + child_elements.extend( + datatree_child_repr(children_list[i], displays, end=False) + for i in range(first_n) + ) child_elements.append(_ellipsis_element()) - for i in range(nchildren - last_n, nchildren): - is_last = i == nchildren - 1 - child_elements.append( - datatree_child_repr(children_list[i], displays, end=is_last) - ) + child_elements.extend( + datatree_child_repr(children_list[i], displays, end=(i == nchildren - 1)) + for i in range(nchildren - last_n, nchildren) + ) children_html = "".join(child_elements) return f"
    {children_html}
    "