Skip to content

Commit baeb688

Browse files
committed
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.
1 parent 4c6cfd7 commit baeb688

3 files changed

Lines changed: 207 additions & 0 deletions

File tree

docs/source/getting_started/quickstart.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ chaining. These two lines are equivalent:
4545
incline = slope(terrain)
4646
incline = terrain.xrs.slope()
4747
48+
To see what's available without tab completion or an editor, print the
49+
accessor. Its repr lists the operations grouped by category, and renders
50+
as a table in a Jupyter notebook:
51+
52+
.. code-block:: python
53+
54+
terrain.xrs # prints slope, hillshade, ndvi, watershed, ... by category
55+
4856
Reading and writing GeoTIFFs
4957
============================
5058

xrspatial/accessor.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
nir.xrs.ndvi(red)
1212
"""
1313

14+
import functools
15+
import html
16+
import inspect
17+
import re
18+
import textwrap
1419
import warnings
1520

1621
import xarray as xr
@@ -579,13 +584,120 @@ def _open_geotiff_windowed(obj, source, *, auto_reproject=False,
579584
return result
580585

581586

587+
# ---------------------------------------------------------------------------
588+
# Accessor repr: list the available ``.xrs`` operations grouped by category so
589+
# users can discover the toolset from a plain REPL or notebook, without relying
590+
# on tab-completion or an LSP (issue #3476).
591+
# ---------------------------------------------------------------------------
592+
593+
# A category banner in the accessor source, e.g. `` # ---- Hydrology ----``.
594+
_REPR_BANNER_RE = re.compile(r'^\s*#\s*----\s*(.+?)\s*----\s*$')
595+
# A method definition inside a class body (indented ``def name(``).
596+
_REPR_METHOD_RE = re.compile(r'^\s+def\s+([A-Za-z][A-Za-z0-9_]*)\s*\(')
597+
598+
599+
@functools.lru_cache(maxsize=None)
600+
def _accessor_categories(cls):
601+
"""Parse ``# ---- Category ----`` banners out of *cls* source.
602+
603+
Returns an ordered tuple of ``(category, (method_name, ...))`` pairs,
604+
one per banner that has at least one public method beneath it. Returns
605+
an empty tuple when the source is unavailable (e.g. a frozen build), so
606+
callers fall back to a flat listing.
607+
"""
608+
try:
609+
source = inspect.getsource(cls)
610+
except (OSError, TypeError):
611+
return ()
612+
categories = []
613+
current = None
614+
for line in source.splitlines():
615+
banner = _REPR_BANNER_RE.match(line)
616+
if banner:
617+
current = (banner.group(1), [])
618+
categories.append(current)
619+
continue
620+
method = _REPR_METHOD_RE.match(line)
621+
if method and current is not None and not method.group(1).startswith('_'):
622+
current[1].append(method.group(1))
623+
return tuple(
624+
(name, tuple(methods)) for name, methods in categories if methods
625+
)
626+
627+
628+
def _public_accessor_methods(cls):
629+
"""Sorted public method names defined directly on *cls*."""
630+
return tuple(sorted(
631+
name for name, member in vars(cls).items()
632+
if callable(member) and not name.startswith('_')
633+
))
634+
635+
636+
def _accessor_kind(cls):
637+
return 'Dataset' if cls.__name__.endswith('DatasetAccessor') else 'DataArray'
638+
639+
640+
def _accessor_repr_text(cls):
641+
"""Plain-text repr listing the accessor's operations by category."""
642+
lines = [
643+
f"<{cls.__name__}> xarray-spatial tools for this {_accessor_kind(cls)}",
644+
"call as: .xrs.<name>(...)",
645+
"",
646+
]
647+
categories = _accessor_categories(cls)
648+
if not categories:
649+
lines.append("(categories unavailable; flat method list)")
650+
categories = (("", _public_accessor_methods(cls)),)
651+
for name, methods in categories:
652+
if name:
653+
lines.append(f"{name}:")
654+
for chunk in textwrap.wrap(
655+
", ".join(methods), width=74,
656+
break_long_words=False, break_on_hyphens=False,
657+
):
658+
lines.append(f" {chunk}")
659+
return "\n".join(lines)
660+
661+
662+
def _accessor_repr_html(cls):
663+
"""Jupyter HTML repr: the same listing as a compact table."""
664+
categories = _accessor_categories(cls)
665+
if not categories:
666+
categories = (("Available tools", _public_accessor_methods(cls)),)
667+
rows = []
668+
for name, methods in categories:
669+
methods_html = ", ".join(
670+
f"<code>{html.escape(m)}</code>" for m in methods
671+
)
672+
rows.append(
673+
"<tr>"
674+
"<td style='text-align:right;vertical-align:top;"
675+
"padding-right:0.75em;font-weight:bold;white-space:nowrap'>"
676+
f"{html.escape(name)}</td>"
677+
f"<td style='text-align:left'>{methods_html}</td>"
678+
"</tr>"
679+
)
680+
return (
681+
f"<div><strong>xarray-spatial</strong> tools for this "
682+
f"{_accessor_kind(cls)} &mdash; call as "
683+
"<code>.xrs.&lt;name&gt;(...)</code>"
684+
f"<table>{''.join(rows)}</table></div>"
685+
)
686+
687+
582688
@xr.register_dataarray_accessor("xrs")
583689
class XrsSpatialDataArrayAccessor:
584690
"""DataArray accessor exposing xarray-spatial operations."""
585691

