From 6383dd15d92b78dcc39429f6060a7fdd5b584f95 Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 12 Dec 2025 16:21:25 +0100 Subject: [PATCH 1/4] adata.extensions module --- src/anndata/_repr/__init__.py | 30 ++++++-- src/anndata/extensions.py | 110 ++++++++++++++++++++++++++++++ tests/test_repr_html.py | 30 ++++---- tests/visual_inspect_repr_html.py | 6 +- 4 files changed, 153 insertions(+), 23 deletions(-) create mode 100644 src/anndata/extensions.py diff --git a/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py index ea378af8a..c60c1a7db 100644 --- a/src/anndata/_repr/__init__.py +++ b/src/anndata/_repr/__init__.py @@ -10,6 +10,18 @@ - Support for nested AnnData objects - Graceful handling of unknown types +.. note:: + + For extending AnnData with custom formatters, prefer importing from + :mod:`anndata.extensions` which provides the public API:: + + from anndata.extensions import ( + register_formatter, + TypeFormatter, + SectionFormatter, + FormattedOutput, + ) + Extensibility ------------- The system is designed to be extensible via two registry patterns: @@ -24,7 +36,7 @@ Example - format by Python type:: - from anndata._repr import register_formatter, TypeFormatter, FormattedOutput + from anndata.extensions import register_formatter, TypeFormatter, FormattedOutput @register_formatter @@ -42,8 +54,12 @@ def format(self, obj, context): Example - format by embedded type hint (for tagged data in uns):: - from anndata._repr import register_formatter, TypeFormatter, FormattedOutput - from anndata._repr import extract_uns_type_hint + from anndata.extensions import ( + register_formatter, + TypeFormatter, + FormattedOutput, + extract_uns_type_hint, + ) @register_formatter @@ -80,8 +96,12 @@ def format(self, obj, context): Example:: - from anndata._repr import register_formatter, SectionFormatter - from anndata._repr import FormattedEntry, FormattedOutput + from anndata.extensions import ( + register_formatter, + SectionFormatter, + FormattedEntry, + FormattedOutput, + ) @register_formatter diff --git a/src/anndata/extensions.py b/src/anndata/extensions.py new file mode 100644 index 000000000..a9bed0233 --- /dev/null +++ b/src/anndata/extensions.py @@ -0,0 +1,110 @@ +""" +Public API for extending AnnData functionality. + +This module provides registration mechanisms for: + +1. **Accessors** - Add custom namespaces to AnnData objects (e.g., `adata.myns.method()`) +2. **HTML Formatters** - Customize how types are displayed in Jupyter notebooks + +Examples +-------- +Register a custom accessor namespace:: + + import anndata as ad + from anndata.extensions import register_anndata_namespace + + @register_anndata_namespace("transform") + class TransformAccessor: + def __init__(self, adata: ad.AnnData): + self._adata = adata + + def log1p(self): + import numpy as np + self._adata.X = np.log1p(self._adata.X) + return self._adata + + # Usage: adata.transform.log1p() + +Register a custom HTML formatter for a type:: + + from anndata.extensions import register_formatter, TypeFormatter, FormattedOutput + + @register_formatter + class MyArrayFormatter(TypeFormatter): + priority = 100 # Higher = checked first + + def can_format(self, obj): + return isinstance(obj, MyArrayType) + + def format(self, obj, context): + return FormattedOutput( + type_name=f"MyArray {obj.shape}", + css_class="dtype-custom", + ) + +Register a custom section formatter (for packages like TreeData, SpatialData):: + + from anndata.extensions import register_formatter, SectionFormatter + from anndata.extensions import FormattedEntry, FormattedOutput + + @register_formatter + class ObstSectionFormatter(SectionFormatter): + section_name = "obst" + after_section = "obsm" # Position in display order + + def should_show(self, obj): + return hasattr(obj, "obst") and len(obj.obst) > 0 + + def get_entries(self, obj, context): + return [ + FormattedEntry( + key=k, + output=FormattedOutput(type_name=f"Tree ({v.n_nodes} nodes)"), + ) + for k, v in obj.obst.items() + ] + +See Also +-------- +anndata._repr : Full documentation of the HTML representation system +""" + +from __future__ import annotations + +# Accessor registration (from PR #1870) +from anndata._core.extensions import register_anndata_namespace + +# HTML representation formatters +from anndata._repr import ( + # Core formatter classes + FormattedEntry, + FormattedOutput, + FormatterContext, + FormatterRegistry, + SectionFormatter, + TypeFormatter, + # Registration function + register_formatter, + # Global registry instance + formatter_registry, + # Type hint utilities for tagged data + UNS_TYPE_HINT_KEY, + extract_uns_type_hint, +) + +__all__ = [ + # Accessor registration + "register_anndata_namespace", + # HTML formatter registration + "register_formatter", + "TypeFormatter", + "SectionFormatter", + "FormattedOutput", + "FormattedEntry", + "FormatterContext", + "FormatterRegistry", + "formatter_registry", + # Type hint utilities + "extract_uns_type_hint", + "UNS_TYPE_HINT_KEY", +] diff --git a/tests/test_repr_html.py b/tests/test_repr_html.py index 22969f3ca..33a8eeb7c 100644 --- a/tests/test_repr_html.py +++ b/tests/test_repr_html.py @@ -689,14 +689,14 @@ class TestFormatterRegistry: def test_registry_has_formatters(self): """Test registry contains registered formatters.""" - from anndata._repr.registry import formatter_registry + from anndata.extensions import formatter_registry # Should have some formatters registered assert len(formatter_registry._type_formatters) > 0 def test_custom_formatter_registration(self): """Test registering a custom formatter.""" - from anndata._repr.registry import ( + from anndata.extensions import ( FormattedOutput, FormatterContext, TypeFormatter, @@ -739,7 +739,7 @@ def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: def test_fallback_formatter_for_unknown_types(self): """Test fallback formatter handles unknown types gracefully.""" - from anndata._repr.registry import FormatterContext, formatter_registry + from anndata.extensions import FormatterContext, formatter_registry class UnknownType: """An unknown type not in the registry.""" @@ -756,7 +756,7 @@ class UnknownType: def test_formatter_priority_order(self): """Test formatters are checked in priority order.""" - from anndata._repr.registry import formatter_registry + from anndata.extensions import formatter_registry # Verify formatters are sorted by priority (highest first) priorities = [f.priority for f in formatter_registry._type_formatters] @@ -764,7 +764,7 @@ def test_formatter_priority_order(self): def test_formatter_sections_filtering(self): """Test formatters are only applied to specified sections.""" - from anndata._repr.registry import ( + from anndata.extensions import ( FormattedOutput, FormatterContext, TypeFormatter, @@ -808,7 +808,7 @@ def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: def test_formatter_sections_none_applies_everywhere(self): """Test formatters with sections=None apply to all sections.""" - from anndata._repr.registry import ( + from anndata.extensions import ( FormattedOutput, FormatterContext, TypeFormatter, @@ -846,7 +846,7 @@ def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: def test_extension_type_graceful_handling(self): """Test extension types (like TreeData, MuData) are handled gracefully.""" - from anndata._repr.registry import FormatterContext, formatter_registry + from anndata.extensions import FormatterContext, formatter_registry # Simulate an extension type that has AnnData-like attributes # We create the class in a way that properly sets __module__ @@ -1704,7 +1704,7 @@ def test_extract_type_hint_malformed_string_format(self): def test_type_formatter_for_tagged_uns_data(self): """Test using TypeFormatter to handle tagged data in uns.""" - from anndata._repr import ( + from anndata.extensions import ( FormattedOutput, TypeFormatter, extract_uns_type_hint, @@ -1761,7 +1761,7 @@ def test_unregistered_type_hint_shows_import_message(self): def test_formatter_error_handled_gracefully(self): """Test that TypeFormatter errors don't crash the repr.""" - from anndata._repr import ( + from anndata.extensions import ( TypeFormatter, extract_uns_type_hint, formatter_registry, @@ -1815,7 +1815,7 @@ def test_string_format_type_hint_in_html(self): def test_type_hint_key_constant_exported(self): """Test that UNS_TYPE_HINT_KEY constant is properly exported.""" - from anndata._repr import UNS_TYPE_HINT_KEY + from anndata.extensions import UNS_TYPE_HINT_KEY assert UNS_TYPE_HINT_KEY == "__anndata_repr__" @@ -1919,7 +1919,7 @@ class TestSectionFormatterCoverage: def test_section_formatter_default_methods(self): """Test SectionFormatter default method implementations.""" - from anndata._repr.registry import SectionFormatter + from anndata.extensions import SectionFormatter class TestSectionFormatter(SectionFormatter): @property @@ -2606,14 +2606,14 @@ class TestRegistryAbstractMethods: def test_type_formatter_is_abstract(self): """Verify TypeFormatter cannot be instantiated directly.""" - from anndata._repr.registry import TypeFormatter + from anndata.extensions import TypeFormatter with pytest.raises(TypeError): TypeFormatter() def test_section_formatter_is_abstract(self): """Verify SectionFormatter cannot be instantiated directly.""" - from anndata._repr.registry import SectionFormatter + from anndata.extensions import SectionFormatter with pytest.raises(TypeError): SectionFormatter() @@ -2624,7 +2624,7 @@ class TestCustomHtmlContent: def test_inline_html_content(self): """Test inline (non-expandable) custom HTML content.""" - from anndata._repr.registry import ( + from anndata.extensions import ( FormattedOutput, TypeFormatter, formatter_registry, @@ -2669,7 +2669,7 @@ def format(self, obj, context): def test_expandable_html_content(self): """Test expandable custom HTML content (e.g., for TreeData visualization).""" - from anndata._repr.registry import ( + from anndata.extensions import ( FormattedOutput, TypeFormatter, formatter_registry, diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index 1dd66c21f..9408e3af9 100644 --- a/tests/visual_inspect_repr_html.py +++ b/tests/visual_inspect_repr_html.py @@ -21,7 +21,7 @@ import anndata as ad from anndata import AnnData -from anndata._repr import ( +from anndata.extensions import ( FormattedOutput, TypeFormatter, extract_uns_type_hint, @@ -40,7 +40,7 @@ import networkx as nx from treedata import TreeData - from anndata._repr import ( + from anndata.extensions import ( FormattedEntry, FormattedOutput, FormatterContext, @@ -238,7 +238,7 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: try: from mudata import MuData - from anndata._repr import ( + from anndata.extensions import ( FormattedEntry, FormattedOutput, FormatterContext, # noqa: TC001 From ee2d8af46195a323d42ecfe7099b61e6e6d7ddc4 Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 12 Dec 2025 16:43:54 +0100 Subject: [PATCH 2/4] Fix Ruff RUF022 on __all__ --- src/anndata/_repr/__init__.py | 6 +++++- src/anndata/extensions.py | 17 +++++++++++------ tests/visual_inspect_repr_html.py | 4 ++-- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py index c60c1a7db..2ef1f48b8 100644 --- a/src/anndata/_repr/__init__.py +++ b/src/anndata/_repr/__init__.py @@ -36,7 +36,11 @@ Example - format by Python type:: - from anndata.extensions import register_formatter, TypeFormatter, FormattedOutput + from anndata.extensions import ( + register_formatter, + TypeFormatter, + FormattedOutput, + ) @register_formatter diff --git a/src/anndata/extensions.py b/src/anndata/extensions.py index a9bed0233..67a0d5de5 100644 --- a/src/anndata/extensions.py +++ b/src/anndata/extensions.py @@ -13,6 +13,7 @@ import anndata as ad from anndata.extensions import register_anndata_namespace + @register_anndata_namespace("transform") class TransformAccessor: def __init__(self, adata: ad.AnnData): @@ -20,15 +21,18 @@ def __init__(self, adata: ad.AnnData): def log1p(self): import numpy as np + self._adata.X = np.log1p(self._adata.X) return self._adata + # Usage: adata.transform.log1p() Register a custom HTML formatter for a type:: from anndata.extensions import register_formatter, TypeFormatter, FormattedOutput + @register_formatter class MyArrayFormatter(TypeFormatter): priority = 100 # Higher = checked first @@ -47,6 +51,7 @@ def format(self, obj, context): from anndata.extensions import register_formatter, SectionFormatter from anndata.extensions import FormattedEntry, FormattedOutput + @register_formatter class ObstSectionFormatter(SectionFormatter): section_name = "obst" @@ -76,6 +81,8 @@ def get_entries(self, obj, context): # HTML representation formatters from anndata._repr import ( + # Type hint utilities for tagged data + UNS_TYPE_HINT_KEY, # Core formatter classes FormattedEntry, FormattedOutput, @@ -83,16 +90,14 @@ def get_entries(self, obj, context): FormatterRegistry, SectionFormatter, TypeFormatter, - # Registration function - register_formatter, + extract_uns_type_hint, # Global registry instance formatter_registry, - # Type hint utilities for tagged data - UNS_TYPE_HINT_KEY, - extract_uns_type_hint, + # Registration function + register_formatter, ) -__all__ = [ +__all__ = [ # noqa: RUF022 # organized by category, not alphabetically # Accessor registration "register_anndata_namespace", # HTML formatter registration diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index 9408e3af9..6b62198dd 100644 --- a/tests/visual_inspect_repr_html.py +++ b/tests/visual_inspect_repr_html.py @@ -238,6 +238,8 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: try: from mudata import MuData + from anndata._repr.html import generate_repr_html + from anndata._repr.utils import format_number from anndata.extensions import ( FormattedEntry, FormattedOutput, @@ -245,8 +247,6 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: SectionFormatter, register_formatter, ) - from anndata._repr.html import generate_repr_html - from anndata._repr.utils import format_number HAS_MUDATA = True From 9377ad86de8e09dd8448d1e66f083505cbdc83bd Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 12 Dec 2025 18:00:28 +0100 Subject: [PATCH 3/4] unified accessor + section viz pattern --- src/anndata/_core/extensions.py | 150 +++++++++++++++++++++++++++++- tests/test_repr_html.py | 91 ++++++++++++++++++ tests/visual_inspect_repr_html.py | 75 +++++++++++++++ 3 files changed, 315 insertions(+), 1 deletion(-) diff --git a/src/anndata/_core/extensions.py b/src/anndata/_core/extensions.py index 180a15a61..031ba3917 100644 --- a/src/anndata/_core/extensions.py +++ b/src/anndata/_core/extensions.py @@ -10,12 +10,17 @@ if TYPE_CHECKING: from collections.abc import Callable + from anndata._repr.registry import FormattedEntry, FormatterContext + # Based off of the extension framework in Polars # https://github.com/pola-rs/polars/blob/main/py-polars/polars/api.py __all__ = ["register_anndata_namespace"] +# Protocol for accessors that provide section visualization +REPR_SECTION_METHOD = "_repr_section_" + # Reserved namespaces include accessors built into AnnData (currently there are none) # and all current attributes of AnnData @@ -121,6 +126,76 @@ def _check_namespace_signature(ns_class: type) -> None: raise TypeError(msg) +def _create_accessor_section_formatter( + name: str, ns_class: type[ExtensionNamespace] +) -> None: + """Create and register a SectionFormatter for an accessor with _repr_section_ method. + + This enables unified accessor + visualization registration. When an accessor + class defines a `_repr_section_` method, a SectionFormatter is automatically + registered that delegates to the accessor instance. + + Parameters + ---------- + name + The accessor name (used as section name) + ns_class + The accessor class that has a _repr_section_ method + """ + from anndata._repr.registry import ( + FormatterContext, + SectionFormatter, + register_formatter, + ) + + # Get optional section configuration from class attributes + after_section = getattr(ns_class, "section_after", None) + display_name = getattr(ns_class, "section_display_name", name) + tooltip = getattr(ns_class, "section_tooltip", "") + + class AccessorSectionFormatter(SectionFormatter): + """Auto-generated SectionFormatter that delegates to accessor._repr_section_.""" + + @property + def section_name(self) -> str: + return name + + @property + def display_name(self) -> str: + return display_name + + @property + def after_section(self) -> str | None: + return after_section + + @property + def tooltip(self) -> str: + return tooltip + + def should_show(self, obj: AnnData) -> bool: + if not hasattr(obj, name): + return False + accessor = getattr(obj, name) + if not hasattr(accessor, REPR_SECTION_METHOD): + return False + # Call _repr_section_ to check if it returns entries + result = getattr(accessor, REPR_SECTION_METHOD)(FormatterContext()) + return result is not None and len(result) > 0 + + def get_entries( + self, obj: AnnData, context: FormatterContext + ) -> list[FormattedEntry]: + accessor = getattr(obj, name) + result = getattr(accessor, REPR_SECTION_METHOD)(context) + return result if result is not None else [] + + # Give it a meaningful name for debugging + AccessorSectionFormatter.__name__ = f"{ns_class.__name__}SectionFormatter" + AccessorSectionFormatter.__qualname__ = f"{ns_class.__name__}SectionFormatter" + + register_formatter(AccessorSectionFormatter()) + + def _create_namespace[NameSpT: ExtensionNamespace]( name: str, cls: type[AnnData] ) -> Callable[[type[NameSpT]], type[NameSpT]]: @@ -138,6 +213,11 @@ def namespace(ns_class: type[NameSpT]) -> type[NameSpT]: ) setattr(cls, name, AccessorNameSpace(name, ns_class)) cls._accessors.add(name) + + # Auto-register SectionFormatter if accessor has _repr_section_ method + if hasattr(ns_class, REPR_SECTION_METHOD): + _create_accessor_section_formatter(name, ns_class) + return ns_class return namespace @@ -169,13 +249,31 @@ def register_anndata_namespace[NameSpT: ExtensionNamespace]( ----- Implementation requirements: - 1. The decorated class must have an `__init__` method that accepts exactly one parameter + 1. The decorated class must have an `__init__`` method that accepts exactly one parameter (besides `self`) named `adata` and annotated with type :class:`~anndata.AnnData`. 2. The namespace will be initialized with the AnnData object on first access and then cached on the instance. 3. If the namespace name conflicts with an existing namespace, a warning is issued. 4. If the namespace name conflicts with a built-in AnnData attribute, an AttributeError is raised. + HTML Representation + ~~~~~~~~~~~~~~~~~~~ + If the accessor class defines a ``_repr_section_`` method, a section will automatically + be added to the HTML representation. This enables unified accessor + visualization + registration with a single decorator. + + The ``_repr_section_`` method should have the signature:: + + def _repr_section_(self, context: FormatterContext) -> list[FormattedEntry] | None: + '''Return entries for HTML repr, or None to hide section.''' + ... + + Optional class attributes for section configuration: + + - ``section_after``: Section name after which this section appears (e.g., "obsm") + - ``section_display_name``: Display name for the section header (defaults to accessor name) + - ``section_tooltip``: Tooltip text for the section header + Examples -------- Simple transformation namespace with two methods: @@ -233,5 +331,55 @@ def register_anndata_namespace[NameSpT: ExtensionNamespace]( >>> adata.transform.arcsinh() # Transforms X and returns the AnnData object AnnData object with n_obs × n_vars = 100 × 2000 layers: 'log1p', 'arcsinh' + + Accessor with HTML section visualization: + + .. code-block:: python + + from anndata.extensions import ( + register_anndata_namespace, + FormattedEntry, + FormattedOutput, + ) + + + @register_anndata_namespace("spatial") + class SpatialAccessor: + # Optional: configure section positioning and display + section_after = "obsm" + section_display_name = "spatial" + section_tooltip = "Spatial data (images, coordinates)" + + def __init__(self, adata: ad.AnnData): + self._adata = adata + + @property + def images(self): + return self._adata.uns.get("spatial_images", {}) + + def add_image(self, key, image): + if "spatial_images" not in self._adata.uns: + self._adata.uns["spatial_images"] = {} + self._adata.uns["spatial_images"][key] = image + + def _repr_section_(self, context) -> list[FormattedEntry] | None: + '''Return entries for HTML repr, or None to hide section.''' + if not self.images: + return None + return [ + FormattedEntry( + key=k, + output=FormattedOutput( + type_name=f"Image {v.shape}", + css_class="dtype-array", + ), + ) + for k, v in self.images.items() + ] + + + # Usage: + adata.spatial.add_image("hires", np.zeros((100, 100, 3))) + adata._repr_html_() # Shows "spatial" section with "hires" entry """ return _create_namespace(name, AnnData) diff --git a/tests/test_repr_html.py b/tests/test_repr_html.py index 33a8eeb7c..29ac9e027 100644 --- a/tests/test_repr_html.py +++ b/tests/test_repr_html.py @@ -2619,6 +2619,97 @@ def test_section_formatter_is_abstract(self): SectionFormatter() +class TestUnifiedAccessorSection: + """Tests for unified accessor + section visualization via _repr_section_.""" + + def test_accessor_with_repr_section_creates_section(self): + """Test that accessor with _repr_section_ automatically gets a section.""" + from anndata.extensions import ( + FormattedEntry, + FormattedOutput, + formatter_registry, + register_anndata_namespace, + ) + + # Register accessor with _repr_section_ + @register_anndata_namespace("unified_test") + class UnifiedTestAccessor: + section_after = "obsm" + + def __init__(self, adata: AnnData): + self._adata = adata + + @property + def items(self): + return self._adata.uns.get("unified_items", {}) + + def add_item(self, key, value): + if "unified_items" not in self._adata.uns: + self._adata.uns["unified_items"] = {} + self._adata.uns["unified_items"][key] = value + + def _repr_section_(self, context): + if not self.items: + return None + return [ + FormattedEntry( + key=k, + output=FormattedOutput(type_name=f"Item: {v}"), + ) + for k, v in self.items.items() + ] + + try: + # Verify section formatter was registered + assert "unified_test" in formatter_registry._section_formatters + + # Test that section appears in HTML when items exist + adata = AnnData(np.zeros((5, 3))) + adata.unified_test.add_item("test_key", "test_value") + + html = adata._repr_html_() + assert "unified_test" in html + assert "test_key" in html + assert "Item: test_value" in html + + # Test that section is hidden when no items + adata2 = AnnData(np.zeros((5, 3))) + html2 = adata2._repr_html_() + assert "unified_test" not in html2 + finally: + # Cleanup: remove the registered accessor and formatter + if hasattr(AnnData, "unified_test"): + delattr(AnnData, "unified_test") + AnnData._accessors.discard("unified_test") + formatter_registry._section_formatters.pop("unified_test", None) + + def test_accessor_without_repr_section_no_section(self): + """Test that accessor without _repr_section_ doesn't create a section.""" + from anndata.extensions import formatter_registry, register_anndata_namespace + + # Register accessor WITHOUT _repr_section_ + @register_anndata_namespace("no_section_test") + class NoSectionAccessor: + def __init__(self, adata: AnnData): + self._adata = adata + + def do_something(self): + return "done" + + try: + # Verify no section formatter was registered + assert "no_section_test" not in formatter_registry._section_formatters + + # Accessor should still work + adata = AnnData(np.zeros((5, 3))) + assert adata.no_section_test.do_something() == "done" + finally: + # Cleanup + if hasattr(AnnData, "no_section_test"): + delattr(AnnData, "no_section_test") + AnnData._accessors.discard("no_section_test") + + class TestCustomHtmlContent: """Tests for custom HTML content in Type Formatters.""" diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index 6b62198dd..d8070dfcd 100644 --- a/tests/visual_inspect_repr_html.py +++ b/tests/visual_inspect_repr_html.py @@ -22,12 +22,67 @@ import anndata as ad from anndata import AnnData from anndata.extensions import ( + FormattedEntry, FormattedOutput, TypeFormatter, extract_uns_type_hint, + register_anndata_namespace, register_formatter, ) +# ============================================================================= +# Example: Unified accessor + section visualization +# ============================================================================= +# This demonstrates how to create an accessor that automatically gets a section +# in the HTML repr by defining a _repr_section_ method. + + +@register_anndata_namespace("spatial_demo") +class SpatialDemoAccessor: + """Demo accessor showing unified accessor + section visualization. + + This accessor provides functionality to store spatial images and + automatically displays them in the HTML representation. + """ + + section_after = "obsm" # Position section after obsm + section_display_name = "spatial" # Display name in HTML + section_tooltip = "Spatial data (images, coordinates)" + + def __init__(self, adata: AnnData): + self._adata = adata + + @property + def images(self) -> dict: + """Get stored spatial images.""" + return self._adata.uns.get("_spatial_images", {}) + + def add_image(self, key: str, image: np.ndarray) -> None: + """Add a spatial image.""" + if "_spatial_images" not in self._adata.uns: + self._adata.uns["_spatial_images"] = {} + self._adata.uns["_spatial_images"][key] = image + + def _repr_section_(self, context) -> list[FormattedEntry] | None: + """Return entries for HTML repr, or None to hide section. + + This method is automatically called by the HTML repr system + when this accessor is registered with register_anndata_namespace. + """ + if not self.images: + return None + return [ + FormattedEntry( + key=k, + output=FormattedOutput( + type_name=f"Image {v.shape}", + css_class="dtype-array", + ), + ) + for k, v in self.images.items() + ] + + # Check optional dependencies try: import dask.array as da @@ -1205,6 +1260,26 @@ def format(self, obj, context): else: print(" 19. MuData (skipped - mudata not installed)") + # Test 20: Unified accessor + section visualization + print(" 20. Unified accessor + section visualization (spatial_demo)") + adata_spatial = AnnData(np.random.randn(50, 20).astype(np.float32)) + adata_spatial.obs["cluster"] = pd.Categorical(["A", "B", "C"] * 16 + ["A", "B"]) + adata_spatial.obsm["X_spatial"] = np.random.randn(50, 2).astype(np.float32) + # Use the spatial_demo accessor to add images + adata_spatial.spatial_demo.add_image("hires", np.zeros((1000, 1000, 3))) + adata_spatial.spatial_demo.add_image("lowres", np.zeros((200, 200, 3))) + adata_spatial.spatial_demo.add_image("segmentation", np.zeros((1000, 1000))) + sections.append(( + "20. Unified Accessor + Section (spatial_demo)", + adata_spatial._repr_html_(), + "Demonstrates the unified accessor + section pattern. The @register_anndata_namespace " + "decorator registers both the accessor (adata.spatial_demo) AND a section in the HTML repr. " + "The accessor class defines _repr_section_(self, context) which returns a list of " + "FormattedEntry objects. Optional class attributes: section_after (positioning), " + "section_display_name, section_tooltip. This is the recommended pattern " + "for external packages (SpatialData, MuData) to add both functionality and visualization.", + )) + # Generate HTML file output_path = Path(__file__).parent / "repr_html_visual_test.html" html_content = create_html_page(sections) From 8696776d56f30992ea45c5df93333c39549c4427 Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 12 Dec 2025 18:08:34 +0100 Subject: [PATCH 4/4] accessor section viz doc_url --- src/anndata/_core/extensions.py | 7 +++++ tests/test_repr_html.py | 47 +++++++++++++++++++++++++++++++ tests/visual_inspect_repr_html.py | 1 + 3 files changed, 55 insertions(+) diff --git a/src/anndata/_core/extensions.py b/src/anndata/_core/extensions.py index 031ba3917..f7703ac19 100644 --- a/src/anndata/_core/extensions.py +++ b/src/anndata/_core/extensions.py @@ -152,6 +152,7 @@ class defines a `_repr_section_` method, a SectionFormatter is automatically after_section = getattr(ns_class, "section_after", None) display_name = getattr(ns_class, "section_display_name", name) tooltip = getattr(ns_class, "section_tooltip", "") + doc_url = getattr(ns_class, "section_doc_url", None) class AccessorSectionFormatter(SectionFormatter): """Auto-generated SectionFormatter that delegates to accessor._repr_section_.""" @@ -172,6 +173,10 @@ def after_section(self) -> str | None: def tooltip(self) -> str: return tooltip + @property + def doc_url(self) -> str | None: + return doc_url + def should_show(self, obj: AnnData) -> bool: if not hasattr(obj, name): return False @@ -273,6 +278,7 @@ def _repr_section_(self, context: FormatterContext) -> list[FormattedEntry] | No - ``section_after``: Section name after which this section appears (e.g., "obsm") - ``section_display_name``: Display name for the section header (defaults to accessor name) - ``section_tooltip``: Tooltip text for the section header + - ``section_doc_url``: URL to documentation (shown as link icon in header) Examples -------- @@ -349,6 +355,7 @@ class SpatialAccessor: section_after = "obsm" section_display_name = "spatial" section_tooltip = "Spatial data (images, coordinates)" + section_doc_url = "https://spatialdata.readthedocs.io/" def __init__(self, adata: ad.AnnData): self._adata = adata diff --git a/tests/test_repr_html.py b/tests/test_repr_html.py index 29ac9e027..759cf295d 100644 --- a/tests/test_repr_html.py +++ b/tests/test_repr_html.py @@ -2709,6 +2709,53 @@ def do_something(self): delattr(AnnData, "no_section_test") AnnData._accessors.discard("no_section_test") + def test_accessor_section_doc_url(self): + """Test that section_doc_url is passed through to the SectionFormatter.""" + from anndata.extensions import ( + FormattedEntry, + FormattedOutput, + formatter_registry, + register_anndata_namespace, + ) + + @register_anndata_namespace("docurl_test") + class DocUrlTestAccessor: + section_after = "obsm" + section_display_name = "docurl" + section_tooltip = "Test tooltip" + section_doc_url = "https://example.com/docs" + + def __init__(self, adata: AnnData): + self._adata = adata + + def _repr_section_(self, context): + return [ + FormattedEntry( + key="item", + output=FormattedOutput(type_name="test"), + ) + ] + + try: + # Verify section formatter was registered with doc_url + assert "docurl_test" in formatter_registry._section_formatters + section_formatter = formatter_registry._section_formatters["docurl_test"] + assert section_formatter.doc_url == "https://example.com/docs" + assert section_formatter.display_name == "docurl" + assert section_formatter.tooltip == "Test tooltip" + assert section_formatter.after_section == "obsm" + + # Test that doc URL appears in HTML + adata = AnnData(np.zeros((5, 3))) + html = adata._repr_html_() + assert "https://example.com/docs" in html + finally: + # Cleanup + if hasattr(AnnData, "docurl_test"): + delattr(AnnData, "docurl_test") + AnnData._accessors.discard("docurl_test") + formatter_registry._section_formatters.pop("docurl_test", None) + class TestCustomHtmlContent: """Tests for custom HTML content in Type Formatters.""" diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index d8070dfcd..52a140416 100644 --- a/tests/visual_inspect_repr_html.py +++ b/tests/visual_inspect_repr_html.py @@ -48,6 +48,7 @@ class SpatialDemoAccessor: section_after = "obsm" # Position section after obsm section_display_name = "spatial" # Display name in HTML section_tooltip = "Spatial data (images, coordinates)" + section_doc_url = "https://spatialdata.scverse.org/" # Documentation link def __init__(self, adata: AnnData): self._adata = adata