From 686b6bc4888e1bb2eef2b5f870b1cbe4f92c4947 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Wed, 24 Jun 2026 08:29:55 -0700 Subject: [PATCH 1/3] Forward per-tool repr to the accessed tool (#3478) Evaluating a single accessor tool such as da.xrs.slope returned a bound method, whose repr embeds repr(self) and therefore the whole accessor catalog added in #3477. Wrap public methods in a callable _AccessorTool proxy on attribute access so the repr shows that tool's own signature and docstring instead. Calls forward unchanged and the delegated docstring is mirrored for help()/inspect. --- xrspatial/accessor.py | 78 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/xrspatial/accessor.py b/xrspatial/accessor.py index 72d7c00e5..44a80eb59 100644 --- a/xrspatial/accessor.py +++ b/xrspatial/accessor.py @@ -689,6 +689,78 @@ def _accessor_repr_html(cls): ) +# --------------------------------------------------------------------------- +# Per-tool repr: evaluating a single tool (``da.xrs.slope``) without calling it +# should show that tool's own signature and docstring, not the whole accessor +# catalog. A bound method's repr is ````, +# and ``repr(self)`` routes through the accessor's catalog ``__repr__``, so +# without intervention the full listing gets embedded (issue #3478). The +# accessor wraps public methods in this proxy on attribute access instead. +# --------------------------------------------------------------------------- + + +def _accessor_tool_repr_text(method): + """Plain-text repr for a single accessor tool: signature + docstring.""" + name = method.__name__ + try: + sig = str(inspect.signature(method)) + except (TypeError, ValueError): + sig = "(...)" + lines = [f".xrs.{name}{sig}"] + doc = inspect.getdoc(method) + if doc: + lines += ["", doc] + return "\n".join(lines) + + +def _accessor_tool_repr_html(method): + """Jupyter HTML repr for a single tool: the text repr in a
 block."""
+    return (
+        "
"
+        f"{html.escape(_accessor_tool_repr_text(method))}"
+        "
" + ) + + +class _AccessorTool: + """Callable proxy returned for a public accessor method on attribute access. + + Forwards calls to the wrapped bound method unchanged, but renders its own + signature and docstring when evaluated in a REPL or notebook instead of the + bound-method repr (which would embed the whole accessor catalog via + ``repr(self)``; issue #3478). ``__doc__`` / ``__wrapped__`` are mirrored so + ``help()`` and ``inspect`` keep seeing the delegated documentation. + """ + + def __init__(self, method): + self._method = method + self.__wrapped__ = method + self.__name__ = method.__name__ + self.__qualname__ = method.__qualname__ + self.__doc__ = method.__doc__ + + def __call__(self, *args, **kwargs): + return self._method(*args, **kwargs) + + def __repr__(self): + return _accessor_tool_repr_text(self._method) + + def _repr_html_(self): + return _accessor_tool_repr_html(self._method) + + +def _wrap_accessor_attr(accessor, name): + """``__getattribute__`` body shared by both accessors. + + Returns public bound methods wrapped in :class:`_AccessorTool`; everything + else (private attributes, the catalog ``__repr__``) is returned untouched. + """ + attr = object.__getattribute__(accessor, name) + if name.startswith("_") or not inspect.ismethod(attr): + return attr + return _AccessorTool(attr) + + @xr.register_dataarray_accessor("xrs") class XrsSpatialDataArrayAccessor: """DataArray accessor exposing xarray-spatial operations.""" @@ -696,6 +768,9 @@ class XrsSpatialDataArrayAccessor: def __init__(self, obj): self._obj = obj + def __getattribute__(self, name): + return _wrap_accessor_attr(self, name) + def __repr__(self): return _accessor_repr_text(type(self)) @@ -1354,6 +1429,9 @@ class XrsSpatialDatasetAccessor: def __init__(self, obj): self._obj = obj + def __getattribute__(self, name): + return _wrap_accessor_attr(self, name) + def __repr__(self): return _accessor_repr_text(type(self)) From 67f1ef44e9a544dd83a1d93a04ebf1f25af0184d Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Wed, 24 Jun 2026 08:30:45 -0700 Subject: [PATCH 2/3] Add per-tool repr tests for the .xrs accessor (#3478) --- xrspatial/tests/test_accessor.py | 72 ++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/xrspatial/tests/test_accessor.py b/xrspatial/tests/test_accessor.py index 5d659d645..0c58db010 100644 --- a/xrspatial/tests/test_accessor.py +++ b/xrspatial/tests/test_accessor.py @@ -628,3 +628,75 @@ def test_categories_fallback_when_source_unavailable(): html_out = _accessor_repr_html(Dummy) assert 'Available tools' in html_out assert 'foo' in html_out + + +# --------------------------------------------------------------------------- +# 12. Per-tool repr — da.xrs.slope shows slope's own info, not the catalog (#3478) +# --------------------------------------------------------------------------- + +from xrspatial.accessor import _AccessorTool # noqa: E402 + + +def test_tool_repr_is_scoped_to_the_tool(elevation): + """repr(da.xrs.slope) shows slope's signature + docstring, not the catalog.""" + from xrspatial import slope + + text = repr(elevation.xrs.slope) + assert text.startswith('.xrs.slope(') + # The catalog repr's header and other categories must not leak in. + assert 'call as: .xrs.(...)' not in text + assert 'Surface:' not in text + assert 'Hydrology:' not in text + # The slope docstring is surfaced instead. + assert inspect.getdoc(slope) in text + + +def test_tool_repr_distinct_per_tool(elevation): + """Different tools render different reprs (no shared catalog block).""" + assert repr(elevation.xrs.slope) != repr(elevation.xrs.aspect) + assert repr(elevation.xrs.aspect).startswith('.xrs.aspect(') + + +def test_tool_access_returns_callable_proxy(elevation): + """The wrapped tool is an _AccessorTool that still forwards calls.""" + from xrspatial.slope import slope + + tool = elevation.xrs.slope + assert isinstance(tool, _AccessorTool) + xr.testing.assert_identical(tool(), slope(elevation)) + + +def test_tool_proxy_preserves_help_metadata(elevation): + """help()/inspect see the delegated docstring and the method name.""" + from xrspatial import slope + + tool = elevation.xrs.slope + assert inspect.getdoc(tool) == inspect.getdoc(slope) + assert tool.__name__ == 'slope' + + +def test_tool_repr_html_scoped_to_tool(elevation): + """The notebook repr of a single tool is the tool's own block, not a table.""" + out = elevation.xrs.slope._repr_html_() + assert '' not in out + assert 'Hydrology' not in out + + +def test_tool_repr_on_dataset_accessor(elevation): + """The Dataset accessor scopes its per-tool repr the same way.""" + ds = xr.Dataset({'elev': elevation}) + text = repr(ds.xrs.slope) + assert text.startswith('.xrs.slope(') + assert 'Surface:' not in text + + +def test_catalog_repr_still_works_with_proxy(elevation): + """The accessor-level catalog repr is unchanged by the per-tool wrapping.""" + text = repr(elevation.xrs) + assert 'Surface:' in text + assert 'slope' in text + # _repr_html_ on the accessor itself still renders the table. + assert '' in elevation.xrs._repr_html_() From 661cac27bd1167c4e80064c05c1c119e9bf1a771 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Wed, 24 Jun 2026 08:31:11 -0700 Subject: [PATCH 3/3] Document the per-tool repr in quickstart (#3478) --- docs/source/getting_started/quickstart.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/source/getting_started/quickstart.rst b/docs/source/getting_started/quickstart.rst index aaa7bba64..0b6ed1cf5 100644 --- a/docs/source/getting_started/quickstart.rst +++ b/docs/source/getting_started/quickstart.rst @@ -53,6 +53,13 @@ as a table in a Jupyter notebook: terrain.xrs # prints slope, hillshade, ndvi, watershed, ... by category +Drop the call parentheses on a single tool to read its signature and +docstring, the same text ``help(terrain.xrs.slope)`` shows: + +.. code-block:: python + + terrain.xrs.slope # prints the slope signature and docstring + Reading and writing GeoTIFFs ============================