Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/source/getting_started/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
============================

Expand Down
78 changes: 78 additions & 0 deletions xrspatial/accessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -689,13 +689,88 @@ 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 ``<bound method Cls.name of {repr(self)}>``,
# 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 <pre> block."""
return (
"<pre style='white-space:pre-wrap'>"
f"{html.escape(_accessor_tool_repr_text(method))}"
"</pre>"
)


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."""

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))

Expand Down Expand Up @@ -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))

Expand Down
72 changes: 72 additions & 0 deletions xrspatial/tests/test_accessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,3 +628,75 @@ def test_categories_fallback_when_source_unavailable():
html_out = _accessor_repr_html(Dummy)
assert 'Available tools' in html_out
assert '<code>foo</code>' 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.<name>(...)' 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 '<pre' in out
assert '.xrs.slope(' in out
# No catalog table / other categories.
assert '<table>' 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 '<table>' in elevation.xrs._repr_html_()
Loading