diff --git a/xarray/core/formatting_html.py b/xarray/core/formatting_html.py index 77842751681..e1f2ae64ab4 100644 --- a/xarray/core/formatting_html.py +++ b/xarray/core/formatting_html.py @@ -3,10 +3,10 @@ import uuid from collections import OrderedDict from collections.abc import Mapping +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 ( @@ -172,25 +172,32 @@ def summarize_indexes(indexes) -> str: def collapsible_section( - name, inline_details="", details="", n_items=None, enabled=True, collapsed=False + header: str, + inline_details="", + details="", + n_items=None, + enabled=True, + collapsed=False, ) -> str: # "unique" id to expand/collapse the section data_id = "section-" + str(uuid.uuid4()) 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 "" + span_grid = " xr-span-grid" if not inline_details else "" - return ( - f"" - f"" - f"
{inline_details}
" - f"
{details}
" + html = ( + f"" + f"" ) + if inline_details: + html += f"
{inline_details}
" + if details: + html += f"
{details}
" + return html def _mapping_section( @@ -201,9 +208,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 @@ -215,9 +223,9 @@ def _mapping_section( inline_details = f"({max_items}/{n_items})" return collapsible_section( - name, + f"{name}:", inline_details=inline_details, - details=details_func(mapping), + details=details_func(mapping, **kwargs), n_items=n_items, enabled=enabled, collapsed=collapsed, @@ -228,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 ) @@ -294,6 +302,11 @@ def _get_indexes_dict(indexes): } +def _sections_repr(sections: list[str]) -> str: + section_items = "".join(f"
  • {s}
  • " for s in sections) + return f"" + + def _obj_repr(obj, header_components, sections): """Return HTML repr of an xarray object. @@ -301,7 +314,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 ( @@ -310,7 +322,7 @@ def _obj_repr(obj, header_components, sections): f"
    {escape(repr(obj))}
    " "" "" ) @@ -384,7 +396,16 @@ def dataset_repr(ds) -> str: return _obj_repr(ds, header_components, sections) -def datatree_node_sections(node: DataTree, root: bool = False) -> list[str]: +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) -> tuple[list[str], int]: from xarray.core.coordinates import Coordinates ds = node._to_dataset_view(rebuild_dims=False, inherit=True) @@ -397,78 +418,146 @@ def datatree_node_sections(node: DataTree, root: bool = False) -> list[str]: ) # 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 - ) + show_dims = node_coords or (root and inherited_coords) or ds.data_vars 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 + 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) + ) + return sections, displayed_line_count -def summarize_datatree_children(children: Mapping[str, DataTree]) -> 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)) - elif n_children > MAX_CHILDREN and i == ceil(MAX_CHILDREN / 2): - children_html.append("
    ...
    ") - - return "".join( - [ - "
    ", - "".join(children_html), - "
    ", - ] +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 + + +@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 + 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 + 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 -children_section = partial( - _mapping_section, - name="Groups", - details_func=summarize_datatree_children, - max_option_name="display_max_children", - expand_option_name="display_expand_groups", -) + item_count = _tree_item_count(node, item_count_cache) + + sections, node_line_count = _datatree_node_sections(node, root) + 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 = [too_many_items_section] + collapsed = True + else: + 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 + ) + 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 -inherited_coord_section = partial( - _mapping_section, - name="Inherited coordinates", - details_func=summarize_coords, - max_items_collapse=25, - expand_option_name="display_expand_coords", -) +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) + return f"
    {children_html}
    " + + +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 = False) -> str: + +def datatree_child_repr( + node: DataTree, + displays: dict[str, _DataTreeDisplay], + end: bool, +) -> str: # Wrap DataTree HTML representation with a tee to the left of it. # # Enclosing HTML tag is a
    with :code:`display: inline-grid` style. @@ -487,26 +576,30 @@ def datatree_child_repr(node: DataTree, end: bool = False) -> str: # └─ [ title ] # | details | # |_____________| - end = bool(end) - height = "100%" if end is False else "1.2em" # height of line + + vline_height = "1.2em" if end else "100%" path = escape(node.path) - sections = datatree_node_sections(node, root=False) - section_items = "".join(f"
  • {s}
  • " for s in sections) + display = displays[node.path] + + group_id = "group-" + str(uuid.uuid4()) + collapsed = " checked" if display.collapsed else "" + tip = " title='Expand/collapse group'" if not display.disabled else "" + + sections = datatree_sections(node, displays) + sections_html = _sections_repr(sections) if sections 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. html = f"""
    -
    +
    -
    -
    {path}
    -
    -
      - {section_items} -
    + + + {sections_html}
    """ @@ -514,12 +607,12 @@ def datatree_child_repr(node: DataTree, end: bool = False) -> str: 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) + 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 c8d00eea5d2..ba0b71d159e 100644 --- a/xarray/core/options.py +++ b/xarray/core/options.py @@ -15,7 +15,9 @@ "cmap_divergent", "cmap_sequential", "display_max_children", + "display_max_html_elements", "display_max_rows", + "display_max_items", "display_values_threshold", "display_style", "display_width", @@ -45,7 +47,9 @@ 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 display_style: Literal["text", "html"] display_width: int @@ -74,8 +78,10 @@ class T_Options(TypedDict): "chunk_manager": "dask", "cmap_divergent": "RdBu_r", "cmap_sequential": "viridis", - "display_max_children": 6, + "display_max_children": 12, + "display_max_html_elements": 300, "display_max_rows": 12, + "display_max_items": 20, "display_values_threshold": 200, "display_style": "html", "display_width": 80, @@ -111,7 +117,9 @@ 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, "display_style": _DISPLAY_OPTIONS.__contains__, "display_width": _positive_integer, @@ -236,10 +244,16 @@ 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_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 Maximum display rows. + 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 8b8e854b62a..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 { @@ -88,8 +89,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 +104,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 +127,26 @@ 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: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-section-item > input:enabled + label:hover, +.xr-group-box-contents > input:enabled + label:hover { color: var(--xr-font-color0); } @@ -159,9 +156,22 @@ body.vscode-dark { font-weight: 500; } +.xr-section-summary > em { + font-weight: normal; +} + +.xr-span-grid { + grid-column-end: -1; +} + .xr-section-summary > span { display: inline-block; - padding-left: 0.5em; + padding-left: 0.3em; +} + +.xr-group-box-contents > input:checked + label > span { + display: inline-block; + padding-left: 0.6em; } .xr-section-summary-in:disabled + label { @@ -189,7 +199,8 @@ body.vscode-dark { } .xr-section-summary, -.xr-section-inline-details { +.xr-section-inline-details, +.xr-group-box-contents > label { padding-top: 4px; } @@ -198,20 +209,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; + padding-top: 4px; +} + .xr-group-box { display: inline-grid; - grid-template-columns: 0px 20px auto; - width: 100%; + grid-template-columns: 0px 30px auto; } .xr-group-box-vline { @@ -225,13 +245,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; + padding-bottom: 4px; +} + +.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 { diff --git a/xarray/tests/test_formatting_html.py b/xarray/tests/test_formatting_html.py index 2f0177f3181..a4c169f6b04 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 @@ -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: