From cb9fee8b391889ccc10646c4f8e00b3d72690ccd Mon Sep 17 00:00:00 2001 From: Dominik Date: Wed, 3 Dec 2025 10:59:29 -0800 Subject: [PATCH 01/21] expose html rep building blocks --- src/anndata/_repr/__init__.py | 28 +++ src/anndata/_repr/constants.py | 5 + src/anndata/_repr/html.py | 328 +++++++++++++++++++++------------ 3 files changed, 248 insertions(+), 113 deletions(-) diff --git a/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py index ea378af8a..ad8b00ff8 100644 --- a/src/anndata/_repr/__init__.py +++ b/src/anndata/_repr/__init__.py @@ -151,6 +151,25 @@ def get_entries(self, obj, context): register_formatter, ) +# Building blocks for packages that want to create their own _repr_html_ +# These allow reusing anndata's styling while building custom representations +from anndata._repr.css import get_css # noqa: E402 +from anndata._repr.javascript import get_javascript # noqa: E402 +from anndata._repr.utils import ( # noqa: E402 + escape_html, + format_memory_size, + format_number, +) + +# HTML rendering helpers for building custom sections +from anndata._repr.html import ( # noqa: E402 + render_formatted_entry, + render_section, +) + +# Inline styles for graceful degradation (from single source of truth) +from anndata._repr.constants import STYLE_HIDDEN # noqa: E402 + __all__ = [ # noqa: RUF022 # organized by category, not alphabetically # Constants "DEFAULT_FOLD_THRESHOLD", @@ -178,4 +197,13 @@ def get_entries(self, obj, context): # Type hint extraction (for tagged data in uns) "extract_uns_type_hint", "UNS_TYPE_HINT_KEY", + # Building blocks for custom _repr_html_ implementations + "get_css", + "get_javascript", + "escape_html", + "format_number", + "format_memory_size", + "render_section", + "render_formatted_entry", + "STYLE_HIDDEN", ] diff --git a/src/anndata/_repr/constants.py b/src/anndata/_repr/constants.py index 659d23f28..99cb64df2 100644 --- a/src/anndata/_repr/constants.py +++ b/src/anndata/_repr/constants.py @@ -20,3 +20,8 @@ # Column widths (pixels) DEFAULT_MAX_FIELD_WIDTH = 400 # Max width for field name column DEFAULT_TYPE_WIDTH = 220 # Width for type column + +# Inline styles for graceful degradation (hidden until JS enables) +STYLE_HIDDEN = "display:none;" +STYLE_SECTION_CONTENT = "padding:0;overflow:hidden;" +STYLE_SECTION_TABLE = "width:100%;border-collapse:collapse;" diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index e13979b78..616db5d6a 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -29,6 +29,11 @@ DOCS_BASE_URL, SECTION_ORDER, ) +from anndata._repr.constants import ( + STYLE_HIDDEN, + STYLE_SECTION_CONTENT, + STYLE_SECTION_TABLE, +) from anndata._repr.css import get_css from anndata._repr.javascript import get_javascript from anndata._repr.registry import ( @@ -72,13 +77,9 @@ # These inline styles are minimal fallbacks that ensure basic readability # if CSS fails to load (e.g., email clients, restrictive embeds). # Colors and theming are intentionally CSS-only to support dark mode. +# STYLE_HIDDEN, STYLE_SECTION_CONTENT, STYLE_SECTION_TABLE are imported +# from constants.py (single source of truth). -# Hidden elements - JS reveals these; must be inline to work without CSS -STYLE_HIDDEN = "display:none;" - -# Section content and tables - basic layout fallback -STYLE_SECTION_CONTENT = "padding:0;overflow:hidden;" -STYLE_SECTION_TABLE = "width:100%;border-collapse:collapse;" STYLE_SECTION_INFO = "padding:6px 12px;" # Category color dot - needs inline for dynamic background color @@ -380,42 +381,82 @@ def _render_custom_section( return "" n_items = len(entries) - should_collapse = n_items > fold_threshold - section_name = formatter.section_name - display_name = getattr(formatter, "display_name", section_name) - doc_url = getattr(formatter, "doc_url", None) - tooltip = getattr(formatter, "tooltip", "") - - parts = [ - f'
' - ] - - # Header - parts.append( - _render_section_header(display_name, f"({n_items} items)", doc_url, tooltip) - ) - - # Content - parts.append(f'
') - parts.append(f'') + # Render entries (with truncation) + rows = [] for i, entry in enumerate(entries): if i >= max_items: - parts.append(_render_truncation_indicator(n_items - max_items)) + rows.append(_render_truncation_indicator(n_items - max_items)) break - parts.append(_render_formatted_entry(entry, section_name)) + rows.append(render_formatted_entry(entry, section_name)) + + # Use render_section for consistent structure + return render_section( + getattr(formatter, "display_name", section_name), + "\n".join(rows), + n_items=n_items, + doc_url=getattr(formatter, "doc_url", None), + tooltip=getattr(formatter, "tooltip", ""), + should_collapse=n_items > fold_threshold, + section_id=section_name, + ) - parts.append("
") - parts.append("
") # anndata-seccontent - parts.append("
") # anndata-sec - return "\n".join(parts) +def render_formatted_entry(entry: FormattedEntry, section: str = "") -> str: + """ + Render a FormattedEntry as a table row. + + This is a public API for packages building their own _repr_html_. + It provides the same flexibility as internal code by accepting + FormattedEntry/FormattedOutput objects. + + Parameters + ---------- + entry + A FormattedEntry containing the key and FormattedOutput + section + Optional section name (for future use) + + Returns + ------- + HTML string for the table row(s) + + Examples + -------- + :: + from anndata._repr import ( + FormattedEntry, + FormattedOutput, + render_formatted_entry, + ) -def _render_formatted_entry(entry: FormattedEntry, section: str) -> str: - """Render a FormattedEntry from a custom section formatter.""" + entry = FormattedEntry( + key="my_array", + output=FormattedOutput( + type_name="ndarray (100, 50) float32", + css_class="dtype-ndarray", + tooltip="My custom array", + warnings=["Some warning"], + ), + ) + html = render_formatted_entry(entry) + + With expandable nested content:: + + nested_html = generate_repr_html(adata, depth=1) + entry = FormattedEntry( + key="cell_table", + output=FormattedOutput( + type_name="AnnData (150 × 30)", + css_class="dtype-anndata", + html_content=nested_html, + is_expandable=True, + ), + ) + html = render_formatted_entry(entry) + """ output = entry.output entry_class = "adata-entry" @@ -440,7 +481,7 @@ def _render_formatted_entry(entry: FormattedEntry, section: str) -> str: f'{escape_html(output.type_name)}' ) if output.warnings or not output.is_serializable: - warnings_list = output.warnings.copy() + warnings_list = list(output.warnings) if not output.is_serializable: warnings_list.insert(0, "Not serializable to H5AD/Zarr") title = escape_html("; ".join(warnings_list)) @@ -458,7 +499,7 @@ def _render_formatted_entry(entry: FormattedEntry, section: str) -> str: parts.append("") - # Meta (empty for custom sections, or could be customized) + # Meta (empty for custom sections) parts.append('') parts.append("") @@ -693,38 +734,24 @@ def _render_dataframe_section( if n_cols == 0: return _render_empty_section(section, doc_url, tooltip) - # Should this section be collapsed? (only via JS, default is expanded) - should_collapse = n_cols > fold_threshold - - # Section - no inline colors to allow dark mode CSS to work - parts = [ - f'
' - ] - - # Header - parts.append( - _render_section_header(section, f"({n_cols} columns)", doc_url, tooltip) - ) - - # Content - always visible by default (JS can hide it) - parts.append(f'
') - parts.append(f'') - - # Render each column + # Render entries (with truncation) + rows = [] for i, col_name in enumerate(df.columns): if i >= max_items: - parts.append(_render_truncation_indicator(n_cols - max_items)) + rows.append(_render_truncation_indicator(n_cols - max_items)) break - col = df[col_name] - parts.append(_render_dataframe_entry(adata, section, col_name, col, context)) - - parts.append("
") - parts.append("
") # anndata-seccontent - parts.append("
") # anndata-sec - - return "\n".join(parts) + rows.append(_render_dataframe_entry(adata, section, col_name, col, context)) + + return render_section( + section, + "\n".join(rows), + n_items=n_cols, + doc_url=doc_url, + tooltip=tooltip, + should_collapse=n_cols > fold_threshold, + count_str=f"({n_cols} columns)", + ) def _render_category_list( @@ -867,36 +894,23 @@ def _render_mapping_section( if n_items == 0: return _render_empty_section(section, doc_url, tooltip) - should_collapse = n_items > fold_threshold - - # Section - no inline colors to allow dark mode CSS to work - parts = [ - f'
' - ] - - # Header - parts.append( - _render_section_header(section, f"({n_items} items)", doc_url, tooltip) - ) - - # Content - always visible by default - parts.append(f'
') - parts.append(f'') - + # Render entries (with truncation) + rows = [] for i, key in enumerate(keys): if i >= max_items: - parts.append(_render_truncation_indicator(n_items - max_items)) + rows.append(_render_truncation_indicator(n_items - max_items)) break - value = mapping[key] - parts.append(_render_mapping_entry(key, value, context, section)) - - parts.append("
") - parts.append("
") - parts.append("
") - - return "\n".join(parts) + rows.append(_render_mapping_entry(key, value, context, section)) + + return render_section( + section, + "\n".join(rows), + n_items=n_items, + doc_url=doc_url, + tooltip=tooltip, + should_collapse=n_items > fold_threshold, + ) def _render_type_cell( @@ -1030,34 +1044,23 @@ def _render_uns_section( if n_items == 0: return _render_empty_section("uns", doc_url, tooltip) - should_collapse = n_items > fold_threshold - - # Section - use data-should-collapse for JS to handle (consistent with other sections) - parts = [ - f'
' - ] - - # Header - parts.append(_render_section_header("uns", f"({n_items} items)", doc_url, tooltip)) - - # Content - parts.append(f'
') - parts.append(f'') - + # Render entries (with truncation) + rows = [] for i, key in enumerate(keys): if i >= max_items: - parts.append(_render_truncation_indicator(n_items - max_items)) + rows.append(_render_truncation_indicator(n_items - max_items)) break - value = uns[key] - parts.append(_render_uns_entry(adata, key, value, context, max_depth)) - - parts.append("
") - parts.append("
") - parts.append("
") - - return "\n".join(parts) + rows.append(_render_uns_entry(adata, key, value, context, max_depth)) + + return render_section( + "uns", + "\n".join(rows), + n_items=n_items, + doc_url=doc_url, + tooltip=tooltip, + should_collapse=n_items > fold_threshold, + ) def _render_uns_entry( @@ -1531,3 +1534,102 @@ def _get_setting(name: str, *, default: Any) -> Any: return getattr(settings, name, default) except (ImportError, AttributeError): return default + + +# ============================================================================= +# Public API for building custom _repr_html_ +# ============================================================================= + + +def render_section( # noqa: PLR0913 + name: str, + entries_html: str, + *, + n_items: int, + doc_url: str | None = None, + tooltip: str = "", + should_collapse: bool = False, + section_id: str | None = None, + count_str: str | None = None, +) -> str: + """ + Render a complete section with header and content. + + This is a public API for packages building their own _repr_html_. + It is also used internally for consistency. + + Parameters + ---------- + name + Display name for the section header (e.g., 'images', 'tables') + entries_html + HTML content for the section body (table rows) + n_items + Number of items (used for empty check and default count string) + doc_url + URL for the help link (? icon) + tooltip + Tooltip text for the help link + should_collapse + Whether this section should start collapsed + section_id + ID for the section in data-section attribute (defaults to name) + count_str + Custom count string for header (defaults to "(N items)") + + Returns + ------- + HTML string for the complete section + + Examples + -------- + :: + + from anndata._repr import ( + FormattedEntry, + FormattedOutput, + render_formatted_entry, + ) + + rows = [] + for key, info in items.items(): + entry = FormattedEntry( + key=key, + output=FormattedOutput( + type_name=info["type"], css_class="dtype-ndarray" + ), + ) + rows.append(render_formatted_entry(entry)) + + html = render_section( + "images", + "\\n".join(rows), + n_items=len(items), + doc_url="https://docs.example.com/images", + tooltip="Image data", + ) + """ + if section_id is None: + section_id = name + + if n_items == 0: + return _render_empty_section(name, doc_url, tooltip) + + if count_str is None: + count_str = f"({n_items} items)" + + parts = [ + f'
' + ] + + # Header + parts.append(_render_section_header(name, count_str, doc_url, tooltip)) + + # Content + parts.append(f'
') + parts.append(f'') + parts.append(entries_html) + parts.append("
") + + return "\n".join(parts) From efc7897871cdf3c2985892406dd2f768984176f5 Mon Sep 17 00:00:00 2001 From: Dominik Date: Wed, 3 Dec 2025 11:36:14 -0800 Subject: [PATCH 02/21] inlcude SpatialData example --- tests/visual_inspect_repr_html.py | 422 ++++++++++++++++++++++++++++++ 1 file changed, 422 insertions(+) diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index dc70008e0..507010ade 100644 --- a/tests/visual_inspect_repr_html.py +++ b/tests/visual_inspect_repr_html.py @@ -317,6 +317,403 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: MuData = None # type: ignore[assignment,misc] +# ============================================================================= +# SpatialData Example: Building custom _repr_html_ using anndata's building blocks +# ============================================================================= +# This demonstrates how packages like SpatialData can create their own _repr_html_ +# while reusing anndata's CSS, JavaScript, and rendering helpers. + +try: + import uuid + + from anndata._repr import ( + STYLE_HIDDEN, + FormattedEntry, + FormattedOutput, + escape_html, + format_memory_size, + format_number, + get_css, + get_javascript, + render_formatted_entry, + render_section, + ) + from anndata._repr.html import generate_repr_html + + HAS_SPATIALDATA_EXAMPLE = True + + # SpatialData documentation base URL + SPATIALDATA_DOCS = "https://spatialdata.scverse.org/en/latest/api/SpatialData.html" + + class MockSpatialData: + """ + Mock SpatialData class demonstrating how to build _repr_html_ using anndata's tools. + + This shows how packages like SpatialData (which have completely different + structures from AnnData) can still reuse anndata's styling infrastructure. + """ + + def __init__( + self, + *, + images: dict | None = None, + labels: dict | None = None, + points: dict | None = None, + shapes: dict | None = None, + tables: dict | None = None, + coordinate_systems: list | None = None, + path: str | None = None, + ): + self.images = images or {} + self.labels = labels or {} + self.points = points or {} + self.shapes = shapes or {} + self.tables = tables or {} # Contains AnnData objects + self._coordinate_systems = coordinate_systems or [] + self._path = path + + @property + def coordinate_systems(self): + return self._coordinate_systems + + def is_backed(self): + return self._path is not None + + @property + def path(self): + return self._path + + def __sizeof__(self): + # Rough estimate for demo + total = 0 + for table in self.tables.values(): + total += table.__sizeof__() + return total + + def _repr_html_(self) -> str: + """ + Generate HTML representation using anndata's building blocks. + + This demonstrates how to: + 1. Reuse anndata's CSS and JavaScript + 2. Build custom header (no shape, custom badges) + 3. Build custom index preview (coordinate systems instead of obs/var names) + 4. Use render_section() and render_entry_row() helpers + 5. Embed nested AnnData with full interactivity + """ + container_id = f"spatialdata-repr-{uuid.uuid4().hex[:8]}" + + parts = [] + + # 1. Include anndata's CSS + parts.append(get_css()) + + # 2. Container with CSS variables + parts.append( + f'
' + ) + + # 3. Custom header (SpatialData has no central shape) + parts.append(self._render_header(container_id)) + + # 4. Custom index preview (coordinate systems instead of obs/var) + parts.append(self._render_coordinate_systems_preview()) + + # 5. Sections container - using render_section() helper + parts.append('
') + parts.append(self._render_images_section()) + parts.append(self._render_labels_section()) + parts.append(self._render_points_section()) + parts.append(self._render_shapes_section()) + parts.append(self._render_tables_section()) + parts.append("
") + + # 6. Custom footer + parts.append(self._render_footer()) + + parts.append("
") + + # 7. Include anndata's JavaScript + parts.append(get_javascript(container_id)) + + return "\n".join(parts) + + def _render_header(self, container_id: str) -> str: + """Render custom header for SpatialData (no shape since there's no central X).""" + parts = ['
'] + parts.append('SpatialData') + + if self.is_backed(): + parts.append( + 'Zarr' + ) + + if self._path: + parts.append( + f'' + f"{escape_html(self._path)}" + ) + + # Search box (hidden until JS enables it) + parts.append('') + search_id = f"{container_id}-search" + parts.append( + f'' + ) + parts.append('') + parts.append("
") + return "\n".join(parts) + + def _render_coordinate_systems_preview(self) -> str: + """Render coordinate systems instead of obs/var names.""" + if not self._coordinate_systems: + return "" + + cs_preview = ", ".join(self._coordinate_systems[:5]) + if len(self._coordinate_systems) > 5: + cs_preview += f", ... ({len(self._coordinate_systems)} total)" + + return ( + f'
' + f"
coordinate_systems: {escape_html(cs_preview)}
" + f"
" + ) + + def _render_images_section(self) -> str: + """Render the images section using FormattedEntry.""" + rows = [] + for name, info in self.images.items(): + shape_str = " × ".join(str(s) for s in info["shape"]) + dims_str = ", ".join(info["dims"]) + entry = FormattedEntry( + key=name, + output=FormattedOutput( + type_name=f"DataArray[{dims_str}] ({shape_str}) {info['dtype']}", + css_class="dtype-ndarray", + tooltip=f"Image: {name}", + ), + ) + rows.append(render_formatted_entry(entry)) + + return render_section( + "images", + "\n".join(rows), + n_items=len(self.images), + doc_url=f"{SPATIALDATA_DOCS}#spatialdata.SpatialData.images", + tooltip="2D/3D image data (xarray.DataArray)", + ) + + def _render_labels_section(self) -> str: + """Render the labels section.""" + rows = [] + for name, info in self.labels.items(): + shape_str = " × ".join(str(s) for s in info["shape"]) + dims_str = ", ".join(info["dims"]) + entry = FormattedEntry( + key=name, + output=FormattedOutput( + type_name=f"DataArray[{dims_str}] ({shape_str}) {info['dtype']}", + css_class="dtype-ndarray", + tooltip=f"Label mask: {name}", + ), + ) + rows.append(render_formatted_entry(entry)) + + return render_section( + "labels", + "\n".join(rows), + n_items=len(self.labels), + doc_url=f"{SPATIALDATA_DOCS}#spatialdata.SpatialData.labels", + tooltip="Segmentation masks (xarray.DataArray)", + ) + + def _render_points_section(self) -> str: + """Render the points section.""" + rows = [] + for name, info in self.points.items(): + entry = FormattedEntry( + key=name, + output=FormattedOutput( + type_name=f"dask.DataFrame ({format_number(info['n_points'])} × {info['n_dims']})", + css_class="dtype-dataframe", + tooltip=f"Points: {name} ({info['n_dims']}D)", + ), + ) + rows.append(render_formatted_entry(entry)) + + return render_section( + "points", + "\n".join(rows), + n_items=len(self.points), + doc_url=f"{SPATIALDATA_DOCS}#spatialdata.SpatialData.points", + tooltip="Point annotations (dask.DataFrame)", + ) + + def _render_shapes_section(self) -> str: + """Render the shapes section.""" + rows = [] + for name, info in self.shapes.items(): + entry = FormattedEntry( + key=name, + output=FormattedOutput( + type_name=f"GeoDataFrame ({format_number(info['n_shapes'])} shapes)", + css_class="dtype-dataframe", + tooltip=f"Shapes: {name} ({info['geometry_type']})", + ), + ) + rows.append(render_formatted_entry(entry)) + + return render_section( + "shapes", + "\n".join(rows), + n_items=len(self.shapes), + doc_url=f"{SPATIALDATA_DOCS}#spatialdata.SpatialData.shapes", + tooltip="Vector shapes (geopandas.GeoDataFrame)", + ) + + def _render_tables_section(self) -> str: + """Render the tables section with expandable nested AnnData. + + This demonstrates using FormattedEntry/FormattedOutput for full flexibility. + """ + rows = [] + for name, adata in self.tables.items(): + # Generate nested HTML using anndata's generate_repr_html + nested_html = generate_repr_html( + adata, depth=1, max_depth=3, show_header=True, show_search=False + ) + + # Use FormattedEntry/FormattedOutput for full customization + entry = FormattedEntry( + key=name, + output=FormattedOutput( + type_name=f"AnnData ({format_number(adata.n_obs)} × {format_number(adata.n_vars)})", + css_class="dtype-anndata", + tooltip=f"Table: {name}", + html_content=nested_html, + is_expandable=True, + ), + ) + rows.append(render_formatted_entry(entry)) + + return render_section( + "tables", + "\n".join(rows), + n_items=len(self.tables), + doc_url=f"{SPATIALDATA_DOCS}#spatialdata.SpatialData.tables", + tooltip="Annotation tables (AnnData)", + ) + + def _render_footer(self) -> str: + """Render custom footer with spatialdata version.""" + parts = ['
'] + parts.append("spatialdata v0.2.0 (mock)") + try: + mem_str = format_memory_size(self.__sizeof__()) + parts.append(f'~{mem_str}') + except Exception: + pass + parts.append("
") + return "\n".join(parts) + + def create_test_spatialdata(): + """Create a mock SpatialData object for testing.""" + np.random.seed(42) + + # Create some AnnData tables + n_cells = 150 + n_genes = 30 + + # Cell annotations table + cell_table = AnnData( + np.random.randn(n_cells, n_genes).astype(np.float32), + obs=pd.DataFrame({ + "cell_type": pd.Categorical( + ["Tumor", "Immune", "Stromal"] * 50 + ), + "area": np.random.uniform(100, 1000, n_cells), + "region": pd.Categorical(["region_A", "region_B"] * 75), + }), + var=pd.DataFrame({ + "gene_name": [f"gene_{i}" for i in range(n_genes)], + "highly_variable": np.random.choice([True, False], n_genes), + }), + ) + cell_table.uns["cell_type_colors"] = ["#e41a1c", "#377eb8", "#4daf4a"] + cell_table.obsm["spatial"] = np.random.randn(n_cells, 2).astype(np.float32) * 1000 + + # Transcript counts table + n_transcripts = 80 + transcript_table = AnnData( + np.random.randn(n_transcripts, 10).astype(np.float32), + obs=pd.DataFrame({ + "gene": pd.Categorical(np.random.choice([f"gene_{i}" for i in range(10)], n_transcripts)), + "quality_score": np.random.uniform(0, 1, n_transcripts), + }), + ) + + return MockSpatialData( + images={ + "raw_image": { + "shape": (3, 2048, 2048), + "dims": ("c", "y", "x"), + "dtype": "uint16", + }, + "processed_image": { + "shape": (3, 1024, 1024), + "dims": ("c", "y", "x"), + "dtype": "float32", + }, + }, + labels={ + "cell_segmentation": { + "shape": (2048, 2048), + "dims": ("y", "x"), + "dtype": "int32", + }, + "nucleus_segmentation": { + "shape": (2048, 2048), + "dims": ("y", "x"), + "dtype": "int32", + }, + }, + points={ + "transcripts": { + "n_points": 50000, + "n_dims": 3, + }, + }, + shapes={ + "cell_boundaries": { + "n_shapes": 150, + "geometry_type": "Polygon", + }, + "nucleus_boundaries": { + "n_shapes": 148, + "geometry_type": "Polygon", + }, + "roi_annotations": { + "n_shapes": 5, + "geometry_type": "Polygon", + }, + }, + tables={ + "cell_annotations": cell_table, + "transcript_counts": transcript_table, + }, + coordinate_systems=["global", "aligned", "microscope"], + path="/data/experiment_001.zarr", + ) + +except Exception: + HAS_SPATIALDATA_EXAMPLE = False + + def create_test_mudata(): """Create a comprehensive test MuData with multiple modalities.""" if not HAS_MUDATA: @@ -1204,6 +1601,31 @@ def format(self, obj, context): else: print(" 19. MuData (skipped - mudata not installed)") + # Test 20: SpatialData (custom _repr_html_ using anndata's building blocks) + # This demonstrates how packages with completely different structures can build + # their own _repr_html_ while reusing anndata's CSS, JavaScript, and formatters. + if HAS_SPATIALDATA_EXAMPLE: + print(" 20. SpatialData (custom _repr_html_ using anndata's building blocks)") + sdata = create_test_spatialdata() + sections.append(( + "20. SpatialData (Custom _repr_html_)", + sdata._repr_html_(), + "Demonstrates how packages like SpatialData " + "can build their own _repr_html_ while reusing anndata's CSS, JavaScript, and utilities. " + "Unlike MuData (which mirrors AnnData's structure), SpatialData has: " + "" + "The tables section contains nested AnnData objects that are fully expandable " + "with all standard features (fold/expand, search, copy buttons). " + "This approach requires more code than using SectionFormatter, but gives complete control.", + )) + else: + print(" 20. SpatialData (skipped - example failed to load)") + # Generate HTML file output_path = Path(__file__).parent / "repr_html_visual_test.html" html_content = create_html_page(sections) From ff563974cdfc82ba5ad4fd3a90374a77181ece46 Mon Sep 17 00:00:00 2001 From: Dominik Date: Wed, 3 Dec 2025 11:57:08 -0800 Subject: [PATCH 03/21] illustrate customization in SpatialData example --- tests/visual_inspect_repr_html.py | 81 +++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index 507010ade..85caafdef 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, ) @@ -330,6 +330,9 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: STYLE_HIDDEN, FormattedEntry, FormattedOutput, + FormatterContext, + FormatterRegistry, + TypeFormatter, escape_html, format_memory_size, format_number, @@ -345,6 +348,42 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: # SpatialData documentation base URL SPATIALDATA_DOCS = "https://spatialdata.scverse.org/en/latest/api/SpatialData.html" + # ========================================================================= + # OPTIONAL: Extensibility via FormatterRegistry + # ========================================================================= + # SpatialData can create its own registry to allow third-party packages + # to register custom formatters for SpatialData's element types. + # This is the same pattern anndata uses for TypeFormatter/SectionFormatter. + + # Create SpatialData's own formatter registry + spatialdata_formatter_registry = FormatterRegistry() + + # Example: A third-party package could register a custom formatter + # for xarray DataTree objects used in SpatialData's images section + class DataTreeFormatter(TypeFormatter): + """Example formatter for xarray DataTree (multiscale images).""" + + priority = 100 # Higher priority than fallback + + def can_format(self, obj) -> bool: + # In real code: return isinstance(obj, xarray.DataTree) + # Here we check for our mock dict structure + return isinstance(obj, dict) and "shape" in obj and "dtype" in obj + + def format(self, obj, context: FormatterContext) -> FormattedOutput: + shape_str = " × ".join(str(s) for s in obj["shape"]) + dims_str = ", ".join(obj.get("dims", ["y", "x"])) + return FormattedOutput( + type_name=f"DataArray[{dims_str}] ({shape_str}) {obj['dtype']}", + css_class="dtype-ndarray", + tooltip=f"Image data with shape {shape_str}", + ) + + # Register the formatter (third-party packages would do this on import) + spatialdata_formatter_registry.register_type_formatter(DataTreeFormatter()) + + # ========================================================================= + class MockSpatialData: """ Mock SpatialData class demonstrating how to build _repr_html_ using anndata's tools. @@ -485,19 +524,19 @@ def _render_coordinate_systems_preview(self) -> str: ) def _render_images_section(self) -> str: - """Render the images section using FormattedEntry.""" + """Render the images section using the formatter registry. + + This demonstrates using FormatterRegistry for extensibility. + Third-party packages can register custom formatters for image types. + """ + # Create context for this section (used by formatters) + context = FormatterContext(section="images") + rows = [] for name, info in self.images.items(): - shape_str = " × ".join(str(s) for s in info["shape"]) - dims_str = ", ".join(info["dims"]) - entry = FormattedEntry( - key=name, - output=FormattedOutput( - type_name=f"DataArray[{dims_str}] ({shape_str}) {info['dtype']}", - css_class="dtype-ndarray", - tooltip=f"Image: {name}", - ), - ) + # Use the registry to format the value - allows third-party customization + output = spatialdata_formatter_registry.format_value(info, context) + entry = FormattedEntry(key=name, output=output) rows.append(render_formatted_entry(entry)) return render_section( @@ -616,7 +655,7 @@ def _render_footer(self) -> str: try: mem_str = format_memory_size(self.__sizeof__()) parts.append(f'~{mem_str}') - except Exception: + except (TypeError, ValueError, AttributeError): pass parts.append("") return "\n".join(parts) @@ -633,9 +672,7 @@ def create_test_spatialdata(): cell_table = AnnData( np.random.randn(n_cells, n_genes).astype(np.float32), obs=pd.DataFrame({ - "cell_type": pd.Categorical( - ["Tumor", "Immune", "Stromal"] * 50 - ), + "cell_type": pd.Categorical(["Tumor", "Immune", "Stromal"] * 50), "area": np.random.uniform(100, 1000, n_cells), "region": pd.Categorical(["region_A", "region_B"] * 75), }), @@ -645,14 +682,18 @@ def create_test_spatialdata(): }), ) cell_table.uns["cell_type_colors"] = ["#e41a1c", "#377eb8", "#4daf4a"] - cell_table.obsm["spatial"] = np.random.randn(n_cells, 2).astype(np.float32) * 1000 + cell_table.obsm["spatial"] = ( + np.random.randn(n_cells, 2).astype(np.float32) * 1000 + ) # Transcript counts table n_transcripts = 80 transcript_table = AnnData( np.random.randn(n_transcripts, 10).astype(np.float32), obs=pd.DataFrame({ - "gene": pd.Categorical(np.random.choice([f"gene_{i}" for i in range(10)], n_transcripts)), + "gene": pd.Categorical( + np.random.choice([f"gene_{i}" for i in range(10)], n_transcripts) + ), "quality_score": np.random.uniform(0, 1, n_transcripts), }), ) @@ -710,7 +751,7 @@ def create_test_spatialdata(): path="/data/experiment_001.zarr", ) -except Exception: +except ImportError: HAS_SPATIALDATA_EXAMPLE = False @@ -1051,7 +1092,7 @@ def strip_script_tags(html: str) -> str: return re.sub(r"", "", html, flags=re.DOTALL) -def main(): # noqa: PLR0915 +def main(): # noqa: PLR0915, PLR0912 """Generate visual test HTML file.""" print("Generating visual test cases...") From a5e44d5fc247f2edb9465cd4786cd45ec6684b2f Mon Sep 17 00:00:00 2001 From: Dominik Date: Wed, 3 Dec 2025 12:03:24 -0800 Subject: [PATCH 04/21] custom sections for SpatialData --- tests/visual_inspect_repr_html.py | 116 ++++++++++++++++++++++++++++-- 1 file changed, 110 insertions(+), 6 deletions(-) diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index 85caafdef..c0bce053d 100644 --- a/tests/visual_inspect_repr_html.py +++ b/tests/visual_inspect_repr_html.py @@ -332,6 +332,7 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: FormattedOutput, FormatterContext, FormatterRegistry, + SectionFormatter, TypeFormatter, escape_html, format_memory_size, @@ -352,14 +353,18 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: # OPTIONAL: Extensibility via FormatterRegistry # ========================================================================= # SpatialData can create its own registry to allow third-party packages - # to register custom formatters for SpatialData's element types. - # This is the same pattern anndata uses for TypeFormatter/SectionFormatter. + # to register custom formatters for SpatialData's element types AND + # add entirely new sections. This is the same pattern anndata uses. # Create SpatialData's own formatter registry spatialdata_formatter_registry = FormatterRegistry() - # Example: A third-party package could register a custom formatter - # for xarray DataTree objects used in SpatialData's images section + # ------------------------------------------------------------------------- + # Example 1: TypeFormatter for custom value rendering + # ------------------------------------------------------------------------- + # A third-party package could register a custom formatter for xarray + # DataTree objects used in SpatialData's images section + class DataTreeFormatter(TypeFormatter): """Example formatter for xarray DataTree (multiscale images).""" @@ -379,9 +384,62 @@ def format(self, obj, context: FormatterContext) -> FormattedOutput: tooltip=f"Image data with shape {shape_str}", ) - # Register the formatter (third-party packages would do this on import) spatialdata_formatter_registry.register_type_formatter(DataTreeFormatter()) + # ------------------------------------------------------------------------- + # Example 2: SectionFormatter for adding new sections + # ------------------------------------------------------------------------- + # A third-party package (e.g., a spatial analysis toolkit) could add + # entirely new sections to SpatialData's repr + + class SpatialDataSectionFormatter(SectionFormatter): + """Base class for SpatialData section formatters.""" + + @property + def section_name(self) -> str: + raise NotImplementedError + + class TransformsSectionFormatter(SpatialDataSectionFormatter): + """Example: Add a 'transforms' section showing coordinate transforms.""" + + section_name = "transforms" + + @property + def doc_url(self) -> str: + return f"{SPATIALDATA_DOCS}#spatialdata.SpatialData.coordinate_systems" + + @property + def tooltip(self) -> str: + return "Coordinate transformations between spaces" + + def should_show(self, obj) -> bool: + # Show if object has coordinate systems (transforms between them) + return ( + hasattr(obj, "coordinate_systems") and len(obj.coordinate_systems) > 1 + ) + + def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: + # Mock: show transforms between coordinate systems + entries = [] + coord_systems = list(obj.coordinate_systems) + for i, cs in enumerate(coord_systems[:-1]): + next_cs = coord_systems[i + 1] + entries.append( + FormattedEntry( + key=f"{cs} → {next_cs}", + output=FormattedOutput( + type_name="Affine (3×3)", + css_class="dtype-ndarray", + tooltip=f"Transform from {cs} to {next_cs}", + ), + ) + ) + return entries + + spatialdata_formatter_registry.register_section_formatter( + TransformsSectionFormatter() + ) + # ========================================================================= class MockSpatialData: @@ -437,8 +495,9 @@ def _repr_html_(self) -> str: 1. Reuse anndata's CSS and JavaScript 2. Build custom header (no shape, custom badges) 3. Build custom index preview (coordinate systems instead of obs/var names) - 4. Use render_section() and render_entry_row() helpers + 4. Use render_section() and render_formatted_entry() helpers 5. Embed nested AnnData with full interactivity + 6. Support custom sections via FormatterRegistry (optional extensibility) """ container_id = f"spatialdata-repr-{uuid.uuid4().hex[:8]}" @@ -466,6 +525,8 @@ def _repr_html_(self) -> str: parts.append(self._render_points_section()) parts.append(self._render_shapes_section()) parts.append(self._render_tables_section()) + # 5b. Render custom sections from registry (extensibility) + parts.append(self._render_custom_sections()) parts.append("") # 6. Custom footer @@ -648,6 +709,49 @@ def _render_tables_section(self) -> str: tooltip="Annotation tables (AnnData)", ) + def _render_custom_sections(self) -> str: + """Render custom sections registered via FormatterRegistry. + + This demonstrates how third-party packages can add new sections + to SpatialData's repr by registering SectionFormatters. + """ + parts = [] + context = FormatterContext() + + # Get all registered section formatters + for ( + section_name + ) in spatialdata_formatter_registry.get_registered_sections(): + formatter = spatialdata_formatter_registry.get_section_formatter( + section_name + ) + if formatter is None: + continue + + # Check if this section should be shown + if not formatter.should_show(self): + continue + + # Get entries from the formatter + entries = formatter.get_entries(self, context) + if not entries: + continue + + # Render each entry + rows = [render_formatted_entry(entry) for entry in entries] + + # Use render_section for consistent structure + section_html = render_section( + formatter.section_name, + "\n".join(rows), + n_items=len(entries), + doc_url=getattr(formatter, "doc_url", None), + tooltip=getattr(formatter, "tooltip", ""), + ) + parts.append(section_html) + + return "\n".join(parts) + def _render_footer(self) -> str: """Render custom footer with spatialdata version.""" parts = ['
'] From 047b06055b9845e35f6fda5e948d35da9c3da263 Mon Sep 17 00:00:00 2001 From: Dominik Date: Sat, 6 Dec 2025 15:10:51 -0800 Subject: [PATCH 05/21] customizable meta column --- src/anndata/_repr/html.py | 7 +++++-- src/anndata/_repr/registry.py | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index 616db5d6a..2d228097a 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -499,8 +499,11 @@ def render_formatted_entry(entry: FormattedEntry, section: str = "") -> str: parts.append("") - # Meta (empty for custom sections) - parts.append('') + # Meta column (for data previews, dimensions, etc.) + parts.append('') + if output.meta_content: + parts.append(output.meta_content) + parts.append("") parts.append("") diff --git a/src/anndata/_repr/registry.py b/src/anndata/_repr/registry.py index 88403895b..6dcb8077d 100644 --- a/src/anndata/_repr/registry.py +++ b/src/anndata/_repr/registry.py @@ -75,7 +75,10 @@ class FormattedOutput: """Child entries for expandable types""" html_content: str | None = None - """Custom HTML content (for special visualizations like colors)""" + """Custom HTML content (for expandable nested content or type column)""" + + meta_content: str | None = None + """HTML content for the meta column (data previews, dimensions, etc.)""" is_expandable: bool = False """Whether this entry can be expanded""" From d0d009ebd71d7f53088172482d44d9a4cf9b5259 Mon Sep 17 00:00:00 2001 From: Dominik Date: Sat, 6 Dec 2025 15:12:14 -0800 Subject: [PATCH 06/21] simplify/explain SpatialData custom html rep --- tests/visual_inspect_repr_html.py | 495 +++++++++++------------------- 1 file changed, 180 insertions(+), 315 deletions(-) diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index c0bce053d..25582e642 100644 --- a/tests/visual_inspect_repr_html.py +++ b/tests/visual_inspect_repr_html.py @@ -322,6 +322,15 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: # ============================================================================= # This demonstrates how packages like SpatialData can create their own _repr_html_ # while reusing anndata's CSS, JavaScript, and rendering helpers. +# +# KEY BUILDING BLOCKS USED: +# - get_css() : Reuse anndata's CSS (dark mode, styling) +# - get_javascript(id) : Reuse anndata's JS (fold, search, copy) +# - render_section() : Render a collapsible section +# - render_formatted_entry() : Render a table row +# - FormattedEntry/Output : Data classes for entry configuration +# - generate_repr_html() : Embed nested AnnData objects +# - FormatterRegistry : (Optional) Allow third-party extensions try: import uuid @@ -346,108 +355,16 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: HAS_SPATIALDATA_EXAMPLE = True - # SpatialData documentation base URL - SPATIALDATA_DOCS = "https://spatialdata.scverse.org/en/latest/api/SpatialData.html" - - # ========================================================================= - # OPTIONAL: Extensibility via FormatterRegistry # ========================================================================= - # SpatialData can create its own registry to allow third-party packages - # to register custom formatters for SpatialData's element types AND - # add entirely new sections. This is the same pattern anndata uses. - - # Create SpatialData's own formatter registry - spatialdata_formatter_registry = FormatterRegistry() - - # ------------------------------------------------------------------------- - # Example 1: TypeFormatter for custom value rendering - # ------------------------------------------------------------------------- - # A third-party package could register a custom formatter for xarray - # DataTree objects used in SpatialData's images section - - class DataTreeFormatter(TypeFormatter): - """Example formatter for xarray DataTree (multiscale images).""" - - priority = 100 # Higher priority than fallback - - def can_format(self, obj) -> bool: - # In real code: return isinstance(obj, xarray.DataTree) - # Here we check for our mock dict structure - return isinstance(obj, dict) and "shape" in obj and "dtype" in obj - - def format(self, obj, context: FormatterContext) -> FormattedOutput: - shape_str = " × ".join(str(s) for s in obj["shape"]) - dims_str = ", ".join(obj.get("dims", ["y", "x"])) - return FormattedOutput( - type_name=f"DataArray[{dims_str}] ({shape_str}) {obj['dtype']}", - css_class="dtype-ndarray", - tooltip=f"Image data with shape {shape_str}", - ) - - spatialdata_formatter_registry.register_type_formatter(DataTreeFormatter()) - - # ------------------------------------------------------------------------- - # Example 2: SectionFormatter for adding new sections - # ------------------------------------------------------------------------- - # A third-party package (e.g., a spatial analysis toolkit) could add - # entirely new sections to SpatialData's repr - - class SpatialDataSectionFormatter(SectionFormatter): - """Base class for SpatialData section formatters.""" - - @property - def section_name(self) -> str: - raise NotImplementedError - - class TransformsSectionFormatter(SpatialDataSectionFormatter): - """Example: Add a 'transforms' section showing coordinate transforms.""" - - section_name = "transforms" - - @property - def doc_url(self) -> str: - return f"{SPATIALDATA_DOCS}#spatialdata.SpatialData.coordinate_systems" - - @property - def tooltip(self) -> str: - return "Coordinate transformations between spaces" - - def should_show(self, obj) -> bool: - # Show if object has coordinate systems (transforms between them) - return ( - hasattr(obj, "coordinate_systems") and len(obj.coordinate_systems) > 1 - ) - - def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: - # Mock: show transforms between coordinate systems - entries = [] - coord_systems = list(obj.coordinate_systems) - for i, cs in enumerate(coord_systems[:-1]): - next_cs = coord_systems[i + 1] - entries.append( - FormattedEntry( - key=f"{cs} → {next_cs}", - output=FormattedOutput( - type_name="Affine (3×3)", - css_class="dtype-ndarray", - tooltip=f"Transform from {cs} to {next_cs}", - ), - ) - ) - return entries - - spatialdata_formatter_registry.register_section_formatter( - TransformsSectionFormatter() - ) - + # MockSpatialData: Minimal example of custom _repr_html_ # ========================================================================= class MockSpatialData: """ - Mock SpatialData class demonstrating how to build _repr_html_ using anndata's tools. + Mock SpatialData demonstrating custom _repr_html_ with anndata's building blocks. - This shows how packages like SpatialData (which have completely different - structures from AnnData) can still reuse anndata's styling infrastructure. + This is a simplified example showing the essential pattern. A real + implementation would have more complex data structures. """ def __init__( @@ -457,7 +374,7 @@ def __init__( labels: dict | None = None, points: dict | None = None, shapes: dict | None = None, - tables: dict | None = None, + tables: dict | None = None, # Contains AnnData objects coordinate_systems: list | None = None, path: str | None = None, ): @@ -465,161 +382,126 @@ def __init__( self.labels = labels or {} self.points = points or {} self.shapes = shapes or {} - self.tables = tables or {} # Contains AnnData objects - self._coordinate_systems = coordinate_systems or [] - self._path = path - - @property - def coordinate_systems(self): - return self._coordinate_systems - - def is_backed(self): - return self._path is not None - - @property - def path(self): - return self._path - - def __sizeof__(self): - # Rough estimate for demo - total = 0 - for table in self.tables.values(): - total += table.__sizeof__() - return total + self.tables = tables or {} + self.coordinate_systems = coordinate_systems or [] + self.path = path def _repr_html_(self) -> str: """ - Generate HTML representation using anndata's building blocks. - - This demonstrates how to: - 1. Reuse anndata's CSS and JavaScript - 2. Build custom header (no shape, custom badges) - 3. Build custom index preview (coordinate systems instead of obs/var names) - 4. Use render_section() and render_formatted_entry() helpers - 5. Embed nested AnnData with full interactivity - 6. Support custom sections via FormatterRegistry (optional extensibility) + Build HTML using anndata's building blocks. + + Pattern: + 1. get_css() - include styling + 2. Container div with unique ID + 3. Custom header (optional) + 4. Sections using render_section() + render_formatted_entry() + 5. Custom sections via FormatterRegistry (optional) + 6. get_javascript(id) - include interactivity """ - container_id = f"spatialdata-repr-{uuid.uuid4().hex[:8]}" - + container_id = f"spatialdata-{uuid.uuid4().hex[:8]}" parts = [] - # 1. Include anndata's CSS + # --- STEP 1: Include anndata's CSS --- parts.append(get_css()) - # 2. Container with CSS variables + # --- STEP 2: Container with anndata-repr class --- parts.append( f'
' + f'style="--anndata-name-col-width: 150px; --anndata-type-col-width: 300px;">' ) - # 3. Custom header (SpatialData has no central shape) - parts.append(self._render_header(container_id)) + # --- STEP 3: Custom header (SpatialData has no shape) --- + parts.append(self._build_header(container_id)) - # 4. Custom index preview (coordinate systems instead of obs/var) - parts.append(self._render_coordinate_systems_preview()) - - # 5. Sections container - using render_section() helper + # --- STEP 4: Sections using render_section() --- parts.append('
') - parts.append(self._render_images_section()) - parts.append(self._render_labels_section()) - parts.append(self._render_points_section()) - parts.append(self._render_shapes_section()) - parts.append(self._render_tables_section()) - # 5b. Render custom sections from registry (extensibility) - parts.append(self._render_custom_sections()) + parts.append(self._build_images_section()) + parts.append(self._build_labels_section()) + parts.append(self._build_points_section()) + parts.append(self._build_shapes_section()) + parts.append(self._build_tables_section()) # Nested AnnData + # --- STEP 5: Custom sections from FormatterRegistry --- + parts.append(self._build_custom_sections()) parts.append("
") - # 6. Custom footer - parts.append(self._render_footer()) - parts.append("
") - # 7. Include anndata's JavaScript + # --- STEP 6: Include anndata's JavaScript --- parts.append(get_javascript(container_id)) return "\n".join(parts) - def _render_header(self, container_id: str) -> str: - """Render custom header for SpatialData (no shape since there's no central X).""" + def _build_header(self, container_id: str) -> str: + """Custom header - shows 'SpatialData' with Zarr badge and file path.""" parts = ['
'] parts.append('SpatialData') - if self.is_backed(): + # Zarr badge (like AnnData's backed badge) + if self.path: parts.append( 'Zarr' ) - - if self._path: parts.append( f'' - f"{escape_html(self._path)}" + f'{escape_html(self.path)}' ) - # Search box (hidden until JS enables it) parts.append('') - search_id = f"{container_id}-search" parts.append( - f'' + f'' ) parts.append('') parts.append("
") return "\n".join(parts) - def _render_coordinate_systems_preview(self) -> str: - """Render coordinate systems instead of obs/var names.""" - if not self._coordinate_systems: - return "" - - cs_preview = ", ".join(self._coordinate_systems[:5]) - if len(self._coordinate_systems) > 5: - cs_preview += f", ... ({len(self._coordinate_systems)} total)" - - return ( - f'
' - f"
coordinate_systems: {escape_html(cs_preview)}
" - f"
" - ) - - def _render_images_section(self) -> str: - """Render the images section using the formatter registry. - - This demonstrates using FormatterRegistry for extensibility. - Third-party packages can register custom formatters for image types. + def _build_images_section(self) -> str: """ - # Create context for this section (used by formatters) - context = FormatterContext(section="images") + Build images section using render_section() + render_formatted_entry(). + This is the core pattern: create FormattedEntry objects and render them. + """ rows = [] for name, info in self.images.items(): - # Use the registry to format the value - allows third-party customization - output = spatialdata_formatter_registry.format_value(info, context) - entry = FormattedEntry(key=name, output=output) + # Build meta content (dimensions info) for the META column + dims_str = ", ".join(info.get("dims", ["y", "x"])) + meta = f'[{dims_str}]' + + # Create a FormattedEntry with FormattedOutput + entry = FormattedEntry( + key=name, + output=FormattedOutput( + type_name=f"DataArray {info['shape']} {info['dtype']}", + css_class="dtype-ndarray", + meta_content=meta, # Content in meta column (rightmost) + ), + ) + # render_formatted_entry() creates the table row HTML rows.append(render_formatted_entry(entry)) + # render_section() wraps rows in a collapsible section return render_section( "images", "\n".join(rows), n_items=len(self.images), - doc_url=f"{SPATIALDATA_DOCS}#spatialdata.SpatialData.images", - tooltip="2D/3D image data (xarray.DataArray)", + tooltip="Image data (xarray.DataArray)", ) - def _render_labels_section(self) -> str: - """Render the labels section.""" + def _build_labels_section(self) -> str: + """Build labels section - same pattern as images.""" rows = [] for name, info in self.labels.items(): - shape_str = " × ".join(str(s) for s in info["shape"]) - dims_str = ", ".join(info["dims"]) + dims_str = ", ".join(info.get("dims", ["y", "x"])) + meta = f'[{dims_str}]' + entry = FormattedEntry( key=name, output=FormattedOutput( - type_name=f"DataArray[{dims_str}] ({shape_str}) {info['dtype']}", + type_name=f"Labels {info['shape']} {info['dtype']}", css_class="dtype-ndarray", - tooltip=f"Label mask: {name}", + meta_content=meta, ), ) rows.append(render_formatted_entry(entry)) @@ -628,20 +510,21 @@ def _render_labels_section(self) -> str: "labels", "\n".join(rows), n_items=len(self.labels), - doc_url=f"{SPATIALDATA_DOCS}#spatialdata.SpatialData.labels", tooltip="Segmentation masks (xarray.DataArray)", ) - def _render_points_section(self) -> str: - """Render the points section.""" + def _build_points_section(self) -> str: + """Build points section.""" rows = [] for name, info in self.points.items(): + meta = f'{info["n_dims"]}D coordinates' + entry = FormattedEntry( key=name, output=FormattedOutput( type_name=f"dask.DataFrame ({format_number(info['n_points'])} × {info['n_dims']})", css_class="dtype-dataframe", - tooltip=f"Points: {name} ({info['n_dims']}D)", + meta_content=meta, ), ) rows.append(render_formatted_entry(entry)) @@ -650,20 +533,21 @@ def _render_points_section(self) -> str: "points", "\n".join(rows), n_items=len(self.points), - doc_url=f"{SPATIALDATA_DOCS}#spatialdata.SpatialData.points", tooltip="Point annotations (dask.DataFrame)", ) - def _render_shapes_section(self) -> str: - """Render the shapes section.""" + def _build_shapes_section(self) -> str: + """Build shapes section.""" rows = [] for name, info in self.shapes.items(): + meta = f'{info["geometry_type"]}' + entry = FormattedEntry( key=name, output=FormattedOutput( type_name=f"GeoDataFrame ({format_number(info['n_shapes'])} shapes)", css_class="dtype-dataframe", - tooltip=f"Shapes: {name} ({info['geometry_type']})", + meta_content=meta, ), ) rows.append(render_formatted_entry(entry)) @@ -672,31 +556,35 @@ def _render_shapes_section(self) -> str: "shapes", "\n".join(rows), n_items=len(self.shapes), - doc_url=f"{SPATIALDATA_DOCS}#spatialdata.SpatialData.shapes", tooltip="Vector shapes (geopandas.GeoDataFrame)", ) - def _render_tables_section(self) -> str: - """Render the tables section with expandable nested AnnData. + def _build_tables_section(self) -> str: + """ + Build tables section with NESTED AnnData objects. - This demonstrates using FormattedEntry/FormattedOutput for full flexibility. + Uses generate_repr_html() to embed full AnnData representations + that are expandable with all standard features. """ rows = [] for name, adata in self.tables.items(): - # Generate nested HTML using anndata's generate_repr_html + # generate_repr_html() creates nested AnnData HTML nested_html = generate_repr_html( - adata, depth=1, max_depth=3, show_header=True, show_search=False + adata, + depth=1, # Nested level + max_depth=3, + show_header=True, + show_search=False, ) - # Use FormattedEntry/FormattedOutput for full customization + # FormattedOutput with is_expandable=True makes it collapsible entry = FormattedEntry( key=name, output=FormattedOutput( - type_name=f"AnnData ({format_number(adata.n_obs)} × {format_number(adata.n_vars)})", + type_name=f"AnnData ({adata.n_obs} × {adata.n_vars})", css_class="dtype-anndata", - tooltip=f"Table: {name}", html_content=nested_html, - is_expandable=True, + is_expandable=True, # Makes the nested content collapsible ), ) rows.append(render_formatted_entry(entry)) @@ -705,147 +593,126 @@ def _render_tables_section(self) -> str: "tables", "\n".join(rows), n_items=len(self.tables), - doc_url=f"{SPATIALDATA_DOCS}#spatialdata.SpatialData.tables", tooltip="Annotation tables (AnnData)", ) - def _render_custom_sections(self) -> str: - """Render custom sections registered via FormatterRegistry. + def _build_custom_sections(self) -> str: + """ + Render custom sections from FormatterRegistry. This demonstrates how third-party packages can add new sections - to SpatialData's repr by registering SectionFormatters. + by registering SectionFormatters with spatialdata_formatter_registry. """ parts = [] context = FormatterContext() - # Get all registered section formatters - for ( - section_name - ) in spatialdata_formatter_registry.get_registered_sections(): - formatter = spatialdata_formatter_registry.get_section_formatter( - section_name - ) - if formatter is None: + for section_name in spatialdata_formatter_registry.get_registered_sections(): + formatter = spatialdata_formatter_registry.get_section_formatter(section_name) + if formatter is None or not formatter.should_show(self): continue - # Check if this section should be shown - if not formatter.should_show(self): - continue - - # Get entries from the formatter entries = formatter.get_entries(self, context) if not entries: continue - # Render each entry rows = [render_formatted_entry(entry) for entry in entries] - - # Use render_section for consistent structure section_html = render_section( formatter.section_name, "\n".join(rows), n_items=len(entries), - doc_url=getattr(formatter, "doc_url", None), tooltip=getattr(formatter, "tooltip", ""), ) parts.append(section_html) return "\n".join(parts) - def _render_footer(self) -> str: - """Render custom footer with spatialdata version.""" - parts = ['
'] - parts.append("spatialdata v0.2.0 (mock)") - try: - mem_str = format_memory_size(self.__sizeof__()) - parts.append(f'~{mem_str}') - except (TypeError, ValueError, AttributeError): - pass - parts.append("
") - return "\n".join(parts) + # ========================================================================= + # OPTIONAL: FormatterRegistry for third-party extensibility + # ========================================================================= + # SpatialData can create its own registry to allow plugins to add + # custom type formatters or new sections. This mirrors anndata's pattern. - def create_test_spatialdata(): - """Create a mock SpatialData object for testing.""" - np.random.seed(42) + # Create SpatialData's own formatter registry + spatialdata_formatter_registry = FormatterRegistry() - # Create some AnnData tables - n_cells = 150 - n_genes = 30 + # Example: TypeFormatter for custom value rendering + class DataTreeFormatter(TypeFormatter): + """Example: format xarray DataTree objects.""" - # Cell annotations table + priority = 100 + + def can_format(self, obj) -> bool: + return isinstance(obj, dict) and "shape" in obj and "dtype" in obj + + def format(self, obj, context: FormatterContext) -> FormattedOutput: + return FormattedOutput( + type_name=f"DataTree {obj['shape']} {obj['dtype']}", + css_class="dtype-ndarray", + ) + + spatialdata_formatter_registry.register_type_formatter(DataTreeFormatter()) + + # Example: SectionFormatter to add new sections + class TransformsSectionFormatter(SectionFormatter): + """Example: add a 'transforms' section.""" + + section_name = "transforms" + + def should_show(self, obj) -> bool: + return hasattr(obj, "coordinate_systems") and len(obj.coordinate_systems) > 1 + + def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: + entries = [] + cs = list(obj.coordinate_systems) + for i in range(len(cs) - 1): + entries.append( + FormattedEntry( + key=f"{cs[i]} → {cs[i+1]}", + output=FormattedOutput(type_name="Affine (3×3)"), + ) + ) + return entries + + spatialdata_formatter_registry.register_section_formatter(TransformsSectionFormatter()) + + # ========================================================================= + # Test data factory + # ========================================================================= + + def create_test_spatialdata(): + """Create a mock SpatialData object for testing.""" + # Create nested AnnData tables cell_table = AnnData( - np.random.randn(n_cells, n_genes).astype(np.float32), + np.random.randn(150, 30).astype(np.float32), obs=pd.DataFrame({ "cell_type": pd.Categorical(["Tumor", "Immune", "Stromal"] * 50), - "area": np.random.uniform(100, 1000, n_cells), - "region": pd.Categorical(["region_A", "region_B"] * 75), + "area": np.random.uniform(100, 1000, 150), }), - var=pd.DataFrame({ - "gene_name": [f"gene_{i}" for i in range(n_genes)], - "highly_variable": np.random.choice([True, False], n_genes), - }), - ) - cell_table.uns["cell_type_colors"] = ["#e41a1c", "#377eb8", "#4daf4a"] - cell_table.obsm["spatial"] = ( - np.random.randn(n_cells, 2).astype(np.float32) * 1000 ) + cell_table.obsm["spatial"] = np.random.randn(150, 2).astype(np.float32) - # Transcript counts table - n_transcripts = 80 transcript_table = AnnData( - np.random.randn(n_transcripts, 10).astype(np.float32), + np.random.randn(80, 10).astype(np.float32), obs=pd.DataFrame({ - "gene": pd.Categorical( - np.random.choice([f"gene_{i}" for i in range(10)], n_transcripts) - ), - "quality_score": np.random.uniform(0, 1, n_transcripts), + "gene": pd.Categorical(np.random.choice([f"gene_{i}" for i in range(10)], 80)), }), ) return MockSpatialData( images={ - "raw_image": { - "shape": (3, 2048, 2048), - "dims": ("c", "y", "x"), - "dtype": "uint16", - }, - "processed_image": { - "shape": (3, 1024, 1024), - "dims": ("c", "y", "x"), - "dtype": "float32", - }, + "raw_image": {"shape": (3, 2048, 2048), "dims": ("c", "y", "x"), "dtype": "uint16"}, + "processed": {"shape": (3, 1024, 1024), "dims": ("c", "y", "x"), "dtype": "float32"}, }, labels={ - "cell_segmentation": { - "shape": (2048, 2048), - "dims": ("y", "x"), - "dtype": "int32", - }, - "nucleus_segmentation": { - "shape": (2048, 2048), - "dims": ("y", "x"), - "dtype": "int32", - }, + "cell_segmentation": {"shape": (2048, 2048), "dims": ("y", "x"), "dtype": "int32"}, + "nucleus_segmentation": {"shape": (2048, 2048), "dims": ("y", "x"), "dtype": "int32"}, }, points={ - "transcripts": { - "n_points": 50000, - "n_dims": 3, - }, + "transcripts": {"n_points": 50000, "n_dims": 3}, }, shapes={ - "cell_boundaries": { - "n_shapes": 150, - "geometry_type": "Polygon", - }, - "nucleus_boundaries": { - "n_shapes": 148, - "geometry_type": "Polygon", - }, - "roi_annotations": { - "n_shapes": 5, - "geometry_type": "Polygon", - }, + "cell_boundaries": {"n_shapes": 150, "geometry_type": "Polygon"}, + "roi_annotations": {"n_shapes": 5, "geometry_type": "Polygon"}, }, tables={ "cell_annotations": cell_table, @@ -855,7 +722,7 @@ def create_test_spatialdata(): path="/data/experiment_001.zarr", ) -except ImportError: +except (ImportError, AttributeError): HAS_SPATIALDATA_EXAMPLE = False @@ -1755,18 +1622,16 @@ def format(self, obj, context): sections.append(( "20. SpatialData (Custom _repr_html_)", sdata._repr_html_(), - "Demonstrates how packages like SpatialData " - "can build their own _repr_html_ while reusing anndata's CSS, JavaScript, and utilities. " - "Unlike MuData (which mirrors AnnData's structure), SpatialData has: " + "Demonstrates how packages can build custom _repr_html_ using anndata's building blocks: " "
    " - "
  • No central X matrix - data is distributed across elements
  • " - "
  • Different sections: images, labels, points, shapes, tables
  • " - "
  • No obs_names/var_names - shows coordinate_systems instead
  • " - "
  • Custom footer - shows spatialdata version
  • " + "
  • get_css() / get_javascript() - reuse styling and interactivity
  • " + "
  • render_section() - create collapsible sections (images, labels, points, shapes, tables)
  • " + "
  • render_formatted_entry() with meta_content - table rows with meta column
  • " + "
  • generate_repr_html() - embed nested AnnData (see 'tables' section)
  • " + "
  • FormatterRegistry - custom 'transforms' section added via SectionFormatter
  • " "
" - "The tables section contains nested AnnData objects that are fully expandable " - "with all standard features (fold/expand, search, copy buttons). " - "This approach requires more code than using SectionFormatter, but gives complete control.", + "Note the meta column shows dimension info like [c, y, x]. " + "The nested AnnData objects in tables are fully interactive (click Expand).", )) else: print(" 20. SpatialData (skipped - example failed to load)") From e8cf0cdea9533710e76f604516b4e89d288593ec Mon Sep 17 00:00:00 2001 From: Dominik Date: Sat, 6 Dec 2025 17:01:22 -0800 Subject: [PATCH 07/21] centralize mure UI elements --- src/anndata/_repr/__init__.py | 15 +++ src/anndata/_repr/html.py | 208 ++++++++++++++++++++++++++---- tests/visual_inspect_repr_html.py | 16 +-- 3 files changed, 207 insertions(+), 32 deletions(-) diff --git a/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py index ad8b00ff8..bee2df99a 100644 --- a/src/anndata/_repr/__init__.py +++ b/src/anndata/_repr/__init__.py @@ -167,6 +167,15 @@ def get_entries(self, obj, context): render_section, ) +# UI component helpers (search box, fold icon, badges, etc.) +from anndata._repr.html import ( # noqa: E402 + render_badge, + render_copy_button, + render_fold_icon, + render_header_badges, + render_search_box, +) + # Inline styles for graceful degradation (from single source of truth) from anndata._repr.constants import STYLE_HIDDEN # noqa: E402 @@ -206,4 +215,10 @@ def get_entries(self, obj, context): "render_section", "render_formatted_entry", "STYLE_HIDDEN", + # UI component helpers + "render_search_box", + "render_fold_icon", + "render_copy_button", + "render_badge", + "render_header_badges", ] diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index 2d228097a..56f3304f0 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -518,6 +518,180 @@ def render_formatted_entry(entry: FormattedEntry, section: str = "") -> str: return "\n".join(parts) +# ============================================================================= +# UI Component Helpers (for external packages) +# ============================================================================= +# These functions generate HTML for common interactive UI elements. +# External packages (SpatialData, MuData, etc.) can use these instead of +# hardcoding CSS classes and inline styles. + + +def render_search_box(container_id: str = "") -> str: + """ + Render a search box with filter indicator. + + The search box is hidden by default and shown when JavaScript is enabled. + It filters entries across all sections by key, type, or content. + + Parameters + ---------- + container_id + Unique ID for the container (used for label association) + + Returns + ------- + HTML string for the search box + + Example + ------- + >>> parts = ['
'] + >>> parts.append('SpatialData') + >>> parts.append('') # Spacer + >>> parts.append(render_search_box(container_id)) + >>> parts.append('
') + """ + search_id = f"{container_id}-search" if container_id else "anndata-search" + return ( + f'' + f'' + ) + + +def render_fold_icon() -> str: + """ + Render a fold/expand icon for section headers. + + The icon is hidden by default and shown when JavaScript is enabled. + It rotates when the section is collapsed. + + Returns + ------- + HTML string for the fold icon + + Example + ------- + >>> parts = ['
'] + >>> parts.append(render_fold_icon()) + >>> parts.append('images') + >>> parts.append('
') + """ + return f'' + + +def render_copy_button(text: str, tooltip: str = "Copy") -> str: + """ + Render a copy-to-clipboard button. + + The button is hidden by default and shown when JavaScript is enabled. + When clicked, it copies the specified text to the clipboard. + + Parameters + ---------- + text + The text to copy when clicked + tooltip + Tooltip text (default: "Copy") + + Returns + ------- + HTML string for the copy button + + Example + ------- + >>> html = f'{name}{render_copy_button(name, "Copy name")}' + """ + escaped_text = escape_html(text) + escaped_tooltip = escape_html(tooltip) + return ( + f'' + ) + + +def render_badge( + text: str, + variant: str = "", + tooltip: str = "", +) -> str: + """ + Render a badge (pill-shaped label). + + Parameters + ---------- + text + Badge text + variant + Variant class for styling. Built-in variants: + - "" (default gray) + - "adata-badge-view" (blue, for views) + - "adata-badge-backed" (orange, for backed mode) + - "adata-badge-sparse" (green, for sparse matrices) + - "adata-badge-dask" (purple, for Dask arrays) + - "adata-badge-extension" (for extension types) + tooltip + Tooltip text on hover + + Returns + ------- + HTML string for the badge + + Example + ------- + >>> render_badge("Zarr", "adata-badge-backed", "Backed by Zarr store") + """ + escaped_text = escape_html(text) + title_attr = f' title="{escape_html(tooltip)}"' if tooltip else "" + # Always include base class, optionally add variant + css_class = f"adata-badge {variant}".strip() if variant else "adata-badge" + return f'{escaped_text}' + + +def render_header_badges( + *, + is_view: bool = False, + is_backed: bool = False, + backing_path: str | None = None, + backing_format: str | None = None, +) -> str: + """ + Render standard header badges for view/backed status. + + Parameters + ---------- + is_view + Whether this is a view + is_backed + Whether this is backed by a file + backing_path + Path to the backing file (for tooltip) + backing_format + Format of the backing file ("H5AD", "Zarr", etc.) + + Returns + ------- + HTML string with badges + + Example + ------- + >>> badges = render_header_badges( + ... is_backed=True, + ... backing_path="/data/sample.zarr", + ... backing_format="Zarr", + ... ) + """ + parts = [] + if is_view: + parts.append(render_badge("View", "adata-badge-view", "This is a view of another object")) + if is_backed: + tooltip = f"Backed by {backing_path}" if backing_path else "Backed mode" + label = backing_format or "Backed" + parts.append(render_badge(label, "adata-badge-backed", tooltip)) + return "".join(parts) + + # ============================================================================= # Name Cell Renderer # ============================================================================= @@ -534,8 +708,7 @@ def _render_name_cell(name: str) -> str: f'' f'
' f'{escaped_name}' - f'' + f"{render_copy_button(name, 'Copy name')}" f"
" f"" ) @@ -560,19 +733,16 @@ def _render_header( shape_str = f"{format_number(adata.n_obs)} obs × {format_number(adata.n_vars)} vars" parts.append(f'{shape_str}') - # Badges + # Badges - use render_badge() helper if is_view(adata): - parts.append('View') + parts.append(render_badge("View", "adata-badge-view")) if is_backed(adata): backing = get_backing_info(adata) filename = backing.get("filename", "") format_str = backing.get("format", "") status = "Open" if backing.get("is_open") else "Closed" - parts.append( - f'' - f"{format_str} ({status})" - ) + parts.append(render_badge(f"{format_str} ({status})", "adata-badge-backed")) # Inline file path (full path, no truncation) if filename: path_style = ( @@ -587,9 +757,7 @@ def _render_header( # Check for extension type (not standard AnnData) if type_name != "AnnData": - parts.append( - f'{type_name}' - ) + parts.append(render_badge(type_name, "adata-badge-extension")) # README icon if uns["README"] exists with a string readme_content = adata.uns.get("README") if hasattr(adata, "uns") else None @@ -610,17 +778,10 @@ def _render_header( f"" ) - # Search box on the right (spacer pushes it right) + # Search box on the right (spacer pushes it right) - use render_search_box() helper if show_search: parts.append('') - # Search input hidden by default (JS shows it) - filter indicator uses CSS .active class - search_id = f"{container_id}-search" if container_id else "anndata-search" - parts.append( - f'' - ) - # Filter indicator visibility controlled by CSS (.adata-filter-indicator.active) - no inline style - parts.append('') + parts.append(render_search_box(container_id)) parts.append("
") return "\n".join(parts) @@ -1464,7 +1625,7 @@ def _render_section_header( ) -> str: """Render a section header - colors handled by CSS for dark mode support.""" parts = ['
'] - parts.append(f'') + parts.append(render_fold_icon()) # Use helper for fold icon parts.append(f'{escape_html(name)}') parts.append(f'{escape_html(count_str)}') if doc_url: @@ -1486,10 +1647,13 @@ def _render_empty_section( if doc_url: help_link = f'?' + # Use render_fold_icon() helper for consistency + fold_icon = render_fold_icon() + return f"""
- + {fold_icon} {escape_html(name)} (empty) {help_link} diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index 25582e642..72674795c 100644 --- a/tests/visual_inspect_repr_html.py +++ b/tests/visual_inspect_repr_html.py @@ -348,7 +348,9 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: format_number, get_css, get_javascript, + render_badge, render_formatted_entry, + render_search_box, render_section, ) from anndata._repr.html import generate_repr_html @@ -436,24 +438,18 @@ def _build_header(self, container_id: str) -> str: parts = ['
'] parts.append('SpatialData') - # Zarr badge (like AnnData's backed badge) + # Zarr badge using render_badge() helper if self.path: - parts.append( - 'Zarr' - ) + parts.append(render_badge("Zarr", "adata-badge-backed", "Backed by Zarr storage")) parts.append( f'' f'{escape_html(self.path)}' ) + # Search box using render_search_box() helper parts.append('') - parts.append( - f'' - ) - parts.append('') + parts.append(render_search_box(container_id)) parts.append("
") return "\n".join(parts) From 611809e1ef69895b41ccd23c37926084de96ecb3 Mon Sep 17 00:00:00 2001 From: Dominik Date: Sat, 6 Dec 2025 17:14:58 -0800 Subject: [PATCH 08/21] improve nested search --- src/anndata/_repr/javascript.py | 74 ++++++++++++++++++++++++++++----- 1 file changed, 64 insertions(+), 10 deletions(-) diff --git a/src/anndata/_repr/javascript.py b/src/anndata/_repr/javascript.py index df7b9c0a6..9ed86a3d0 100644 --- a/src/anndata/_repr/javascript.py +++ b/src/anndata/_repr/javascript.py @@ -136,13 +136,14 @@ def get_javascript(container_id: str) -> str: let totalMatches = 0; let totalEntries = 0; - // Filter all entries - container.querySelectorAll('.adata-entry').forEach(entry => { - totalEntries++; + // Get all entries + const allEntries = container.querySelectorAll('.adata-entry'); + // First pass: determine which entries match directly + const matchingEntries = new Set(); + allEntries.forEach(entry => { if (!query) { - entry.classList.remove('hidden'); - totalMatches++; + matchingEntries.add(entry); return; } @@ -150,9 +151,41 @@ def get_javascript(container_id: str) -> str: const dtype = (entry.dataset.dtype || '').toLowerCase(); const text = entry.textContent.toLowerCase(); - const matches = key.includes(query) || dtype.includes(query) || text.includes(query); + if (key.includes(query) || dtype.includes(query) || text.includes(query)) { + matchingEntries.add(entry); + } + }); + + // Second pass: for matching entries inside nested content, also show their parent entries + // This ensures hierarchical search works (e.g., searching for a field in nested AnnData + // keeps the parent entry visible) + const entriesToAdd = []; + matchingEntries.forEach(entry => { + // Walk up the DOM to find if this entry is inside nested content + let element = entry.parentElement; + let iterations = 0; + const maxIterations = 100; // Guard against infinite loops + while (element && element !== container && iterations < maxIterations) { + iterations++; + if (element.classList.contains('adata-nested-content')) { + // Found nested content - find the parent entry (previous sibling of nested-row) + const nestedRow = element.closest('.adata-nested-row'); + if (nestedRow && nestedRow.previousElementSibling && + nestedRow.previousElementSibling.classList.contains('adata-entry')) { + entriesToAdd.push(nestedRow.previousElementSibling); + } + break; // Found the nesting level, stop here + } + element = element.parentElement; + } + }); + entriesToAdd.forEach(e => matchingEntries.add(e)); + + // Apply visibility to entries + allEntries.forEach(entry => { + totalEntries++; - if (matches) { + if (matchingEntries.has(entry)) { entry.classList.remove('hidden'); totalMatches++; @@ -162,16 +195,37 @@ def get_javascript(container_id: str) -> str: section.classList.remove('collapsed'); } - // Expand nested content if match is inside + // Expand nested content if match is inside nested area const nestedContent = entry.closest('.adata-nested-content'); - if (nestedContent && !nestedContent.classList.contains('expanded')) { - nestedContent.classList.add('expanded'); + if (nestedContent) { + const nestedRow = nestedContent.closest('.adata-nested-row'); + if (nestedRow && !nestedRow.classList.contains('expanded')) { + nestedRow.classList.add('expanded'); + } } } else { entry.classList.add('hidden'); } }); + // Also filter X entries in nested AnnData (they use adata-x-entry class, not adata-entry) + // This prevents orphaned X rows from showing when their sibling entries are hidden + if (query) { + container.querySelectorAll('.adata-nested-content .adata-x-entry').forEach(xEntry => { + // Check if the nested AnnData has any visible entries + const nestedRepr = xEntry.closest('.anndata-repr'); + if (nestedRepr) { + const hasVisibleEntries = nestedRepr.querySelector('.adata-entry:not(.hidden)'); + xEntry.style.display = hasVisibleEntries ? '' : 'none'; + } + }); + } else { + // Reset X entries when no query + container.querySelectorAll('.adata-nested-content .adata-x-entry').forEach(xEntry => { + xEntry.style.display = ''; + }); + } + // Update filter indicator if (filterIndicator) { if (query) { From 628e9c442164a2e2ed309d5b25813aa5b817f1d4 Mon Sep 17 00:00:00 2001 From: Dominik Date: Sat, 6 Dec 2025 17:21:29 -0800 Subject: [PATCH 09/21] regex and case sensitive search --- src/anndata/_repr/css.py | 70 +++++++++++++++++++++++++++--- src/anndata/_repr/html.py | 13 +++++- src/anndata/_repr/javascript.py | 75 +++++++++++++++++++++++++++++---- 3 files changed, 140 insertions(+), 18 deletions(-) diff --git a/src/anndata/_repr/css.py b/src/anndata/_repr/css.py index d69b816df..88172880d 100644 --- a/src/anndata/_repr/css.py +++ b/src/anndata/_repr/css.py @@ -357,23 +357,32 @@ def get_css() -> str: border-bottom: 1px solid var(--anndata-border-light); } -.anndata-repr .adata-search-input { - width: 100%; +/* Search box container with inline toggle buttons */ +.anndata-repr .adata-search-box { + display: inline-flex; + align-items: center; max-width: 300px; - padding: 6px 10px; - font-size: 12px; border: 1px solid var(--anndata-border-color); border-radius: var(--anndata-radius); background: var(--anndata-bg-primary); - color: var(--anndata-text-primary); - outline: none; transition: border-color 0.15s; } -.anndata-repr .adata-search-input:focus { +.anndata-repr .adata-search-box:focus-within { border-color: var(--anndata-accent-color); } +.anndata-repr .adata-search-input { + flex: 1; + min-width: 120px; + padding: 6px 8px; + font-size: 12px; + border: none; + background: transparent; + color: var(--anndata-text-primary); + outline: none; +} + .anndata-repr .adata-search-input::placeholder { color: var(--anndata-text-muted); } @@ -389,6 +398,53 @@ def get_css() -> str: display: inline; } +/* Search toggle buttons (case sensitive, regex) - inside search box */ +.anndata-repr .adata-search-toggles { + display: flex; + gap: 1px; + padding-right: 4px; + border-left: 1px solid var(--anndata-border-light); + margin-left: 4px; + padding-left: 4px; +} + +.anndata-repr .adata-search-toggle { + display: none; /* Hidden until JS enables */ + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 3px; + background: transparent; + color: var(--anndata-text-muted); + font-size: 10px; + font-family: var(--anndata-font-mono); + font-weight: 600; + cursor: pointer; + transition: all 0.15s; +} + +.anndata-repr .adata-search-toggle:hover { + background: var(--anndata-bg-secondary); + color: var(--anndata-text-primary); +} + +.anndata-repr .adata-search-toggle.active { + background: var(--anndata-accent-color); + color: white; +} + +/* Regex error indicator */ +.anndata-repr .adata-search-box.regex-error { + border-color: #dc3545; +} + +.anndata-repr .adata-search-box.regex-error .adata-search-input { + background: rgba(220, 53, 69, 0.05); +} + /* Metadata bar */ .anndata-repr .adata-metadata { display: flex; diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index 56f3304f0..e1a5c7d1a 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -528,10 +528,11 @@ def render_formatted_entry(entry: FormattedEntry, section: str = "") -> str: def render_search_box(container_id: str = "") -> str: """ - Render a search box with filter indicator. + Render a search box with filter indicator and search mode toggles. The search box is hidden by default and shown when JavaScript is enabled. It filters entries across all sections by key, type, or content. + Includes toggle buttons for case-sensitive search and regex mode. Parameters ---------- @@ -552,9 +553,17 @@ def render_search_box(container_id: str = "") -> str: """ search_id = f"{container_id}-search" if container_id else "anndata-search" return ( + f'' f'' + f'' + f'' + f'' + f'' + f'' f'' ) diff --git a/src/anndata/_repr/javascript.py b/src/anndata/_repr/javascript.py index 9ed86a3d0..50e73af9e 100644 --- a/src/anndata/_repr/javascript.py +++ b/src/anndata/_repr/javascript.py @@ -52,12 +52,15 @@ def get_javascript(container_id: str) -> str: container.querySelectorAll('.adata-copy-btn').forEach(btn => { btn.style.display = 'inline-flex'; }); - container.querySelectorAll('.adata-search-input').forEach(input => { - input.style.display = 'inline-block'; + container.querySelectorAll('.adata-search-box').forEach(box => { + box.style.display = 'inline-flex'; }); container.querySelectorAll('.adata-expand-btn').forEach(btn => { btn.style.display = 'inline-block'; }); + container.querySelectorAll('.adata-search-toggle').forEach(btn => { + btn.style.display = 'inline-flex'; + }); // Filter indicator is shown via CSS .active class, no need to set display here // Apply initial collapse state from data attributes @@ -110,18 +113,27 @@ def get_javascript(container_id: str) -> str: }); // Search/filter functionality + const searchBox = container.querySelector('.adata-search-box'); const searchInput = container.querySelector('.adata-search-input'); const filterIndicator = container.querySelector('.adata-filter-indicator'); + const caseToggle = container.querySelector('.adata-toggle-case'); + const regexToggle = container.querySelector('.adata-toggle-regex'); + + // Search state + let caseSensitive = false; + let useRegex = false; if (searchInput) { let debounceTimer; - searchInput.addEventListener('input', (e) => { + const triggerFilter = () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { - filterEntries(e.target.value.toLowerCase().trim()); + filterEntries(searchInput.value.trim()); }, 150); - }); + }; + + searchInput.addEventListener('input', triggerFilter); // Clear on Escape searchInput.addEventListener('keydown', (e) => { @@ -130,6 +142,51 @@ def get_javascript(container_id: str) -> str: filterEntries(''); } }); + + // Toggle button handlers + if (caseToggle) { + caseToggle.addEventListener('click', (e) => { + e.stopPropagation(); + caseSensitive = !caseSensitive; + caseToggle.classList.toggle('active', caseSensitive); + caseToggle.setAttribute('aria-pressed', caseSensitive); + triggerFilter(); + }); + } + + if (regexToggle) { + regexToggle.addEventListener('click', (e) => { + e.stopPropagation(); + useRegex = !useRegex; + regexToggle.classList.toggle('active', useRegex); + regexToggle.setAttribute('aria-pressed', useRegex); + triggerFilter(); + }); + } + } + + // Helper: test if text matches query (respects case sensitivity and regex mode) + function matchesQuery(text, query) { + if (!query) return true; + if (useRegex) { + try { + const flags = caseSensitive ? '' : 'i'; + const regex = new RegExp(query, flags); + if (searchBox) searchBox.classList.remove('regex-error'); + return regex.test(text); + } catch (e) { + // Invalid regex - show error state but don't crash + if (searchBox) searchBox.classList.add('regex-error'); + return false; + } + } else { + if (searchBox) searchBox.classList.remove('regex-error'); + if (caseSensitive) { + return text.includes(query); + } else { + return text.toLowerCase().includes(query.toLowerCase()); + } + } } function filterEntries(query) { @@ -147,11 +204,11 @@ def get_javascript(container_id: str) -> str: return; } - const key = (entry.dataset.key || '').toLowerCase(); - const dtype = (entry.dataset.dtype || '').toLowerCase(); - const text = entry.textContent.toLowerCase(); + const key = entry.dataset.key || ''; + const dtype = entry.dataset.dtype || ''; + const text = entry.textContent; - if (key.includes(query) || dtype.includes(query) || text.includes(query)) { + if (matchesQuery(key, query) || matchesQuery(dtype, query) || matchesQuery(text, query)) { matchingEntries.add(entry); } }); From a920208727ae49f3a3fd75d4e0b8552b9713ffdd Mon Sep 17 00:00:00 2001 From: Dominik Date: Thu, 11 Dec 2025 10:47:15 +0100 Subject: [PATCH 10/21] SpatialData exmaple coordinate systems --- tests/visual_inspect_repr_html.py | 133 +++++++++++++++++++++++------- 1 file changed, 104 insertions(+), 29 deletions(-) diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index cfaad19bb..a328957eb 100644 --- a/tests/visual_inspect_repr_html.py +++ b/tests/visual_inspect_repr_html.py @@ -337,7 +337,6 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: import uuid from anndata._repr import ( - STYLE_HIDDEN, FormattedEntry, FormattedOutput, FormatterContext, @@ -345,7 +344,6 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: SectionFormatter, TypeFormatter, escape_html, - format_memory_size, format_number, get_css, get_javascript, @@ -397,9 +395,10 @@ def _repr_html_(self) -> str: 1. get_css() - include styling 2. Container div with unique ID 3. Custom header (optional) - 4. Sections using render_section() + render_formatted_entry() - 5. Custom sections via FormatterRegistry (optional) - 6. get_javascript(id) - include interactivity + 4. Coordinate systems preview (like obs_names/var_names in AnnData) + 5. Sections using render_section() + render_formatted_entry() + 6. Custom sections via FormatterRegistry (optional) + 7. get_javascript(id) - include interactivity """ container_id = f"spatialdata-{uuid.uuid4().hex[:8]}" parts = [] @@ -416,20 +415,23 @@ def _repr_html_(self) -> str: # --- STEP 3: Custom header (SpatialData has no shape) --- parts.append(self._build_header(container_id)) - # --- STEP 4: Sections using render_section() --- + # --- STEP 4: Coordinate systems preview (alternative to obs_names/var_names) --- + parts.append(self._build_coordinate_systems_preview()) + + # --- STEP 5: Sections using render_section() --- parts.append('
') parts.append(self._build_images_section()) parts.append(self._build_labels_section()) parts.append(self._build_points_section()) parts.append(self._build_shapes_section()) parts.append(self._build_tables_section()) # Nested AnnData - # --- STEP 5: Custom sections from FormatterRegistry --- + # --- STEP 6: Custom sections from FormatterRegistry --- parts.append(self._build_custom_sections()) parts.append("
") parts.append("
") - # --- STEP 6: Include anndata's JavaScript --- + # --- STEP 7: Include anndata's JavaScript --- parts.append(get_javascript(container_id)) return "\n".join(parts) @@ -441,11 +443,13 @@ def _build_header(self, container_id: str) -> str: # Zarr badge using render_badge() helper if self.path: - parts.append(render_badge("Zarr", "adata-badge-backed", "Backed by Zarr storage")) + parts.append( + render_badge("Zarr", "adata-badge-backed", "Backed by Zarr storage") + ) parts.append( f'' - f'{escape_html(self.path)}' + f"{escape_html(self.path)}" ) # Search box using render_search_box() helper @@ -454,6 +458,50 @@ def _build_header(self, container_id: str) -> str: parts.append("
") return "\n".join(parts) + def _build_coordinate_systems_preview(self) -> str: + """ + Build coordinate systems preview - SpatialData's equivalent to obs_names/var_names. + + Simple list of coordinate system names with element details in tooltips. + """ + if not self.coordinate_systems: + return "" + + # Collect element names for tooltips + all_elements = [] + if self.images: + all_elements.extend([f"{k} (Images)" for k in self.images]) + if self.labels: + all_elements.extend([f"{k} (Labels)" for k in self.labels]) + if self.points: + all_elements.extend([f"{k} (Points)" for k in self.points]) + if self.shapes: + all_elements.extend([f"{k} (Shapes)" for k in self.shapes]) + + elements_str = ", ".join(all_elements) if all_elements else "no elements" + + # Build simple inline list + parts = ['
'] + parts.append( + 'coordinate_systems: ' + ) + + # Render coordinate systems as simple badges with tooltips + cs_parts = [] + for cs_name in self.coordinate_systems: + tooltip = f"Elements: {elements_str}" + cs_parts.append( + f'' + f"'{escape_html(cs_name)}'" + ) + + parts.append(", ".join(cs_parts)) + parts.append("
") + return "".join(parts) + def _build_images_section(self) -> str: """ Build images section using render_section() + render_formatted_entry(). @@ -603,8 +651,12 @@ def _build_custom_sections(self) -> str: parts = [] context = FormatterContext() - for section_name in spatialdata_formatter_registry.get_registered_sections(): - formatter = spatialdata_formatter_registry.get_section_formatter(section_name) + for ( + section_name + ) in spatialdata_formatter_registry.get_registered_sections(): + formatter = spatialdata_formatter_registry.get_section_formatter( + section_name + ) if formatter is None or not formatter.should_show(self): continue @@ -656,21 +708,23 @@ class TransformsSectionFormatter(SectionFormatter): section_name = "transforms" def should_show(self, obj) -> bool: - return hasattr(obj, "coordinate_systems") and len(obj.coordinate_systems) > 1 + return ( + hasattr(obj, "coordinate_systems") and len(obj.coordinate_systems) > 1 + ) def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: - entries = [] cs = list(obj.coordinate_systems) - for i in range(len(cs) - 1): - entries.append( - FormattedEntry( - key=f"{cs[i]} → {cs[i+1]}", - output=FormattedOutput(type_name="Affine (3×3)"), - ) + return [ + FormattedEntry( + key=f"{cs[i]} → {cs[i + 1]}", + output=FormattedOutput(type_name="Affine (3×3)"), ) - return entries + for i in range(len(cs) - 1) + ] - spatialdata_formatter_registry.register_section_formatter(TransformsSectionFormatter()) + spatialdata_formatter_registry.register_section_formatter( + TransformsSectionFormatter() + ) # ========================================================================= # Test data factory @@ -691,18 +745,36 @@ def create_test_spatialdata(): transcript_table = AnnData( np.random.randn(80, 10).astype(np.float32), obs=pd.DataFrame({ - "gene": pd.Categorical(np.random.choice([f"gene_{i}" for i in range(10)], 80)), + "gene": pd.Categorical( + np.random.choice([f"gene_{i}" for i in range(10)], 80) + ), }), ) return MockSpatialData( images={ - "raw_image": {"shape": (3, 2048, 2048), "dims": ("c", "y", "x"), "dtype": "uint16"}, - "processed": {"shape": (3, 1024, 1024), "dims": ("c", "y", "x"), "dtype": "float32"}, + "raw_image": { + "shape": (3, 2048, 2048), + "dims": ("c", "y", "x"), + "dtype": "uint16", + }, + "processed": { + "shape": (3, 1024, 1024), + "dims": ("c", "y", "x"), + "dtype": "float32", + }, }, labels={ - "cell_segmentation": {"shape": (2048, 2048), "dims": ("y", "x"), "dtype": "int32"}, - "nucleus_segmentation": {"shape": (2048, 2048), "dims": ("y", "x"), "dtype": "int32"}, + "cell_segmentation": { + "shape": (2048, 2048), + "dims": ("y", "x"), + "dtype": "int32", + }, + "nucleus_segmentation": { + "shape": (2048, 2048), + "dims": ("y", "x"), + "dtype": "int32", + }, }, points={ "transcripts": {"n_points": 50000, "n_dims": 3}, @@ -1619,7 +1691,9 @@ def format(self, obj, context): sections.append(( "20. SpatialData (Custom _repr_html_)", sdata._repr_html_(), - "Demonstrates how packages can build custom _repr_html_ using anndata's building blocks: " + "Demonstrates how packages like SpatialData can build custom _repr_html_ " + "using anndata's building blocks: " "
    " "
  • get_css() / get_javascript() - reuse styling and interactivity
  • " "
  • render_section() - create collapsible sections (images, labels, points, shapes, tables)
  • " @@ -1628,7 +1702,8 @@ def format(self, obj, context): "
  • FormatterRegistry - custom 'transforms' section added via SectionFormatter
  • " "
" "Note the meta column shows dimension info like [c, y, x]. " - "The nested AnnData objects in tables are fully interactive (click Expand).", + "The nested AnnData objects in tables are fully interactive (click Expand). " + "Hover over coordinate system names to see associated elements.", )) else: print(" 20. SpatialData (skipped - example failed to load)") From 7ee414a51d3a430b5eb306c1fbb1a228ede62f4d Mon Sep 17 00:00:00 2001 From: Dominik Date: Mon, 15 Dec 2025 12:32:53 +0100 Subject: [PATCH 11/21] improve .raw render in `_rep_html_` --- src/anndata/_core/anndata.py | 8 +- src/anndata/_repr/html.py | 230 ++++++++++++++++++++++++++---- src/anndata/_repr/utils.py | 14 +- tests/visual_inspect_repr_html.py | 44 ++++++ 4 files changed, 261 insertions(+), 35 deletions(-) diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py index 95a222384..a7819a65e 100644 --- a/src/anndata/_core/anndata.py +++ b/src/anndata/_core/anndata.py @@ -599,9 +599,13 @@ def _repr_html_(self) -> str | None: from anndata._repr import generate_repr_html return generate_repr_html(self) - except Exception: # noqa: BLE001 + except Exception as e: # noqa: BLE001 # Intentional broad catch: HTML repr should never crash the notebook - # Fall back to text repr if HTML generation fails + # Fall back to text repr if HTML generation fails, but log the error + warn( + f"HTML repr failed, falling back to text repr: {e}", + stacklevel=2, + ) return None def __eq__(self, other): diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index e1a5c7d1a..a52e773e1 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -549,7 +549,7 @@ def render_search_box(container_id: str = "") -> str: >>> parts.append('SpatialData') >>> parts.append('') # Spacer >>> parts.append(render_search_box(container_id)) - >>> parts.append('
') + >>> parts.append("") """ search_id = f"{container_id}-search" if container_id else "anndata-search" return ( @@ -562,8 +562,8 @@ def render_search_box(container_id: str = "") -> str: f'title="Match case" aria-label="Match case" aria-pressed="false">Aa' f'' - f'' - f'' + f"" + f"" f'' ) @@ -584,7 +584,7 @@ def render_fold_icon() -> str: >>> parts = ['
'] >>> parts.append(render_fold_icon()) >>> parts.append('images') - >>> parts.append('
') + >>> parts.append("") """ return f'' @@ -609,7 +609,7 @@ def render_copy_button(text: str, tooltip: str = "Copy") -> str: Example ------- - >>> html = f'{name}{render_copy_button(name, "Copy name")}' + >>> html = f"{name}{render_copy_button(name, 'Copy name')}" """ escaped_text = escape_html(text) escaped_tooltip = escape_html(tooltip) @@ -693,7 +693,9 @@ def render_header_badges( """ parts = [] if is_view: - parts.append(render_badge("View", "adata-badge-view", "This is a view of another object")) + parts.append( + render_badge("View", "adata-badge-view", "This is a view of another object") + ) if is_backed: tooltip = f"Backed by {backing_path}" if backing_path else "Backed mode" label = backing_format or "Backed" @@ -889,6 +891,42 @@ def _render_x_entry(adata: AnnData, context: FormatterContext) -> str: return "\n".join(parts) +def _render_x_entry_for_raw(raw, context: FormatterContext) -> str: + """Render X as a single compact entry row for Raw objects. + + Similar to _render_x_entry but works with Raw objects instead of AnnData. + """ + X = raw.X + + parts = ['
'] + parts.append("X") + + if X is None: + parts.append("None") + else: + # Format the X matrix + output = formatter_registry.format_value(X, context) + + # Build compact type string + type_parts = [output.type_name] + + # Add sparsity info inline for sparse matrices + if "sparsity" in output.details and output.details["sparsity"] is not None: + sparsity = output.details["sparsity"] + nnz = output.details.get("nnz", "?") + type_parts.append(f"{sparsity:.1%} sparse ({format_number(nnz)} stored)") + + # Chunk info for Dask + if "chunks" in output.details: + type_parts.append(f"chunks={output.details['chunks']}") + + type_str = " · ".join(type_parts) + parts.append(f'{escape_html(type_str)}') + + parts.append("
") + return "\n".join(parts) + + def _render_dataframe_section( adata: AnnData, section: str, @@ -1573,50 +1611,182 @@ def _render_raw_section( fold_threshold: int, max_items: int, ) -> str: - """Render the raw section.""" + """Render the raw section as a single expandable row. + + The raw section shows unprocessed data that was saved before filtering/normalization. + It contains raw.X (the matrix), raw.var (variable annotations), and raw.varm + (multi-dimensional variable annotations). + + Unlike the main AnnData, raw shares obs with the parent but has its own var + (which may have more variables than the filtered main data). + + Rendered as a single row with an expand button (no section header). + When expanded, shows a full AnnData-like repr for Raw contents (X, var, varm). + The depth parameter prevents infinite recursion. + """ raw = getattr(adata, "raw", None) if raw is None: return "" - # Raw section always starts collapsed (use data-should-collapse for consistency) - parts = ['
'] - - # Header - doc_url = f"{DOCS_BASE_URL}generated/anndata.AnnData.raw.html" + n_obs = getattr(raw, "n_obs", "?") n_vars = getattr(raw, "n_vars", "?") - parts.append( - _render_section_header( - "raw", - f"(n_vars = {format_number(n_vars)})", - doc_url, - "Raw data (original unprocessed)", + max_depth = context.max_depth + + # Check if we can expand (same logic as nested AnnData) + can_expand = context.depth < max_depth - 1 + + # Build meta info string + meta_parts = [] + if hasattr(raw, "var") and len(raw.var.columns) > 0: + meta_parts.append(f"var: {len(raw.var.columns)} cols") + if hasattr(raw, "varm") and len(raw.varm) > 0: + meta_parts.append(f"varm: {len(raw.varm)}") + meta_text = ", ".join(meta_parts) if meta_parts else "" + + # Single row container (like a minimal section with just one entry) + parts = ['
'] + parts.append(f'') + + # Single row with raw info and expand button + parts.append('') + parts.append(_render_name_cell("raw")) + parts.append('") + parts.append(f'') + parts.append("") + + # Nested content (hidden by default, shown on expand) + if can_expand: + parts.append('') + parts.append('") + parts.append("") + + parts.append("
') + # Show just dimensions - "raw" is already in the name cell + type_str = f"{format_number(n_obs)} obs × {format_number(n_vars)} var" + parts.append(f'{escape_html(type_str)}') + if can_expand: + parts.append( + f'' + ) + parts.append("
') + parts.append('
') + + nested_html = _generate_raw_repr_html( + raw, + depth=context.depth + 1, + max_depth=max_depth, + fold_threshold=fold_threshold, + max_items=max_items, ) + parts.append(nested_html) + + parts.append("
") + parts.append("
") + parts.append("
") + + return "\n".join(parts) + + +def _generate_raw_repr_html( + raw, + depth: int = 0, + max_depth: int | None = None, + fold_threshold: int | None = None, + max_items: int | None = None, +) -> str: + """Generate HTML repr for a Raw object. + + This renders X, var, and varm sections similar to AnnData, + but without obs, obsm, layers, obsp, varp, uns, or raw sections. + + Parameters + ---------- + raw + Raw object to render + depth + Current nesting depth + max_depth + Maximum nesting depth (defaults to settings or DEFAULT_MAX_DEPTH) + fold_threshold + Number of items before a section auto-folds (defaults to settings or DEFAULT_FOLD_THRESHOLD) + max_items + Maximum items to display per section (defaults to settings or DEFAULT_MAX_ITEMS) + """ + # Use configured settings with fallback to 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) + from anndata._repr.registry import FormatterContext + + n_obs = getattr(raw, "n_obs", "?") + n_vars = getattr(raw, "n_vars", "?") + + context = FormatterContext( + depth=depth, + max_depth=max_depth, + adata_ref=None, ) - # Content - parts.append(f'
') + parts = [] + + # Container with header showing Raw shape + container_id = f"raw-repr-{id(raw)}" + parts.append(f'
') + + # Header for Raw - same structure as AnnData header + parts.append('
') + parts.append('Raw') + shape_str = f"{format_number(n_obs)} obs × {format_number(n_vars)} var" + parts.append(f'{shape_str}') + parts.append("
") - # raw.X info + # X section - show matrix info if hasattr(raw, "X") and raw.X is not None: - output = formatter_registry.format_value(raw.X, context) - parts.append( - f'
raw.X: {escape_html(output.type_name)}
' - ) + parts.append(_render_x_entry_for_raw(raw, context)) - # raw.var columns + # var section (like AnnData's var) + # _render_dataframe_section expects an object with a .var attribute if hasattr(raw, "var") and len(raw.var.columns) > 0: + var_context = FormatterContext( + depth=depth, + max_depth=max_depth, + adata_ref=None, + section="var", + ) parts.append( - f'
raw.var: {len(raw.var.columns)} columns
' + _render_dataframe_section( + raw, # Pass raw object, not raw.var + "var", + var_context, + fold_threshold=fold_threshold, + max_items=max_items, + ) ) - # raw.varm + # varm section (like AnnData's varm) if hasattr(raw, "varm") and len(raw.varm) > 0: + varm_context = FormatterContext( + depth=depth, + max_depth=max_depth, + adata_ref=None, + section="varm", + ) parts.append( - f'
raw.varm: {len(raw.varm)} items
' + _render_mapping_section( + raw, # Pass raw object, not raw.varm + "varm", + varm_context, + fold_threshold=fold_threshold, + max_items=max_items, + ) ) parts.append("
") - parts.append("
") return "\n".join(parts) diff --git a/src/anndata/_repr/utils.py b/src/anndata/_repr/utils.py index 14ee5d7a9..a2cefff92 100644 --- a/src/anndata/_repr/utils.py +++ b/src/anndata/_repr/utils.py @@ -207,6 +207,10 @@ def get_matching_column_colors( ------- List of color strings if colors exist and match, None otherwise """ + # Handle objects without .uns (e.g., Raw) + if not hasattr(adata, "uns"): + return None + color_key = f"{column_name}_colors" if color_key not in adata.uns: return None @@ -215,9 +219,9 @@ def get_matching_column_colors( # Find the column in obs or var col = None - if column_name in adata.obs.columns: + if hasattr(adata, "obs") and column_name in adata.obs.columns: col = adata.obs[column_name] - elif column_name in adata.var.columns: + elif hasattr(adata, "var") and column_name in adata.var.columns: col = adata.var[column_name] if col is None: @@ -244,7 +248,7 @@ def check_color_category_mismatch( Parameters ---------- adata - AnnData object + AnnData object (or object with .uns attribute) column_name Name of the column to check @@ -252,6 +256,10 @@ def check_color_category_mismatch( ------- Warning message if mismatch, None otherwise """ + # Handle objects without .uns (e.g., Raw) + if not hasattr(adata, "uns"): + return None + color_key = f"{column_name}_colors" if color_key not in adata.uns: return None diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index a328957eb..8b280671d 100644 --- a/tests/visual_inspect_repr_html.py +++ b/tests/visual_inspect_repr_html.py @@ -1708,6 +1708,50 @@ def format(self, obj, context): else: print(" 20. SpatialData (skipped - example failed to load)") + # Test 21: Raw section with detailed info + print(" 21. Raw section (unprocessed data)") + # Create an AnnData that simulates a typical workflow: + # 1. Start with more genes (raw) + # 2. Filter to fewer genes (current) + n_obs, n_vars_raw = 100, 2000 + n_vars_filtered = 500 + adata_raw = AnnData( + # Current filtered data + np.random.randn(n_obs, n_vars_filtered).astype(np.float32), + obs=pd.DataFrame({ + "cell_type": pd.Categorical( + ["T cell", "B cell", "NK cell"] * 33 + ["T cell"] + ), + "n_counts": np.random.randint(1000, 10000, n_obs), + }), + var=pd.DataFrame({ + "gene_name": [f"HVG_{i}" for i in range(n_vars_filtered)], + "highly_variable": [True] * n_vars_filtered, + "mean_expression": np.random.randn(n_vars_filtered).astype(np.float32), + }), + ) + # Set raw to have more genes (simulating pre-filtering state) + raw_X = np.random.randn(n_obs, n_vars_raw).astype(np.float32) + raw_var = pd.DataFrame( + { + "gene_name": [f"gene_{i}" for i in range(n_vars_raw)], + "highly_variable": [i < n_vars_filtered for i in range(n_vars_raw)], + }, + index=[f"gene_{i}" for i in range(n_vars_raw)], + ) + adata_raw.raw = AnnData(raw_X, var=raw_var) + # Add varm to raw + adata_raw.raw.varm["PCs"] = np.random.randn(n_vars_raw, 50).astype(np.float32) + sections.append(( + "21. Raw Section (Unprocessed Data)", + adata_raw._repr_html_(), + "Shows the .raw attribute which stores unprocessed data before filtering. " + "The raw section now displays: (1) full shape in header (100 × 2,000 vs filtered 100 × 500), " + "(2) raw.X matrix info, (3) each raw.var column with type info, " + "(4) raw.varm items. Click the section header to expand. " + "This addresses PR #349.", + )) + # Generate HTML file output_path = Path(__file__).parent / "repr_html_visual_test.html" html_content = create_html_page(sections) From e6f261f2b28b9806233c7a9c6bbc67f53e5c5718 Mon Sep 17 00:00:00 2001 From: Dominik Date: Mon, 15 Dec 2025 13:45:12 +0100 Subject: [PATCH 12/21] better error handling for html rep --- src/anndata/_core/anndata.py | 2 +- src/anndata/_repr/html.py | 317 +++++++++++++++++++++--------- tests/visual_inspect_repr_html.py | 189 +++++++++++++++++- 3 files changed, 407 insertions(+), 101 deletions(-) diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py index a7819a65e..39b099e4b 100644 --- a/src/anndata/_core/anndata.py +++ b/src/anndata/_core/anndata.py @@ -604,7 +604,7 @@ def _repr_html_(self) -> str | None: # Fall back to text repr if HTML generation fails, but log the error warn( f"HTML repr failed, falling back to text repr: {e}", - stacklevel=2, + UserWarning, ) return None diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index a52e773e1..44a61c248 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -103,9 +103,13 @@ def _calculate_field_name_width(adata: AnnData, max_width: int) -> int: # Mapping sections (uns, obsm, varm, layers, obsp, varp) for attr in ("uns", "obsm", "varm", "layers", "obsp", "varp"): - mapping = getattr(adata, attr, None) - if mapping is not None: - all_names.extend(mapping.keys()) + try: + mapping = getattr(adata, attr, None) + if mapping is not None: + all_names.extend(mapping.keys()) + except Exception: # noqa: BLE001 + # Skip sections that fail to access (will show error during rendering) + pass if not all_names: return 100 # Minimum default @@ -298,6 +302,11 @@ def _render_all_sections( for section_formatter in custom_sections_after[None] ) + # Detect and show unknown sections (mapping-like attributes not in SECTION_ORDER) + unknown_sections = _detect_unknown_sections(adata) + if unknown_sections: + parts.append(_render_unknown_sections(unknown_sections)) + return parts @@ -311,15 +320,25 @@ def _render_section( max_depth: int, ) -> str: """Render a single standard section.""" - if section == "raw": - return _render_raw_section(adata, context, fold_threshold, max_items) - if section in ("obs", "var"): - return _render_dataframe_section( + try: + if section == "X": + return _render_x_entry(adata, context) + if section == "raw": + return _render_raw_section(adata, context, fold_threshold, max_items) + if section in ("obs", "var"): + return _render_dataframe_section( + adata, section, context, fold_threshold, max_items + ) + if section == "uns": + return _render_uns_section( + adata, context, fold_threshold, max_items, max_depth + ) + return _render_mapping_section( adata, section, context, fold_threshold, max_items ) - if section == "uns": - return _render_uns_section(adata, context, fold_threshold, max_items, max_depth) - return _render_mapping_section(adata, section, context, fold_threshold, max_items) + except Exception as e: # noqa: BLE001 + # Show error instead of hiding the section + return _render_error_entry(section, str(e)) def _get_custom_sections_by_position(adata: Any) -> dict[str | None, list]: @@ -854,49 +873,12 @@ def _format_index_preview(index: pd.Index, name: str) -> str: return ", ".join(items) -def _render_x_entry(adata: AnnData, context: FormatterContext) -> str: - """Render X as a single compact entry row.""" - X = adata.X - - parts = ['
'] - parts.append("X") - - if X is None: - parts.append("None") - else: - # Format the X matrix - output = formatter_registry.format_value(X, context) - - # Build compact type string - type_parts = [output.type_name] - - # Add sparsity info inline for sparse matrices - if "sparsity" in output.details and output.details["sparsity"] is not None: - sparsity = output.details["sparsity"] - nnz = output.details.get("nnz", "?") - type_parts.append(f"{sparsity:.1%} sparse ({format_number(nnz)} stored)") - - # Chunk info for Dask - if "chunks" in output.details: - type_parts.append(f"chunks={output.details['chunks']}") - - # Backed info - if is_backed(adata): - type_parts.append("on disk") - - type_str = " · ".join(type_parts) - parts.append(f'{escape_html(type_str)}') - - parts.append("
") - return "\n".join(parts) - - -def _render_x_entry_for_raw(raw, context: FormatterContext) -> str: - """Render X as a single compact entry row for Raw objects. +def _render_x_entry(obj: Any, context: FormatterContext) -> str: + """Render X as a single compact entry row. - Similar to _render_x_entry but works with Raw objects instead of AnnData. + Works with both AnnData and Raw objects. """ - X = raw.X + X = obj.X parts = ['
'] parts.append("X") @@ -920,6 +902,10 @@ def _render_x_entry_for_raw(raw, context: FormatterContext) -> str: if "chunks" in output.details: type_parts.append(f"chunks={output.details['chunks']}") + # Backed info (only for AnnData, not Raw) + if is_backed(obj): + type_parts.append("on disk") + type_str = " · ".join(type_parts) parts.append(f'{escape_html(type_str)}') @@ -1605,6 +1591,142 @@ def _render_nested_anndata_entry( return "\n".join(parts) +def _detect_unknown_sections(adata) -> list[tuple[str, str]]: + """Detect mapping-like attributes that aren't in SECTION_ORDER. + + Returns list of (attr_name, type_description) tuples for unknown sections. + """ + from collections.abc import Mapping + + # Known sections and internal attributes to skip + known = set(SECTION_ORDER) | { + # Internal/meta attributes + "shape", + "n_obs", + "n_vars", + "obs_names", + "var_names", + "filename", + "file", + "is_view", + "isbacked", + "isview", + "T", + # Methods (not data) + "obs_keys", + "var_keys", + "uns_keys", + "obsm_keys", + "varm_keys", + } + + unknown = [] + for attr in dir(adata): + # Skip private, known, and callable attributes + if attr.startswith("_") or attr in known: + continue + + try: + val = getattr(adata, attr) + # Check if it's a data container (mapping-like or has keys()) + if isinstance(val, Mapping) or ( + hasattr(val, "keys") + and hasattr(val, "__getitem__") + and not callable(val) + ): + # Get type description + type_name = type(val).__name__ + try: + n_items = len(val) + type_desc = f"{type_name} ({n_items} items)" + except Exception: # noqa: BLE001 + type_desc = type_name + unknown.append((attr, type_desc)) + except Exception: # noqa: BLE001 + # If we can't even access the attribute, note it as inaccessible + unknown.append((attr, "inaccessible")) + + return unknown + + +def _render_unknown_sections(unknown_sections: list[tuple[str, str]]) -> str: + """Render a section showing unknown/unrecognized attributes.""" + parts = [ + '
' + ] + parts.append('
') + parts.append(render_fold_icon()) + parts.append('other') + parts.append(f'({len(unknown_sections)})') + parts.append("
") + + parts.append(f'
') + parts.append(f'') + + for attr_name, type_desc in unknown_sections: + parts.append(f'') + parts.append(_render_name_cell(attr_name)) + parts.append('") + parts.append('') + parts.append("") + + parts.append("
') + parts.append( + f'' + f"{escape_html(type_desc)}" + ) + parts.append("
") + parts.append("
") + parts.append("
") + + return "\n".join(parts) + + +def _render_error_entry(section: str, error: str) -> str: + """Render an error indicator for a section that failed to render.""" + error_escaped = escape_html(str(error)[:200]) # Truncate long errors + return f""" +
+
+ {render_fold_icon()} + {escape_html(section)} + (error) +
+
+
+ Failed to render: {error_escaped} +
+
+
+""" + + +def _safe_get_attr(obj, attr: str, default="?"): + """Safely get an attribute with fallback.""" + try: + val = getattr(obj, attr, None) + return val if val is not None else default + except Exception: # noqa: BLE001 + return default + + +def _get_raw_meta_parts(raw) -> list[str]: + """Build meta info parts for raw section.""" + meta_parts = [] + try: + if hasattr(raw, "var") and raw.var is not None and len(raw.var.columns) > 0: + meta_parts.append(f"var: {len(raw.var.columns)} cols") + except Exception: # noqa: BLE001 + pass + try: + if hasattr(raw, "varm") and raw.varm is not None and len(raw.varm) > 0: + meta_parts.append(f"varm: {len(raw.varm)}") + except Exception: # noqa: BLE001 + pass + return meta_parts + + def _render_raw_section( adata: AnnData, context: FormatterContext, @@ -1628,19 +1750,16 @@ def _render_raw_section( if raw is None: return "" - n_obs = getattr(raw, "n_obs", "?") - n_vars = getattr(raw, "n_vars", "?") + # Safely get dimensions with fallbacks + n_obs = _safe_get_attr(raw, "n_obs", "?") + n_vars = _safe_get_attr(raw, "n_vars", "?") max_depth = context.max_depth # Check if we can expand (same logic as nested AnnData) can_expand = context.depth < max_depth - 1 - # Build meta info string - meta_parts = [] - if hasattr(raw, "var") and len(raw.var.columns) > 0: - meta_parts.append(f"var: {len(raw.var.columns)} cols") - if hasattr(raw, "varm") and len(raw.varm) > 0: - meta_parts.append(f"varm: {len(raw.varm)}") + # Build meta info string safely + meta_parts = _get_raw_meta_parts(raw) meta_text = ", ".join(meta_parts) if meta_parts else "" # Single row container (like a minimal section with just one entry) @@ -1723,8 +1842,9 @@ def _generate_raw_repr_html( max_items = _get_setting("repr_html_max_items", default=DEFAULT_MAX_ITEMS) from anndata._repr.registry import FormatterContext - n_obs = getattr(raw, "n_obs", "?") - n_vars = getattr(raw, "n_vars", "?") + # Safely get dimensions + n_obs = _safe_get_attr(raw, "n_obs", "?") + n_vars = _safe_get_attr(raw, "n_vars", "?") context = FormatterContext( depth=depth, @@ -1745,46 +1865,55 @@ def _generate_raw_repr_html( parts.append(f'{shape_str}') parts.append("
") - # X section - show matrix info - if hasattr(raw, "X") and raw.X is not None: - parts.append(_render_x_entry_for_raw(raw, context)) + # X section - show matrix info (with error handling) + try: + if hasattr(raw, "X") and raw.X is not None: + parts.append(_render_x_entry(raw, context)) + except Exception as e: # noqa: BLE001 + parts.append(_render_error_entry("X", str(e))) # var section (like AnnData's var) # _render_dataframe_section expects an object with a .var attribute - if hasattr(raw, "var") and len(raw.var.columns) > 0: - var_context = FormatterContext( - depth=depth, - max_depth=max_depth, - adata_ref=None, - section="var", - ) - parts.append( - _render_dataframe_section( - raw, # Pass raw object, not raw.var - "var", - var_context, - fold_threshold=fold_threshold, - max_items=max_items, + try: + if hasattr(raw, "var") and raw.var is not None and len(raw.var.columns) > 0: + var_context = FormatterContext( + depth=depth, + max_depth=max_depth, + adata_ref=None, + section="var", ) - ) + parts.append( + _render_dataframe_section( + raw, # Pass raw object, not raw.var + "var", + var_context, + fold_threshold=fold_threshold, + max_items=max_items, + ) + ) + except Exception as e: # noqa: BLE001 + parts.append(_render_error_entry("var", str(e))) # varm section (like AnnData's varm) - if hasattr(raw, "varm") and len(raw.varm) > 0: - varm_context = FormatterContext( - depth=depth, - max_depth=max_depth, - adata_ref=None, - section="varm", - ) - parts.append( - _render_mapping_section( - raw, # Pass raw object, not raw.varm - "varm", - varm_context, - fold_threshold=fold_threshold, - max_items=max_items, + try: + if hasattr(raw, "varm") and raw.varm is not None and len(raw.varm) > 0: + varm_context = FormatterContext( + depth=depth, + max_depth=max_depth, + adata_ref=None, + section="varm", ) - ) + parts.append( + _render_mapping_section( + raw, # Pass raw object, not raw.varm + "varm", + varm_context, + fold_threshold=fold_threshold, + max_items=max_items, + ) + ) + except Exception as e: # noqa: BLE001 + parts.append(_render_error_entry("varm", str(e))) parts.append("
") diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index 8b280671d..6d80957c6 100644 --- a/tests/visual_inspect_repr_html.py +++ b/tests/visual_inspect_repr_html.py @@ -1743,13 +1743,190 @@ def format(self, obj, context): # Add varm to raw adata_raw.raw.varm["PCs"] = np.random.randn(n_vars_raw, 50).astype(np.float32) sections.append(( - "21. Raw Section (Unprocessed Data)", + "21a. Raw Section - Dense Matrix with var and varm", adata_raw._repr_html_(), - "Shows the .raw attribute which stores unprocessed data before filtering. " - "The raw section now displays: (1) full shape in header (100 × 2,000 vs filtered 100 × 500), " - "(2) raw.X matrix info, (3) each raw.var column with type info, " - "(4) raw.varm items. Click the section header to expand. " - "This addresses PR #349.", + "Shows the .raw attribute with dense matrix, var columns, and varm. " + "Click the raw row to expand and see the full Raw repr with X, var, and varm sections.", + )) + + # Test 21b: Raw with sparse matrix + print(" 21b. Raw section (sparse matrix)") + from scipy import sparse as sp + + adata_sparse_raw = AnnData( + sp.random(n_obs, n_vars_filtered, density=0.1, format="csr", dtype=np.float32), + var=pd.DataFrame(index=[f"gene_{i}" for i in range(n_vars_filtered)]), + ) + sparse_raw_X = sp.random( + n_obs, n_vars_raw, density=0.05, format="csr", dtype=np.float32 + ) + adata_sparse_raw.raw = AnnData(sparse_raw_X, var=raw_var) + sections.append(( + "21b. Raw Section - Sparse Matrix", + adata_sparse_raw._repr_html_(), + "Raw with sparse CSR matrix. The X section should show sparsity info.", + )) + + # Test 21c: Raw with no varm (minimal raw) + print(" 21c. Raw section (minimal - no varm)") + adata_minimal_raw = AnnData( + np.random.randn(50, 100).astype(np.float32), + var=pd.DataFrame(index=[f"gene_{i}" for i in range(100)]), + ) + minimal_raw_var = pd.DataFrame( + {"gene_symbol": [f"GENE{i}" for i in range(200)]}, + index=[f"gene_{i}" for i in range(200)], + ) + adata_minimal_raw.raw = AnnData( + np.random.randn(50, 200).astype(np.float32), + var=minimal_raw_var, + ) + sections.append(( + "21c. Raw Section - Minimal (no varm)", + adata_minimal_raw._repr_html_(), + "Raw with only X and var (no varm). Shows graceful handling of optional attributes.", + )) + + # Test 21d: Raw with empty var columns + print(" 21d. Raw section (empty var columns)") + adata_empty_var_raw = AnnData( + np.random.randn(30, 50).astype(np.float32), + var=pd.DataFrame(index=[f"gene_{i}" for i in range(50)]), + ) + empty_raw_var = pd.DataFrame(index=[f"gene_{i}" for i in range(80)]) # No columns + adata_empty_var_raw.raw = AnnData( + np.random.randn(30, 80).astype(np.float32), + var=empty_raw_var, + ) + sections.append(( + "21d. Raw Section - Empty var columns", + adata_empty_var_raw._repr_html_(), + "Raw where var has no columns (only index). The meta info should not show 'var: 0 cols'.", + )) + + # Test 22: Unknown sections and error handling + print(" 22. Unknown sections and error handling") + + class ExtendedAnnData(AnnData): + """AnnData subclass with custom mapping attributes.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._custom_mappings = {} + + @property + def custom_data(self): + """Custom mapping-like attribute.""" + return self._custom_mappings + + @property + def failing_data(self): + """Property that raises an error when accessed.""" + msg = "This property intentionally fails for testing" + raise RuntimeError(msg) + + adata_extended = ExtendedAnnData( + np.random.randn(50, 100).astype(np.float32), + obs=pd.DataFrame({"cluster": pd.Categorical(["A", "B"] * 25)}), + ) + # Add some custom data + adata_extended._custom_mappings = { + "embedding": np.random.randn(50, 2), + "config": {"param1": 1, "param2": "value"}, + } + sections.append(( + "22a. Unknown Sections", + adata_extended._repr_html_(), + "Demonstrates two important features for scientific accuracy:
" + "
    " + "
  1. Unknown sections: The custom_data mapping attribute " + "appears in an 'other' section at the bottom, ensuring no data is silently hidden.
  2. " + "
  3. Error handling: The failing_data property raises an " + "error when accessed. Instead of silently hiding it, the repr shows it as 'inaccessible' " + "in the 'other' section.
  4. " + "
" + "This ensures researchers always know what data exists, even if it can't be rendered.", + )) + + # Test 22b: Failing section rendering (using real rendering mechanism) + # Use unittest.mock to safely patch properties + print(" 22b. Failing section rendering (real errors)") + from unittest.mock import PropertyMock, patch + + # Create a mapping-like object that raises an error when accessed + class FailingMapping: + """A mapping that raises an error when iterated.""" + + def __init__(self, error_msg: str): + self._error_msg = error_msg + + def keys(self): + raise RuntimeError(self._error_msg) + + def __len__(self): + return 1 # Report as non-empty so it tries to render + + def __iter__(self): + raise RuntimeError(self._error_msg) + + # Create a real AnnData with data + rng = np.random.default_rng(42) + adata_failing = ad.AnnData( + X=rng.random((100, 50)), + obs=pd.DataFrame( + {"cell_type": ["A", "B", "C"] * 33 + ["A"]}, + index=[f"cell_{i}" for i in range(100)], + ), + var=pd.DataFrame( + {"gene_name": [f"gene_{i}" for i in range(50)]}, + index=[f"gene_{i}" for i in range(50)], + ), + obsm={"X_pca": rng.random((100, 10)), "X_umap": rng.random((100, 2))}, + varm={"loadings": rng.random((50, 10))}, + layers={"counts": rng.integers(0, 100, (100, 50))}, + obsp={"distances": sp.csr_matrix(rng.random((100, 100)))}, + uns={"method": "test", "params": {"k": 10}}, + ) + + # Create failing mappings + failing_varm = FailingMapping( + "Failed to decompress data block (corrupted zarr chunk)" + ) + failing_layers = FailingMapping( + "IOError: [Errno 5] Input/output error reading '/data/counts.h5'" + ) + + # Use unittest.mock.patch to safely patch properties (auto-restores on exit) + with ( + patch.object( + type(adata_failing), + "varm", + new_callable=PropertyMock, + return_value=failing_varm, + ), + patch.object( + type(adata_failing), + "layers", + new_callable=PropertyMock, + return_value=failing_layers, + ), + ): + # Use the real repr mechanism - this will trigger errors on varm and layers + failing_html = adata_failing._repr_html_() + + sections.append(( + "22b. Failing Section Rendering (Real Errors)", + failing_html, + "Demonstrates the actual error handling mechanism in the repr. " + "This test uses unittest.mock.patch to make varm and " + "layers properties raise real exceptions when accessed.
" + "
    " + "
  • Normal sections: X, obs, var, uns, obsm, obsp, varp render correctly
  • " + "
  • varm (error): Real RuntimeError from corrupted data simulation
  • " + "
  • layers (error): Real IOError from I/O failure simulation
  • " + "
" + "This tests the actual _render_section try/except error handling " + "using the real generate_repr_html pipeline.", )) # Generate HTML file From 2fbd1dbc024fb5c81576fc6003a3b0b887c829bd Mon Sep 17 00:00:00 2001 From: Dominik Date: Mon, 15 Dec 2025 21:35:35 +0100 Subject: [PATCH 13/21] multi section SectionFormatter & do not list fomratted sections in "other" --- src/anndata/_repr/__init__.py | 44 ++++++++++++++----------------- src/anndata/_repr/html.py | 4 +++ src/anndata/_repr/registry.py | 37 +++++++++++++++++++++++--- tests/visual_inspect_repr_html.py | 28 ++++++++++++++++++++ 4 files changed, 86 insertions(+), 27 deletions(-) diff --git a/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py index bee2df99a..0d1e2cedd 100644 --- a/src/anndata/_repr/__init__.py +++ b/src/anndata/_repr/__init__.py @@ -135,7 +135,26 @@ def get_entries(self, obj, context): ) # Import main functionality -from anndata._repr.html import generate_repr_html # noqa: E402 +# Inline styles for graceful degradation (from single source of truth) +from anndata._repr.constants import STYLE_HIDDEN # noqa: E402 + +# Building blocks for packages that want to create their own _repr_html_ +# These allow reusing anndata's styling while building custom representations +from anndata._repr.css import get_css # noqa: E402 + +# HTML rendering helpers for building custom sections +# UI component helpers (search box, fold icon, badges, etc.) +from anndata._repr.html import ( # noqa: E402 # noqa: E402 + generate_repr_html, + render_badge, + render_copy_button, + render_fold_icon, + render_formatted_entry, + render_header_badges, + render_search_box, + render_section, +) +from anndata._repr.javascript import get_javascript # noqa: E402 from anndata._repr.registry import ( # noqa: E402 UNS_TYPE_HINT_KEY, FormattedEntry, @@ -150,35 +169,12 @@ def get_entries(self, obj, context): formatter_registry, register_formatter, ) - -# Building blocks for packages that want to create their own _repr_html_ -# These allow reusing anndata's styling while building custom representations -from anndata._repr.css import get_css # noqa: E402 -from anndata._repr.javascript import get_javascript # noqa: E402 from anndata._repr.utils import ( # noqa: E402 escape_html, format_memory_size, format_number, ) -# HTML rendering helpers for building custom sections -from anndata._repr.html import ( # noqa: E402 - render_formatted_entry, - render_section, -) - -# UI component helpers (search box, fold icon, badges, etc.) -from anndata._repr.html import ( # noqa: E402 - render_badge, - render_copy_button, - render_fold_icon, - render_header_badges, - render_search_box, -) - -# Inline styles for graceful degradation (from single source of truth) -from anndata._repr.constants import STYLE_HIDDEN # noqa: E402 - __all__ = [ # noqa: RUF022 # organized by category, not alphabetically # Constants "DEFAULT_FOLD_THRESHOLD", diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index 44a61c248..d8f6454cc 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -1620,6 +1620,10 @@ def _detect_unknown_sections(adata) -> list[tuple[str, str]]: "varm_keys", } + # Also exclude sections that have registered custom formatters + # (including those with should_show=False that suppress display) + known |= set(formatter_registry.get_registered_sections()) + unknown = [] for attr in dir(adata): # Skip private, known, and callable attributes diff --git a/src/anndata/_repr/registry.py b/src/anndata/_repr/registry.py index 6dcb8077d..3b67b8f1b 100644 --- a/src/anndata/_repr/registry.py +++ b/src/anndata/_repr/registry.py @@ -210,6 +210,10 @@ class SectionFormatter(ABC): are formatted. This allows packages like TreeData, MuData, SpatialData to add custom sections (e.g., obst, vart, mod, spatial). + A single SectionFormatter can handle multiple sections by setting + ``section_names`` (tuple). Use ``should_show()`` returning False to + suppress sections entirely (they won't appear in "other" either). + Example usage:: from anndata._repr import ( @@ -237,14 +241,40 @@ def get_entries(self, obj, context): ) entries.append(FormattedEntry(key=key, output=output)) return entries + + Example - suppress multiple sections:: + + @register_formatter + class SuppressInternalSections(SectionFormatter): + section_names = ("obsmap", "varmap", "axis") + + @property + def section_name(self) -> str: + return self.section_names[0] + + def should_show(self, obj) -> bool: + return False # Never show + + def get_entries(self, obj, context): + return [] """ @property @abstractmethod def section_name(self) -> str: - """Name of the section this formatter handles.""" + """Primary name of the section this formatter handles.""" ... + @property + def section_names(self) -> tuple[str, ...]: + """ + All section names this formatter handles. + + Override this to handle multiple sections with one formatter. + Defaults to a tuple containing just section_name. + """ + return (self.section_name,) + @property def display_name(self) -> str: """Display name (defaults to section_name).""" @@ -366,8 +396,9 @@ def register_type_formatter(self, formatter: TypeFormatter) -> None: self._type_formatters.sort(key=lambda f: -f.priority) def register_section_formatter(self, formatter: SectionFormatter) -> None: - """Register a section formatter.""" - self._section_formatters[formatter.section_name] = formatter + """Register a section formatter for all its section_names.""" + for name in formatter.section_names: + self._section_formatters[name] = formatter def unregister_type_formatter(self, formatter: TypeFormatter) -> bool: """Unregister a type formatter. Returns True if found and removed.""" diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index 6d80957c6..570293e2c 100644 --- a/tests/visual_inspect_repr_html.py +++ b/tests/visual_inspect_repr_html.py @@ -13,6 +13,7 @@ from __future__ import annotations import tempfile +import warnings from pathlib import Path import numpy as np @@ -20,6 +21,15 @@ import scipy.sparse as sp import anndata as ad + +# Suppress anndata warning about string index transformation (not relevant for visual tests) +from anndata._warnings import ImplicitModificationWarning + +warnings.filterwarnings( + "ignore", + message="Transforming to str index", + category=ImplicitModificationWarning, +) from anndata import AnnData from anndata._repr import ( FormattedOutput, @@ -250,6 +260,24 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: HAS_MUDATA = True + # Suppress MuData's internal mapping attributes using a SectionFormatter + # that handles multiple sections and returns empty (suppresses them) + @register_formatter + class MuDataInternalSectionsFormatter(SectionFormatter): + """Suppress MuData's internal mapping attributes.""" + + section_names = ("obsmap", "varmap", "axis") + + @property + def section_name(self) -> str: + return self.section_names[0] # Primary name for compatibility + + def should_show(self, obj) -> bool: + return False # Never show these sections + + def get_entries(self, obj, context): + return [] # No entries + # Register a SectionFormatter for MuData's .mod section # This allows generate_repr_html() to work directly on MuData objects @register_formatter From 55c6f50516ffac611f1ec971fad7f4b61131a6f5 Mon Sep 17 00:00:00 2001 From: Dominik Date: Mon, 15 Dec 2025 22:46:22 +0100 Subject: [PATCH 14/21] document "Building Custom _repr_html_" --- src/anndata/_repr/__init__.py | 116 ++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py index 0d1e2cedd..b438123fe 100644 --- a/src/anndata/_repr/__init__.py +++ b/src/anndata/_repr/__init__.py @@ -100,6 +100,122 @@ def get_entries(self, obj, context): ) for k, v in obj.obst.items() ] + +Building Custom _repr_html_ +--------------------------- +For packages with AnnData-adjacent objects (like SpatialData, MuData) that need +their own ``_repr_html_``, you can reuse anndata's CSS, JavaScript, and helpers. + +**Basic structure**:: + + from anndata._repr import get_css, get_javascript + + + class MyData: + def _repr_html_(self): + container_id = f"mydata-{id(self)}" + return f''' + {get_css()} +
+
+ MyData + 100 items +
+
+ +
+
+ {get_javascript(container_id)} + ''' + +**CSS classes** (stable, can be used directly): + + - ``anndata-repr``: Main container (required for JS and styling) + - ``anndata-hdr``: Header row (flexbox, contains type/shape/badges) + - ``anndata-ftr``: Footer row (version, memory info) + - ``adata-type``: Type name span in header + - ``adata-shape``: Shape/dimensions span in header + - ``adata-sections``: Container for all sections + - ``adata-section``: Individual section wrapper + - ``adata-section-header``: Section header (clickable to fold) + - ``adata-section-content``: Section content (rows) + +**CSS variables** (set on ``.anndata-repr`` element): + + - ``--anndata-name-col-width``: Width of name column (default: 150px) + - ``--anndata-type-col-width``: Width of type column (default: 220px) + +**Using render helpers** for consistent section rendering:: + + from anndata._repr import ( + get_css, + get_javascript, + render_section, + render_formatted_entry, + render_badge, + render_search_box, + FormattedEntry, + FormattedOutput, + ) + + + def _repr_html_(self): + container_id = f"mydata-{id(self)}" + parts = [get_css()] + + # Header + parts.append(f''' +
+
+ MyData + {render_badge("Zarr", "adata-badge-backed")} + + {render_search_box(container_id)} +
+
+ ''') + + # Build section entries + entries = [] + for key, value in self.items.items(): + entry = FormattedEntry( + key=key, + output=FormattedOutput( + type_name=f"array {value.shape}", + css_class="dtype-ndarray", + ), + ) + entries.append(render_formatted_entry(entry)) + + # Render section + parts.append( + render_section( + "items", + "\\n".join(entries), + n_items=len(self.items), + ) + ) + + parts.append("
") + parts.append(get_javascript(container_id)) + return "\\n".join(parts) + +**Embedding nested AnnData** with full interactivity:: + + from anndata._repr import generate_repr_html, FormattedEntry, FormattedOutput + + nested_html = generate_repr_html(adata, depth=1, max_depth=3) + entry = FormattedEntry( + key="table", + output=FormattedOutput( + type_name=f"AnnData ({adata.n_obs} x {adata.n_vars})", + html_content=nested_html, + is_expandable=True, + ), + ) + +**Complete example**: See ``MockSpatialData`` in ``tests/visual_inspect_repr_html.py`` +for a full implementation with images, labels, points, shapes, and nested tables. """ from __future__ import annotations From 2fc88e8cfd7a649fcc7d4f3a6d0e79109713f8bf Mon Sep 17 00:00:00 2001 From: Dominik Date: Mon, 15 Dec 2025 22:50:42 +0100 Subject: [PATCH 15/21] test html rep public API --- tests/test_repr_html.py | 383 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 383 insertions(+) diff --git a/tests/test_repr_html.py b/tests/test_repr_html.py index 22969f3ca..69f4ef7fd 100644 --- a/tests/test_repr_html.py +++ b/tests/test_repr_html.py @@ -4245,3 +4245,386 @@ def test_readme_icon_accessibility(self): assert 'role="button"' in html assert 'tabindex="0"' in html assert 'aria-label="View README"' in html + + +# ============================================================================= +# Test render_header_badges Helper +# ============================================================================= + + +class TestRenderHeaderBadges: + """Tests for the render_header_badges public helper.""" + + def test_no_badges(self): + """Test render_header_badges with no flags set.""" + from anndata._repr import render_header_badges + + html = render_header_badges() + assert html == "" + + def test_view_badge_only(self): + """Test render_header_badges with view flag.""" + from anndata._repr import render_header_badges + + html = render_header_badges(is_view=True) + assert "View" in html + assert "adata-badge-view" in html + assert "This is a view" in html + + def test_backed_badge_only(self): + """Test render_header_badges with backed flag.""" + from anndata._repr import render_header_badges + + html = render_header_badges(is_backed=True) + assert "Backed" in html + assert "adata-badge-backed" in html + + def test_backed_badge_with_format(self): + """Test render_header_badges with backing format.""" + from anndata._repr import render_header_badges + + html = render_header_badges(is_backed=True, backing_format="Zarr") + assert "Zarr" in html + assert "adata-badge-backed" in html + + def test_backed_badge_with_path(self): + """Test render_header_badges with backing path in tooltip.""" + from anndata._repr import render_header_badges + + html = render_header_badges( + is_backed=True, + backing_path="/data/sample.h5ad", + backing_format="H5AD", + ) + assert "H5AD" in html + assert "/data/sample.h5ad" in html + + def test_both_badges(self): + """Test render_header_badges with both view and backed.""" + from anndata._repr import render_header_badges + + html = render_header_badges( + is_view=True, + is_backed=True, + backing_format="Zarr", + ) + assert "View" in html + assert "Zarr" in html + assert "adata-badge-view" in html + assert "adata-badge-backed" in html + + +# ============================================================================= +# Test Error Handling in HTML Rendering +# ============================================================================= + + +class TestErrorHandling: + """Tests for error handling in HTML rendering.""" + + def test_error_entry_display(self): + """Test that error entries are displayed with error styling.""" + from anndata._repr.html import _render_error_entry + + html = _render_error_entry("bad_key", "Something went wrong") + assert "bad_key" in html + assert "Something went wrong" in html + assert "adata-error" in html or "error" in html.lower() + + def test_formatter_exception_caught(self): + """Test that formatter exceptions don't crash the repr.""" + from anndata._repr import ( + TypeFormatter, + formatter_registry, + ) + + class CrashingFormatter(TypeFormatter): + priority = 1000 # High priority to be checked first + + def can_format(self, obj): + return isinstance(obj, dict) and obj.get("__crash__") + + def format(self, obj, context): + msg = "Intentional crash" + raise RuntimeError(msg) + + formatter = CrashingFormatter() + formatter_registry.register_type_formatter(formatter) + + try: + # Create AnnData with data that triggers the crashing formatter + adata = AnnData(np.zeros((5, 3))) + adata.uns["test"] = {"__crash__": True, "data": "value"} + + # Should not raise, should fall back gracefully + import warnings + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + html = adata._repr_html_() + # Should have generated HTML despite the crash + assert "anndata-repr" in html + # Should have warned about the formatter failure + assert any("CrashingFormatter" in str(warning.message) for warning in w) + finally: + formatter_registry.unregister_type_formatter(formatter) + + def test_section_formatter_exception_caught(self): + """Test that section formatter exceptions don't crash the repr.""" + from anndata._repr import ( + SectionFormatter, + register_formatter, + ) + + class CrashingSectionFormatter(SectionFormatter): + @property + def section_name(self): + return "crashing_section" + + def should_show(self, obj): + return True + + def get_entries(self, obj, context): + msg = "Section formatter crash" + raise RuntimeError(msg) + + formatter = CrashingSectionFormatter() + register_formatter(formatter) + + try: + adata = AnnData(np.zeros((5, 3))) + import warnings + + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + html = adata._repr_html_() + # Should still generate HTML + assert "anndata-repr" in html + finally: + # Clean up - remove from registry + from anndata._repr import formatter_registry + + if "crashing_section" in formatter_registry._section_formatters: + del formatter_registry._section_formatters["crashing_section"] + + +# ============================================================================= +# Test Unknown Sections Detection +# ============================================================================= + + +class TestUnknownSectionsDetection: + """Tests for detecting and displaying unknown sections.""" + + def test_standard_sections_not_in_other(self): + """Test that standard sections don't appear in 'other'.""" + adata = AnnData( + np.zeros((10, 5)), + obs=pd.DataFrame({"col": range(10)}), + var=pd.DataFrame({"gene": range(5)}), + ) + adata.obsm["X_pca"] = np.zeros((10, 2)) + adata.uns["neighbors"] = {"key": "value"} + + html = adata._repr_html_() + + # Standard sections should be present + assert 'data-section="obs"' in html + assert 'data-section="obsm"' in html + assert 'data-section="uns"' in html + + # Should NOT have an "other" section for standard attributes + # (other section only appears for truly unknown attributes) + assert 'data-section="other"' not in html or "unknown" not in html.lower() + + def test_unknown_attribute_in_other(self): + """Test that unknown attributes appear in 'other' section.""" + adata = AnnData(np.zeros((10, 5))) + # Add a non-standard attribute + adata._custom_attr = {"special": "data"} + + html = adata._repr_html_() + + # The repr should still work + assert "anndata-repr" in html + + def test_registered_section_not_in_other(self): + """Test that registered custom sections don't appear in 'other'.""" + from anndata._repr import ( + FormattedEntry, + FormattedOutput, + SectionFormatter, + formatter_registry, + register_formatter, + ) + + class CustomSectionFormatter(SectionFormatter): + @property + def section_name(self): + return "custom_test_section" + + def should_show(self, obj): + return hasattr(obj, "uns") + + def get_entries(self, obj, context): + return [ + FormattedEntry( + key="test_entry", + output=FormattedOutput(type_name="test"), + ) + ] + + formatter = CustomSectionFormatter() + register_formatter(formatter) + + try: + adata = AnnData(np.zeros((5, 3))) + html = adata._repr_html_() + + # Custom section should be present + assert 'data-section="custom_test_section"' in html + assert "test_entry" in html + finally: + # Clean up + if "custom_test_section" in formatter_registry._section_formatters: + del formatter_registry._section_formatters["custom_test_section"] + + def test_suppressed_section_not_in_other(self): + """Test that suppressed sections (should_show=False) don't appear in 'other'.""" + from anndata._repr import ( + SectionFormatter, + formatter_registry, + register_formatter, + ) + + class SuppressedSectionFormatter(SectionFormatter): + @property + def section_name(self): + return "suppressed_section" + + def should_show(self, obj): + return False # Never show + + def get_entries(self, obj, context): + return [] + + formatter = SuppressedSectionFormatter() + register_formatter(formatter) + + try: + adata = AnnData(np.zeros((5, 3))) + html = adata._repr_html_() + + # Suppressed section should NOT appear + assert "suppressed_section" not in html + # And should not be in "other" either + assert "suppressed_section" not in html + finally: + # Clean up + if "suppressed_section" in formatter_registry._section_formatters: + del formatter_registry._section_formatters["suppressed_section"] + + +# ============================================================================= +# Test Public API Exports +# ============================================================================= + + +class TestPublicAPIExports: + """Tests that all documented public API is actually importable.""" + + def test_css_js_exports(self): + """Test CSS and JS helper exports.""" + from anndata._repr import get_css, get_javascript + + css = get_css() + assert "