From 4dc657295bd28afd0b115862f66b1ed72e233ac0 Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 2 Dec 2025 19:23:15 -0800 Subject: [PATCH] implement ObjectFormatter and SpatialData example --- src/anndata/_repr/__init__.py | 7 + src/anndata/_repr/html.py | 342 +++++++++++++++++++++----- src/anndata/_repr/registry.py | 174 ++++++++++++- tests/visual_inspect_repr_html.py | 394 +++++++++++++++++++++++++++++- 4 files changed, 845 insertions(+), 72 deletions(-) diff --git a/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py index ea378af8a..01ea8f533 100644 --- a/src/anndata/_repr/__init__.py +++ b/src/anndata/_repr/__init__.py @@ -143,6 +143,10 @@ def get_entries(self, obj, context): FormatterContext, # Type formatter registry FormatterRegistry, + # Object-level customization + HeaderConfig, + IndexPreviewConfig, + ObjectFormatter, SectionFormatter, TypeFormatter, # Type hint extraction (for tagged data in uns) @@ -172,6 +176,9 @@ def get_entries(self, obj, context): "register_formatter", "SectionFormatter", "TypeFormatter", + "ObjectFormatter", + "HeaderConfig", + "IndexPreviewConfig", "FormattedOutput", "FormattedEntry", "FormatterContext", diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index e13979b78..97fa98d67 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -11,6 +11,7 @@ from __future__ import annotations +import contextlib import uuid from typing import TYPE_CHECKING @@ -57,7 +58,12 @@ import pandas as pd from anndata import AnnData - from anndata._repr.registry import FormattedEntry, FormattedOutput + from anndata._repr.registry import ( + FormattedEntry, + FormattedOutput, + HeaderConfig, + IndexPreviewConfig, + ) # Import formatters to register them (side-effect import) import anndata._repr.formatters # noqa: F401 @@ -120,8 +126,68 @@ def _calculate_field_name_width(adata: AnnData, max_width: int) -> int: return max(80, min(width_px, max_width)) +def _get_repr_settings( + max_depth: int | None, fold_threshold: int | None, max_items: int | None +) -> tuple[int, int, int]: + """Get repr settings with defaults.""" + if max_depth is None: + max_depth = _get_setting("repr_html_max_depth", default=DEFAULT_MAX_DEPTH) + if fold_threshold is None: + fold_threshold = _get_setting( + "repr_html_fold_threshold", default=DEFAULT_FOLD_THRESHOLD + ) + if max_items is None: + max_items = _get_setting("repr_html_max_items", default=DEFAULT_MAX_ITEMS) + return max_depth, fold_threshold, max_items + + +def _render_header_part( + obj: Any, + context: FormatterContext, + object_formatter: Any, + *, + show_search: bool, + depth: int, + container_id: str, +) -> str: + """Render the header using either ObjectFormatter or default.""" + if object_formatter is not None: + header_config = object_formatter.get_header_config(obj, context) + return _render_header_from_config( + obj, + header_config, + show_search=show_search and depth == 0, + container_id=container_id, + ) + return _render_header( + obj, + show_search=show_search and depth == 0, + container_id=container_id, + ) + + +def _render_index_part( + obj: Any, context: FormatterContext, object_formatter: Any +) -> str: + """Render the index preview using either ObjectFormatter or default.""" + if object_formatter is not None: + index_config = object_formatter.get_index_preview_config(obj, context) + if index_config is not None: + return _render_index_preview_from_config(index_config) + return "" + return _render_index_preview(obj) + + +def _render_footer_part(obj: Any, object_formatter: Any) -> str: + """Render the footer using either ObjectFormatter or default.""" + if object_formatter is not None: + custom_version = object_formatter.get_footer_version(obj) + return _render_footer(obj, custom_version=custom_version) + return _render_footer(obj) + + def generate_repr_html( - adata: AnnData, + obj: Any, *, depth: int = 0, max_depth: int | None = None, @@ -132,14 +198,19 @@ def generate_repr_html( _container_id: str | None = None, ) -> str: """ - Generate HTML representation for an AnnData object. + Generate HTML representation for an AnnData or AnnData-like object. + + This function supports extension packages via ObjectFormatter registration. + If an ObjectFormatter is registered for the object type, it controls the + header, index preview, sections, and footer. Otherwise, the default + AnnData rendering is used. Parameters ---------- - adata - The AnnData object to represent + obj + The object to represent (AnnData, MuData, SpatialData, etc.) depth - Current recursion depth (for nested AnnData in .uns) + Current recursion depth (for nested objects) max_depth Maximum recursion depth. Uses settings/default if None. fold_threshold @@ -157,97 +228,127 @@ def generate_repr_html( ------- HTML string """ - # Get settings with defaults - if max_depth is None: - max_depth = _get_setting("repr_html_max_depth", default=DEFAULT_MAX_DEPTH) - if fold_threshold is None: - fold_threshold = _get_setting( - "repr_html_fold_threshold", default=DEFAULT_FOLD_THRESHOLD - ) - if max_items is None: - max_items = _get_setting("repr_html_max_items", default=DEFAULT_MAX_ITEMS) + max_depth, fold_threshold, max_items = _get_repr_settings( + max_depth, fold_threshold, max_items + ) - # Check if HTML repr is enabled + # Early returns for special cases if not _get_setting("repr_html_enabled", default=True): - # Fallback to text repr - return f"
{escape_html(repr(adata))}
" - - # Check max depth + return f"
{escape_html(repr(obj))}
" if depth >= max_depth: - return _render_max_depth_indicator(adata) + return _render_max_depth_indicator(obj) - # Generate unique container ID container_id = _container_id or f"anndata-repr-{uuid.uuid4().hex[:8]}" - - # Create formatter context - context = FormatterContext( - depth=depth, - max_depth=max_depth, - adata_ref=adata, - ) + context = FormatterContext(depth=depth, max_depth=max_depth, adata_ref=obj) + object_formatter = formatter_registry.get_object_formatter(obj) # Build HTML parts parts = [] + is_top_level = depth == 0 - # CSS and JS only at top level - if depth == 0: + if is_top_level: parts.append(get_css()) - # Calculate field name column width based on content + # Container setup max_field_width = _get_setting( "repr_html_max_field_width", default=DEFAULT_MAX_FIELD_WIDTH ) - field_width = _calculate_field_name_width(adata, max_field_width) - - # Get type column width from settings + field_width = _calculate_field_name_width_generic(obj, max_field_width) type_width = _get_setting("repr_html_type_width", default=DEFAULT_TYPE_WIDTH) - - # Container with computed column widths as CSS variables style = f"--anndata-name-col-width: {field_width}px; --anndata-type-col-width: {type_width}px;" parts.append( f'
' ) - # Header (with search box integrated on the right) + # Header if show_header: parts.append( - _render_header( - adata, show_search=show_search and depth == 0, container_id=container_id + _render_header_part( + obj, + context, + object_formatter, + show_search=show_search, + depth=depth, + container_id=container_id, ) ) # Index preview (only at top level) - if depth == 0: - parts.append(_render_index_preview(adata)) + if is_top_level: + parts.append(_render_index_part(obj, context, object_formatter)) - # Sections container + # Sections parts.append('
') - parts.append(_render_x_entry(adata, context)) + if object_formatter is None or object_formatter.should_render_x(obj): + parts.append(_render_x_entry(obj, context)) parts.extend( - _render_all_sections(adata, context, fold_threshold, max_items, max_depth) + _render_all_sections(obj, context, fold_threshold, max_items, max_depth) ) - parts.append("
") # adata-sections - - # Footer with metadata (only at top level) - if depth == 0: - parts.append(_render_footer(adata)) - - parts.append("
") # anndata-repr + parts.append("") - # JavaScript (only at top level) - if depth == 0: + # Footer and JS (only at top level) + if is_top_level: + parts.append(_render_footer_part(obj, object_formatter)) + parts.append("") + if is_top_level: parts.append(get_javascript(container_id)) return "\n".join(parts) +def _calculate_field_name_width_generic(obj: Any, max_width: int) -> int: + """ + Calculate the optimal field name column width for any object. + + Works with AnnData and extension types by checking for common attributes. + """ + all_names: list[str] = [] + + # Collect names from DataFrame columns (obs, var) + for attr in ("obs", "var"): + df = getattr(obj, attr, None) + if df is not None: + with contextlib.suppress(Exception): + all_names.extend(df.columns.tolist()) + + # Mapping sections (both AnnData and extension types) + mapping_attrs = ( + "uns", + "obsm", + "varm", + "layers", + "obsp", + "varp", # AnnData + "images", + "labels", + "points", + "shapes", + "tables", + "mod", # Extensions + ) + for attr in mapping_attrs: + mapping = getattr(obj, attr, None) + if mapping is not None: + with contextlib.suppress(Exception): + all_names.extend(mapping.keys()) + + if not all_names: + return 100 # Minimum default + + # Find longest name and convert to pixels + max_len = max(len(name) for name in all_names) + width_px = (max_len * CHAR_WIDTH_PX) + 30 # Padding for copy button + + return max(80, min(width_px, max_width)) + + # ============================================================================= # Custom Section Support # ============================================================================= def _render_all_sections( - adata: AnnData, + obj: Any, context: FormatterContext, fold_threshold: int, max_items: int, @@ -255,22 +356,42 @@ def _render_all_sections( ) -> list[str]: """Render all standard and custom sections.""" parts = [] - custom_sections_after = _get_custom_sections_by_position(adata) + custom_sections_after = _get_custom_sections_by_position(obj) + + # Check if there's an ObjectFormatter that defines custom sections + object_formatter = formatter_registry.get_object_formatter(obj) + if object_formatter is not None: + # ObjectFormatter controls which sections to render + sections_to_render = object_formatter.get_sections(obj) + if not sections_to_render: + # No standard sections - only render custom sections + for section_formatters in custom_sections_after.values(): + parts.extend( + _render_custom_section( + obj, section_formatter, context, fold_threshold, max_items + ) + for section_formatter in section_formatters + ) + return parts + # Use the sections specified by the ObjectFormatter + section_order = sections_to_render + else: + section_order = SECTION_ORDER - for section in SECTION_ORDER: + for section in section_order: if section == "X": # X is already rendered, but check for custom sections after X if "X" in custom_sections_after: parts.extend( _render_custom_section( - adata, section_formatter, context, fold_threshold, max_items + obj, section_formatter, context, fold_threshold, max_items ) for section_formatter in custom_sections_after["X"] ) continue parts.append( _render_section( - adata, + obj, section, context, fold_threshold=fold_threshold, @@ -283,7 +404,7 @@ def _render_all_sections( if section in custom_sections_after: parts.extend( _render_custom_section( - adata, section_formatter, context, fold_threshold, max_items + obj, section_formatter, context, fold_threshold, max_items ) for section_formatter in custom_sections_after[section] ) @@ -292,7 +413,7 @@ def _render_all_sections( if None in custom_sections_after: parts.extend( _render_custom_section( - adata, section_formatter, context, fold_threshold, max_items + obj, section_formatter, context, fold_threshold, max_items ) for section_formatter in custom_sections_after[None] ) @@ -582,17 +703,94 @@ def _render_header( return "\n".join(parts) -def _render_footer(adata: AnnData) -> str: +def _render_header_from_config( + obj: Any, + config: HeaderConfig, + *, + show_search: bool = False, + container_id: str = "", +) -> str: + """Render the header using a HeaderConfig from an ObjectFormatter.""" + parts = ['
'] + + # Type name + parts.append(f'{escape_html(config.type_name)}') + + # Shape (optional) + if config.shape_str: + parts.append( + f'{escape_html(config.shape_str)}' + ) + + # Badges + for badge_text, badge_class, badge_tooltip in config.badges: + tooltip_attr = f' title="{escape_html(badge_tooltip)}"' if badge_tooltip else "" + parts.append( + f'' + f"{escape_html(badge_text)}" + ) + + # File path (for backed objects) + if config.file_path: + path_style = ( + "font-family:ui-monospace,monospace;font-size:11px;" + "color:var(--anndata-text-secondary, #6c757d);" + ) + parts.append( + f'' + f"{escape_html(config.file_path)}" + f"" + ) + + # README icon if enabled and uns["README"] exists + if config.show_readme: + readme_content = None + if hasattr(obj, "uns"): + with contextlib.suppress(Exception): + readme_content = obj.uns.get("README") + if isinstance(readme_content, str) and readme_content.strip(): + escaped_readme = escape_html(readme_content) + tooltip_text = readme_content[:500] + if len(readme_content) > 500: + tooltip_text += "..." + escaped_tooltip = escape_html(tooltip_text) + parts.append( + f'' + f"ⓘ" + f"" + ) + + # Search box on the right + if show_search: + parts.append('') + search_id = f"{container_id}-search" if container_id else "anndata-search" + parts.append( + f'' + ) + parts.append('') + + parts.append("
") + return "\n".join(parts) + + +def _render_footer(obj: Any, *, custom_version: str | None = None) -> str: """Render the footer with version and memory info.""" parts = ['
'] - # Version - version = get_anndata_version() - parts.append(f"anndata v{version}") + # Version (custom or anndata) + if custom_version: + parts.append(f"{escape_html(custom_version)}") + else: + version = get_anndata_version() + parts.append(f"anndata v{version}") # Memory usage try: - mem_bytes = adata.__sizeof__() + mem_bytes = obj.__sizeof__() mem_str = format_memory_size(mem_bytes) parts.append(f'~{mem_str}') except Exception: # noqa: BLE001 @@ -603,6 +801,18 @@ def _render_footer(adata: AnnData) -> str: return "\n".join(parts) +def _render_index_preview_from_config(config: IndexPreviewConfig) -> str: + """Render index preview using an IndexPreviewConfig from an ObjectFormatter.""" + if not config.items: + return "" + + parts = ['
'] + for label, preview_html in config.items: + parts.append(f"
{escape_html(label)} {preview_html}
") + parts.append("
") + return "\n".join(parts) + + def _render_index_preview(adata: AnnData) -> str: """Render preview of obs_names and var_names.""" parts = ['
'] diff --git a/src/anndata/_repr/registry.py b/src/anndata/_repr/registry.py index 88403895b..fe6030f59 100644 --- a/src/anndata/_repr/registry.py +++ b/src/anndata/_repr/registry.py @@ -199,6 +199,144 @@ def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: ... +@dataclass +class HeaderConfig: + """ + Configuration for customizing the header of an object's HTML repr. + + This allows extension packages to customize what's shown in the header, + including badges, shape info, and whether to show certain elements. + """ + + type_name: str + """Display name for the type (e.g., 'AnnData', 'SpatialData', 'MuData')""" + + shape_str: str | None = None + """Shape string to display (e.g., '100 obs × 50 vars'). None to hide.""" + + badges: list[tuple[str, str, str]] = field(default_factory=list) + """List of (text, css_class, tooltip) tuples for badges""" + + file_path: str | None = None + """File path to display (for backed objects)""" + + show_readme: bool = True + """Whether to show README icon if uns['README'] exists""" + + +@dataclass +class IndexPreviewConfig: + """Configuration for index preview section.""" + + items: list[tuple[str, str]] = field(default_factory=list) + """List of (label, preview_html) tuples to display""" + + +class ObjectFormatter(ABC): + """ + Base class for object-level formatters that customize the entire repr. + + Subclass this when you need to customize everything about how an object + is represented, including: + - Header (type name, shape, badges) + - Index preview (obs_names, var_names, or custom) + - Which sections to show (can skip X, add custom sections) + - Footer content + + This is ideal for packages like SpatialData that have completely different + structures than AnnData but want to reuse the styling and infrastructure. + + Example usage:: + + from anndata._repr import ( + register_formatter, + ObjectFormatter, + HeaderConfig, + IndexPreviewConfig, + ) + + + @register_formatter + class SpatialDataFormatter(ObjectFormatter): + priority = 100 + + def can_format(self, obj): + return type(obj).__name__ == "SpatialData" + + def get_header_config(self, obj, context): + return HeaderConfig( + type_name="SpatialData", + shape_str=None, # No central shape + badges=[("Backed", "adata-badge-backed", "Zarr storage")] + if obj.is_backed() + else [], + ) + + def get_index_preview_config(self, obj, context): + # SpatialData shows coordinate systems instead of obs/var names + cs_preview = ", ".join(obj.coordinate_systems[:5]) + return IndexPreviewConfig(items=[("coordinate_systems:", cs_preview)]) + + def get_sections(self, obj): + # Return list of section names to render + return ["images", "labels", "points", "shapes", "tables"] + + def should_render_x(self, obj): + return False # SpatialData has no X + + Note: The formatter still uses the CSS, JavaScript, and rendering + infrastructure from anndata. Custom sections are rendered using + registered SectionFormatters. + """ + + priority: int = 0 + """Priority for checking this formatter (higher = checked first)""" + + @abstractmethod + def can_format(self, obj: Any) -> bool: + """Return True if this formatter handles the given object.""" + ... + + @abstractmethod + def get_header_config(self, obj: Any, context: FormatterContext) -> HeaderConfig: + """Get header configuration for this object.""" + ... + + def get_index_preview_config( + self, obj: Any, context: FormatterContext + ) -> IndexPreviewConfig | None: + """ + Get index preview configuration. + + Return None to skip the index preview section entirely. + """ + return None + + def get_sections(self, obj: Any) -> list[str]: + """ + Get list of section names to render. + + Return standard section names (obs, var, uns, etc.) and/or custom + section names that have registered SectionFormatters. + + Return empty list to only render custom sections (via SectionFormatters + that have should_show() return True for this object). + """ + return [] + + def should_render_x(self, obj: Any) -> bool: + """Whether to render the X entry. Return False for objects without X.""" + return True + + def get_footer_version(self, obj: Any) -> str | None: + """ + Get version string for footer. + + Return None to use anndata version, or a custom version string. + """ + return None + + class SectionFormatter(ABC): """ Base class for section-specific formatters. @@ -338,7 +476,7 @@ def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: class FormatterRegistry: """ - Registry for type and section formatters. + Registry for type, section, and object formatters. This is the central registry that manages all formatters. It supports: - Registering new formatters at runtime @@ -350,6 +488,7 @@ class FormatterRegistry: def __init__(self) -> None: self._type_formatters: list[TypeFormatter] = [] self._section_formatters: dict[str, SectionFormatter] = {} + self._object_formatters: list[ObjectFormatter] = [] self._fallback = FallbackFormatter() def register_type_formatter(self, formatter: TypeFormatter) -> None: @@ -366,6 +505,16 @@ def register_section_formatter(self, formatter: SectionFormatter) -> None: """Register a section formatter.""" self._section_formatters[formatter.section_name] = formatter + def register_object_formatter(self, formatter: ObjectFormatter) -> None: + """ + Register an object formatter. + + Object formatters are checked in priority order (highest first). + """ + self._object_formatters.append(formatter) + # Keep sorted by priority (highest first) + self._object_formatters.sort(key=lambda f: -f.priority) + def unregister_type_formatter(self, formatter: TypeFormatter) -> bool: """Unregister a type formatter. Returns True if found and removed.""" try: @@ -374,6 +523,21 @@ def unregister_type_formatter(self, formatter: TypeFormatter) -> bool: except ValueError: return False + def get_object_formatter(self, obj: Any) -> ObjectFormatter | None: + """ + Get an object formatter for the given object, or None if none match. + + Tries each registered object formatter in priority order. + """ + for formatter in self._object_formatters: + try: + if formatter.can_format(obj): + return formatter + except Exception: # noqa: BLE001 + # Intentional broad catch: formatters shouldn't crash + continue + return None + def format_value(self, obj: Any, context: FormatterContext) -> FormattedOutput: """ Format a value using the appropriate formatter. @@ -521,8 +685,8 @@ def format(self, obj, context): def register_formatter( - formatter: TypeFormatter | SectionFormatter, -) -> TypeFormatter | SectionFormatter: + formatter: TypeFormatter | SectionFormatter | ObjectFormatter, +) -> TypeFormatter | SectionFormatter | ObjectFormatter: """ Register a formatter with the global registry. @@ -544,8 +708,10 @@ class MyFormatter(TypeFormatter): formatter_registry.register_type_formatter(formatter) elif isinstance(formatter, SectionFormatter): formatter_registry.register_section_formatter(formatter) + elif isinstance(formatter, ObjectFormatter): + formatter_registry.register_object_formatter(formatter) else: - msg = f"Expected TypeFormatter or SectionFormatter, got {type(formatter)}" + msg = f"Expected TypeFormatter, SectionFormatter, or ObjectFormatter, got {type(formatter)}" raise TypeError(msg) return formatter diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index dc70008e0..134342830 100644 --- a/tests/visual_inspect_repr_html.py +++ b/tests/visual_inspect_repr_html.py @@ -240,7 +240,7 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: from anndata._repr import ( FormattedEntry, FormattedOutput, - FormatterContext, # noqa: TC001 + FormatterContext, SectionFormatter, register_formatter, ) @@ -317,6 +317,296 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: MuData = None # type: ignore[assignment,misc] +# Check for SpatialData +try: + from spatialdata import SpatialData + + from anndata._repr import ( + FormattedEntry, + FormattedOutput, + FormatterContext, # noqa: TC001 + HeaderConfig, + IndexPreviewConfig, + ObjectFormatter, + SectionFormatter, + register_formatter, + ) + from anndata._repr.html import generate_repr_html + from anndata._repr.utils import format_number + + HAS_SPATIALDATA = True + + # Register an ObjectFormatter for SpatialData + @register_formatter + class SpatialDataObjectFormatter(ObjectFormatter): + """ + ObjectFormatter for SpatialData. + + This demonstrates how packages with completely different structures + (no central X, different sections) can still reuse anndata's HTML repr. + """ + + priority = 100 + + def can_format(self, obj) -> bool: + return type(obj).__name__ == "SpatialData" + + def get_header_config(self, obj, context: FormatterContext) -> HeaderConfig: + badges = [] + file_path = None + + # Check for backed/zarr storage + if obj.is_backed(): + path = str(obj.path) if obj.path else None + badges.append(( + "Zarr", + "adata-badge adata-badge-backed", + "Zarr storage", + )) + file_path = path + + return HeaderConfig( + type_name="SpatialData", + shape_str=None, # SpatialData has no central shape + badges=badges, + file_path=file_path, + show_readme=False, # SpatialData doesn't use README in uns + ) + + def get_index_preview_config( + self, obj, context: FormatterContext + ) -> IndexPreviewConfig | None: + # Show coordinate systems instead of obs/var names + items = [] + coord_systems = list(obj.coordinate_systems) + if coord_systems: + preview = ", ".join(coord_systems[:5]) + if len(coord_systems) > 5: + preview += f", ... ({len(coord_systems)} total)" + items.append(("coordinate_systems:", preview)) + return IndexPreviewConfig(items=items) if items else None + + def get_sections(self, obj) -> list[str]: + # SpatialData uses only custom sections + return [] + + def should_render_x(self, obj) -> bool: + return False # SpatialData has no X + + def get_footer_version(self, obj) -> str | None: + try: + from importlib.metadata import version + + return f"spatialdata v{version('spatialdata')}" + except Exception: # noqa: BLE001 + return "spatialdata" + + # Register SectionFormatters for each SpatialData section + @register_formatter + class ImagesSectionFormatter(SectionFormatter): + """SectionFormatter for SpatialData's .images attribute.""" + + section_name = "images" + priority = 200 + + @property + def after_section(self) -> str | None: + return None # Show at the beginning + + @property + def doc_url(self) -> str: + return "https://spatialdata.scverse.org/en/latest/api/SpatialData.html#spatialdata.SpatialData.images" + + @property + def tooltip(self) -> str: + return "2D/3D image data (xarray.DataArray)" + + def should_show(self, obj) -> bool: + return hasattr(obj, "images") and len(obj.images) > 0 + + def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: + entries = [] + for name, image in obj.images.items(): + # Get shape info from DataArray + shape_str = " × ".join(str(s) for s in image.shape) + dims_str = ", ".join(image.dims) + dtype_str = str(image.dtype) + output = FormattedOutput( + type_name=f"DataArray[{dims_str}] ({shape_str}) {dtype_str}", + css_class="dtype-ndarray", + tooltip=f"Image: {name}", + is_expandable=False, + is_serializable=True, + ) + entries.append(FormattedEntry(key=name, output=output)) + return entries + + @register_formatter + class LabelsSectionFormatter(SectionFormatter): + """SectionFormatter for SpatialData's .labels attribute.""" + + section_name = "labels" + priority = 190 + + @property + def after_section(self) -> str | None: + return None + + @property + def doc_url(self) -> str: + return "https://spatialdata.scverse.org/en/latest/api/SpatialData.html#spatialdata.SpatialData.labels" + + @property + def tooltip(self) -> str: + return "2D/3D label/segmentation data (xarray.DataArray)" + + def should_show(self, obj) -> bool: + return hasattr(obj, "labels") and len(obj.labels) > 0 + + def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: + entries = [] + for name, label in obj.labels.items(): + shape_str = " × ".join(str(s) for s in label.shape) + dims_str = ", ".join(label.dims) + dtype_str = str(label.dtype) + output = FormattedOutput( + type_name=f"DataArray[{dims_str}] ({shape_str}) {dtype_str}", + css_class="dtype-ndarray", + tooltip=f"Labels: {name}", + is_expandable=False, + is_serializable=True, + ) + entries.append(FormattedEntry(key=name, output=output)) + return entries + + @register_formatter + class PointsSectionFormatter(SectionFormatter): + """SectionFormatter for SpatialData's .points attribute.""" + + section_name = "points" + priority = 180 + + @property + def after_section(self) -> str | None: + return None + + @property + def doc_url(self) -> str: + return "https://spatialdata.scverse.org/en/latest/api/SpatialData.html#spatialdata.SpatialData.points" + + @property + def tooltip(self) -> str: + return "Point cloud data (dask.dataframe.DataFrame)" + + def should_show(self, obj) -> bool: + return hasattr(obj, "points") and len(obj.points) > 0 + + def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: + entries = [] + for name, pts in obj.points.items(): + # Dask dataframe - get shape info + n_cols = len(pts.columns) + output = FormattedOutput( + type_name=f"DataFrame (? × {n_cols})", + css_class="dtype-dataframe", + tooltip=f"Points: {name} (Dask DataFrame, rows computed lazily)", + is_expandable=False, + is_serializable=True, + ) + entries.append(FormattedEntry(key=name, output=output)) + return entries + + @register_formatter + class ShapesSectionFormatter(SectionFormatter): + """SectionFormatter for SpatialData's .shapes attribute.""" + + section_name = "shapes" + priority = 170 + + @property + def after_section(self) -> str | None: + return None + + @property + def doc_url(self) -> str: + return "https://spatialdata.scverse.org/en/latest/api/SpatialData.html#spatialdata.SpatialData.shapes" + + @property + def tooltip(self) -> str: + return "Geometric shapes (geopandas.GeoDataFrame)" + + def should_show(self, obj) -> bool: + return hasattr(obj, "shapes") and len(obj.shapes) > 0 + + def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: + entries = [] + for name, shape in obj.shapes.items(): + n_rows, n_cols = shape.shape + output = FormattedOutput( + type_name=f"GeoDataFrame ({format_number(n_rows)} × {n_cols})", + css_class="dtype-dataframe", + tooltip=f"Shapes: {name}", + is_expandable=False, + is_serializable=True, + ) + entries.append(FormattedEntry(key=name, output=output)) + return entries + + @register_formatter + class TablesSectionFormatter(SectionFormatter): + """SectionFormatter for SpatialData's .tables attribute.""" + + section_name = "tables" + priority = 160 + + @property + def after_section(self) -> str | None: + return None + + @property + def doc_url(self) -> str: + return "https://spatialdata.scverse.org/en/latest/api/SpatialData.html#spatialdata.SpatialData.tables" + + @property + def tooltip(self) -> str: + return "Annotation tables (AnnData)" + + def should_show(self, obj) -> bool: + return hasattr(obj, "tables") and len(obj.tables) > 0 + + def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: + entries = [] + for name, table in obj.tables.items(): + shape_str = ( + f"{format_number(table.n_obs)} × {format_number(table.n_vars)}" + ) + # Generate nested HTML for expandable content + can_expand = context.depth < context.max_depth + nested_html = None + if can_expand: + nested_html = generate_repr_html( + table, + depth=context.depth + 1, + max_depth=context.max_depth, + show_header=True, + show_search=False, + ) + output = FormattedOutput( + type_name=f"AnnData ({shape_str})", + css_class="dtype-anndata", + tooltip=f"Table: {name}", + html_content=nested_html, + is_expandable=can_expand, + is_serializable=True, + ) + entries.append(FormattedEntry(key=name, output=output)) + return entries + +except ImportError: + HAS_SPATIALDATA = False + SpatialData = None # type: ignore[assignment,misc] + + def create_test_mudata(): """Create a comprehensive test MuData with multiple modalities.""" if not HAS_MUDATA: @@ -388,6 +678,81 @@ def create_test_mudata(): return mdata +def create_test_spatialdata(): + """Create a comprehensive test SpatialData with all element types.""" + if not HAS_SPATIALDATA: + return None + + import dask.array as da + import dask.dataframe as dd + import geopandas as gpd + from shapely.geometry import Point + from spatialdata.models import ( + Image2DModel, + Labels2DModel, + PointsModel, + ShapesModel, + TableModel, + ) + + np.random.seed(42) + + # Create image (3-channel RGB, 100x100) + img_data = da.zeros((3, 100, 100), dtype=np.uint8) + image = Image2DModel.parse(img_data, c_coords=["r", "g", "b"]) + + # Create labels (segmentation mask) + labels_data = da.zeros((100, 100), dtype=np.int32) + labels = Labels2DModel.parse(labels_data) + + # Create points (transcript locations) + n_points = 50 + points_df = pd.DataFrame({ + "x": np.random.uniform(0, 100, n_points), + "y": np.random.uniform(0, 100, n_points), + "gene": np.random.choice(["GAPDH", "ACTB", "CD3E", "MS4A1"], n_points), + }) + points = PointsModel.parse(dd.from_pandas(points_df, npartitions=1)) + + # Create shapes (cell boundaries) + n_cells = 20 + circles = gpd.GeoDataFrame({ + "geometry": [ + Point(np.random.uniform(10, 90), np.random.uniform(10, 90)).buffer(3) + for _ in range(n_cells) + ], + "cell_id": list(range(n_cells)), + }) + shapes = ShapesModel.parse(circles) + + # Create table (AnnData with cell annotations) + table = AnnData( + np.random.randn(n_cells, 10), + obs=pd.DataFrame({ + "cell_type": pd.Categorical( + ["T cell", "B cell", "Macrophage", "Epithelial"] * 5 + ), + "region": ["cells"] * n_cells, # Must match region parameter + "cell_id": list(range(n_cells)), + }), + var=pd.DataFrame({"gene_name": [f"marker_{i}" for i in range(10)]}), + ) + table = TableModel.parse( + table, region="cells", region_key="region", instance_key="cell_id" + ) + + # Create SpatialData + sdata = SpatialData( + images={"tissue_image": image}, + labels={"cell_segmentation": labels}, + points={"transcripts": points}, + shapes={"cells": shapes}, + tables={"cell_annotations": table}, + ) + + return sdata + + def create_test_treedata(): """Create a TreeData object with observation and variable trees.""" if not HAS_TREEDATA: @@ -654,7 +1019,7 @@ def strip_script_tags(html: str) -> str: return re.sub(r"", "", html, flags=re.DOTALL) -def main(): # noqa: PLR0915 +def main(): # noqa: PLR0912, PLR0915 """Generate visual test HTML file.""" print("Generating visual test cases...") @@ -1204,6 +1569,31 @@ def format(self, obj, context): else: print(" 19. MuData (skipped - mudata not installed)") + # Test 20: SpatialData (spatial omics data) + # This demonstrates how SpatialData can fully customize the repr using ObjectFormatter: + # 1. Registering an ObjectFormatter to customize header, index preview, and skip X + # 2. Registering SectionFormatters for images, labels, points, shapes, tables + if HAS_SPATIALDATA: + print(" 20. SpatialData (spatial omics data)") + from anndata._repr.html import generate_repr_html + + sdata = create_test_spatialdata() + if sdata is not None: + sections.append(( + "20. SpatialData (Spatial Omics Data)", + generate_repr_html(sdata), + "Demonstrates how packages with completely different structures can reuse " + "anndata's HTML repr by registering an ObjectFormatter. " + "SpatialData has no central X matrix, uses different sections (images, labels, " + "points, shapes, tables), and shows coordinate systems instead of obs/var names. " + "The ObjectFormatter customizes the header (no shape), index preview " + "(coordinate_systems), footer (spatialdata version), and skips X. Each section " + "has its own SectionFormatter with documentation links. " + "The tables section contains expandable AnnData objects.", + )) + else: + print(" 20. SpatialData (skipped - spatialdata not installed)") + # Generate HTML file output_path = Path(__file__).parent / "repr_html_visual_test.html" html_content = create_html_page(sections)