Skip to content

Commit c703ce4

Browse files
authored
Improve display of HTML reprs (#10785)
* Improve display of DataTree HTML repr * Fix test failure * add whats new * Clean up nested HTML and add folder icon * Hide missing sections in Dataset and DataArray HTML reprs, too
1 parent b98add1 commit c703ce4

File tree

5 files changed

+295
-355
lines changed

5 files changed

+295
-355
lines changed

doc/whats-new.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ Breaking changes
3737
restore the prior defaults (:issue:`10657`).
3838
By `Stephan Hoyer <https://github.com/shoyer>`_.
3939

40+
- The HTML reprs for :py:class:`DataArray`, :py:class:`Dataset` and
41+
:py:class:`DataTree` have been tweaked to hide empty sections, consistent
42+
with the text reprs. The ``DataTree`` HTML repr also now automatically expands
43+
sub-groups (:pull:`10785`).
44+
By `Stephan Hoyer <https://github.com/shoyer>`_.
45+
4046
Deprecations
4147
~~~~~~~~~~~~
4248

xarray/core/formatting.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,7 +1092,7 @@ def inherited_vars(mapping: ChainMap) -> dict:
10921092
return {k: v for k, v in mapping.parents.items() if k not in mapping.maps[0]}
10931093

10941094

1095-
def _datatree_node_repr(node: DataTree, show_inherited: bool) -> str:
1095+
def _datatree_node_repr(node: DataTree, root: bool) -> str:
10961096
summary = [f"Group: {node.path}"]
10971097

10981098
col_width = _calculate_col_width(node.variables)
@@ -1103,11 +1103,11 @@ def _datatree_node_repr(node: DataTree, show_inherited: bool) -> str:
11031103
# Only show dimensions if also showing a variable or coordinates section.
11041104
show_dims = (
11051105
node._node_coord_variables
1106-
or (show_inherited and inherited_coords)
1106+
or (root and inherited_coords)
11071107
or node._data_variables
11081108
)
11091109

1110-
dim_sizes = node.sizes if show_inherited else node._node_dims
1110+
dim_sizes = node.sizes if root else node._node_dims
11111111

11121112
if show_dims:
11131113
# Includes inherited dimensions.
@@ -1121,7 +1121,7 @@ def _datatree_node_repr(node: DataTree, show_inherited: bool) -> str:
11211121
node_coords = node.to_dataset(inherit=False).coords
11221122
summary.append(coords_repr(node_coords, col_width=col_width, max_rows=max_rows))
11231123

1124-
if show_inherited and inherited_coords:
1124+
if root and inherited_coords:
11251125
summary.append(
11261126
inherited_coords_repr(node, col_width=col_width, max_rows=max_rows)
11271127
)
@@ -1139,7 +1139,7 @@ def _datatree_node_repr(node: DataTree, show_inherited: bool) -> str:
11391139
)
11401140

11411141
# TODO: only show indexes defined at this node, with a separate section for
1142-
# inherited indexes (if show_inherited=True)
1142+
# inherited indexes (if root=True)
11431143
display_default_indexes = _get_boolean_with_default(
11441144
"display_default_indexes", False
11451145
)
@@ -1165,16 +1165,19 @@ def datatree_repr(dt: DataTree) -> str:
11651165
header = f"<xarray.DataTree{name_info}>"
11661166

11671167
lines = [header]
1168-
show_inherited = True
1168+
root = True
11691169

11701170
for pre, fill, node in renderer:
11711171
if isinstance(node, str):
11721172
lines.append(f"{fill}{node}")
11731173
continue
11741174

1175-
node_repr = _datatree_node_repr(node, show_inherited=show_inherited)
1176-
show_inherited = False # only show inherited coords on the root
1175+
node_repr = _datatree_node_repr(node, root=root)
1176+
root = False # only the first node is the root
11771177

1178+
# TODO: figure out if we can restructure this logic to move child groups
1179+
# up higher in the repr, directly below the <xarray.DataTree> header.
1180+
# This would be more consistent with the HTML repr.
11781181
raw_repr_lines = node_repr.splitlines()
11791182

11801183
node_line = f"{pre}{raw_repr_lines[0]}"

xarray/core/formatting_html.py

Lines changed: 136 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import TYPE_CHECKING, Literal
1111

