Skip to content

Commit fde2c14

Browse files
committed
Merge remote-tracking branch 'upstream/master' into interp-na-maxgap
* upstream/master: Escaping dtypes (pydata#3444) Html repr (pydata#3425)
2 parents a411cc2 + bb0a5a2 commit fde2c14

File tree

11 files changed

+804
-3
lines changed

11 files changed

+804
-3
lines changed

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ prune doc/generated
66
global-exclude .DS_Store
77
include versioneer.py
88
include xarray/_version.py
9+
recursive-include xarray/static *

doc/whats-new.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ New Features
3939
``pip install git+https://github.com/andrewgsavage/pint.git@refs/pull/6/head)``.
4040
Even with it, interaction with non-numpy array libraries, e.g. dask or sparse, is broken.
4141

42+
- Added new :py:meth:`Dataset._repr_html_` and :py:meth:`DataArray._repr_html_` to improve
43+
representation of objects in jupyter. By default this feature is turned off
44+
for now. Enable it with :py:meth:`xarray.set_options(display_style="html")`.
45+
(:pull:`3425`) by `Benoit Bovy <https://github.com/benbovy>`_ and
46+
`Julia Signell <https://github.com/jsignell>`_.
47+
4248
Bug fixes
4349
~~~~~~~~~
4450
- Fix regression introduced in v0.14.0 that would cause a crash if dask is installed

setup.py

100644100755
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,5 +104,7 @@
104104
tests_require=TESTS_REQUIRE,
105105
url=URL,
106106
packages=find_packages(),
107-
package_data={"xarray": ["py.typed", "tests/data/*"]},
107+
package_data={
108+
"xarray": ["py.typed", "tests/data/*", "static/css/*", "static/html/*"]
109+
},
108110
)

