Skip to content

Commit bdfebe2

Browse files
authored
Forward per-tool repr to the accessed tool (#3478) (#3479)
* 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. * Add per-tool repr tests for the .xrs accessor (#3478) * Document the per-tool repr in quickstart (#3478)
1 parent 8bb7c16 commit bdfebe2

3 files changed

Lines changed: 157 additions & 0 deletions

File tree

docs/source/getting_started/quickstart.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ as a table in a Jupyter notebook:
5353
5454
terrain.xrs # prints slope, hillshade, ndvi, watershed, ... by category
5555
56+
Drop the call parentheses on a single tool to read its signature and
57+
docstring, the same text ``help(terrain.xrs.slope)`` shows:
58+
59+
.. code-block:: python
60+
61+
terrain.xrs.slope # prints the slope signature and docstring
62+
5663
Reading and writing GeoTIFFs
5764
============================
5865

xrspatial/accessor.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,13 +689,88 @@ def _accessor_repr_html(cls):
689689
)
690690

691691

692+
# ---------------------------------------------------------------------------
693+
# Per-tool repr: evaluating a single tool (``da.xrs.slope``) without calling it
694+
# should show that tool's own signature and docstring, not the whole accessor
695+
# catalog. A bound method's repr is ``<bound method Cls.name of {repr(self)}>``,
696+
# and ``repr(self)`` routes through the accessor's catalog ``__repr__``, so
697+
# without intervention the full listing gets embedded (issue #3478). The
698+
# accessor wraps public methods in this proxy on attribute access instead.
699+
# ---------------------------------------------------------------------------
700+
701+
702+
def _accessor_tool_repr_text(method):
703+
"""Plain-text repr for a single accessor tool: signature + docstring."""
704+
name = method.__name__
705+
try:
706+
sig = str(inspect.signature(method))
707+
except (TypeError, ValueError):
708+
sig = "(...)"
709+
lines = [f".xrs.{name}{sig}"]
710+
doc = inspect.getdoc(method)
711+
if doc:
712+
lines += ["", doc]
713+
return "\n".join(lines)
714+
715+
716+
def _accessor_tool_repr_html(method):
717+
"""Jupyter HTML repr for a single tool: the text repr in a <pre> block."""
718+
return (
719+
"<pre style='white-space:pre-wrap'>"
720+
f"{html.escape(_accessor_tool_repr_text(method))}"
721+
"</pre>"
722+
)
723+
724+
725+
class _AccessorTool:
726+
"""Callable proxy returned for a public accessor method on attribute access.
727+
728+
Forwards calls to the wrapped bound method unchanged, but renders its own
729+
signature and docstring when evaluated in a REPL or notebook instead of the
730+
bound-method repr (which would embed the whole accessor catalog via
731+
``repr(self)``; issue #3478). ``__doc__`` / ``__wrapped__`` are mirrored so
732+
``help()`` and ``inspect`` keep seeing the delegated documentation.
733+
"""
734+
735+
def __init__(self, method):
736+
self._method = method
737+
self.__wrapped__ = method
738+
self.__name__ = method.__name__
739+
self.__qualname__ = method.__qualname__
740+
self.__doc__ = method.__doc__
741+
742+
def __call__(self, *args, **kwargs):
743+
return self._method(*args, **kwargs)
744+
745+
def __repr__(self):
746+
return _accessor_tool_repr_text(self._method)
747+
748+
def _repr_html_(self):
749+
return _accessor_tool_repr_html(self._method)
750+
751+
752+
def _wrap_accessor_attr(accessor, name):
753+
"""``__getattribute__`` body shared by both accessors.
754+
755+
Returns public bound methods wrapped in :class:`_AccessorTool`; everything
756+
else (private attributes, the catalog ``__repr__``) is returned untouched.
757+
"""
758+
attr = object.__getattribute__(accessor, name)
759+
if name.startswith("_") or not inspect.ismethod(attr):
760+
return attr
761+
return _AccessorTool(attr)
762+
763+
692764
@xr.register_dataarray_accessor("xrs")
693765
class XrsSpatialDataArrayAccessor:
694766
"""DataArray accessor exposing xarray-spatial operations."""
695767

696768
def __init__(self, obj):
697769
self._obj = obj
698770

771+
def __getattribute__(self, name):
772+
return _wrap_accessor_attr(self, name)
773+
699774
def __repr__(self):
700775
return _accessor_repr_text(type(self))
701776

@@ -1354,6 +1429,9 @@ class XrsSpatialDatasetAccessor:
13541429
def __init__(self, obj):
13551430
self._obj = obj
13561431

1432+
def __getattribute__(self, name):
1433+
return _wrap_accessor_attr(self, name)
1434+
13571435
def __repr__(self):
13581436
return _accessor_repr_text(type(self))
13591437

xrspatial/tests/test_accessor.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,3 +628,75 @@ def test_categories_fallback_when_source_unavailable():
628628
html_out = _accessor_repr_html(Dummy)
629629
assert 'Available tools' in html_out
630630
assert '<code>foo</code>' in html_out
631+
632+
633+
# ---------------------------------------------------------------------------
634+
# 12. Per-tool repr — da.xrs.slope shows slope's own info, not the catalog (#3478)
635+
# ---------------------------------------------------------------------------
636+
637+
from xrspatial.accessor import _AccessorTool # noqa: E402
638+
639+
640+
def test_tool_repr_is_scoped_to_the_tool(elevation):
641+
"""repr(da.xrs.slope) shows slope's signature + docstring, not the catalog."""
642+
from xrspatial import slope
643+
644+
text = repr(elevation.xrs.slope)
645+
assert text.startswith('.xrs.slope(')
646+
# The catalog repr's header and other categories must not leak in.
647+
assert 'call as: .xrs.<name>(...)' not in text
648+
assert 'Surface:' not in text
649+
assert 'Hydrology:' not in text
650+
# The slope docstring is surfaced instead.
651+
assert inspect.getdoc(slope) in text
652+
653+
654+
def test_tool_repr_distinct_per_tool(elevation):
655+
"""Different tools render different reprs (no shared catalog block)."""
656+
assert repr(elevation.xrs.slope) != repr(elevation.xrs.aspect)
657+
assert repr(elevation.xrs.aspect).startswith('.xrs.aspect(')
658+
659+
660+
def test_tool_access_returns_callable_proxy(elevation):
661+
"""The wrapped tool is an _AccessorTool that still forwards calls."""
662+
from xrspatial.slope import slope
663+
664+
tool = elevation.xrs.slope
665+
assert isinstance(tool, _AccessorTool)
666+
xr.testing.assert_identical(tool(), slope(elevation))
667+
668+
669+
def test_tool_proxy_preserves_help_metadata(elevation):
670+
"""help()/inspect see the delegated docstring and the method name."""
671+
from xrspatial import slope
672+
673+
tool = elevation.xrs.slope
674+
assert inspect.getdoc(tool) == inspect.getdoc(slope)
675+
assert tool.__name__ == 'slope'
676+
677+
678+
def test_tool_repr_html_scoped_to_tool(elevation):
679+
"""The notebook repr of a single tool is the tool's own block, not a table."""
680+
out = elevation.xrs.slope._repr_html_()
681+
assert '<pre' in out
682+
assert '.xrs.slope(' in out
683+
# No catalog table / other categories.
684+
assert '<table>' not in out
685+
assert 'Hydrology' not in out
686+
687+
688+
def test_tool_repr_on_dataset_accessor(elevation):
689+
"""The Dataset accessor scopes its per-tool repr the same way."""
690+
ds = xr.Dataset({'elev': elevation})
691+
text = repr(ds.xrs.slope)
692+
assert text.startswith('.xrs.slope(')
693+
assert 'Surface:' not in text
694+
695+
696+
def test_catalog_repr_still_works_with_proxy(elevation):
697+
"""The accessor-level catalog repr is unchanged by the per-tool wrapping."""
698+
text = repr(elevation.xrs)
699+
assert 'Surface:' in text
700+
assert 'slope' in text
701+
# _repr_html_ on the accessor itself still renders the table.
702+
assert '<table>' in elevation.xrs._repr_html_()

0 commit comments

Comments
 (0)