1212
from xarray.core.formatting import (
13+
filter_nondefault_indexes,
1314
inherited_vars,
1415
inline_index_repr,
1516
inline_variable_array_repr,
@@ -323,24 +324,33 @@ def array_repr(arr) -> str:
323324
indexed_dims = {}
324325

325326
obj_type = f"xarray.{type(arr).__name__}"
326-
arr_name = f"'{arr.name}'" if getattr(arr, "name", None) else ""
327+
arr_name = escape(repr(arr.name)) if getattr(arr, "name", None) else ""
327328

328329
header_components = [
329330
f"<div class='xr-obj-type'>{obj_type}</div>",
330-
f"<div class='xr-array-name'>{arr_name}</div>",
331+
f"<div class='xr-obj-name'>{arr_name}</div>",
331332
format_dims(dims, indexed_dims),
332333
]
333334

334335
sections = [array_section(arr)]
335336

336337
if hasattr(arr, "coords"):
337-
sections.append(coord_section(arr.coords))
338+
if arr.coords:
339+
sections.append(coord_section(arr.coords))
338340

339341
if hasattr(arr, "xindexes"):
340-
indexes = _get_indexes_dict(arr.xindexes)
341-
sections.append(index_section(indexes))
342-
343-
sections.append(attr_section(arr.attrs))
342+
display_default_indexes = _get_boolean_with_default(
343+
"display_default_indexes", False
344+
)
345+
xindexes = filter_nondefault_indexes(
346+
_get_indexes_dict(arr.xindexes), not display_default_indexes
347+
)
348+
if xindexes:
349+
indexes = _get_indexes_dict(arr.xindexes)
350+
sections.append(index_section(indexes))
351+
352+
if arr.attrs:
353+
sections.append(attr_section(arr.attrs))
344354

345355
return _obj_repr(arr, header_components, sections)
346356

@@ -350,28 +360,85 @@ def dataset_repr(ds) -> str:
350360

351361
header_components = [f"<div class='xr-obj-type'>{escape(obj_type)}</div>"]
352362

353-
sections = [
354-
dim_section(ds),
355-
coord_section(ds.coords),
356-
datavar_section(ds.data_vars),
357-
index_section(_get_indexes_dict(ds.xindexes)),
358-
attr_section(ds.attrs),
359-
]
363+
sections = []
364+
365+
sections.append(dim_section(ds))
366+
367+
if ds.coords:
368+
sections.append(coord_section(ds.coords))
369+
370+
sections.append(datavar_section(ds.data_vars))
371+
372+
display_default_indexes = _get_boolean_with_default(
373+
"display_default_indexes", False
374+
)
375+
xindexes = filter_nondefault_indexes(
376+
_get_indexes_dict(ds.xindexes), not display_default_indexes
377+
)
378+
if xindexes:
379+
sections.append(index_section(xindexes))
380+
381+
if ds.attrs:
382+
sections.append(attr_section(ds.attrs))
360383

361384
return _obj_repr(ds, header_components, sections)
362385

363386

387+
def datatree_node_sections(node: DataTree, root: bool = False) -> list[str]:
388+
from xarray.core.coordinates import Coordinates
389+
390+
ds = node._to_dataset_view(rebuild_dims=False, inherit=True)
391+
node_coords = node.to_dataset(inherit=False).coords
392+
393+
# use this class to get access to .xindexes property
394+
inherited_coords = Coordinates(
395+
coords=inherited_vars(node._coord_variables),
396+
indexes=inherited_vars(node._indexes),
397+
)
398+
399+
# Only show dimensions if also showing a variable or coordinates section.
400+
show_dims = (
401+
node._node_coord_variables
402+
or (root and inherited_coords)
403+
or node._data_variables
404+
)
405+
406+
sections = []
407+
408+
if node.children:
409+
children_max_items = 1 if ds.data_vars else 6
410+
sections.append(
411+
children_section(node.children, max_items_collapse=children_max_items)
412+
)
413+
414+
if show_dims:
415+
sections.append(dim_section(ds))
416+
417+
if node_coords:
418+
sections.append(coord_section(node_coords))
419+
420+
# only show inherited coordinates on the root
421+
if root and inherited_coords:
422+
sections.append(inherited_coord_section(inherited_coords))
423+
424+
if ds.data_vars:
425+
sections.append(datavar_section(ds.data_vars))
426+
427+
if ds.attrs:
428+
sections.append(attr_section(ds.attrs))
429+
430+
return sections
431+
432+
364433
def summarize_datatree_children(children: Mapping[str, DataTree]) -> str:
365434
MAX_CHILDREN = OPTIONS["display_max_children"]
366435
n_children = len(children)
367436

368437
children_html = []
369-
for i, (n, c) in enumerate(children.items()):
438+
for i, child in enumerate(children.values()):
370439
if i < ceil(MAX_CHILDREN / 2) or i >= ceil(n_children - MAX_CHILDREN / 2):
371440
is_last = i == (n_children - 1)
372-
children_html.append(
373-
_wrap_datatree_repr(datatree_node_repr(n, c), end=is_last)
374-
)
441+
children_html.append(datatree_child_repr(child, end=is_last))
375442
elif n_children > MAX_CHILDREN and i == ceil(MAX_CHILDREN / 2):
376443
children_html.append("<div>...</div>")
377444

@@ -388,7 +455,6 @@ def summarize_datatree_children(children: Mapping[str, DataTree]) -> str:
388455
_mapping_section,
389456
name="Groups",
390457
details_func=summarize_datatree_children,
391-
max_items_collapse=1,
392458
max_option_name="display_max_children",
393459
expand_option_name="display_expand_groups",
394460
)
@@ -402,108 +468,58 @@ def summarize_datatree_children(children: Mapping[str, DataTree]) -> str:
402468
)
403469

404470