xarray/core/common.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import warnings
22
from contextlib import suppress
3+
from html import escape
34
from textwrap import dedent
45
from typing import (
56
Any,
@@ -18,10 +19,10 @@
1819
import numpy as np
1920
import pandas as pd
2021

21-
from . import dtypes, duck_array_ops, formatting, ops
22+
from . import dtypes, duck_array_ops, formatting, formatting_html, ops
2223
from .arithmetic import SupportsArithmetic
2324
from .npcompat import DTypeLike
24-
from .options import _get_keep_attrs
25+
from .options import OPTIONS, _get_keep_attrs
2526
from .pycompat import dask_array_type
2627
from .rolling_exp import RollingExp
2728
from .utils import Frozen, ReprObject, either_dict_or_kwargs
@@ -134,6 +135,11 @@ def __array__(self: Any, dtype: DTypeLike = None) -> np.ndarray:
134135
def __repr__(self) -> str:
135136
return formatting.array_repr(self)
136137

138+
def _repr_html_(self):
139+
if OPTIONS["display_style"] == "text":
140+
return f"<pre>{escape(repr(self))}</pre>"
141+
return formatting_html.array_repr(self)
142+
137143
def _iter(self: Any) -> Iterator[Any]:
138144
for n in range(len(self)):
139145
yield self[n]

xarray/core/dataset.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import sys
44
import warnings
55
from collections import defaultdict
6+
from html import escape
67
from numbers import Number
78
from pathlib import Path
89
from typing import (
@@ -39,6 +40,7 @@
3940
dtypes,
4041
duck_array_ops,
4142
formatting,
43+
formatting_html,
4244
groupby,
4345
ops,
4446
resample,
@@ -1619,6 +1621,11 @@ def to_zarr(
16191621
def __repr__(self) -> str:
16201622
return formatting.dataset_repr(self)
16211623

1624+
def _repr_html_(self):
1625+
if OPTIONS["display_style"] == "text":
1626+
return f"<pre>{escape(repr(self))}</pre>"
1627+
return formatting_html.dataset_repr(self)
1628+
16221629
def info(self, buf=None) -> None:
16231630
"""
16241631
Concise summary of a Dataset variables and attributes.

xarray/core/formatting_html.py

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import uuid
2+
import pkg_resources
3+
from collections import OrderedDict
4+
from functools import partial
5+
from html import escape
6+
7+
from .formatting import inline_variable_array_repr, short_data_repr
8+
9+
10+
CSS_FILE_PATH = "/".join(("static", "css", "style.css"))
11+
CSS_STYLE = pkg_resources.resource_string("xarray", CSS_FILE_PATH).decode("utf8")
12+
13+
14+
ICONS_SVG_PATH = "/".join(("static", "html", "icons-svg-inline.html"))
15+
ICONS_SVG = pkg_resources.resource_string("xarray", ICONS_SVG_PATH).decode("utf8")
16+
17+
18+
def short_data_repr_html(array):
19+
"""Format "data" for DataArray and Variable."""
20+
internal_data = getattr(array, "variable", array)._data
21+
if hasattr(internal_data, "_repr_html_"):
22+
return internal_data._repr_html_()
23+
return escape(short_data_repr(array))
24+
25+
26+
def format_dims(dims, coord_names):
27+
if not dims:
28+
return ""
29+
30+
dim_css_map = {
31+
k: " class='xr-has-index'" if k in coord_names else "" for k, v in dims.items()
32+
}
33+
34+
dims_li = "".join(
35+
f"<li><span{dim_css_map[dim]}>" f"{escape(dim)}</span>: {size}</li>"
36+
for dim, size in dims.items()
37+
)
38+
39+
return f"<ul class='xr-dim-list'>{dims_li}</ul>"
40+
41+
42+
def summarize_attrs(attrs):
43+
attrs_dl = "".join(
44+
f"<dt><span>{escape(k)} :</span></dt>" f"<dd>{escape(str(v))}</dd>"
45+
for k, v in attrs.items()
46+
)
47+
48+
return f"<dl class='xr-attrs'>{attrs_dl}</dl>"
49+
50+
51+
def _icon(icon_name):
52+
# icon_name should be defined in xarray/static/html/icon-svg-inline.html
53+
return (
54+
"<svg class='icon xr-{0}'>"
55+
"<use xlink:href='#{0}'>"
56+
"</use>"
57+
"</svg>".format(icon_name)
58+
)
59+
60+
61+
def _summarize_coord_multiindex(name, coord):
62+
preview = f"({', '.join(escape(l) for l in coord.level_names)})"
63+
return summarize_variable(
64+
name, coord, is_index=True, dtype="MultiIndex", preview=preview
65+
)
66+
67+
68+
def summarize_coord(name, var):
69+
is_index = name in var.dims
70+
if is_index:
71+
coord = var.variable.to_index_variable()
72+
if coord.level_names is not None:
73+
coords = {}
74+
coords[name] = _summarize_coord_multiindex(name, coord)
75+
for lname in coord.level_names:
76+
var = coord.get_level_variable(lname)
77+
coords[lname] = summarize_variable(lname, var)
78+
return coords
79+
80+
return {name: summarize_variable(name, var, is_index)}
81+
82+
83+
def summarize_coords(variables):
84+
coords = {}
85+
for k, v in variables.items():
86+
coords.update(**summarize_coord(k, v))
87+
88+
vars_li = "".join(f"<li class='xr-var-item'>{v}</li>" for v in coords.values())
89+
90+
return f"<ul class='xr-var-list'>{vars_li}</ul>"
91+
92+
93+
def summarize_variable(name, var, is_index=False, dtype=None, preview=None):
94+
variable = var.variable if hasattr(var, "variable") else var
95+
96+
cssclass_idx = " class='xr-has-index'" if is_index else ""
97+
dims_str = f"({', '.join(escape(dim) for dim in var.dims)})"
98+
name = escape(name)
99+
dtype = dtype or escape(str(var.dtype))
100+
101+
# "unique" ids required to expand/collapse subsections
102+
attrs_id = "attrs-" + str(uuid.uuid4())
103+
data_id = "data-" + str(uuid.uuid4())
104+
disabled = "" if len(var.attrs) else "disabled"
105+
106+
preview = preview or escape(inline_variable_array_repr(variable, 35))
107+
attrs_ul = summarize_attrs(var.attrs)
108+
data_repr = short_data_repr_html(variable)
109+
110+
attrs_icon = _icon("icon-file-text2")
111+
data_icon = _icon("icon-database")
112+
113+
return (
114+
f"<div class='xr-var-name'><span{cssclass_idx}>{name}</span></div>"
115+
f"<div class='xr-var-dims'>{dims_str}</div>"
116+
f"<div class='xr-var-dtype'>{dtype}</div>"
117+
f"<div class='xr-var-preview xr-preview'>{preview}</div>"
118+
f"<input id='{attrs_id}' class='xr-var-attrs-in' "
119+
f"type='checkbox' {disabled}>"
120+
f"<label for='{attrs_id}' title='Show/Hide attributes'>"
121+
f"{attrs_icon}</label>"
122+
f"<input id='{data_id}' class='xr-var-data-in' type='checkbox'>"
123+
f"<label for='{data_id}' title='Show/Hide data repr'>"
124+
f"{data_icon}</label>"
125+
f"<div class='xr-var-attrs'>{attrs_ul}</div>"
126+
f"<pre class='xr-var-data'>{data_repr}</pre>"
127+
)
128+
129+
130+
def summarize_vars(variables):
131+
vars_li = "".join(
132+
f"<li class='xr-var-item'>{summarize_variable(k, v)}</li>"
133+
for k, v in variables.items()
134+
)
135+
136+
return f"<ul class='xr-var-list'>{vars_li}</ul>"
137+
138+
139+
def collapsible_section(
140+
name, inline_details="", details="", n_items=None, enabled=True, collapsed=False
141+
):
142+
# "unique" id to expand/collapse the section
143+
data_id = "section-" + str(uuid.uuid4())
144+
145+
has_items = n_items is not None and n_items
146+
n_items_span = "" if n_items is None else f" <span>({n_items})</span>"
147+
enabled = "" if enabled and has_items else "disabled"
148+
collapsed = "" if collapsed or not has_items else "checked"
149+
tip = " title='Expand/collapse section'" if enabled else ""
150+
151+
return (
152+
f"<input id='{data_id}' class='xr-section-summary-in' "
153+
f"type='checkbox' {enabled} {collapsed}>"
154+
f"<label for='{data_id}' class='xr-section-summary' {tip}>"
155+
f"{name}:{n_items_span}</label>"
156+
f"<div class='xr-section-inline-details'>{inline_details}</div>"
157+
f"<div class='xr-section-details'>{details}</div>"
158+
)
159+
160+
161+
def _mapping_section(mapping, name, details_func, max_items_collapse, enabled=True):
162+
n_items = len(mapping)
163+
collapsed = n_items >= max_items_collapse
164+
165+
return collapsible_section(
166+
name,
167+
details=details_func(mapping),
168+
n_items=n_items,
169+
enabled=enabled,
170+
collapsed=collapsed,
171+
)
172+
173+
174+
def dim_section(obj):
175+
dim_list = format_dims(obj.dims, list(obj.coords))
176+
177+
return collapsible_section(
178+
"Dimensions", inline_details=dim_list, enabled=False, collapsed=True
179+
)
180+
181+
182+
def array_section(obj):
183+
# "unique" id to expand/collapse the section
184+
data_id = "section-" + str(uuid.uuid4())
185+
collapsed = ""
186+
preview = escape(inline_variable_array_repr(obj.variable, max_width=70))
187+
data_repr = short_data_repr_html(obj)
188+
data_icon = _icon("icon-database")
189+
190+
return (
191+
"<div class='xr-array-wrap'>"
192+
f"<input id='{data_id}' class='xr-array-in' type='checkbox' {collapsed}>"
193+
f"<label for='{data_id}' title='Show/hide data repr'>{data_icon}</label>"
194+
f"<div class='xr-array-preview xr-preview'><span>{preview}</span></div>"
195+
f"<pre class='xr-array-data'>{data_repr}</pre>"
196+
"</div>"
197+
)
198+
199+
200+
coord_section = partial(
201+
_mapping_section,
202+
name="Coordinates",
203+
details_func=summarize_coords,
204+
max_items_collapse=25,
205+
)
206+
207+
208+
datavar_section = partial(
209+
_mapping_section,
210+
name="Data variables",
211+
details_func=summarize_vars,
212+
max_items_collapse=15,
213+
)
214+
215+
216+
attr_section = partial(
217+
_mapping_section,
218+
name="Attributes",
219+
details_func=summarize_attrs,
220+
max_items_collapse=10,
221+
)
222+
223+
224+
def _obj_repr(header_components, sections):
225+
header = f"<div class='xr-header'>{''.join(h for h in header_components)}</div>"
226+
sections = "".join(f"<li class='xr-section-item'>{s}</li>" for s in sections)
227+
228+
return (
229+
"<div>"
230+
f"{ICONS_SVG}<style>{CSS_STYLE}</style>"
231+
"<div class='xr-wrap'>"
232+
f"{header}"
233+
f"<ul class='xr-sections'>{sections}</ul>"
234+
"</div>"
235+
"</div>"
236+
)
237+
238+
239+
def array_repr(arr):
240+
dims = OrderedDict((k, v) for k, v in zip(arr.dims, arr.shape))
241+
242+
obj_type = "xarray.{}".format(type(arr).__name__)
243+
arr_name = "'{}'".format(arr.name) if getattr(arr, "name", None) else ""
244+
coord_names = list(arr.coords) if hasattr(arr, "coords") else []
245+
246+
header_components = [
247+
"<div class='xr-obj-type'>{}</div>".format(obj_type),
248+
"<div class='xr-array-name'>{}</div>".format(arr_name),
249+
format_dims(dims, coord_names),
250+
]
251+
252+
sections = [array_section(arr)]
253+
254+
if hasattr(arr, "coords"):
255+
sections.append(coord_section(arr.coords))
256+
257+
sections.append(attr_section(arr.attrs))
258+
259+
return _obj_repr(header_components, sections)
260+
261+
262+
def dataset_repr(ds):
263+
obj_type = "xarray.{}".format(type(ds).__name__)
264+
265+
header_components = [f"<div class='xr-obj-type'>{escape(obj_type)}</div>"]
266+
267+
sections = [
268+
dim_section(ds),
269+
coord_section(ds.coords),
270+
datavar_section(ds.data_vars),
271+
attr_section(ds.attrs),
272+
]
273+
274+
return _obj_repr(header_components, sections)

xarray/core/options.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
CMAP_SEQUENTIAL = "cmap_sequential"
99
CMAP_DIVERGENT = "cmap_divergent"
1010
KEEP_ATTRS = "keep_attrs"
11+
DISPLAY_STYLE = "display_style"
1112

1213

1314
OPTIONS = {
@@ -19,9 +20,11 @@
1920
CMAP_SEQUENTIAL: "viridis",
2021
CMAP_DIVERGENT: "RdBu_r",
2122
KEEP_ATTRS: "default",
23+
DISPLAY_STYLE: "text",
2224
}
2325

2426
_JOIN_OPTIONS = frozenset(["inner", "outer", "left", "right", "exact"])
27+
_DISPLAY_OPTIONS = frozenset(["text", "html"])
2528

2629

2730
def _positive_integer(value):
@@ -35,6 +38,7 @@ def _positive_integer(value):
3538
FILE_CACHE_MAXSIZE: _positive_integer,
3639
WARN_FOR_UNCLOSED_FILES: lambda value: isinstance(value, bool),
3740
KEEP_ATTRS: lambda choice: choice in [True, False, "default"],
41+
DISPLAY_STYLE: _DISPLAY_OPTIONS.__contains__,
3842
}
3943

4044

@@ -98,6 +102,9 @@ class set_options:
98102
attrs, ``False`` to always discard them, or ``'default'`` to use original
99103
logic that attrs should only be kept in unambiguous circumstances.
100104
Default: ``'default'``.
105+
- ``display_style``: display style to use in jupyter for xarray objects.
106+
Default: ``'text'``. Other options are ``'html'``.
107+
101108
102109
You can use ``set_options`` either as a context manager:
103110

0 commit comments

Comments
 (0)