From baeb688e447fed97012d5dcd179ebac8b9cf15b5 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Wed, 24 Jun 2026 07:52:12 -0700 Subject: [PATCH 1/2] Add categorized repr to the .xrs accessor (#3476) Printing `da.xrs` or `ds.xrs` now lists the available operations grouped by category instead of showing the default object id. A helper parses the `# ---- Category ----` banners already present in the accessor source, so the listing stays in sync with the methods. `__repr__` renders grouped text; `_repr_html_` renders a table for notebooks. Falls back to a flat public-method list when source introspection is unavailable. --- docs/source/getting_started/quickstart.rst | 8 ++ xrspatial/accessor.py | 118 +++++++++++++++++++++ xrspatial/tests/test_accessor.py | 81 ++++++++++++++ 3 files changed, 207 insertions(+) diff --git a/docs/source/getting_started/quickstart.rst b/docs/source/getting_started/quickstart.rst index 3db4f5b8e..aaa7bba64 100644 --- a/docs/source/getting_started/quickstart.rst +++ b/docs/source/getting_started/quickstart.rst @@ -45,6 +45,14 @@ chaining. These two lines are equivalent: incline = slope(terrain) incline = terrain.xrs.slope() +To see what's available without tab completion or an editor, print the +accessor. Its repr lists the operations grouped by category, and renders +as a table in a Jupyter notebook: + +.. code-block:: python + + terrain.xrs # prints slope, hillshade, ndvi, watershed, ... by category + Reading and writing GeoTIFFs ============================ diff --git a/xrspatial/accessor.py b/xrspatial/accessor.py index 5ba6fa064..a851748c6 100644 --- a/xrspatial/accessor.py +++ b/xrspatial/accessor.py @@ -11,6 +11,11 @@ nir.xrs.ndvi(red) """ +import functools +import html +import inspect +import re +import textwrap import warnings import xarray as xr @@ -579,6 +584,107 @@ def _open_geotiff_windowed(obj, source, *, auto_reproject=False, return result +# --------------------------------------------------------------------------- +# Accessor repr: list the available ``.xrs`` operations grouped by category so +# users can discover the toolset from a plain REPL or notebook, without relying +# on tab-completion or an LSP (issue #3476). +# --------------------------------------------------------------------------- + +# A category banner in the accessor source, e.g. `` # ---- Hydrology ----``. +_REPR_BANNER_RE = re.compile(r'^\s*#\s*----\s*(.+?)\s*----\s*$') +# A method definition inside a class body (indented ``def name(``). +_REPR_METHOD_RE = re.compile(r'^\s+def\s+([A-Za-z][A-Za-z0-9_]*)\s*\(') + + +@functools.lru_cache(maxsize=None) +def _accessor_categories(cls): + """Parse ``# ---- Category ----`` banners out of *cls* source. + + Returns an ordered tuple of ``(category, (method_name, ...))`` pairs, + one per banner that has at least one public method beneath it. Returns + an empty tuple when the source is unavailable (e.g. a frozen build), so + callers fall back to a flat listing. + """ + try: + source = inspect.getsource(cls) + except (OSError, TypeError): + return () + categories = [] + current = None + for line in source.splitlines(): + banner = _REPR_BANNER_RE.match(line) + if banner: + current = (banner.group(1), []) + categories.append(current) + continue + method = _REPR_METHOD_RE.match(line) + if method and current is not None and not method.group(1).startswith('_'): + current[1].append(method.group(1)) + return tuple( + (name, tuple(methods)) for name, methods in categories if methods + ) + + +def _public_accessor_methods(cls): + """Sorted public method names defined directly on *cls*.""" + return tuple(sorted( + name for name, member in vars(cls).items() + if callable(member) and not name.startswith('_') + )) + + +def _accessor_kind(cls): + return 'Dataset' if cls.__name__.endswith('DatasetAccessor') else 'DataArray' + + +def _accessor_repr_text(cls): + """Plain-text repr listing the accessor's operations by category.""" + lines = [ + f"<{cls.__name__}> xarray-spatial tools for this {_accessor_kind(cls)}", + "call as: .xrs.(...)", + "", + ] + categories = _accessor_categories(cls) + if not categories: + lines.append("(categories unavailable; flat method list)") + categories = (("", _public_accessor_methods(cls)),) + for name, methods in categories: + if name: + lines.append(f"{name}:") + for chunk in textwrap.wrap( + ", ".join(methods), width=74, + break_long_words=False, break_on_hyphens=False, + ): + lines.append(f" {chunk}") + return "\n".join(lines) + + +def _accessor_repr_html(cls): + """Jupyter HTML repr: the same listing as a compact table.""" + categories = _accessor_categories(cls) + if not categories: + categories = (("Available tools", _public_accessor_methods(cls)),) + rows = [] + for name, methods in categories: + methods_html = ", ".join( + f"{html.escape(m)}" for m in methods + ) + rows.append( + "" + "" + f"{html.escape(name)}" + f"{methods_html}" + "" + ) + return ( + f"
xarray-spatial tools for this " + f"{_accessor_kind(cls)} — call as " + ".xrs.<name>(...)" + f"{''.join(rows)}
" + ) + + @xr.register_dataarray_accessor("xrs") class XrsSpatialDataArrayAccessor: """DataArray accessor exposing xarray-spatial operations.""" @@ -586,6 +692,12 @@ class XrsSpatialDataArrayAccessor: def __init__(self, obj): self._obj = obj + def __repr__(self): + return _accessor_repr_text(type(self)) + + def _repr_html_(self): + return _accessor_repr_html(type(self)) + # ---- Plot ---- def plot(self, **kwargs): @@ -1238,6 +1350,12 @@ class XrsSpatialDatasetAccessor: def __init__(self, obj): self._obj = obj + def __repr__(self): + return _accessor_repr_text(type(self)) + + def _repr_html_(self): + return _accessor_repr_html(type(self)) + # ---- Plot ---- def plot(self, vars=None, cols=3, **kwargs): diff --git a/xrspatial/tests/test_accessor.py b/xrspatial/tests/test_accessor.py index 2f1862fcc..6751485a1 100644 --- a/xrspatial/tests/test_accessor.py +++ b/xrspatial/tests/test_accessor.py @@ -545,3 +545,84 @@ def test_hydro_accessor_delegation_resolves(method_name, elevation): pytest.fail(f'{method_name} delegation still broken: {exc}') except Exception: pass # any non-import error proves the import path resolved + + +# --------------------------------------------------------------------------- +# 11. Rich accessor repr — discoverable tool listing by category (#3476) +# --------------------------------------------------------------------------- + +from xrspatial.accessor import ( # noqa: E402 + _accessor_categories, + _accessor_repr_html, + _accessor_repr_text, + _public_accessor_methods, +) + +_ACCESSOR_CLASSES = [XrsSpatialDataArrayAccessor, XrsSpatialDatasetAccessor] + + +@pytest.mark.parametrize('cls', _ACCESSOR_CLASSES) +def test_categories_cover_every_public_method(cls): + """Every public method falls under exactly one category banner. + + Guards against a method added without a ``# ---- Category ----`` banner + above it, which would silently drop it from the repr. + """ + categorized = [m for _, methods in _accessor_categories(cls) for m in methods] + assert sorted(categorized) == sorted(_public_accessor_methods(cls)) + assert len(categorized) == len(set(categorized)), 'method listed twice' + + +@pytest.mark.parametrize('cls', _ACCESSOR_CLASSES) +def test_repr_text_lists_categories_and_methods(cls): + text = _accessor_repr_text(cls) + assert cls.__name__ in text + assert '.xrs.(...)' in text + # A representative category banner and a method beneath it. + assert 'Surface:' in text + assert 'slope' in text + + +def test_da_repr_distinct_from_ds_repr(): + da_text = _accessor_repr_text(XrsSpatialDataArrayAccessor) + ds_text = _accessor_repr_text(XrsSpatialDatasetAccessor) + # Only the DataArray accessor exposes viewshed / interpolation tools. + assert 'viewshed' in da_text + assert 'viewshed' not in ds_text + + +def test_repr_on_instance(elevation): + """repr(da.xrs) renders the categorized listing, not the object id.""" + text = repr(elevation.xrs) + assert 'object at 0x' not in text + assert 'ndvi' in text + + +@pytest.mark.parametrize('cls', _ACCESSOR_CLASSES) +def test_repr_html_renders_methods(cls): + out = _accessor_repr_html(cls) + assert '' in out + assert 'slope' in out + assert 'xarray-spatial' in out + + +def test_repr_html_escapes_method_names(monkeypatch): + """HTML-special characters in a method name are escaped, not injected.""" + monkeypatch.setattr( + 'xrspatial.accessor._accessor_categories', + lambda cls: (('Danger', ('