586692
def __init__(self, obj):
587693
self._obj = obj
588694

695+
def __repr__(self):
696+
return _accessor_repr_text(type(self))
697+
698+
def _repr_html_(self):
699+
return _accessor_repr_html(type(self))
700+
589701
# ---- Plot ----
590702

591703
def plot(self, **kwargs):
@@ -1238,6 +1350,12 @@ class XrsSpatialDatasetAccessor:
12381350
def __init__(self, obj):
12391351
self._obj = obj
12401352

1353+
def __repr__(self):
1354+
return _accessor_repr_text(type(self))
1355+
1356+
def _repr_html_(self):
1357+
return _accessor_repr_html(type(self))
1358+
12411359
# ---- Plot ----
12421360

12431361
def plot(self, vars=None, cols=3, **kwargs):

xrspatial/tests/test_accessor.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,3 +545,84 @@ def test_hydro_accessor_delegation_resolves(method_name, elevation):
545545
pytest.fail(f'{method_name} delegation still broken: {exc}')
546546
except Exception:
547547
pass # any non-import error proves the import path resolved
548+
549+
550+
# ---------------------------------------------------------------------------
551+
# 11. Rich accessor repr — discoverable tool listing by category (#3476)
552+
# ---------------------------------------------------------------------------
553+
554+
from xrspatial.accessor import ( # noqa: E402
555+
_accessor_categories,
556+
_accessor_repr_html,
557+
_accessor_repr_text,
558+
_public_accessor_methods,
559+
)
560+
561+
_ACCESSOR_CLASSES = [XrsSpatialDataArrayAccessor, XrsSpatialDatasetAccessor]
562+
563+
564+
@pytest.mark.parametrize('cls', _ACCESSOR_CLASSES)
565+
def test_categories_cover_every_public_method(cls):
566+
"""Every public method falls under exactly one category banner.
567+
568+
Guards against a method added without a ``# ---- Category ----`` banner
569+
above it, which would silently drop it from the repr.
570+
"""
571+
categorized = [m for _, methods in _accessor_categories(cls) for m in methods]
572+
assert sorted(categorized) == sorted(_public_accessor_methods(cls))
573+
assert len(categorized) == len(set(categorized)), 'method listed twice'
574+
575+
576+
@pytest.mark.parametrize('cls', _ACCESSOR_CLASSES)
577+
def test_repr_text_lists_categories_and_methods(cls):
578+
text = _accessor_repr_text(cls)
579+
assert cls.__name__ in text
580+
assert '.xrs.<name>(...)' in text
581+
# A representative category banner and a method beneath it.
582+
assert 'Surface:' in text
583+
assert 'slope' in text
584+
585+
586+
def test_da_repr_distinct_from_ds_repr():
587+
da_text = _accessor_repr_text(XrsSpatialDataArrayAccessor)
588+
ds_text = _accessor_repr_text(XrsSpatialDatasetAccessor)
589+
# Only the DataArray accessor exposes viewshed / interpolation tools.
590+
assert 'viewshed' in da_text
591+
assert 'viewshed' not in ds_text
592+
593+
594+
def test_repr_on_instance(elevation):
595+
"""repr(da.xrs) renders the categorized listing, not the object id."""
596+
text = repr(elevation.xrs)
597+
assert 'object at 0x' not in text
598+
assert 'ndvi' in text
599+
600+
601+
@pytest.mark.parametrize('cls', _ACCESSOR_CLASSES)
602+
def test_repr_html_renders_methods(cls):
603+
out = _accessor_repr_html(cls)
604+
assert '<table>' in out
605+
assert '<code>slope</code>' in out
606+
assert 'xarray-spatial' in out
607+
608+
609+
def test_repr_html_escapes_method_names(monkeypatch):
610+
"""HTML-special characters in a method name are escaped, not injected."""
611+
monkeypatch.setattr(
612+
'xrspatial.accessor._accessor_categories',
613+
lambda cls: (('Danger', ('<script>',)),),
614+
)
615+
out = _accessor_repr_html(XrsSpatialDataArrayAccessor)
616+
assert '<script>' not in out
617+
assert '&lt;script&gt;' in out
618+
619+
620+
def test_categories_fallback_when_source_unavailable():
621+
"""A class with no retrievable source yields a flat fallback listing."""
622+
Dummy = type('DummyAccessor', (), {'foo': lambda self: None})
623+
assert _accessor_categories(Dummy) == ()
624+
text = _accessor_repr_text(Dummy)
625+
assert 'foo' in text
626+
assert 'flat method list' in text
627+
html_out = _accessor_repr_html(Dummy)
628+
assert '<code>foo</code>' in html_out

0 commit comments

Comments
 (0)