405-
def datatree_node_repr(group_title: str, node: DataTree, show_inherited=False) -> str:
406-
from xarray.core.coordinates import Coordinates
407-
408-
header_components = [f"<div class='xr-obj-type'>{escape(group_title)}</div>"]
409-
410-
ds = node._to_dataset_view(rebuild_dims=False, inherit=True)
411-
node_coords = node.to_dataset(inherit=False).coords
412-
413-
# use this class to get access to .xindexes property
414-
inherited_coords = Coordinates(
415-
coords=inherited_vars(node._coord_variables),
416-
indexes=inherited_vars(node._indexes),
417-
)
418-
419-
sections = [
420-
children_section(node.children),
421-
dim_section(ds),
422-
coord_section(node_coords),
423-
]
424-
425-
# only show inherited coordinates on the root
426-
if show_inherited:
427-
sections.append(inherited_coord_section(inherited_coords))
428-
429-
sections += [
430-
datavar_section(ds.data_vars),
431-
attr_section(ds.attrs),
432-
]
433-
434-
return _obj_repr(ds, header_components, sections)
435-
436-
437-
def _wrap_datatree_repr(r: str, end: bool = False) -> str:
471+
def datatree_child_repr(node: DataTree, end: bool = False) -> str:
472+
# Wrap DataTree HTML representation with a tee to the left of it.
473+
#
474+
# Enclosing HTML tag is a <div> with :code:`display: inline-grid` style.
475+
#
476+
# Turns:
477+
# [ title ]
478+
# | details |
479+
# |_____________|
480+
#
481+
# into (A):
482+
# |─ [ title ]
483+
# | | details |
484+
# | |_____________|
485+
#
486+
# or (B):
487+
# └─ [ title ]
488+
# | details |
489+
# |_____________|
490+
end = bool(end)
491+
height = "100%" if end is False else "1.2em" # height of line
492+
493+
path = escape(node.path)
494+
sections = datatree_node_sections(node, root=False)
495+
section_items = "".join(f"<li class='xr-section-item'>{s}</li>" for s in sections)
496+
497+
# TODO: Can we make the group name clickable to toggle the sections below?
498+
# This looks like it would require the input/label pattern used above.
499+
html = f"""
500+
<div class='xr-group-box'>
501+
<div class='xr-group-box-vline' style='height: {height}'></div>
502+
<div class='xr-group-box-hline'></div>
503+
<div class='xr-group-box-contents'>
504+
<div class='xr-header'>
505+
<div class='xr-group-name'>{path}</div>
506+
</div>
507+
<ul class='xr-sections'>
508+
{section_items}
509+
</ul>
510+
</div>
511+
</div>
438512
"""
439-
Wrap HTML representation with a tee to the left of it.
440-
441-
Enclosing HTML tag is a <div> with :code:`display: inline-grid` style.
513+
return "".join(t.strip() for t in html.split("\n"))
442514

443-
Turns:
444-
[ title ]
445-
| details |
446-
|_____________|
447-
448-
into (A):
449-
|─ [ title ]
450-
| | details |
451-
| |_____________|
452-
453-
or (B):
454-
└─ [ title ]
455-
| details |
456-
|_____________|
457-
458-
Parameters
459-
----------
460-
r: str
461-
HTML representation to wrap.
462-
end: bool
463-
Specify if the line on the left should continue or end.
464-
465-
Default is True.
466-
467-
Returns
468-
-------
469-
str
470-
Wrapped HTML representation.
471-
472-
Tee color is set to the variable :code:`--xr-border-color`.
473-
"""
474-
# height of line
475-
end = bool(end)
476-
height = "100%" if end is False else "1.2em"
477-
return "".join(
478-
[
479-
"<div style='display: inline-grid; grid-template-columns: 0px 20px auto; width: 100%;'>",
480-
"<div style='",
481-
"grid-column-start: 1;",
482-
"border-right: 0.2em solid;",
483-
"border-color: var(--xr-border-color);",
484-
f"height: {height};",
485-
"width: 0px;",
486-
"'>",
487-
"</div>",
488-
"<div style='",
489-
"grid-column-start: 2;",
490-
"grid-row-start: 1;",
491-
"height: 1em;",
492-
"width: 20px;",
493-
"border-bottom: 0.2em solid;",
494-
"border-color: var(--xr-border-color);",
495-
"'>",
496-
"</div>",
497-
"<div style='",
498-
"grid-column-start: 3;",
499-
"'>",
500-
r,
501-
"</div>",
502-
"</div>",
503-
]
504-
)
505515

516+
def datatree_repr(node: DataTree) -> str:
517+
header_components = [
518+
f"<div class='xr-obj-type'>xarray.{type(node).__name__}</div>",
519+
]
520+
if node.name is not None:
521+
name = escape(repr(node.name))
522+
header_components.append(f"<div class='xr-obj-name'>{name}</div>")
506523

507-
def datatree_repr(dt: DataTree) -> str:
508-
obj_type = f"xarray.{type(dt).__name__}"
509-
return datatree_node_repr(obj_type, dt, show_inherited=True)
524+
sections = datatree_node_sections(node, root=True)
525+
return _obj_repr(node, header_components, sections)

0 commit comments

Comments
 (0)