diff --git a/src/anndata/_core/extensions.py b/src/anndata/_core/extensions.py
index 180a15a61..f7703ac19 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,81 @@ 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", "")
+ doc_url = getattr(ns_class, "section_doc_url", None)
+
+ 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
+
+ @property
+ def doc_url(self) -> str | None:
+ return doc_url
+
+ 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 +218,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 +254,32 @@ 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
+ - ``section_doc_url``: URL to documentation (shown as link icon in header)
+
Examples
--------
Simple transformation namespace with two methods:
@@ -233,5 +337,56 @@ 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)"
+ section_doc_url = "https://spatialdata.readthedocs.io/"
+
+ 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/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py
index ea378af8a..2ef1f48b8 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,11 @@
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 +58,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 +100,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..67a0d5de5
--- /dev/null
+++ b/src/anndata/extensions.py
@@ -0,0 +1,115 @@
+"""
+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 (
+ # Type hint utilities for tagged data
+ UNS_TYPE_HINT_KEY,
+ # Core formatter classes
+ FormattedEntry,
+ FormattedOutput,
+ FormatterContext,
+ FormatterRegistry,
+ SectionFormatter,
+ TypeFormatter,
+ extract_uns_type_hint,
+ # Global registry instance
+ formatter_registry,
+ # Registration function
+ register_formatter,
+)
+
+__all__ = [ # noqa: RUF022 # organized by category, not alphabetically
+ # 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..759cf295d 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,25 +2606,163 @@ 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()
+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")
+
+ 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."""
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 +2807,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..52a140416 100644
--- a/tests/visual_inspect_repr_html.py
+++ b/tests/visual_inspect_repr_html.py
@@ -21,13 +21,69 @@
import anndata as ad
from anndata import AnnData
-from anndata._repr import (
+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)"
+ section_doc_url = "https://spatialdata.scverse.org/" # Documentation link
+
+ 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
@@ -40,7 +96,7 @@
import networkx as nx
from treedata import TreeData
- from anndata._repr import (
+ from anndata.extensions import (
FormattedEntry,
FormattedOutput,
FormatterContext,
@@ -238,15 +294,15 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]:
try:
from mudata import MuData
- from anndata._repr import (
+ from anndata._repr.html import generate_repr_html
+ from anndata._repr.utils import format_number
+ from anndata.extensions import (
FormattedEntry,
FormattedOutput,
FormatterContext, # noqa: TC001
SectionFormatter,
register_formatter,
)
- from anndata._repr.html import generate_repr_html
- from anndata._repr.utils import format_number
HAS_MUDATA = True
@@ -1205,6 +1261,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)