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
8 changes: 8 additions & 0 deletions docs/source/getting_started/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
============================

Expand Down
122 changes: 122 additions & 0 deletions xrspatial/accessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
nir.xrs.ndvi(red)
"""

import functools
import html
import inspect
import re
import textwrap
import warnings

import xarray as xr
Expand Down Expand Up @@ -579,13 +584,124 @@ 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'


# Single category used by both reprs when the source banners can't be parsed.
_FALLBACK_CATEGORY = 'Available tools'


def _categories_or_fallback(cls):
"""Parsed categories, or one flat ``Available tools`` group as a fallback."""
categories = _accessor_categories(cls)
if categories:
return categories
return ((_FALLBACK_CATEGORY, _public_accessor_methods(cls)),)


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.<name>(...)",
"",
]
for name, methods in _categories_or_fallback(cls):
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."""
rows = []
for name, methods in _categories_or_fallback(cls):
methods_html = ", ".join(
f"<code>{html.escape(m)}</code>" for m in methods
)
rows.append(
"<tr>"
"<td style='text-align:right;vertical-align:top;"
"padding-right:0.75em;font-weight:bold;white-space:nowrap'>"
f"{html.escape(name)}</td>"
f"<td style='text-align:left'>{methods_html}</td>"
"</tr>"
)
return (
f"<div><strong>xarray-spatial</strong> tools for this "
f"{_accessor_kind(cls)} &mdash; call as "
"<code>.xrs.&lt;name&gt;(...)</code>"
f"<table>{''.join(rows)}</table></div>"
)


@xr.register_dataarray_accessor("xrs")
class XrsSpatialDataArrayAccessor:
"""DataArray accessor exposing xarray-spatial operations."""

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):
Expand Down Expand Up @@ -1238,6 +1354,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):
Expand Down
83 changes: 83 additions & 0 deletions xrspatial/tests/test_accessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,3 +545,86 @@ 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.<name>(...)' 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 '<table>' in out
assert '<code>slope</code>' 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', ('<script>',)),),
)
out = _accessor_repr_html(XrsSpatialDataArrayAccessor)
assert '<script>' not in out
assert '&lt;script&gt;' in out


def test_categories_fallback_when_source_unavailable():
"""A class with no retrievable source yields a flat fallback listing."""
Dummy = type('DummyAccessor', (), {'foo': lambda self: None})
assert _accessor_categories(Dummy) == ()
# Both reprs fall back to the same single "Available tools" group.
text = _accessor_repr_text(Dummy)
assert 'Available tools:' in text
assert 'foo' in text
html_out = _accessor_repr_html(Dummy)
assert 'Available tools' in html_out
assert '<code>foo</code>' in html_out
Loading