From f19b29f420c7ad3d4d4a600973a14d7c2841b70f Mon Sep 17 00:00:00 2001 From: Michael Niklas Date: Fri, 9 Oct 2026 14:37:30 +0200 Subject: [PATCH 1/5] Add show_backends and print show_versions as markdown show_backends prints the available IO backends, including those of other packages, with their description, url and providing package, in the order they are tried when no engine is given. show_versions now prints markdown tables wrapped in a
block, so the output can be pasted as is into GitHub issues, and displays formatted tables in Jupyter notebooks. Versions are read from the package metadata instead of importing the packages. Co-authored-by: Nick Hodgskin Co-authored-by: Claude Co-Authored-By: Claude Opus 5.5 --- .github/ISSUE_TEMPLATE/bugreport.yml | 9 +- doc/api/top-level.rst | 1 + doc/whats-new.rst | 10 ++ pyproject.toml | 1 + xarray/__init__.py | 2 + xarray/backends/plugins.py | 60 +++++++++++- xarray/tests/backends/test_plugins.py | 51 ++++++++++ xarray/tests/test_print_versions.py | 50 +++++++++- xarray/util/_report.py | 85 ++++++++++++++++ xarray/util/print_versions.py | 134 +++++++++++++------------- 10 files changed, 325 insertions(+), 78 deletions(-) create mode 100644 xarray/util/_report.py diff --git a/.github/ISSUE_TEMPLATE/bugreport.yml b/.github/ISSUE_TEMPLATE/bugreport.yml index f5276925ab5..454dbbc71b1 100644 --- a/.github/ISSUE_TEMPLATE/bugreport.yml +++ b/.github/ISSUE_TEMPLATE/bugreport.yml @@ -92,12 +92,7 @@ body: attributes: label: Environment description: | - Paste the output of `xr.show_versions()` between the `
` tags, leaving an empty line following the opening tag. - value: | -
- - - -
+ Paste the output of `xr.show_versions()` (in notebooks `xr.show_versions(file=sys.stdout)`). + Older versions of xarray do not wrap the output in `
` tags, please add them yourself, leaving an empty line following the opening tag. validations: required: true diff --git a/doc/api/top-level.rst b/doc/api/top-level.rst index c6f45c1863c..3494ca8a765 100644 --- a/doc/api/top-level.rst +++ b/doc/api/top-level.rst @@ -58,6 +58,7 @@ Miscellaneous decode_cf infer_freq + show_backends show_versions set_options get_options diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 38fe147860b..3cdecccf980 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -21,6 +21,16 @@ New Features type defaults to ``Hashable``, so a bare ``Variable`` annotation means ``Variable[Hashable]`` and keeps its previous meaning (:pull:`11677`). By `Michael Niklas `_. +- Add :py:func:`~xarray.show_backends` to print the available IO backends, + including those of other packages, and in which order they are tried + (:issue:`6577`, :pull:`11700`). + By `Nick Hodgskin `_ and + `Michael Niklas `_. +- :py:func:`~xarray.show_versions` now prints markdown tables wrapped in a + ``
`` block that can be pasted as is into GitHub issues, and displays + formatted tables in Jupyter notebooks. Package versions are read from the + package metadata, so the packages are no longer imported (:pull:`11700`). + By `Michael Niklas `_. Breaking Changes diff --git a/pyproject.toml b/pyproject.toml index 31e3b4e3862..d60598d3417 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -151,6 +151,7 @@ module = [ "fsspec.*", "h5netcdf.*", "h5py.*", + "IPython.*", "iris.*", "mpl_toolkits.*", "nc_time_axis.*", diff --git a/xarray/__init__.py b/xarray/__init__.py index 7d56816cdfe..b646f00d46f 100644 --- a/xarray/__init__.py +++ b/xarray/__init__.py @@ -11,6 +11,7 @@ open_groups, open_mfdataset, ) +from xarray.backends.plugins import show_backends from xarray.backends.writers import save_mfdataset from xarray.backends.zarr import open_zarr from xarray.coding.cftime_offsets import cftime_range, date_range, date_range_like @@ -114,6 +115,7 @@ "register_datatree_accessor", "save_mfdataset", "set_options", + "show_backends", "show_versions", "unify_chunks", "where", diff --git a/xarray/backends/plugins.py b/xarray/backends/plugins.py index db79dbae7ff..72faaf58a82 100644 --- a/xarray/backends/plugins.py +++ b/xarray/backends/plugins.py @@ -7,11 +7,12 @@ import warnings from collections.abc import Callable from importlib.metadata import entry_points -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TextIO from xarray.backends.common import BACKEND_ENTRYPOINTS, BackendEntrypoint from xarray.core.options import OPTIONS from xarray.core.utils import is_remote_uri, module_available +from xarray.util._report import package_version, show_report if TYPE_CHECKING: from importlib.metadata import EntryPoint, EntryPoints @@ -136,6 +137,63 @@ def refresh_engines() -> None: list_engines.cache_clear() +def _engine_sources() -> dict[str, tuple[str, str | None]]: + """Map each engine name to its providing package and version.""" + sources: dict[str, tuple[str, str | None]] = dict.fromkeys( + BACKEND_ENTRYPOINTS, ("xarray", None) + ) + for ep in entry_points(group="xarray.backends"): + if ep.dist is not None: + sources[ep.name] = (ep.dist.name, ep.dist.version) + else: + sources[ep.name] = (ep.module.partition(".")[0], None) + return sources + + +def show_backends(file: TextIO | None = None) -> None: + """Print the available backends (engines) and information about them. + + The backends are listed in the order in which they are tried when opening + a file without specifying the ``engine``. + Use :py:func:`xarray.backends.list_engines` to get the backend objects. + + The output is markdown that can be pasted as is into a GitHub issue. + In Jupyter notebooks it is displayed formatted instead; pass + ``file=sys.stdout`` to get the markdown to copy. + + Parameters + ---------- + file : file-like, optional + print to the given file-like object. Defaults to sys.stdout. + """ + engines = list_engines() + sources = _engine_sources() + + lines = [] + for name, backend in engines.items(): + package, version = sources.get(name, ("unknown", None)) + header = f"- **{name}**: `{type(backend).__name__}` from {package}" + if version is not None: + header += f" {version}" + details = [] + if package == "xarray" and name in BACKEND_ENTRYPOINTS: + dependency = BACKEND_ENTRYPOINTS[name][0] + if dependency is not None: + details.append(f"using {dependency} {package_version(dependency)}") + if backend.supports_groups: + details.append("supports groups") + if details: + header += f" ({', '.join(details)})" + lines.append(header) + if backend.description: + lines.append(f" - {' '.join(backend.description.split())}") + if backend.url: + lines.append(f" - {backend.url}") + + body = "\n".join(lines) or "No backends available." + show_report("AVAILABLE BACKENDS", body, file=file) + + def guess_engine( store_spec: str | os.PathLike[Any] diff --git a/xarray/tests/backends/test_plugins.py b/xarray/tests/backends/test_plugins.py index 8c674d8fd69..8bfada1043f 100644 --- a/xarray/tests/backends/test_plugins.py +++ b/xarray/tests/backends/test_plugins.py @@ -1,5 +1,6 @@ from __future__ import annotations +import io import subprocess import sys from importlib.metadata import EntryPoint, EntryPoints @@ -8,6 +9,7 @@ import pytest +import xarray as xr from xarray.backends import common, plugins from xarray.core.options import OPTIONS from xarray.tests import ( @@ -362,6 +364,55 @@ def test_refresh_engines() -> None: refresh_engines() +class DummyDescribedBackend(common.BackendEntrypoint): + description = "Open dummy files" + url = "https://example.com/dummy" + supports_groups = True + + def open_dataset(self, filename_or_obj, *, decoder): # type: ignore[override] + pass + + +def test_show_backends() -> None: + from xarray.backends import list_engines, refresh_engines + + dist = mock.MagicMock() + dist.name = "dummy-pkg" + dist.version = "1.2.3" + ep = mock.MagicMock() + ep.name = "dummy" + ep.dist = dist + ep.load.return_value = DummyDescribedBackend + + f = io.StringIO() + with mock.patch( + "xarray.backends.plugins.entry_points", return_value=EntryPoints([ep]) + ): + list_engines.cache_clear() + xr.show_backends(file=f) + refresh_engines() + out = f.getvalue() + + assert out.startswith("
AVAILABLE BACKENDS\n\n") + expected_dummy = ( + "- **dummy**: `DummyDescribedBackend` from dummy-pkg 1.2.3 (supports groups)\n" + " - Open dummy files\n" + " - https://example.com/dummy\n" + ) + assert expected_dummy in out + expected_store = ( + "- **store**: `StoreBackendEntrypoint` from xarray\n" + " - Open AbstractDataStore instances in Xarray\n" + " - https://docs.xarray.dev/en/stable/generated/" + "xarray.backends.StoreBackendEntrypoint.html\n" + ) + assert expected_store in out + # backends that are not installed are not listed + assert ("**scipy**" in out) == has_scipy + if has_scipy: + assert "`ScipyBackendEntrypoint` from xarray (using scipy " in out + + @requires_h5netcdf @requires_netCDF4 @requires_zarr diff --git a/xarray/tests/test_print_versions.py b/xarray/tests/test_print_versions.py index 23b6350fb57..6f0e8eb48e0 100644 --- a/xarray/tests/test_print_versions.py +++ b/xarray/tests/test_print_versions.py @@ -1,10 +1,15 @@ from __future__ import annotations +import importlib.metadata import io +import sys +import types +from unittest import mock import pytest import xarray +from xarray.util import _report # importing setuptools after distutils (done by some dependencies) warns @@ -12,4 +17,47 @@ def test_show_versions() -> None: f = io.StringIO() xarray.show_versions(file=f) - assert "INSTALLED VERSIONS" in f.getvalue() + out = f.getvalue() + assert out.startswith("
INSTALLED VERSIONS\n\n") + assert out.endswith("\n
\n") + assert "| xarray " in out + assert xarray.__version__ in out + assert "Not installed: " in out + + +def test_package_version() -> None: + assert _report.package_version("xarray") == importlib.metadata.version("xarray") + assert _report.package_version("not_a_real_package_xyz") is None + + +def test_package_version_dist_name_differs() -> None: + with mock.patch.object( + _report, "_packages_distributions", return_value={"fake_mod": ["pytest"]} + ): + assert _report.package_version("fake_mod") == pytest.__version__ + + +def test_markdown_table() -> None: + actual = _report.markdown_table(("a", "bb"), [("x", "1 | 2"), ("long", None)]) + expected = ( + "| a | bb |\n| ---- | ------ |\n| x | 1 \\| 2 |\n| long | None |" + ) + assert actual == expected + + +def test_show_report_rich_frontend(monkeypatch) -> None: + displayed: list[str] = [] + display_mod = types.ModuleType("IPython.display") + display_mod.Markdown = lambda text: text # type: ignore[attr-defined] + display_mod.display = displayed.append # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "IPython.display", display_mod) + monkeypatch.setattr(_report, "_in_rich_frontend", lambda: True) + + _report.show_report("TITLE", "body") + assert displayed == ["**TITLE**\n\nbody"] + + # an explicit file always gets the plain markdown + f = io.StringIO() + _report.show_report("TITLE", "body", file=f) + assert f.getvalue() == "
TITLE\n\nbody\n\n
\n" + assert len(displayed) == 1 diff --git a/xarray/util/_report.py b/xarray/util/_report.py new file mode 100644 index 00000000000..ffefb6f5c49 --- /dev/null +++ b/xarray/util/_report.py @@ -0,0 +1,85 @@ +"""Helpers to render diagnostic reports (show_versions, show_backends) as markdown. + +The markdown is designed to be readable as plain text in a terminal, to render +nicely when pasted into a GitHub issue and to be displayed as rich output in +notebooks. +""" + +from __future__ import annotations + +import functools +import importlib.metadata +import sys +from collections.abc import Iterable, Mapping, Sequence +from typing import TextIO + + +@functools.cache +def _packages_distributions() -> Mapping[str, list[str]]: + return importlib.metadata.packages_distributions() + + +def package_version(module_name: str) -> str | None: + """Return the installed version of the package providing ``module_name``. + + Uses the package metadata, so the module is not imported. + Returns None if the package is not installed. + """ + try: + return importlib.metadata.version(module_name) + except importlib.metadata.PackageNotFoundError: + pass + # the distribution name differs from the module name, e.g. scitools-iris + for dist_name in _packages_distributions().get(module_name, []): + try: + return importlib.metadata.version(dist_name) + except importlib.metadata.PackageNotFoundError: + continue + return None + + +def _escape_cell(value: object) -> str: + return " ".join(str(value).split()).replace("|", r"\|") + + +def markdown_table(header: Sequence[str], rows: Iterable[Sequence[object]]) -> str: + """Render an aligned markdown table that is also readable as plain text.""" + cells = [[_escape_cell(v) for v in row] for row in [header, *rows]] + widths = [max(len(row[i]) for row in cells) for i in range(len(header))] + widths = [max(w, 3) for w in widths] + + def fmt(row: Sequence[str]) -> str: + return ( + "| " + + " | ".join(c.ljust(w) for c, w in zip(row, widths, strict=True)) + + " |" + ) + + lines = [fmt(cells[0]), fmt(["-" * w for w in widths])] + lines.extend(fmt(row) for row in cells[1:]) + return "\n".join(lines) + + +def _in_rich_frontend() -> bool: + """Whether we run in a frontend that can display markdown, e.g. Jupyter.""" + ipython = sys.modules.get("IPython") + if ipython is None: + return False + shell = ipython.get_ipython() + return shell is not None and type(shell).__name__ == "ZMQInteractiveShell" + + +def show_report(title: str, body: str, file: TextIO | None = None) -> None: + """Print a markdown report, or display it as rich output in notebooks. + + When printed, the report is wrapped in a ``
`` block, so it can be + pasted as is into a GitHub issue. + """ + if file is None and _in_rich_frontend(): + from IPython.display import Markdown, display + + display(Markdown(f"**{title}**\n\n{body}")) + return + + text = f"
{title}\n\n{body}\n\n
" + print(text, file=sys.stdout if file is None else file) diff --git a/xarray/util/print_versions.py b/xarray/util/print_versions.py index 0be9713c3dc..03db026af46 100644 --- a/xarray/util/print_versions.py +++ b/xarray/util/print_versions.py @@ -1,13 +1,15 @@ """Utility functions for printing version information.""" import contextlib -import importlib import locale import os import platform import struct import subprocess import sys +from typing import TextIO + +from xarray.util._report import markdown_table, package_version, show_report def get_sys_info(): @@ -77,8 +79,51 @@ def netcdf_and_hdf5_versions(): return [("libhdf5", libhdf5_version), ("libnetcdf", libnetcdf_version)] -def show_versions(file=sys.stdout): - """print the versions of xarray and its dependencies +DEPENDENCIES = [ + "xarray", + "pandas", + "numpy", + "scipy", + # xarray optionals + "netCDF4", + "pydap", + "h5netcdf", + "h5py", + "zarr", + "cftime", + "nc_time_axis", + "iris", + "bottleneck", + "dask", + "distributed", + "matplotlib", + "cartopy", + "seaborn", + "numbagg", + "fsspec", + "cupy", + "pint", + "sparse", + "flox", + "numpy_groupies", + # xarray setup/test + "setuptools", + "pip", + "conda", + "pytest", + "mypy", + # Misc. + "IPython", + "sphinx", +] + + +def show_versions(file: TextIO | None = None) -> None: + """Print the versions of xarray and its dependencies. + + The output is markdown that can be pasted as is into a GitHub issue. + In Jupyter notebooks it is displayed as formatted tables instead; pass + ``file=sys.stdout`` to get the markdown to copy. Parameters ---------- @@ -90,72 +135,23 @@ def show_versions(file=sys.stdout): try: sys_info.extend(netcdf_and_hdf5_versions()) except Exception as e: - print(f"Error collecting netcdf / hdf5 version: {e}") - - deps = [ - # (MODULE_NAME, f(mod) -> mod version) - ("xarray", lambda mod: mod.__version__), - ("pandas", lambda mod: mod.__version__), - ("numpy", lambda mod: mod.__version__), - ("scipy", lambda mod: mod.__version__), - # xarray optionals - ("netCDF4", lambda mod: mod.__version__), - ("pydap", lambda mod: mod.__version__), - ("h5netcdf", lambda mod: mod.__version__), - ("h5py", lambda mod: mod.__version__), - ("zarr", lambda mod: mod.__version__), - ("cftime", lambda mod: mod.__version__), - ("nc_time_axis", lambda mod: mod.__version__), - ("iris", lambda mod: mod.__version__), - ("bottleneck", lambda mod: mod.__version__), - ("dask", lambda mod: mod.__version__), - ("distributed", lambda mod: mod.__version__), - ("matplotlib", lambda mod: mod.__version__), - ("cartopy", lambda mod: mod.__version__), - ("seaborn", lambda mod: mod.__version__), - ("numbagg", lambda mod: mod.__version__), - ("fsspec", lambda mod: mod.__version__), - ("cupy", lambda mod: mod.__version__), - ("pint", lambda mod: mod.__version__), - ("sparse", lambda mod: mod.__version__), - ("flox", lambda mod: mod.__version__), - ("numpy_groupies", lambda mod: mod.__version__), - # xarray setup/test - ("setuptools", lambda mod: mod.__version__), - ("pip", lambda mod: mod.__version__), - ("conda", lambda mod: mod.__version__), - ("pytest", lambda mod: mod.__version__), - ("mypy", lambda mod: importlib.metadata.version(mod.__name__)), - # Misc. - ("IPython", lambda mod: mod.__version__), - ("sphinx", lambda mod: mod.__version__), - ] - - deps_blob = [] - for modname, ver_f in deps: - try: - if modname in sys.modules: - mod = sys.modules[modname] - else: - mod = importlib.import_module(modname) - except Exception: - deps_blob.append((modname, None)) - else: - try: - ver = ver_f(mod) - deps_blob.append((modname, ver)) - except Exception: - deps_blob.append((modname, "installed")) - - print("\nINSTALLED VERSIONS", file=file) - print("------------------", file=file) - - for k, stat in sys_info: - print(f"{k}: {stat}", file=file) - - print("", file=file) - for k, stat in deps_blob: - print(f"{k}: {stat}", file=file) + sys_info.append(("libhdf5 / libnetcdf", f"error: {e}")) + + versions = {name: package_version(name) for name in DEPENDENCIES} + installed = [(name, ver) for name, ver in versions.items() if ver is not None] + missing = [name for name, ver in versions.items() if ver is None] + + body = "\n\n".join( + [ + markdown_table( + ("system", "info"), + [(k, v) for k, v in sys_info if v not in (None, "")], + ), + markdown_table(("package", "version"), installed), + f"Not installed: {', '.join(missing) or 'none'}", + ] + ) + show_report("INSTALLED VERSIONS", body, file=file) if __name__ == "__main__": From 597dcf9bca036ecb6a2561c2a6ef70bfc4e77812 Mon Sep 17 00:00:00 2001 From: Michael Niklas Date: Fri, 9 Oct 2026 14:45:59 +0200 Subject: [PATCH 2/5] Print plain text by default, markdown on request Terminals get the simple text output again, notebooks still display the formatted markdown and markdown=True prints it for GitHub issues. This also restores the bug report template, as old xarray versions print plain text. Co-authored-by: Claude Co-Authored-By: Claude Opus 5.5 --- .github/ISSUE_TEMPLATE/bugreport.yml | 9 ++++-- doc/whats-new.rst | 6 ++-- xarray/backends/plugins.py | 36 ++++++++++++--------- xarray/tests/backends/test_plugins.py | 34 +++++++++++--------- xarray/tests/test_print_versions.py | 45 +++++++++++++++++++++------ xarray/util/_report.py | 33 +++++++++++++------- xarray/util/print_versions.py | 31 +++++++++++------- 7 files changed, 127 insertions(+), 67 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bugreport.yml b/.github/ISSUE_TEMPLATE/bugreport.yml index 454dbbc71b1..f5276925ab5 100644 --- a/.github/ISSUE_TEMPLATE/bugreport.yml +++ b/.github/ISSUE_TEMPLATE/bugreport.yml @@ -92,7 +92,12 @@ body: attributes: label: Environment description: | - Paste the output of `xr.show_versions()` (in notebooks `xr.show_versions(file=sys.stdout)`). - Older versions of xarray do not wrap the output in `
` tags, please add them yourself, leaving an empty line following the opening tag. + Paste the output of `xr.show_versions()` between the `
` tags, leaving an empty line following the opening tag. + value: | +
+ + + +
validations: required: true diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 3cdecccf980..21e5e3a2468 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -26,9 +26,9 @@ New Features (:issue:`6577`, :pull:`11700`). By `Nick Hodgskin `_ and `Michael Niklas `_. -- :py:func:`~xarray.show_versions` now prints markdown tables wrapped in a - ``
`` block that can be pasted as is into GitHub issues, and displays - formatted tables in Jupyter notebooks. Package versions are read from the +- :py:func:`~xarray.show_versions` displays formatted tables in Jupyter + notebooks and gained a ``markdown`` option to print markdown that can be pasted + as is into GitHub issues. Package versions are read from the package metadata, so the packages are no longer imported (:pull:`11700`). By `Michael Niklas `_. diff --git a/xarray/backends/plugins.py b/xarray/backends/plugins.py index 72faaf58a82..98c8ca6809f 100644 --- a/xarray/backends/plugins.py +++ b/xarray/backends/plugins.py @@ -150,31 +150,32 @@ def _engine_sources() -> dict[str, tuple[str, str | None]]: return sources -def show_backends(file: TextIO | None = None) -> None: +def show_backends(file: TextIO | None = None, markdown: bool = False) -> None: """Print the available backends (engines) and information about them. The backends are listed in the order in which they are tried when opening a file without specifying the ``engine``. Use :py:func:`xarray.backends.list_engines` to get the backend objects. - - The output is markdown that can be pasted as is into a GitHub issue. - In Jupyter notebooks it is displayed formatted instead; pass - ``file=sys.stdout`` to get the markdown to copy. + In Jupyter notebooks the backends are displayed formatted. Parameters ---------- file : file-like, optional print to the given file-like object. Defaults to sys.stdout. + markdown : bool, default: False + print a markdown list wrapped in a ``
`` block, which can be + pasted as is into a GitHub issue. """ engines = list_engines() sources = _engine_sources() - lines = [] + text_lines = [] + md_lines = [] for name, backend in engines.items(): package, version = sources.get(name, ("unknown", None)) - header = f"- **{name}**: `{type(backend).__name__}` from {package}" + summary = f"from {package}" if version is not None: - header += f" {version}" + summary += f" {version}" details = [] if package == "xarray" and name in BACKEND_ENTRYPOINTS: dependency = BACKEND_ENTRYPOINTS[name][0] @@ -183,15 +184,22 @@ def show_backends(file: TextIO | None = None) -> None: if backend.supports_groups: details.append("supports groups") if details: - header += f" ({', '.join(details)})" - lines.append(header) + summary += f" ({', '.join(details)})" + cls_name = type(backend).__name__ + text_lines.append(f"{name}: {cls_name} {summary}") + md_lines.append(f"- **{name}**: `{cls_name}` {summary}") + + extra = [] if backend.description: - lines.append(f" - {' '.join(backend.description.split())}") + extra.append(" ".join(backend.description.split())) if backend.url: - lines.append(f" - {backend.url}") + extra.append(backend.url) + text_lines.extend(f" {line}" for line in extra) + md_lines.extend(f" - {line}" for line in extra) - body = "\n".join(lines) or "No backends available." - show_report("AVAILABLE BACKENDS", body, file=file) + text = "\n".join(text_lines) or "No backends available." + md = "\n".join(md_lines) or "No backends available." + show_report("AVAILABLE BACKENDS", text, md, file=file, as_markdown=markdown) def guess_engine( diff --git a/xarray/tests/backends/test_plugins.py b/xarray/tests/backends/test_plugins.py index 8bfada1043f..9db5ab3ff73 100644 --- a/xarray/tests/backends/test_plugins.py +++ b/xarray/tests/backends/test_plugins.py @@ -384,15 +384,31 @@ def test_show_backends() -> None: ep.dist = dist ep.load.return_value = DummyDescribedBackend - f = io.StringIO() + text = io.StringIO() + md = io.StringIO() with mock.patch( "xarray.backends.plugins.entry_points", return_value=EntryPoints([ep]) ): list_engines.cache_clear() - xr.show_backends(file=f) + xr.show_backends(file=text) + xr.show_backends(file=md, markdown=True) refresh_engines() - out = f.getvalue() + out = text.getvalue() + assert out.startswith("AVAILABLE BACKENDS\n------------------\n") + expected_dummy = ( + "dummy: DummyDescribedBackend from dummy-pkg 1.2.3 (supports groups)\n" + " Open dummy files\n" + " https://example.com/dummy\n" + ) + assert expected_dummy in out + assert "store: StoreBackendEntrypoint from xarray\n" in out + # backends that are not installed are not listed + assert ("\nscipy: " in out) == has_scipy + if has_scipy: + assert "scipy: ScipyBackendEntrypoint from xarray (using scipy " in out + + out = md.getvalue() assert out.startswith("
AVAILABLE BACKENDS\n\n") expected_dummy = ( "- **dummy**: `DummyDescribedBackend` from dummy-pkg 1.2.3 (supports groups)\n" @@ -400,17 +416,7 @@ def test_show_backends() -> None: " - https://example.com/dummy\n" ) assert expected_dummy in out - expected_store = ( - "- **store**: `StoreBackendEntrypoint` from xarray\n" - " - Open AbstractDataStore instances in Xarray\n" - " - https://docs.xarray.dev/en/stable/generated/" - "xarray.backends.StoreBackendEntrypoint.html\n" - ) - assert expected_store in out - # backends that are not installed are not listed - assert ("**scipy**" in out) == has_scipy - if has_scipy: - assert "`ScipyBackendEntrypoint` from xarray (using scipy " in out + assert "- **store**: `StoreBackendEntrypoint` from xarray\n" in out @requires_h5netcdf diff --git a/xarray/tests/test_print_versions.py b/xarray/tests/test_print_versions.py index 6f0e8eb48e0..6c2597a273c 100644 --- a/xarray/tests/test_print_versions.py +++ b/xarray/tests/test_print_versions.py @@ -18,11 +18,20 @@ def test_show_versions() -> None: f = io.StringIO() xarray.show_versions(file=f) out = f.getvalue() + assert out.startswith("INSTALLED VERSIONS\n------------------\n") + assert f"\nxarray: {importlib.metadata.version('xarray')}\n" in out + assert "Not installed: " in out + + +@pytest.mark.filterwarnings("ignore:Setuptools is replacing distutils:UserWarning") +def test_show_versions_markdown() -> None: + f = io.StringIO() + xarray.show_versions(file=f, markdown=True) + out = f.getvalue() assert out.startswith("
INSTALLED VERSIONS\n\n") assert out.endswith("\n
\n") + assert "| package " in out assert "| xarray " in out - assert xarray.__version__ in out - assert "Not installed: " in out def test_package_version() -> None: @@ -45,19 +54,35 @@ def test_markdown_table() -> None: assert actual == expected -def test_show_report_rich_frontend(monkeypatch) -> None: +def test_show_report(monkeypatch) -> None: displayed: list[str] = [] display_mod = types.ModuleType("IPython.display") display_mod.Markdown = lambda text: text # type: ignore[attr-defined] display_mod.display = displayed.append # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "IPython.display", display_mod) - monkeypatch.setattr(_report, "_in_rich_frontend", lambda: True) - _report.show_report("TITLE", "body") - assert displayed == ["**TITLE**\n\nbody"] + def show(**kwargs) -> str: + f = io.StringIO() + _report.show_report("TITLE", "text", "*md*", file=f, **kwargs) + return f.getvalue() - # an explicit file always gets the plain markdown - f = io.StringIO() - _report.show_report("TITLE", "body", file=f) - assert f.getvalue() == "
TITLE\n\nbody\n\n
\n" + assert show() == "TITLE\n-----\ntext\n" + assert show(as_markdown=True) == ( + "
TITLE\n\n*md*\n\n
\n" + ) + + # in notebooks the markdown is displayed, unless printing is requested + monkeypatch.setattr(_report, "_in_rich_frontend", lambda: True) + _report.show_report("TITLE", "text", "*md*") + assert displayed == ["**TITLE**\n\n*md*"] + assert show() == "TITLE\n-----\ntext\n" + stdout = io.StringIO() + monkeypatch.setattr(sys, "stdout", stdout) + _report.show_report("TITLE", "text", "*md*", as_markdown=True) + assert stdout.getvalue().startswith("
") assert len(displayed) == 1 + + +def test_in_rich_frontend_without_ipython(monkeypatch) -> None: + monkeypatch.delitem(sys.modules, "IPython", raising=False) + assert not _report._in_rich_frontend() diff --git a/xarray/util/_report.py b/xarray/util/_report.py index ffefb6f5c49..443900baa1e 100644 --- a/xarray/util/_report.py +++ b/xarray/util/_report.py @@ -1,8 +1,7 @@ -"""Helpers to render diagnostic reports (show_versions, show_backends) as markdown. +"""Helpers to render diagnostic reports (show_versions, show_backends). -The markdown is designed to be readable as plain text in a terminal, to render -nicely when pasted into a GitHub issue and to be displayed as rich output in -notebooks. +Reports are printed as plain text in terminals, displayed as formatted markdown +in notebooks and can be printed as markdown to paste them into GitHub issues. """ from __future__ import annotations @@ -69,17 +68,27 @@ def _in_rich_frontend() -> bool: return shell is not None and type(shell).__name__ == "ZMQInteractiveShell" -def show_report(title: str, body: str, file: TextIO | None = None) -> None: - """Print a markdown report, or display it as rich output in notebooks. +def show_report( + title: str, + text: str, + markdown: str, + file: TextIO | None = None, + as_markdown: bool = False, +) -> None: + """Print a report as plain text or markdown, or display it in notebooks. - When printed, the report is wrapped in a ``
`` block, so it can be - pasted as is into a GitHub issue. + In Jupyter notebooks the markdown is displayed formatted, unless a ``file`` + is given or ``as_markdown`` is requested. The printed markdown is wrapped + in a ``
`` block, so it can be pasted as is into a GitHub issue. """ - if file is None and _in_rich_frontend(): + if file is None and not as_markdown and _in_rich_frontend(): from IPython.display import Markdown, display - display(Markdown(f"**{title}**\n\n{body}")) + display(Markdown(f"**{title}**\n\n{markdown}")) return - text = f"
{title}\n\n{body}\n\n
" - print(text, file=sys.stdout if file is None else file) + if as_markdown: + out = f"
{title}\n\n{markdown}\n\n
" + else: + out = f"{title}\n{'-' * len(title)}\n{text}" + print(out, file=sys.stdout if file is None else file) diff --git a/xarray/util/print_versions.py b/xarray/util/print_versions.py index 03db026af46..719f97cf54b 100644 --- a/xarray/util/print_versions.py +++ b/xarray/util/print_versions.py @@ -118,17 +118,18 @@ def netcdf_and_hdf5_versions(): ] -def show_versions(file: TextIO | None = None) -> None: +def show_versions(file: TextIO | None = None, markdown: bool = False) -> None: """Print the versions of xarray and its dependencies. - The output is markdown that can be pasted as is into a GitHub issue. - In Jupyter notebooks it is displayed as formatted tables instead; pass - ``file=sys.stdout`` to get the markdown to copy. + In Jupyter notebooks the versions are displayed as formatted tables. Parameters ---------- file : file-like, optional print to the given file-like object. Defaults to sys.stdout. + markdown : bool, default: False + print markdown tables wrapped in a ``
`` block, which can be + pasted as is into a GitHub issue. """ sys_info = get_sys_info() @@ -137,21 +138,27 @@ def show_versions(file: TextIO | None = None) -> None: except Exception as e: sys_info.append(("libhdf5 / libnetcdf", f"error: {e}")) + sys_info = [(k, v) for k, v in sys_info if v not in (None, "")] versions = {name: package_version(name) for name in DEPENDENCIES} installed = [(name, ver) for name, ver in versions.items() if ver is not None] - missing = [name for name, ver in versions.items() if ver is None] + missing = ", ".join(name for name, ver in versions.items() if ver is None) + not_installed = f"Not installed: {missing or 'none'}" - body = "\n\n".join( + text = "\n\n".join( [ - markdown_table( - ("system", "info"), - [(k, v) for k, v in sys_info if v not in (None, "")], - ), + "\n".join(f"{k}: {v}" for k, v in sys_info), + "\n".join(f"{k}: {v}" for k, v in installed), + not_installed, + ] + ) + md = "\n\n".join( + [ + markdown_table(("system", "info"), sys_info), markdown_table(("package", "version"), installed), - f"Not installed: {', '.join(missing) or 'none'}", + not_installed, ] ) - show_report("INSTALLED VERSIONS", body, file=file) + show_report("INSTALLED VERSIONS", text, md, file=file, as_markdown=markdown) if __name__ == "__main__": From ff362685c2012ad543101bf7d31a0477b64f11f9 Mon Sep 17 00:00:00 2001 From: Michael Niklas Date: Fri, 9 Oct 2026 16:24:31 +0200 Subject: [PATCH 3/5] Display show_versions as side by side HTML tables in notebooks The markdown tables rendered right aligned and squeezed together in Jupyter. Use titled, left aligned HTML tables next to each other instead. Co-authored-by: Claude Co-Authored-By: Claude Opus 5.5 --- xarray/tests/test_print_versions.py | 14 +++++++++++- xarray/util/_report.py | 33 ++++++++++++++++++++++++----- xarray/util/print_versions.py | 20 +++++++++++++++-- 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/xarray/tests/test_print_versions.py b/xarray/tests/test_print_versions.py index 6c2597a273c..81c15fa1397 100644 --- a/xarray/tests/test_print_versions.py +++ b/xarray/tests/test_print_versions.py @@ -58,6 +58,7 @@ def test_show_report(monkeypatch) -> None: displayed: list[str] = [] display_mod = types.ModuleType("IPython.display") display_mod.Markdown = lambda text: text # type: ignore[attr-defined] + display_mod.HTML = lambda text: f"html:{text}" # type: ignore[attr-defined] display_mod.display = displayed.append # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "IPython.display", display_mod) @@ -74,7 +75,10 @@ def show(**kwargs) -> str: # in notebooks the markdown is displayed, unless printing is requested monkeypatch.setattr(_report, "_in_rich_frontend", lambda: True) _report.show_report("TITLE", "text", "*md*") - assert displayed == ["**TITLE**\n\n*md*"] + assert displayed == ["#### TITLE\n\n*md*"] + _report.show_report("TITLE", "text", "*md*", html="

x

") + assert displayed[-1] == "html:

TITLE

x

" + del displayed[-1] assert show() == "TITLE\n-----\ntext\n" stdout = io.StringIO() monkeypatch.setattr(sys, "stdout", stdout) @@ -86,3 +90,11 @@ def show(**kwargs) -> str: def test_in_rich_frontend_without_ipython(monkeypatch) -> None: monkeypatch.delitem(sys.modules, "IPython", raising=False) assert not _report._in_rich_frontend() + + +def test_html_table() -> None: + actual = _report.html_table("T<", [("a", "")]) + assert actual == ( + '
T<' + '
a<b>
' + ) diff --git a/xarray/util/_report.py b/xarray/util/_report.py index 443900baa1e..b60932408aa 100644 --- a/xarray/util/_report.py +++ b/xarray/util/_report.py @@ -10,6 +10,7 @@ import importlib.metadata import sys from collections.abc import Iterable, Mapping, Sequence +from html import escape from typing import TextIO @@ -59,6 +60,23 @@ def fmt(row: Sequence[str]) -> str: return "\n".join(lines) +def html_table(title: str, rows: Iterable[Sequence[object]]) -> str: + """Render a titled HTML table for the notebook display.""" + body = "".join( + "" + + "".join(f'{escape(str(c))}' for c in row) + + "" + for row in rows + ) + return f"
{escape(title)}{body}
" + + +def html_columns(*blocks: str) -> str: + """Lay out HTML blocks side by side, wrapping when there is not enough space.""" + style = "display: flex; flex-wrap: wrap; gap: 0 3em; align-items: flex-start" + return f'
{"".join(blocks)}
' + + def _in_rich_frontend() -> bool: """Whether we run in a frontend that can display markdown, e.g. Jupyter.""" ipython = sys.modules.get("IPython") @@ -74,17 +92,22 @@ def show_report( markdown: str, file: TextIO | None = None, as_markdown: bool = False, + html: str | None = None, ) -> None: """Print a report as plain text or markdown, or display it in notebooks. - In Jupyter notebooks the markdown is displayed formatted, unless a ``file`` - is given or ``as_markdown`` is requested. The printed markdown is wrapped - in a ``
`` block, so it can be pasted as is into a GitHub issue. + In Jupyter notebooks the ``html``, or else the markdown, is displayed + formatted, unless a ``file`` is given or ``as_markdown`` is requested. + The printed markdown is wrapped in a ``
`` block, so it can be + pasted as is into a GitHub issue. """ if file is None and not as_markdown and _in_rich_frontend(): - from IPython.display import Markdown, display + from IPython.display import HTML, Markdown, display - display(Markdown(f"**{title}**\n\n{markdown}")) + if html is not None: + display(HTML(f"

{escape(title)}

{html}")) + else: + display(Markdown(f"#### {title}\n\n{markdown}")) return if as_markdown: diff --git a/xarray/util/print_versions.py b/xarray/util/print_versions.py index 719f97cf54b..3238a5360d8 100644 --- a/xarray/util/print_versions.py +++ b/xarray/util/print_versions.py @@ -7,9 +7,16 @@ import struct import subprocess import sys +from html import escape from typing import TextIO -from xarray.util._report import markdown_table, package_version, show_report +from xarray.util._report import ( + html_columns, + html_table, + markdown_table, + package_version, + show_report, +) def get_sys_info(): @@ -158,7 +165,16 @@ def show_versions(file: TextIO | None = None, markdown: bool = False) -> None: not_installed, ] ) - show_report("INSTALLED VERSIONS", text, md, file=file, as_markdown=markdown) + html = ( + html_columns( + html_table("System", sys_info), + html_table("Packages", installed), + ) + + f"

{escape(not_installed)}

" + ) + show_report( + "INSTALLED VERSIONS", text, md, file=file, as_markdown=markdown, html=html + ) if __name__ == "__main__": From 193dc0d9cbeee8c2aaa6fd41367e79c479d23917 Mon Sep 17 00:00:00 2001 From: Michael Niklas Date: Fri, 9 Oct 2026 16:31:02 +0200 Subject: [PATCH 4/5] Copy the markdown when copying the notebook output Copying the displayed show_versions or show_backends output in a notebook gives the markdown to paste into a GitHub issue. show_backends is displayed as HTML too, and the display includes the plain text for frontends without HTML support like jupyter console. Co-authored-by: Claude Co-Authored-By: Claude Opus 5.5 --- xarray/backends/plugins.py | 21 +++++++++++++--- xarray/tests/test_print_versions.py | 38 +++++++++++++++++------------ xarray/util/_report.py | 37 ++++++++++++++++------------ xarray/util/print_versions.py | 4 +-- 4 files changed, 62 insertions(+), 38 deletions(-) diff --git a/xarray/backends/plugins.py b/xarray/backends/plugins.py index 98c8ca6809f..bfad0065b93 100644 --- a/xarray/backends/plugins.py +++ b/xarray/backends/plugins.py @@ -6,6 +6,7 @@ import os import warnings from collections.abc import Callable +from html import escape from importlib.metadata import entry_points from typing import TYPE_CHECKING, Any, TextIO @@ -171,6 +172,7 @@ def show_backends(file: TextIO | None = None, markdown: bool = False) -> None: text_lines = [] md_lines = [] + html_items = [] for name, backend in engines.items(): package, version = sources.get(name, ("unknown", None)) summary = f"from {package}" @@ -190,16 +192,27 @@ def show_backends(file: TextIO | None = None, markdown: bool = False) -> None: md_lines.append(f"- **{name}**: `{cls_name}` {summary}") extra = [] + html_extra = [] if backend.description: - extra.append(" ".join(backend.description.split())) + description = " ".join(backend.description.split()) + extra.append(description) + html_extra.append(escape(description)) if backend.url: extra.append(backend.url) + url = escape(backend.url) + html_extra.append(f'{url}') text_lines.extend(f" {line}" for line in extra) md_lines.extend(f" - {line}" for line in extra) + html_items.append( + f"
  • {escape(name)}: {escape(cls_name)} " + f"{escape(summary)}
      {''.join(f'
    • {x}
    • ' for x in html_extra)}
  • " + ) - text = "\n".join(text_lines) or "No backends available." - md = "\n".join(md_lines) or "No backends available." - show_report("AVAILABLE BACKENDS", text, md, file=file, as_markdown=markdown) + empty = "No backends available." + text = "\n".join(text_lines) or empty + md = "\n".join(md_lines) or empty + html = f"
      {''.join(html_items)}
    " if html_items else f"

    {empty}

    " + show_report("AVAILABLE BACKENDS", text, md, html, file=file, as_markdown=markdown) def guess_engine( diff --git a/xarray/tests/test_print_versions.py b/xarray/tests/test_print_versions.py index 81c15fa1397..a026072a9d6 100644 --- a/xarray/tests/test_print_versions.py +++ b/xarray/tests/test_print_versions.py @@ -4,6 +4,7 @@ import io import sys import types +from html import escape from unittest import mock import pytest @@ -55,35 +56,40 @@ def test_markdown_table() -> None: def test_show_report(monkeypatch) -> None: - displayed: list[str] = [] + displayed: list[dict[str, str]] = [] + + def display(bundle, raw=False): + assert raw + displayed.append(bundle) + display_mod = types.ModuleType("IPython.display") - display_mod.Markdown = lambda text: text # type: ignore[attr-defined] - display_mod.HTML = lambda text: f"html:{text}" # type: ignore[attr-defined] - display_mod.display = displayed.append # type: ignore[attr-defined] + display_mod.display = display # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "IPython.display", display_mod) + md = "
    TITLE\n\n*md*\n\n
    " + def show(**kwargs) -> str: f = io.StringIO() - _report.show_report("TITLE", "text", "*md*", file=f, **kwargs) + _report.show_report("TITLE", "text", "*md*", "

    x

    ", file=f, **kwargs) return f.getvalue() assert show() == "TITLE\n-----\ntext\n" - assert show(as_markdown=True) == ( - "
    TITLE\n\n*md*\n\n
    \n" - ) + assert show(as_markdown=True) == md + "\n" - # in notebooks the markdown is displayed, unless printing is requested + # in notebooks the html is displayed, unless printing is requested monkeypatch.setattr(_report, "_in_rich_frontend", lambda: True) - _report.show_report("TITLE", "text", "*md*") - assert displayed == ["#### TITLE\n\n*md*"] - _report.show_report("TITLE", "text", "*md*", html="

    x

    ") - assert displayed[-1] == "html:

    TITLE

    x

    " - del displayed[-1] + _report.show_report("TITLE", "text", "*md*", "

    x

    ") + (bundle,) = displayed + assert bundle["text/plain"] == "TITLE\n-----\ntext" + # copying the output copies the markdown + assert bundle["text/html"].startswith(f'

    TITLE

    x

    ") + assert show() == "TITLE\n-----\ntext\n" stdout = io.StringIO() monkeypatch.setattr(sys, "stdout", stdout) - _report.show_report("TITLE", "text", "*md*", as_markdown=True) - assert stdout.getvalue().startswith("
    ") + _report.show_report("TITLE", "text", "*md*", "

    x

    ", as_markdown=True) + assert stdout.getvalue() == md + "\n" assert len(displayed) == 1 diff --git a/xarray/util/_report.py b/xarray/util/_report.py index b60932408aa..897aaa77112 100644 --- a/xarray/util/_report.py +++ b/xarray/util/_report.py @@ -86,32 +86,39 @@ def _in_rich_frontend() -> bool: return shell is not None and type(shell).__name__ == "ZMQInteractiveShell" +# replace the copied content of a report with its markdown, so that copying the +# notebook output gives something to paste into a GitHub issue +_ON_COPY = ( + "event.clipboardData.setData('text/plain', this.dataset.md); event.preventDefault()" +) + + def show_report( title: str, text: str, markdown: str, + html: str, file: TextIO | None = None, as_markdown: bool = False, - html: str | None = None, ) -> None: """Print a report as plain text or markdown, or display it in notebooks. - In Jupyter notebooks the ``html``, or else the markdown, is displayed - formatted, unless a ``file`` is given or ``as_markdown`` is requested. - The printed markdown is wrapped in a ``
    `` block, so it can be - pasted as is into a GitHub issue. + In Jupyter notebooks the ``html`` is displayed, unless a ``file`` is given or + ``as_markdown`` is requested. Copying the displayed report copies the + markdown instead. The markdown is wrapped in a ``
    `` block, so it + can be pasted as is into a GitHub issue. """ + md = f"
    {title}\n\n{markdown}\n\n
    " + plain = f"{title}\n{'-' * len(title)}\n{text}" + if file is None and not as_markdown and _in_rich_frontend(): - from IPython.display import HTML, Markdown, display + from IPython.display import display - if html is not None: - display(HTML(f"

    {escape(title)}

    {html}")) - else: - display(Markdown(f"#### {title}\n\n{markdown}")) + out = ( + f'
    ' + f"

    {escape(title)}

    {html}
    " + ) + display({"text/html": out, "text/plain": plain}, raw=True) return - if as_markdown: - out = f"
    {title}\n\n{markdown}\n\n
    " - else: - out = f"{title}\n{'-' * len(title)}\n{text}" - print(out, file=sys.stdout if file is None else file) + print(md if as_markdown else plain, file=sys.stdout if file is None else file) diff --git a/xarray/util/print_versions.py b/xarray/util/print_versions.py index 3238a5360d8..8c8c186f0a0 100644 --- a/xarray/util/print_versions.py +++ b/xarray/util/print_versions.py @@ -172,9 +172,7 @@ def show_versions(file: TextIO | None = None, markdown: bool = False) -> None: ) + f"

    {escape(not_installed)}

    " ) - show_report( - "INSTALLED VERSIONS", text, md, file=file, as_markdown=markdown, html=html - ) + show_report("INSTALLED VERSIONS", text, md, html, file=file, as_markdown=markdown) if __name__ == "__main__": From 0f50403693d93a85b4c5cb4bcbfe57c6df472629 Mon Sep 17 00:00:00 2001 From: Michael Niklas Date: Fri, 9 Oct 2026 16:35:22 +0200 Subject: [PATCH 5/5] Ask for show_versions(markdown=True) in the bug report template Co-authored-by: Claude Co-Authored-By: Claude Opus 5.5 --- .github/ISSUE_TEMPLATE/bugreport.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bugreport.yml b/.github/ISSUE_TEMPLATE/bugreport.yml index f5276925ab5..40313ea81b1 100644 --- a/.github/ISSUE_TEMPLATE/bugreport.yml +++ b/.github/ISSUE_TEMPLATE/bugreport.yml @@ -92,7 +92,8 @@ body: attributes: label: Environment description: | - Paste the output of `xr.show_versions()` between the `
    ` tags, leaving an empty line following the opening tag. + Replace the `
    ` block below with the output of `xr.show_versions(markdown=True)`, which already includes the `
    ` tags. + Older versions of xarray do not support `markdown=True`; then paste the output of `xr.show_versions()` between the `
    ` tags, leaving an empty line following the opening tag. value: |