Skip to content
Open
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
157 changes: 156 additions & 1 deletion src/anndata/_core/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]]:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
34 changes: 29 additions & 5 deletions src/anndata/_repr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions src/anndata/extensions.py
Original file line number Diff line number Diff line change
@@ -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",
]
Loading