From 30a1e71d5d07a272c721b1d7d678646b8942dd4b Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 28 Nov 2025 11:55:41 -0800 Subject: [PATCH 001/194] implement html representation --- src/anndata/_core/anndata.py | 34 + src/anndata/_repr/__init__.py | 59 ++ src/anndata/_repr/css.py | 568 ++++++++++++++++ src/anndata/_repr/formatters.py | 626 +++++++++++++++++ src/anndata/_repr/html.py | 850 +++++++++++++++++++++++ src/anndata/_repr/javascript.py | 238 +++++++ src/anndata/_repr/registry.py | 346 ++++++++++ src/anndata/_repr/utils.py | 495 ++++++++++++++ src/anndata/_settings.py | 34 + src/anndata/_settings.pyi | 4 + tests/test_repr_html.py | 1132 +++++++++++++++++++++++++++++++ 11 files changed, 4386 insertions(+) create mode 100644 src/anndata/_repr/__init__.py create mode 100644 src/anndata/_repr/css.py create mode 100644 src/anndata/_repr/formatters.py create mode 100644 src/anndata/_repr/html.py create mode 100644 src/anndata/_repr/javascript.py create mode 100644 src/anndata/_repr/registry.py create mode 100644 src/anndata/_repr/utils.py create mode 100644 tests/test_repr_html.py diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py index 35a679d20..8d494f814 100644 --- a/src/anndata/_core/anndata.py +++ b/src/anndata/_core/anndata.py @@ -543,6 +543,40 @@ def __repr__(self) -> str: else: return self._gen_repr(self.n_obs, self.n_vars) + def _repr_html_(self) -> str | None: + """Rich HTML representation for Jupyter notebooks. + + Returns an interactive HTML representation with: + - Foldable sections for each attribute + - Search/filter functionality + - Copy-to-clipboard buttons + - Color visualization for categorical data + - Serialization warnings + - Memory usage information + + The representation can be configured via settings: + - ``anndata.settings.repr_html_enabled``: Enable/disable HTML repr + - ``anndata.settings.repr_html_fold_threshold``: Auto-fold threshold + - ``anndata.settings.repr_html_max_depth``: Max recursion depth + - ``anndata.settings.repr_html_max_items``: Max items to display + + Returns + ------- + HTML string if enabled, None otherwise (falls back to text repr). + """ + from anndata._settings import settings + + if not settings.repr_html_enabled: + return None + + try: + from anndata._repr import generate_repr_html + + return generate_repr_html(self) + except Exception: + # Fall back to text repr if HTML generation fails + return None + def __eq__(self, other): """Equality testing""" msg = ( diff --git a/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py new file mode 100644 index 000000000..4e09b87b2 --- /dev/null +++ b/src/anndata/_repr/__init__.py @@ -0,0 +1,59 @@ +""" +Rich HTML representation for AnnData objects in Jupyter notebooks. + +This module provides an extensible HTML representation system with: +- Foldable sections with auto-collapse +- Search/filter functionality +- Color visualization for categorical data +- Serialization warnings +- Support for nested AnnData objects +- Graceful handling of unknown types + +The system is designed to be extensible via a registry pattern, +allowing new data types (e.g., TreeData, MuData, SpatialData) to +register custom formatters without modifying core code. +""" + +from __future__ import annotations + +# Constants - these can be overridden via settings +DEFAULT_FOLD_THRESHOLD = 5 # Auto-fold sections with more than N entries +DEFAULT_MAX_DEPTH = 3 # Maximum recursion depth for nested objects +DEFAULT_MAX_ITEMS = 200 # Maximum items to show per section +DEFAULT_MAX_STRING_LENGTH = 100 # Truncate strings longer than this +DEFAULT_PREVIEW_ITEMS = 5 # Number of items to show in previews (first/last) + +# Documentation base URL +DOCS_BASE_URL = "https://anndata.readthedocs.io/en/latest/" + +# Section order for display +SECTION_ORDER = ("X", "obs", "var", "uns", "obsm", "varm", "layers", "obsp", "varp", "raw") + +# Import main functionality +from anndata._repr.html import generate_repr_html +from anndata._repr.registry import ( + FormatterRegistry, + formatter_registry, + register_formatter, + SectionFormatter, + TypeFormatter, +) + +__all__ = [ + # Constants + "DEFAULT_FOLD_THRESHOLD", + "DEFAULT_MAX_DEPTH", + "DEFAULT_MAX_ITEMS", + "DEFAULT_MAX_STRING_LENGTH", + "DEFAULT_PREVIEW_ITEMS", + "DOCS_BASE_URL", + "SECTION_ORDER", + # Main function + "generate_repr_html", + # Registry for extensibility + "FormatterRegistry", + "formatter_registry", + "register_formatter", + "SectionFormatter", + "TypeFormatter", +] diff --git a/src/anndata/_repr/css.py b/src/anndata/_repr/css.py new file mode 100644 index 000000000..3ad5fb3e0 --- /dev/null +++ b/src/anndata/_repr/css.py @@ -0,0 +1,568 @@ +""" +CSS styles for AnnData HTML representation. + +Provides inline CSS with: +- Light and dark mode support +- Jupyter notebook theme detection +- Responsive design +- Accessible color contrasts +""" + +from __future__ import annotations + + +def get_css() -> str: + """Get the complete CSS for the HTML representation.""" + return f"""""" + + +_CSS_CONTENT = """ +/* AnnData HTML Representation Styles */ +/* Scoped to .anndata-repr to avoid conflicts */ + +.anndata-repr { + /* CSS Variables - Light mode (default) */ + --ad-bg-primary: #ffffff; + --ad-bg-secondary: #f8f9fa; + --ad-bg-tertiary: #e9ecef; + --ad-text-primary: #212529; + --ad-text-secondary: #6c757d; + --ad-text-muted: #adb5bd; + --ad-border-color: #dee2e6; + --ad-border-light: #e9ecef; + --ad-accent-color: #0d6efd; + --ad-accent-hover: #0b5ed7; + --ad-warning-color: #ffc107; + --ad-warning-bg: #fff3cd; + --ad-error-color: #dc3545; + --ad-error-bg: #f8d7da; + --ad-success-color: #198754; + --ad-info-color: #0dcaf0; + --ad-link-color: #0d6efd; + --ad-code-bg: #f8f9fa; + --ad-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + --ad-radius: 4px; + --ad-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + --ad-font-size: 13px; + --ad-line-height: 1.4; + + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: var(--ad-font-size); + line-height: var(--ad-line-height); + color: var(--ad-text-primary); + background: var(--ad-bg-primary); + border: 1px solid var(--ad-border-color); + border-radius: var(--ad-radius); + padding: 0; + margin: 8px 0; + max-width: 100%; + overflow: hidden; +} + +/* Dark mode - via media query */ +@media (prefers-color-scheme: dark) { + .anndata-repr { + --ad-bg-primary: #1e1e1e; + --ad-bg-secondary: #252526; + --ad-bg-tertiary: #2d2d2d; + --ad-text-primary: #e0e0e0; + --ad-text-secondary: #a0a0a0; + --ad-text-muted: #707070; + --ad-border-color: #404040; + --ad-border-light: #333333; + --ad-accent-color: #58a6ff; + --ad-accent-hover: #79b8ff; + --ad-warning-color: #d29922; + --ad-warning-bg: #3d3200; + --ad-error-color: #f85149; + --ad-error-bg: #3d1a1a; + --ad-success-color: #3fb950; + --ad-info-color: #58a6ff; + --ad-link-color: #58a6ff; + --ad-code-bg: #2d2d2d; + --ad-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + } +} + +/* Jupyter dark theme detection */ +[data-jp-theme-light="false"] .anndata-repr, +.jp-Theme-Dark .anndata-repr, +body.vscode-dark .anndata-repr, +body[data-vscode-theme-kind="vscode-dark"] .anndata-repr { + --ad-bg-primary: #1e1e1e; + --ad-bg-secondary: #252526; + --ad-bg-tertiary: #2d2d2d; + --ad-text-primary: #e0e0e0; + --ad-text-secondary: #a0a0a0; + --ad-text-muted: #707070; + --ad-border-color: #404040; + --ad-border-light: #333333; + --ad-accent-color: #58a6ff; + --ad-accent-hover: #79b8ff; + --ad-warning-color: #d29922; + --ad-warning-bg: #3d3200; + --ad-error-color: #f85149; + --ad-error-bg: #3d1a1a; + --ad-success-color: #3fb950; + --ad-info-color: #58a6ff; + --ad-link-color: #58a6ff; + --ad-code-bg: #2d2d2d; + --ad-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +/* Header */ +.anndata-repr .ad-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: var(--ad-bg-secondary); + border-bottom: 1px solid var(--ad-border-color); +} + +.anndata-repr .ad-type { + font-weight: 600; + font-size: 14px; + color: var(--ad-accent-color); +} + +.anndata-repr .ad-shape { + font-family: var(--ad-font-mono); + font-size: 12px; + color: var(--ad-text-secondary); +} + +.anndata-repr .ad-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + font-size: 11px; + font-weight: 500; + border-radius: 10px; + white-space: nowrap; +} + +.anndata-repr .ad-badge-view { + background: var(--ad-info-color); + color: white; +} + +.anndata-repr .ad-badge-backed { + background: var(--ad-success-color); + color: white; +} + +.anndata-repr .ad-badge-extension { + background: var(--ad-accent-color); + color: white; +} + +/* Search box */ +.anndata-repr .ad-search { + padding: 8px 12px; + background: var(--ad-bg-primary); + border-bottom: 1px solid var(--ad-border-light); +} + +.anndata-repr .ad-search-input { + width: 100%; + max-width: 300px; + padding: 6px 10px; + font-size: 12px; + border: 1px solid var(--ad-border-color); + border-radius: var(--ad-radius); + background: var(--ad-bg-primary); + color: var(--ad-text-primary); + outline: none; + transition: border-color 0.15s; +} + +.anndata-repr .ad-search-input:focus { + border-color: var(--ad-accent-color); +} + +.anndata-repr .ad-search-input::placeholder { + color: var(--ad-text-muted); +} + +.anndata-repr .ad-filter-indicator { + display: none; + margin-left: 8px; + font-size: 11px; + color: var(--ad-accent-color); +} + +.anndata-repr .ad-filter-indicator.active { + display: inline; +} + +/* Metadata bar */ +.anndata-repr .ad-metadata { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 6px 12px; + font-size: 11px; + color: var(--ad-text-secondary); + background: var(--ad-bg-tertiary); + border-bottom: 1px solid var(--ad-border-light); +} + +.anndata-repr .ad-metadata span { + white-space: nowrap; +} + +/* Index preview */ +.anndata-repr .ad-index-preview { + padding: 8px 12px; + font-size: 11px; + font-family: var(--ad-font-mono); + color: var(--ad-text-secondary); + background: var(--ad-bg-primary); + border-bottom: 1px solid var(--ad-border-light); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.anndata-repr .ad-index-preview strong { + color: var(--ad-text-primary); + font-weight: 500; +} + +/* Sections container */ +.anndata-repr .ad-sections { + padding: 0; +} + +/* Individual section */ +.anndata-repr .ad-section { + border-bottom: 1px solid var(--ad-border-light); +} + +.anndata-repr .ad-section:last-child { + border-bottom: none; +} + +.anndata-repr .ad-section-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + user-select: none; + background: var(--ad-bg-primary); + transition: background-color 0.15s; +} + +.anndata-repr .ad-section-header:hover { + background: var(--ad-bg-secondary); +} + +.anndata-repr .ad-fold-icon { + width: 16px; + font-size: 10px; + color: var(--ad-text-muted); + transition: transform 0.15s; +} + +.anndata-repr .ad-section.collapsed .ad-fold-icon { + transform: rotate(-90deg); +} + +.anndata-repr .ad-section-name { + font-weight: 600; + color: var(--ad-text-primary); +} + +.anndata-repr .ad-section-count { + font-size: 11px; + color: var(--ad-text-secondary); +} + +.anndata-repr .ad-help-link { + margin-left: auto; + padding: 2px 6px; + font-size: 11px; + color: var(--ad-text-muted); + text-decoration: none; + border-radius: var(--ad-radius); + transition: color 0.15s, background-color 0.15s; +} + +.anndata-repr .ad-help-link:hover { + color: var(--ad-accent-color); + background: var(--ad-bg-tertiary); +} + +/* Section content */ +.anndata-repr .ad-section-content { + padding: 0; + overflow: hidden; + transition: max-height 0.2s ease-out; +} + +.anndata-repr .ad-section.collapsed .ad-section-content { + max-height: 0 !important; + padding: 0; +} + +/* Entries table */ +.anndata-repr .ad-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.anndata-repr .ad-entry { + border-bottom: 1px solid var(--ad-border-light); + transition: background-color 0.1s; +} + +.anndata-repr .ad-entry:last-child { + border-bottom: none; +} + +.anndata-repr .ad-entry:hover { + background: var(--ad-bg-secondary); +} + +.anndata-repr .ad-entry.hidden { + display: none; +} + +.anndata-repr .ad-entry.warning { + background: var(--ad-warning-bg); +} + +.anndata-repr .ad-entry.error { + background: var(--ad-error-bg); +} + +.anndata-repr .ad-entry td { + padding: 6px 12px; + vertical-align: middle; +} + +.anndata-repr .ad-entry-name { + font-family: var(--ad-font-mono); + font-weight: 500; + color: var(--ad-text-primary); + white-space: nowrap; +} + +.anndata-repr .ad-entry-type { + font-family: var(--ad-font-mono); + font-size: 11px; + color: var(--ad-text-secondary); +} + +.anndata-repr .ad-entry-meta { + font-size: 11px; + color: var(--ad-text-muted); + text-align: right; +} + +/* Copy button */ +.anndata-repr .ad-copy-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + margin-left: 4px; + padding: 0; + font-size: 11px; + color: var(--ad-text-muted); + background: transparent; + border: none; + border-radius: 3px; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s, color 0.15s, background-color 0.15s; +} + +.anndata-repr .ad-entry:hover .ad-copy-btn { + opacity: 1; +} + +.anndata-repr .ad-copy-btn:hover { + color: var(--ad-accent-color); + background: var(--ad-bg-tertiary); +} + +.anndata-repr .ad-copy-btn.copied { + color: var(--ad-success-color); +} + +/* Type-specific styling */ +.anndata-repr .dtype-category { color: #8250df; } +.anndata-repr .dtype-int { color: #0550ae; } +.anndata-repr .dtype-float { color: #0550ae; } +.anndata-repr .dtype-bool { color: #cf222e; } +.anndata-repr .dtype-string { color: #0a3069; } +.anndata-repr .dtype-object { color: #6e7781; } +.anndata-repr .dtype-sparse { color: #1a7f37; } +.anndata-repr .dtype-array { color: #0550ae; } +.anndata-repr .dtype-dataframe { color: #8250df; } +.anndata-repr .dtype-anndata { color: #cf222e; font-weight: 600; } +.anndata-repr .dtype-unknown { color: #6e7781; font-style: italic; } +.anndata-repr .dtype-extension { color: #8250df; } +.anndata-repr .dtype-warning { color: var(--ad-warning-color); } +.anndata-repr .dtype-dask { color: #fb8500; } +.anndata-repr .dtype-gpu { color: #76b900; } +.anndata-repr .dtype-awkward { color: #e85d04; } + +/* Dark mode type colors */ +@media (prefers-color-scheme: dark) { + .anndata-repr .dtype-category { color: #d2a8ff; } + .anndata-repr .dtype-int { color: #79c0ff; } + .anndata-repr .dtype-float { color: #79c0ff; } + .anndata-repr .dtype-bool { color: #ff7b72; } + .anndata-repr .dtype-string { color: #a5d6ff; } + .anndata-repr .dtype-sparse { color: #7ee787; } + .anndata-repr .dtype-array { color: #79c0ff; } + .anndata-repr .dtype-dataframe { color: #d2a8ff; } + .anndata-repr .dtype-anndata { color: #ff7b72; } +} + +[data-jp-theme-light="false"] .anndata-repr .dtype-category, +.jp-Theme-Dark .anndata-repr .dtype-category { color: #d2a8ff; } +[data-jp-theme-light="false"] .anndata-repr .dtype-int, +.jp-Theme-Dark .anndata-repr .dtype-int { color: #79c0ff; } + +/* Color swatches */ +.anndata-repr .ad-color-swatches { + display: inline-flex; + gap: 2px; + margin-left: 6px; + vertical-align: middle; +} + +.anndata-repr .ad-color-swatch { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 2px; + border: 1px solid var(--ad-border-color); +} + +/* Warning indicator */ +.anndata-repr .ad-warning-icon { + color: var(--ad-warning-color); + margin-left: 4px; +} + +/* Expandable nested content */ +.anndata-repr .ad-expand-btn { + padding: 2px 8px; + font-size: 11px; + color: var(--ad-accent-color); + background: transparent; + border: 1px solid var(--ad-accent-color); + border-radius: var(--ad-radius); + cursor: pointer; + transition: background-color 0.15s, color 0.15s; +} + +.anndata-repr .ad-expand-btn:hover { + background: var(--ad-accent-color); + color: white; +} + +.anndata-repr .ad-nested-content { + display: none; + padding: 8px 12px 8px 24px; + background: var(--ad-bg-secondary); + border-top: 1px solid var(--ad-border-light); +} + +.anndata-repr .ad-nested-content.expanded { + display: block; +} + +/* Nested AnnData */ +.anndata-repr .ad-nested-anndata { + margin: 8px 0; + border: 1px solid var(--ad-border-color); + border-radius: var(--ad-radius); +} + +/* X section (special) */ +.anndata-repr .ad-x-section { + padding: 8px 12px; + background: var(--ad-bg-secondary); + border-bottom: 1px solid var(--ad-border-light); +} + +.anndata-repr .ad-x-info { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 12px; + font-size: 12px; +} + +.anndata-repr .ad-x-info dt { + color: var(--ad-text-secondary); + font-weight: 500; +} + +.anndata-repr .ad-x-info dd { + margin: 0; + font-family: var(--ad-font-mono); + color: var(--ad-text-primary); +} + +/* Truncation indicator */ +.anndata-repr .ad-truncated { + padding: 8px 12px; + font-size: 11px; + color: var(--ad-text-muted); + text-align: center; + font-style: italic; +} + +/* Max depth indicator */ +.anndata-repr .ad-max-depth { + padding: 8px 12px; + font-size: 11px; + color: var(--ad-text-muted); + background: var(--ad-bg-tertiary); + border-radius: var(--ad-radius); + text-align: center; +} + +/* Empty section indicator */ +.anndata-repr .ad-empty { + padding: 8px 12px; + font-size: 11px; + color: var(--ad-text-muted); + font-style: italic; +} + +/* Tooltip */ +.anndata-repr [data-tooltip] { + position: relative; +} + +.anndata-repr [data-tooltip]:hover::after { + content: attr(data-tooltip); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + padding: 4px 8px; + font-size: 11px; + font-weight: normal; + color: white; + background: #333; + border-radius: var(--ad-radius); + white-space: nowrap; + z-index: 1000; + pointer-events: none; +} +""" diff --git a/src/anndata/_repr/formatters.py b/src/anndata/_repr/formatters.py new file mode 100644 index 000000000..7f4486272 --- /dev/null +++ b/src/anndata/_repr/formatters.py @@ -0,0 +1,626 @@ +""" +Built-in formatters for common types. + +This module registers formatters for: +- NumPy arrays (dense and masked) +- SciPy sparse matrices +- Pandas DataFrames, Series, Categorical +- Dask arrays +- CuPy arrays (GPU) +- Awkward arrays +- AnnData objects (for recursive display in .uns) +- Python built-in types +- Color lists + +The formatters are registered automatically when this module is imported. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np +import pandas as pd + +from anndata._repr.registry import ( + FormattedOutput, + FormatterContext, + TypeFormatter, + formatter_registry, +) +from anndata._repr.utils import ( + format_number, + is_serializable, +) + +if TYPE_CHECKING: + pass + + +# ============================================================================= +# NumPy Formatters +# ============================================================================= + + +class NumpyArrayFormatter(TypeFormatter): + """Formatter for numpy.ndarray.""" + + priority = 100 + + def can_format(self, obj: Any) -> bool: + return isinstance(obj, np.ndarray) + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + arr: np.ndarray = obj + shape_str = " × ".join(format_number(s) for s in arr.shape) + dtype_str = str(arr.dtype) + + # Determine CSS class based on dtype + css_class = _get_dtype_css_class(arr.dtype) + + details = { + "shape": arr.shape, + "dtype": dtype_str, + "ndim": arr.ndim, + } + + if arr.ndim == 2: + type_name = f"ndarray ({shape_str}) {dtype_str}" + elif arr.ndim == 1: + type_name = f"ndarray ({shape_str},) {dtype_str}" + else: + type_name = f"ndarray {arr.shape} {dtype_str}" + + return FormattedOutput( + type_name=type_name, + css_class=css_class, + details=details, + is_serializable=True, + ) + + +class NumpyMaskedArrayFormatter(TypeFormatter): + """Formatter for numpy.ma.MaskedArray.""" + + priority = 110 + + def can_format(self, obj: Any) -> bool: + return isinstance(obj, np.ma.MaskedArray) + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + arr: np.ma.MaskedArray = obj + shape_str = " × ".join(format_number(s) for s in arr.shape) + dtype_str = str(arr.dtype) + n_masked = int(np.sum(arr.mask)) if arr.mask is not np.ma.nomask else 0 + + return FormattedOutput( + type_name=f"MaskedArray ({shape_str}) {dtype_str}", + css_class=_get_dtype_css_class(arr.dtype), + tooltip=f"{n_masked} masked values" if n_masked > 0 else "", + details={ + "shape": arr.shape, + "dtype": dtype_str, + "n_masked": n_masked, + }, + is_serializable=True, + ) + + +# ============================================================================= +# SciPy Sparse Formatters +# ============================================================================= + + +class SparseMatrixFormatter(TypeFormatter): + """Formatter for scipy.sparse matrices.""" + + priority = 100 + + def can_format(self, obj: Any) -> bool: + try: + import scipy.sparse as sp + + return sp.issparse(obj) + except ImportError: + return False + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + import scipy.sparse as sp + + shape_str = " × ".join(format_number(s) for s in obj.shape) + dtype_str = str(obj.dtype) + + # Calculate sparsity + n_elements = obj.shape[0] * obj.shape[1] if len(obj.shape) == 2 else 1 + if n_elements > 0: + sparsity = 1 - (obj.nnz / n_elements) + sparsity_str = f"{sparsity:.1%} sparse" + else: + sparsity_str = "" + + # Determine format name + if sp.isspmatrix_csr(obj): + format_name = "csr_matrix" + elif sp.isspmatrix_csc(obj): + format_name = "csc_matrix" + elif sp.isspmatrix_coo(obj): + format_name = "coo_matrix" + elif sp.isspmatrix_lil(obj): + format_name = "lil_matrix" + elif sp.isspmatrix_dok(obj): + format_name = "dok_matrix" + elif sp.isspmatrix_dia(obj): + format_name = "dia_matrix" + elif sp.isspmatrix_bsr(obj): + format_name = "bsr_matrix" + else: + format_name = type(obj).__name__ + + return FormattedOutput( + type_name=f"{format_name} ({shape_str}) {dtype_str}", + css_class="dtype-sparse", + tooltip=f"{format_number(obj.nnz)} stored elements, {sparsity_str}", + details={ + "shape": obj.shape, + "dtype": dtype_str, + "nnz": obj.nnz, + "format": format_name, + "sparsity": sparsity if n_elements > 0 else None, + }, + is_serializable=True, + ) + + +# ============================================================================= +# Pandas Formatters +# ============================================================================= + + +class DataFrameFormatter(TypeFormatter): + """Formatter for pandas.DataFrame.""" + + priority = 100 + + def can_format(self, obj: Any) -> bool: + return isinstance(obj, pd.DataFrame) + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + df: pd.DataFrame = obj + return FormattedOutput( + type_name=f"DataFrame ({format_number(len(df))} × {format_number(len(df.columns))})", + css_class="dtype-dataframe", + details={ + "n_rows": len(df), + "n_cols": len(df.columns), + "columns": list(df.columns), + }, + is_serializable=True, + ) + + +class SeriesFormatter(TypeFormatter): + """Formatter for pandas.Series.""" + + priority = 100 + + def can_format(self, obj: Any) -> bool: + return isinstance(obj, pd.Series) and not hasattr(obj, "cat") + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + series: pd.Series = obj + dtype_str = str(series.dtype) + css_class = _get_pandas_dtype_css_class(series.dtype) + + return FormattedOutput( + type_name=f"{dtype_str}", + css_class=css_class, + details={ + "length": len(series), + "dtype": dtype_str, + }, + is_serializable=True, + ) + + +class CategoricalFormatter(TypeFormatter): + """Formatter for pandas.Categorical and categorical Series.""" + + priority = 110 + + def can_format(self, obj: Any) -> bool: + if isinstance(obj, pd.Categorical): + return True + if isinstance(obj, pd.Series) and hasattr(obj, "cat"): + return True + return False + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + if isinstance(obj, pd.Series): + cat = obj.cat + n_categories = len(cat.categories) + else: + cat = obj + n_categories = len(obj.categories) + + return FormattedOutput( + type_name=f"category ({n_categories})", + css_class="dtype-category", + details={ + "n_categories": n_categories, + "ordered": getattr(cat, "ordered", False), + }, + is_serializable=True, + ) + + +# ============================================================================= +# Dask Array Formatter +# ============================================================================= + + +class DaskArrayFormatter(TypeFormatter): + """Formatter for dask.array.Array.""" + + priority = 120 + + def can_format(self, obj: Any) -> bool: + try: + import dask.array as da + + return isinstance(obj, da.Array) + except ImportError: + return False + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + shape_str = " × ".join(format_number(s) for s in obj.shape) + dtype_str = str(obj.dtype) + + # Get chunk info + chunks_str = str(obj.chunksize) if hasattr(obj, "chunksize") else "unknown" + + return FormattedOutput( + type_name=f"dask.array ({shape_str}) {dtype_str}", + css_class="dtype-dask", + tooltip=f"Chunks: {chunks_str}, {obj.npartitions} partitions", + details={ + "shape": obj.shape, + "dtype": dtype_str, + "chunks": obj.chunks, + "npartitions": obj.npartitions, + }, + is_serializable=True, + ) + + +# ============================================================================= +# CuPy Array Formatter +# ============================================================================= + + +class CuPyArrayFormatter(TypeFormatter): + """Formatter for cupy.ndarray (GPU arrays).""" + + priority = 120 + + def can_format(self, obj: Any) -> bool: + return type(obj).__module__.startswith("cupy") + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + shape_str = " × ".join(format_number(s) for s in obj.shape) + dtype_str = str(obj.dtype) + + device_info = "" + if hasattr(obj, "device"): + device_info = f"GPU:{obj.device.id}" + + return FormattedOutput( + type_name=f"cupy.ndarray ({shape_str}) {dtype_str}", + css_class="dtype-gpu", + tooltip=device_info, + details={ + "shape": obj.shape, + "dtype": dtype_str, + "device": getattr(obj, "device", None), + }, + is_serializable=True, + ) + + +# ============================================================================= +# Awkward Array Formatter +# ============================================================================= + + +class AwkwardArrayFormatter(TypeFormatter): + """Formatter for awkward.Array (ragged/jagged arrays).""" + + priority = 120 + + def can_format(self, obj: Any) -> bool: + return type(obj).__module__.startswith("awkward") + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + try: + length = len(obj) + type_str = str(obj.type) if hasattr(obj, "type") else "unknown" + except Exception: + length = "?" + type_str = "unknown" + + return FormattedOutput( + type_name=f"awkward.Array ({length} records)", + css_class="dtype-awkward", + tooltip=f"Type: {type_str}", + details={ + "length": length, + "type": type_str, + }, + is_serializable=True, + ) + + +# ============================================================================= +# AnnData Formatter (for nested AnnData in .uns) +# ============================================================================= + + +class AnnDataFormatter(TypeFormatter): + """Formatter for nested AnnData objects.""" + + priority = 150 + + def can_format(self, obj: Any) -> bool: + # Check by class name to avoid circular imports + return type(obj).__name__ == "AnnData" and hasattr(obj, "n_obs") + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + shape_str = f"{format_number(obj.n_obs)} × {format_number(obj.n_vars)}" + + return FormattedOutput( + type_name=f"AnnData ({shape_str})", + css_class="dtype-anndata", + tooltip="Nested AnnData object", + details={ + "n_obs": obj.n_obs, + "n_vars": obj.n_vars, + "is_view": getattr(obj, "is_view", False), + }, + is_expandable=context.depth < context.max_depth, + is_serializable=True, + ) + + +# ============================================================================= +# Python Built-in Type Formatters +# ============================================================================= + + +class NoneFormatter(TypeFormatter): + """Formatter for None.""" + + priority = 50 + + def can_format(self, obj: Any) -> bool: + return obj is None + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + return FormattedOutput( + type_name="None", + css_class="dtype-object", + is_serializable=True, + ) + + +class BoolFormatter(TypeFormatter): + """Formatter for bool.""" + + priority = 50 + + def can_format(self, obj: Any) -> bool: + return isinstance(obj, bool) + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + return FormattedOutput( + type_name=str(obj), + css_class="dtype-bool", + is_serializable=True, + ) + + +class IntFormatter(TypeFormatter): + """Formatter for int.""" + + priority = 50 + + def can_format(self, obj: Any) -> bool: + return isinstance(obj, (int, np.integer)) and not isinstance(obj, bool) + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + return FormattedOutput( + type_name=f"int ({format_number(obj)})", + css_class="dtype-int", + is_serializable=True, + ) + + +class FloatFormatter(TypeFormatter): + """Formatter for float.""" + + priority = 50 + + def can_format(self, obj: Any) -> bool: + return isinstance(obj, (float, np.floating)) + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + return FormattedOutput( + type_name=f"float ({obj:.6g})", + css_class="dtype-float", + is_serializable=True, + ) + + +class StringFormatter(TypeFormatter): + """Formatter for str.""" + + priority = 50 + + def can_format(self, obj: Any) -> bool: + return isinstance(obj, str) + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + from anndata._repr import DEFAULT_MAX_STRING_LENGTH + from anndata._repr.utils import truncate_string + + display = truncate_string(obj, DEFAULT_MAX_STRING_LENGTH) + return FormattedOutput( + type_name=f'str "{display}"', + css_class="dtype-string", + tooltip=obj if len(obj) > DEFAULT_MAX_STRING_LENGTH else "", + is_serializable=True, + ) + + +class DictFormatter(TypeFormatter): + """Formatter for dict.""" + + priority = 50 + + def can_format(self, obj: Any) -> bool: + return isinstance(obj, dict) + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + n_items = len(obj) + + # Check serializability of contents + is_serial, reason = is_serializable(obj) + warnings = [] if is_serial else [reason] + + return FormattedOutput( + type_name=f"dict ({n_items} items)", + css_class="dtype-object", + details={"n_items": n_items, "keys": list(obj.keys())[:10]}, + is_expandable=n_items > 0 and context.depth < context.max_depth, + is_serializable=is_serial, + warnings=warnings, + ) + + +class ListFormatter(TypeFormatter): + """Formatter for list and tuple.""" + + priority = 50 + + def can_format(self, obj: Any) -> bool: + return isinstance(obj, (list, tuple)) + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + type_name = "list" if isinstance(obj, list) else "tuple" + n_items = len(obj) + + # Check serializability + is_serial, reason = is_serializable(obj) + warnings = [] if is_serial else [reason] + + return FormattedOutput( + type_name=f"{type_name} ({n_items} items)", + css_class="dtype-object", + details={"n_items": n_items}, + is_serializable=is_serial, + warnings=warnings, + ) + + +# ============================================================================= +# Color List Formatter +# ============================================================================= + + +class ColorListFormatter(TypeFormatter): + """Special formatter for color lists (*_colors in .uns).""" + + priority = 200 # High priority to catch before generic list + + def can_format(self, obj: Any) -> bool: + # This is context-dependent, handled in section formatter + return False + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + # Not used directly - handled specially in UnsSection + raise NotImplementedError + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def _get_dtype_css_class(dtype: np.dtype) -> str: + """Get CSS class for a numpy dtype.""" + kind = dtype.kind + if kind in ("i", "u"): + return "dtype-int" + elif kind == "f": + return "dtype-float" + elif kind == "b": + return "dtype-bool" + elif kind in ("U", "S", "O"): + return "dtype-string" + elif kind == "c": + return "dtype-float" # complex + else: + return "dtype-object" + + +def _get_pandas_dtype_css_class(dtype) -> str: + """Get CSS class for a pandas dtype.""" + dtype_name = str(dtype) + if "int" in dtype_name: + return "dtype-int" + elif "float" in dtype_name: + return "dtype-float" + elif "bool" in dtype_name: + return "dtype-bool" + elif dtype_name == "category": + return "dtype-category" + elif "object" in dtype_name or "string" in dtype_name: + return "dtype-string" + else: + return "dtype-object" + + +# ============================================================================= +# Register all formatters +# ============================================================================= + + +def _register_builtin_formatters() -> None: + """Register all built-in formatters with the global registry.""" + formatters = [ + # High priority (specific types) + AnnDataFormatter(), + DaskArrayFormatter(), + CuPyArrayFormatter(), + AwkwardArrayFormatter(), + NumpyMaskedArrayFormatter(), + CategoricalFormatter(), + # Medium priority + NumpyArrayFormatter(), + SparseMatrixFormatter(), + DataFrameFormatter(), + SeriesFormatter(), + # Low priority (builtins) + NoneFormatter(), + BoolFormatter(), + IntFormatter(), + FloatFormatter(), + StringFormatter(), + DictFormatter(), + ListFormatter(), + ] + + for formatter in formatters: + formatter_registry.register_type_formatter(formatter) + + +# Auto-register on import +_register_builtin_formatters() diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py new file mode 100644 index 000000000..3d677b857 --- /dev/null +++ b/src/anndata/_repr/html.py @@ -0,0 +1,850 @@ +""" +Main HTML generator for AnnData representation. + +This module generates the complete HTML representation by: +1. Building the header with badges +2. Rendering the search box +3. Generating metadata (version, memory) +4. Rendering each section (X, obs, var, uns, etc.) +5. Handling nested objects recursively +""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING, Any + +import numpy as np +import pandas as pd + +from anndata._repr import ( + DEFAULT_FOLD_THRESHOLD, + DEFAULT_MAX_DEPTH, + DEFAULT_MAX_ITEMS, + DEFAULT_PREVIEW_ITEMS, + DOCS_BASE_URL, + SECTION_ORDER, +) +from anndata._repr.css import get_css +from anndata._repr.javascript import get_javascript +from anndata._repr.registry import ( + FormattedEntry, + FormattedOutput, + FormatterContext, + formatter_registry, +) +from anndata._repr.utils import ( + check_color_category_mismatch, + escape_html, + format_memory_size, + format_number, + get_anndata_version, + get_backing_info, + get_matching_column_colors, + is_backed, + is_color_list, + is_serializable, + is_view, + sanitize_for_id, + should_warn_string_column, + truncate_string, +) + +if TYPE_CHECKING: + from anndata import AnnData + +# Import formatters to register them +import anndata._repr.formatters # noqa: F401 + + +def generate_repr_html( + adata: AnnData, + *, + depth: int = 0, + max_depth: int | None = None, + fold_threshold: int | None = None, + max_items: int | None = None, + show_header: bool = True, + show_search: bool = True, + _container_id: str | None = None, +) -> str: + """ + Generate HTML representation for an AnnData object. + + Parameters + ---------- + adata + The AnnData object to represent + depth + Current recursion depth (for nested AnnData in .uns) + max_depth + Maximum recursion depth. Uses settings/default if None. + fold_threshold + Auto-fold sections with more entries than this. Uses settings/default if None. + max_items + Maximum items to show per section. Uses settings/default if None. + show_header + Whether to show the header (for nested display) + show_search + Whether to show the search box (only at top level) + _container_id + Internal: container ID for scoping + + Returns + ------- + HTML string + """ + # Get settings with defaults + if max_depth is None: + max_depth = _get_setting("repr_html_max_depth", DEFAULT_MAX_DEPTH) + if fold_threshold is None: + fold_threshold = _get_setting("repr_html_fold_threshold", DEFAULT_FOLD_THRESHOLD) + if max_items is None: + max_items = _get_setting("repr_html_max_items", DEFAULT_MAX_ITEMS) + + # Check if HTML repr is enabled + if not _get_setting("repr_html_enabled", True): + # Fallback to text repr + return f"
{escape_html(repr(adata))}
" + + # Check max depth + if depth >= max_depth: + return _render_max_depth_indicator(adata) + + # Generate unique container ID + container_id = _container_id or f"anndata-repr-{uuid.uuid4().hex[:8]}" + + # Create formatter context + context = FormatterContext( + depth=depth, + max_depth=max_depth, + adata_ref=adata, + ) + + # Build HTML parts + parts = [] + + # CSS and JS only at top level + if depth == 0: + parts.append(get_css()) + + # Container + parts.append(f'
') + + # Header + if show_header: + parts.append(_render_header(adata)) + + # Search box (only at top level) + if show_search and depth == 0: + parts.append(_render_search_box()) + + # Metadata bar + if depth == 0: + parts.append(_render_metadata(adata)) + + # Index preview (only at top level) + if depth == 0: + parts.append(_render_index_preview(adata)) + + # Sections container + parts.append('
') + + # X section + parts.append(_render_x_section(adata, context)) + + # Standard sections + for section in SECTION_ORDER: + if section == "X": + continue # Already rendered + if section == "raw": + parts.append(_render_raw_section(adata, context, fold_threshold, max_items)) + elif section in ("obs", "var"): + parts.append( + _render_dataframe_section( + adata, section, context, fold_threshold, max_items + ) + ) + elif section == "uns": + parts.append( + _render_uns_section(adata, context, fold_threshold, max_items, max_depth) + ) + else: + parts.append( + _render_mapping_section( + adata, section, context, fold_threshold, max_items + ) + ) + + parts.append("
") # ad-sections + parts.append("
") # anndata-repr + + # JavaScript (only at top level) + if depth == 0: + parts.append(get_javascript(container_id)) + + return "\n".join(parts) + + +# ============================================================================= +# Section Renderers +# ============================================================================= + + +def _render_header(adata: AnnData) -> str: + """Render the header with type, shape, and badges.""" + parts = ['
'] + + # Type name - allow for extension types + type_name = type(adata).__name__ + parts.append(f'{escape_html(type_name)}') + + # Shape + shape_str = f"n_obs × n_vars = {format_number(adata.n_obs)} × {format_number(adata.n_vars)}" + parts.append(f'{shape_str}') + + # Badges + if is_view(adata): + parts.append('View') + + if is_backed(adata): + backing = get_backing_info(adata) + title = escape_html(backing.get("filename", "")) + format_str = backing.get("format", "") + status = "Open" if backing.get("is_open") else "Closed" + parts.append( + f'' + f'📁 {format_str} ({status})' + ) + + # Check for extension type (not standard AnnData) + if type_name != "AnnData": + parts.append(f'{type_name}') + + parts.append("
") + return "\n".join(parts) + + +def _render_search_box() -> str: + """Render the search/filter input.""" + return """ + +""" + + +def _render_metadata(adata: AnnData) -> str: + """Render the metadata bar with version and memory info.""" + parts = ['
'] + + # Version + version = get_anndata_version() + parts.append(f"anndata v{version}") + + # Memory usage + try: + mem_bytes = adata.__sizeof__() + mem_str = format_memory_size(mem_bytes) + parts.append(f'~{mem_str}') + except Exception: + pass + + # Creation date if available + if hasattr(adata, "uns") and "created_date" in adata.uns: + parts.append(f'Created: {escape_html(str(adata.uns["created_date"]))}') + + parts.append("
") + return "\n".join(parts) + + +def _render_index_preview(adata: AnnData) -> str: + """Render preview of obs_names and var_names.""" + parts = ['
'] + + # obs_names preview + obs_preview = _format_index_preview(adata.obs_names, "obs_names") + parts.append(f"
obs_names: {obs_preview}
") + + # var_names preview + var_preview = _format_index_preview(adata.var_names, "var_names") + parts.append(f"
var_names: {var_preview}
") + + parts.append("
") + return "\n".join(parts) + + +def _format_index_preview(index: pd.Index, name: str) -> str: + """Format a preview of an index.""" + n = len(index) + if n == 0: + return "empty" + + preview_n = DEFAULT_PREVIEW_ITEMS + if n <= preview_n * 2: + # Show all + items = [escape_html(str(x)) for x in index] + else: + # Show first and last + first = [escape_html(str(x)) for x in index[:preview_n]] + last = [escape_html(str(x)) for x in index[-preview_n:]] + items = first + ["..."] + last + + return ", ".join(items) + + +def _render_x_section(adata: AnnData, context: FormatterContext) -> str: + """Render the X matrix section.""" + parts = ['
'] + parts.append('
') + + X = adata.X + + if X is None: + parts.append("
X
None
") + else: + # Format the X matrix + output = formatter_registry.format_value(X, context) + + parts.append(f"
Type:
{escape_html(output.type_name)}
") + + # Shape + if "shape" in output.details: + shape = output.details["shape"] + shape_str = " × ".join(format_number(s) for s in shape) + parts.append(f"
Shape:
{shape_str}
") + + # Dtype + if "dtype" in output.details: + parts.append(f"
Dtype:
{escape_html(str(output.details['dtype']))}
") + + # Sparsity for sparse matrices + if "sparsity" in output.details and output.details["sparsity"] is not None: + sparsity = output.details["sparsity"] + nnz = output.details.get("nnz", "?") + parts.append(f"
Sparsity:
{sparsity:.1%} sparse ({format_number(nnz)} stored)
") + + # Chunk info for Dask + if "chunks" in output.details: + chunks = output.details["chunks"] + parts.append(f"
Chunks:
{chunks}
") + + # Backed info + if is_backed(adata): + parts.append("
Storage:
📁 On disk
") + + parts.append("
") + parts.append("
") + return "\n".join(parts) + + +def _render_dataframe_section( + adata: AnnData, + section: str, + context: FormatterContext, + fold_threshold: int, + max_items: int, +) -> str: + """Render obs or var section.""" + df: pd.DataFrame = getattr(adata, section) + n_cols = len(df.columns) + + if n_cols == 0: + return _render_empty_section(section) + + # Should this section be collapsed? + collapsed = n_cols > fold_threshold + + parts = [f'
'] + + # Header + doc_url = f"{DOCS_BASE_URL}generated/anndata.AnnData.{section}.html" + tooltip = "Observation annotations" if section == "obs" else "Variable annotations" + parts.append(_render_section_header(section, f"({n_cols} columns)", doc_url, tooltip)) + + # Content + parts.append('
') + parts.append('') + + # Render each column + for i, col_name in enumerate(df.columns): + if i >= max_items: + parts.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("
") # ad-section-content + parts.append("
") # ad-section + + return "\n".join(parts) + + +def _render_dataframe_entry( + adata: AnnData, + section: str, + col_name: str, + col: pd.Series, + context: FormatterContext, +) -> str: + """Render a single DataFrame column entry.""" + # Format the column + output = formatter_registry.format_value(col, context) + + # Check for string->category warning + warnings = list(output.warnings) + should_warn, warn_msg = should_warn_string_column(col) + if should_warn: + warnings.append(warn_msg) + + # Check for color mismatch + color_warning = check_color_category_mismatch(adata, col_name) + if color_warning: + warnings.append(color_warning) + + # Get colors if categorical + colors = get_matching_column_colors(adata, col_name) + + # Build entry class + entry_class = "ad-entry" + if warnings: + entry_class += " warning" + + # Build row + parts = [f''] + + # Name cell + parts.append('') + parts.append(escape_html(col_name)) + parts.append(f'') + parts.append("") + + # Type cell + parts.append('') + if warnings: + title = escape_html("; ".join(warnings)) + parts.append(f'') + parts.append(f"{escape_html(output.type_name)} ⚠️") + parts.append("") + else: + parts.append(f'{escape_html(output.type_name)}') + + # Color swatches + if colors: + parts.append('') + for color in colors[:10]: # Limit to 10 swatches + parts.append(f'') + if len(colors) > 10: + parts.append(f"+{len(colors) - 10}") + parts.append("") + + parts.append("") + + # Meta cell + parts.append('') + if hasattr(col, "cat"): + parts.append(f"({len(col.cat.categories)} categories)") + elif hasattr(col, "nunique"): + try: + parts.append(f"({col.nunique()} unique)") + except Exception: + pass + parts.append("") + + parts.append("") + return "\n".join(parts) + + +def _render_mapping_section( + adata: AnnData, + section: str, + context: FormatterContext, + fold_threshold: int, + max_items: int, +) -> str: + """Render obsm, varm, layers, obsp, varp sections.""" + mapping = getattr(adata, section, None) + if mapping is None: + return "" + + keys = list(mapping.keys()) + n_items = len(keys) + + if n_items == 0: + return _render_empty_section(section) + + collapsed = n_items > fold_threshold + + parts = [f'
'] + + # Header + doc_url = f"{DOCS_BASE_URL}generated/anndata.AnnData.{section}.html" + tooltip = _get_section_tooltip(section) + parts.append(_render_section_header(section, f"({n_items} items)", doc_url, tooltip)) + + # Content + parts.append('
') + parts.append('') + + for i, key in enumerate(keys): + if i >= max_items: + parts.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) + + +def _render_mapping_entry( + key: str, + value: Any, + context: FormatterContext, + section: str, +) -> str: + """Render a single mapping entry.""" + output = formatter_registry.format_value(value, context) + + entry_class = "ad-entry" + if output.warnings: + entry_class += " warning" + if not output.is_serializable: + entry_class += " error" + + parts = [f''] + + # Name + parts.append('') + parts.append(escape_html(key)) + parts.append(f'') + parts.append("") + + # Type + parts.append('') + if output.warnings or not output.is_serializable: + warnings = output.warnings.copy() + if not output.is_serializable: + warnings.insert(0, "Not serializable to H5AD/Zarr") + title = escape_html("; ".join(warnings)) + parts.append(f'') + parts.append(f"{escape_html(output.type_name)} ⚠️") + parts.append("") + else: + parts.append(f'{escape_html(output.type_name)}') + parts.append("") + + # Meta - show shape/cols for obsm/varm + parts.append('') + if "shape" in output.details and section in ("obsm", "varm", "layers"): + shape = output.details["shape"] + if len(shape) >= 2: + parts.append(f"({format_number(shape[1])} cols)") + parts.append("") + + parts.append("") + return "\n".join(parts) + + +def _render_uns_section( + adata: AnnData, + context: FormatterContext, + fold_threshold: int, + max_items: int, + max_depth: int, +) -> str: + """Render the uns section with special handling.""" + uns = adata.uns + keys = list(uns.keys()) + n_items = len(keys) + + if n_items == 0: + return _render_empty_section("uns") + + collapsed = n_items > fold_threshold + + parts = [f'
'] + + # Header + doc_url = f"{DOCS_BASE_URL}generated/anndata.AnnData.uns.html" + parts.append(_render_section_header("uns", f"({n_items} items)", doc_url, "Unstructured annotation")) + + # Content + parts.append('
') + parts.append('') + + for i, key in enumerate(keys): + if i >= max_items: + parts.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) + + +def _render_uns_entry( + adata: AnnData, + key: str, + value: Any, + context: FormatterContext, + max_depth: int, +) -> str: + """Render a single uns entry with special type handling.""" + parts = [] + + # Check for color list + if is_color_list(key, value): + return _render_color_list_entry(key, value) + + # Check for nested AnnData + if type(value).__name__ == "AnnData" and hasattr(value, "n_obs"): + return _render_nested_anndata_entry(key, value, context, max_depth) + + # Regular entry + output = formatter_registry.format_value(value, context) + + entry_class = "ad-entry" + if output.warnings: + entry_class += " warning" + if not output.is_serializable: + entry_class += " error" + + parts.append(f'') + + # Name + parts.append('') + parts.append(escape_html(key)) + parts.append(f'') + parts.append("") + + # Type + parts.append('') + if output.warnings or not output.is_serializable: + warnings = output.warnings.copy() + if not output.is_serializable: + warnings.insert(0, "Not serializable to H5AD/Zarr") + title = escape_html("; ".join(warnings)) + parts.append(f'') + parts.append(f"{escape_html(output.type_name)} ⚠️") + parts.append("") + else: + parts.append(f'{escape_html(output.type_name)}') + parts.append("") + + # Meta + parts.append('') + parts.append("") + + return "\n".join(parts) + + +def _render_color_list_entry(key: str, value: Any) -> str: + """Render a color list entry with swatches.""" + colors = list(value) if hasattr(value, "__iter__") else [] + n_colors = len(colors) + + parts = [f''] + + # Name + parts.append('') + parts.append(escape_html(key)) + parts.append(f'') + parts.append("") + + # Type with color swatches + parts.append('') + parts.append(f'colors ({n_colors})') + parts.append('') + for color in colors[:15]: # Limit preview + parts.append(f'') + if n_colors > 15: + parts.append(f"+{n_colors - 15}") + parts.append("") + parts.append("") + + parts.append('') + parts.append("") + + return "\n".join(parts) + + +def _render_nested_anndata_entry( + key: str, + value: Any, + context: FormatterContext, + max_depth: int, +) -> str: + """Render a nested AnnData entry.""" + n_obs = getattr(value, "n_obs", "?") + n_vars = getattr(value, "n_vars", "?") + + can_expand = context.depth < max_depth - 1 + + parts = [f''] + + # Name + parts.append('') + parts.append(escape_html(key)) + parts.append(f'') + parts.append("") + + # Type + parts.append('') + parts.append(f'AnnData ({format_number(n_obs)} × {format_number(n_vars)})') + if can_expand: + parts.append('') + parts.append("") + + parts.append('') + parts.append("") + + # Nested content (hidden by default) + if can_expand: + parts.append('') + parts.append('') + parts.append('
') + # Recursive call + nested_html = generate_repr_html( + value, + depth=context.depth + 1, + max_depth=max_depth, + show_header=True, + show_search=False, + ) + parts.append(nested_html) + parts.append("
") + parts.append("") + parts.append("") + + return "\n".join(parts) + + +def _render_raw_section( + adata: AnnData, + context: FormatterContext, + fold_threshold: int, + max_items: int, +) -> str: + """Render the raw section.""" + raw = getattr(adata, "raw", None) + if raw is None: + return "" + + parts = ['") + + return "\n".join(parts) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def _render_section_header( + name: str, + count_str: str, + doc_url: str | None, + tooltip: str, +) -> str: + """Render a section header.""" + parts = ['
'] + parts.append('') + parts.append(f'{escape_html(name)}') + parts.append(f'{escape_html(count_str)}') + if doc_url: + parts.append(f'?') + parts.append("
") + return "\n".join(parts) + + +def _render_empty_section(name: str) -> str: + """Render an empty section indicator.""" + return f""" + +""" + + +def _render_truncation_indicator(remaining: int) -> str: + """Render a truncation indicator.""" + return f'... and {format_number(remaining)} more' + + +def _render_max_depth_indicator(adata: AnnData) -> str: + """Render indicator when max depth is reached.""" + n_obs = getattr(adata, "n_obs", "?") + n_vars = getattr(adata, "n_vars", "?") + return f'
AnnData ({format_number(n_obs)} × {format_number(n_vars)}) - max depth reached
' + + +def _get_section_tooltip(section: str) -> str: + """Get tooltip text for a section.""" + tooltips = { + "obs": "Observation (cell) annotations", + "var": "Variable (gene) annotations", + "uns": "Unstructured annotation", + "obsm": "Multi-dimensional observation annotations", + "varm": "Multi-dimensional variable annotations", + "layers": "Additional data layers (same shape as X)", + "obsp": "Pairwise observation annotations", + "varp": "Pairwise variable annotations", + "raw": "Raw data (original unprocessed)", + } + return tooltips.get(section, "") + + +def _get_setting(name: str, default: Any) -> Any: + """Get a setting value, falling back to default.""" + try: + from anndata import settings + + return getattr(settings, name, default) + except (ImportError, AttributeError): + return default diff --git a/src/anndata/_repr/javascript.py b/src/anndata/_repr/javascript.py new file mode 100644 index 000000000..38d21ae9f --- /dev/null +++ b/src/anndata/_repr/javascript.py @@ -0,0 +1,238 @@ +""" +JavaScript for AnnData HTML representation interactivity. + +Provides: +- Section folding/unfolding +- Search/filter functionality across all levels +- Copy to clipboard +- Nested content expansion +""" + +from __future__ import annotations + + +def get_javascript(container_id: str) -> str: + """ + Get the JavaScript code for a specific container. + + Parameters + ---------- + container_id + Unique ID for the container element + + Returns + ------- + JavaScript code wrapped in script tags + """ + return f"""""" + + +_JS_CONTENT = """ + // Toggle section fold/unfold + function toggleSection(header) { + const section = header.closest('.ad-section'); + if (!section) return; + + section.classList.toggle('collapsed'); + + // Update ARIA + const content = section.querySelector('.ad-section-content'); + if (content) { + content.setAttribute('aria-hidden', section.classList.contains('collapsed')); + } + } + + // Attach click handlers to section headers + container.querySelectorAll('.ad-section-header').forEach(header => { + header.addEventListener('click', (e) => { + // Don't toggle if clicking on help link + if (e.target.closest('.ad-help-link')) return; + toggleSection(header); + }); + + // Keyboard accessibility + header.setAttribute('tabindex', '0'); + header.setAttribute('role', 'button'); + header.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleSection(header); + } + }); + }); + + // Search/filter functionality + const searchInput = container.querySelector('.ad-search-input'); + const filterIndicator = container.querySelector('.ad-filter-indicator'); + + if (searchInput) { + let debounceTimer; + + searchInput.addEventListener('input', (e) => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + filterEntries(e.target.value.toLowerCase().trim()); + }, 150); + }); + + // Clear on Escape + searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + searchInput.value = ''; + filterEntries(''); + } + }); + } + + function filterEntries(query) { + let totalMatches = 0; + let totalEntries = 0; + + // Filter all entries + container.querySelectorAll('.ad-entry').forEach(entry => { + totalEntries++; + + if (!query) { + entry.classList.remove('hidden'); + totalMatches++; + return; + } + + const key = (entry.dataset.key || '').toLowerCase(); + const dtype = (entry.dataset.dtype || '').toLowerCase(); + const text = entry.textContent.toLowerCase(); + + const matches = key.includes(query) || dtype.includes(query) || text.includes(query); + + if (matches) { + entry.classList.remove('hidden'); + totalMatches++; + + // Expand parent sections to show match + const section = entry.closest('.ad-section'); + if (section && section.classList.contains('collapsed')) { + section.classList.remove('collapsed'); + } + + // Expand nested content if match is inside + const nestedContent = entry.closest('.ad-nested-content'); + if (nestedContent && !nestedContent.classList.contains('expanded')) { + nestedContent.classList.add('expanded'); + } + } else { + entry.classList.add('hidden'); + } + }); + + // Update filter indicator + if (filterIndicator) { + if (query) { + filterIndicator.classList.add('active'); + filterIndicator.textContent = `Showing ${totalMatches} of ${totalEntries}`; + } else { + filterIndicator.classList.remove('active'); + } + } + + // Hide sections with no visible entries + container.querySelectorAll('.ad-section').forEach(section => { + const visibleEntries = section.querySelectorAll('.ad-entry:not(.hidden)'); + const sectionHeader = section.querySelector('.ad-section-header'); + + if (query && visibleEntries.length === 0) { + section.style.display = 'none'; + } else { + section.style.display = ''; + } + }); + } + + // Copy to clipboard + container.querySelectorAll('.ad-copy-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + + const text = btn.dataset.copy; + if (!text) return; + + try { + await navigator.clipboard.writeText(text); + + // Visual feedback + btn.classList.add('copied'); + const originalText = btn.textContent; + btn.textContent = '✓'; + + setTimeout(() => { + btn.classList.remove('copied'); + btn.textContent = originalText; + }, 1500); + } catch (err) { + // Fallback for older browsers + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + + try { + document.execCommand('copy'); + btn.classList.add('copied'); + setTimeout(() => btn.classList.remove('copied'), 1500); + } catch (e) { + console.error('Copy failed:', e); + } + + document.body.removeChild(textarea); + } + }); + }); + + // Expand/collapse nested content + container.querySelectorAll('.ad-expand-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + + const entry = btn.closest('.ad-entry'); + if (!entry) return; + + const nestedContent = entry.nextElementSibling; + if (!nestedContent || !nestedContent.classList.contains('ad-nested-content')) return; + + const isExpanded = nestedContent.classList.toggle('expanded'); + + btn.textContent = isExpanded ? 'Collapse ▲' : 'Expand ▼'; + btn.setAttribute('aria-expanded', isExpanded); + nestedContent.setAttribute('aria-hidden', !isExpanded); + }); + }); + + // Expand all / Collapse all (if buttons exist) + const expandAllBtn = container.querySelector('.ad-expand-all'); + const collapseAllBtn = container.querySelector('.ad-collapse-all'); + + if (expandAllBtn) { + expandAllBtn.addEventListener('click', () => { + container.querySelectorAll('.ad-section.collapsed').forEach(section => { + section.classList.remove('collapsed'); + }); + }); + } + + if (collapseAllBtn) { + collapseAllBtn.addEventListener('click', () => { + container.querySelectorAll('.ad-section:not(.collapsed)').forEach(section => { + section.classList.add('collapsed'); + }); + }); + } +""" diff --git a/src/anndata/_repr/registry.py b/src/anndata/_repr/registry.py new file mode 100644 index 000000000..2804a22a7 --- /dev/null +++ b/src/anndata/_repr/registry.py @@ -0,0 +1,346 @@ +""" +Registry pattern for extensible HTML formatting. + +This module provides a registry system that allows: +1. New data types (TreeData, MuData, SpatialData) to register custom formatters +2. Graceful fallback for unknown types +3. Override of default formatters +4. Section-level and type-level customization + +Usage for extending to new types: + + from anndata._repr import register_formatter, TypeFormatter + + class MyDataFormatter(TypeFormatter): + def can_format(self, obj: Any) -> bool: + return isinstance(obj, MyDataType) + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + return FormattedOutput( + type_name="MyDataType", + css_class="dtype-mydata", + details={"custom": "info"}, + ) + + register_formatter(MyDataFormatter()) +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Callable +import warnings + +if TYPE_CHECKING: + from collections.abc import Sequence + + +@dataclass +class FormattedOutput: + """Output from a formatter.""" + + type_name: str + """Display name for the type (e.g., 'ndarray (100, 50) float32')""" + + css_class: str = "dtype-unknown" + """CSS class for styling""" + + tooltip: str = "" + """Tooltip text on hover""" + + details: dict[str, Any] = field(default_factory=dict) + """Additional details (shape, dtype, sparsity, etc.)""" + + warnings: list[str] = field(default_factory=list) + """Warning messages to display""" + + children: list[FormattedEntry] | None = None + """Child entries for expandable types""" + + html_content: str | None = None + """Custom HTML content (for special visualizations like colors)""" + + is_expandable: bool = False + """Whether this entry can be expanded""" + + is_serializable: bool = True + """Whether this type can be serialized to H5AD/Zarr""" + + +@dataclass +class FormattedEntry: + """A single entry in a section (e.g., one column in obs).""" + + key: str + """The key/name of this entry""" + + output: FormattedOutput + """Formatted output for this entry""" + + copy_text: str | None = None + """Text to copy to clipboard (defaults to key)""" + + +@dataclass +class FormatterContext: + """Context passed to formatters for stateful formatting.""" + + depth: int = 0 + """Current recursion depth""" + + max_depth: int = 3 + """Maximum recursion depth""" + + parent_keys: tuple[str, ...] = () + """Keys of parent objects (for building access paths)""" + + adata_ref: Any = None + """Reference to the root AnnData object (for color lookups etc.)""" + + section: str = "" + """Current section being formatted (obs, var, uns, etc.)""" + + def child(self, key: str) -> FormatterContext: + """Create a child context for nested formatting.""" + return FormatterContext( + depth=self.depth + 1, + max_depth=self.max_depth, + parent_keys=(*self.parent_keys, key), + adata_ref=self.adata_ref, + section=self.section, + ) + + @property + def access_path(self) -> str: + """Build Python access path string.""" + if not self.parent_keys: + return "" + parts = [] + for key in self.parent_keys: + if key.isidentifier(): + parts.append(f".{key}") + else: + parts.append(f"[{key!r}]") + return "".join(parts) + + +class TypeFormatter(ABC): + """ + Base class for type-specific formatters. + + Subclass this to add support for new types. The formatter will be + called when can_format() returns True for an object. + + Priority determines order of checking (higher = checked first). + """ + + priority: int = 0 + + @abstractmethod + def can_format(self, obj: Any) -> bool: + """Return True if this formatter can handle the given object.""" + ... + + @abstractmethod + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + """Format the object and return FormattedOutput.""" + ... + + +class SectionFormatter(ABC): + """ + Base class for section-specific formatters. + + Subclass this to customize how entire sections (obs, var, uns, etc.) + are formatted. This allows packages like MuData to add custom sections. + """ + + @property + @abstractmethod + def section_name(self) -> str: + """Name of the section this formatter handles.""" + ... + + @property + def display_name(self) -> str: + """Display name (defaults to section_name).""" + return self.section_name + + @property + def doc_url(self) -> str | None: + """URL to documentation for this section.""" + return None + + @property + def tooltip(self) -> str: + """Tooltip text for section header.""" + return "" + + @abstractmethod + def get_entries(self, obj: Any, context: FormatterContext) -> list[FormattedEntry]: + """Get all entries for this section.""" + ... + + def should_show(self, obj: Any) -> bool: + """Return True if this section should be displayed.""" + return True + + +class FallbackFormatter(TypeFormatter): + """ + Fallback formatter for unknown types. + + This ensures the repr never breaks, even for completely unknown types. + """ + + priority: int = -1000 # Lowest priority, always checked last + + def can_format(self, obj: Any) -> bool: + return True # Can format anything + + def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: + type_name = type(obj).__name__ + module = type(obj).__module__ + + # Build a useful type description + if module and module != "builtins": + full_name = f"{module}.{type_name}" + else: + full_name = type_name + + # Try to get useful info + details = {} + tooltip_parts = [f"Type: {full_name}"] + + if hasattr(obj, "shape"): + details["shape"] = obj.shape + tooltip_parts.append(f"Shape: {obj.shape}") + + if hasattr(obj, "dtype"): + details["dtype"] = str(obj.dtype) + tooltip_parts.append(f"Dtype: {obj.dtype}") + + if hasattr(obj, "__len__"): + try: + details["length"] = len(obj) + tooltip_parts.append(f"Length: {len(obj)}") + except (TypeError, RuntimeError): + pass + + # Check if this might be from an extension package + is_extension = module and not module.startswith(("anndata", "numpy", "pandas", "scipy")) + + return FormattedOutput( + type_name=type_name, + css_class="dtype-unknown" if not is_extension else "dtype-extension", + tooltip="\n".join(tooltip_parts), + details=details, + warnings=[f"Unknown type: {full_name}"] if not is_extension else [], + is_serializable=False, # Assume unknown types aren't serializable + ) + + +class FormatterRegistry: + """ + Registry for type and section formatters. + + This is the central registry that manages all formatters. It supports: + - Registering new formatters at runtime + - Priority-based formatter selection + - Graceful fallback for unknown types + - Thread-safe operation + """ + + def __init__(self) -> None: + self._type_formatters: list[TypeFormatter] = [] + self._section_formatters: dict[str, SectionFormatter] = {} + self._fallback = FallbackFormatter() + + def register_type_formatter(self, formatter: TypeFormatter) -> None: + """ + Register a type formatter. + + Formatters are checked in priority order (highest first). + """ + self._type_formatters.append(formatter) + # Keep sorted by priority (highest first) + 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 + + def unregister_type_formatter(self, formatter: TypeFormatter) -> bool: + """Unregister a type formatter. Returns True if found and removed.""" + try: + self._type_formatters.remove(formatter) + return True + except ValueError: + return False + + def format_value(self, obj: Any, context: FormatterContext) -> FormattedOutput: + """ + Format a value using the appropriate formatter. + + Tries each registered formatter in priority order, falling back + to the fallback formatter if none match. + """ + for formatter in self._type_formatters: + try: + if formatter.can_format(obj): + return formatter.format(obj, context) + except Exception as e: + # Log but don't fail - try next formatter + warnings.warn( + f"Formatter {type(formatter).__name__} failed for " + f"{type(obj).__name__}: {e}", + stacklevel=2, + ) + continue + + # Use fallback + return self._fallback.format(obj, context) + + def get_section_formatter(self, section: str) -> SectionFormatter | None: + """Get the formatter for a section, or None if not registered.""" + return self._section_formatters.get(section) + + def get_registered_sections(self) -> list[str]: + """Get list of registered section names.""" + return list(self._section_formatters.keys()) + + +# Global registry instance +formatter_registry = FormatterRegistry() + + +def register_formatter( + formatter: TypeFormatter | SectionFormatter, +) -> TypeFormatter | SectionFormatter: + """ + Register a formatter with the global registry. + + Can be used as a decorator: + + @register_formatter + class MyFormatter(TypeFormatter): + ... + + Or called directly: + + register_formatter(MyFormatter()) + """ + if isinstance(formatter, type): + # Called with class, instantiate it + formatter = formatter() + + if isinstance(formatter, TypeFormatter): + formatter_registry.register_type_formatter(formatter) + elif isinstance(formatter, SectionFormatter): + formatter_registry.register_section_formatter(formatter) + else: + msg = f"Expected TypeFormatter or SectionFormatter, got {type(formatter)}" + raise TypeError(msg) + + return formatter diff --git a/src/anndata/_repr/utils.py b/src/anndata/_repr/utils.py new file mode 100644 index 000000000..d75c47dac --- /dev/null +++ b/src/anndata/_repr/utils.py @@ -0,0 +1,495 @@ +""" +Utility functions for HTML representation. + +This module provides: +- Serialization checking using the anndata IO registry +- String-to-category warning detection +- Color list detection and validation +- HTML escaping and sanitization +- Memory size formatting +""" + +from __future__ import annotations + +import html +import re +from typing import TYPE_CHECKING, Any + +import numpy as np +import pandas as pd + +if TYPE_CHECKING: + from anndata import AnnData + + +def is_serializable( + obj: Any, + *, + _depth: int = 0, + _max_depth: int = 10, +) -> tuple[bool, str]: + """ + Check if an object can be serialized to H5AD/Zarr. + + Uses the actual anndata IO registry to check if a type has a registered writer. + For containers (dict, list), recursively checks all elements. + + Parameters + ---------- + obj + Object to check + _depth + Current recursion depth (internal) + _max_depth + Maximum recursion depth to prevent infinite loops + + Returns + ------- + tuple of (is_serializable, reason_if_not) + """ + if _depth > _max_depth: + return False, "Maximum nesting depth exceeded" + + # Handle None + if obj is None: + return True, "" + + # Check containers recursively + if isinstance(obj, dict): + for k, v in obj.items(): + ok, reason = is_serializable(v, _depth=_depth + 1, _max_depth=_max_depth) + if not ok: + return False, f"Key '{k}': {reason}" + return True, "" + + if isinstance(obj, (list, tuple)): + for i, v in enumerate(obj): + ok, reason = is_serializable(v, _depth=_depth + 1, _max_depth=_max_depth) + if not ok: + return False, f"Index {i}: {reason}" + return True, "" + + # Use the actual IO registry + try: + from anndata._io.specs.registry import _REGISTRY + + _REGISTRY.get_spec(obj) + return True, "" + except (KeyError, TypeError): + pass + + # Check for basic Python types that are serializable + if isinstance(obj, (bool, int, float, str, bytes)): + return True, "" + + # Check numpy scalar types + if isinstance(obj, np.generic): + return True, "" + + return False, f"Type '{type(obj).__module__}.{type(obj).__name__}' has no registered writer" + + +def should_warn_string_column(series: pd.Series) -> tuple[bool, str]: + """ + Check if a string column will be auto-converted to categorical on save. + + This replicates the logic from AnnData.strings_to_categoricals(): + - Column must be string type (infer_dtype == "string") + - Number of unique values must be less than total values + + Parameters + ---------- + series + Pandas Series to check + + Returns + ------- + tuple of (should_warn, warning_message) + """ + from pandas.api.types import infer_dtype + + dtype_str = infer_dtype(series) + if dtype_str != "string": + return False, "" + + try: + n_unique = series.nunique() + n_total = len(series) + except Exception: + return False, "" + + if n_unique < n_total: + return ( + True, + f"String column ({n_unique} unique values). " + f"Will be converted to categorical on save.", + ) + + return False, "" + + +def is_color_list(key: str, value: Any) -> bool: + """ + Check if a value is a color list following the *_colors convention. + + Parameters + ---------- + key + The key name (should end with '_colors') + value + The value to check + + Returns + ------- + True if this appears to be a color list + """ + if not key.endswith("_colors"): + return False + + if not isinstance(value, (list, np.ndarray, tuple)): + return False + + # Empty list is valid + if len(value) == 0: + return True + + # Check first element + first = value[0] if len(value) > 0 else None + if first is None: + return False + + if isinstance(first, str): + # Hex color + if first.startswith("#"): + return True + # Named color (basic check) + if first.lower() in _NAMED_COLORS: + return True + # RGB/RGBA string like "rgb(255, 0, 0)" + if first.lower().startswith(("rgb(", "rgba(")): + return True + + return False + + +def get_matching_column_colors( + adata: AnnData, + column_name: str, +) -> list[str] | None: + """ + Get colors for a categorical column if they exist and match. + + Parameters + ---------- + adata + AnnData object + column_name + Name of the column to get colors for + + Returns + ------- + List of color strings if colors exist and match, None otherwise + """ + color_key = f"{column_name}_colors" + if color_key not in adata.uns: + return None + + colors = adata.uns[color_key] + + # Find the column in obs or var + col = None + if column_name in adata.obs.columns: + col = adata.obs[column_name] + elif column_name in adata.var.columns: + col = adata.var[column_name] + + if col is None: + return None + + # Must be categorical + if not hasattr(col, "cat"): + return None + + n_categories = len(col.cat.categories) + if len(colors) != n_categories: + return None # Mismatch + + return list(colors) + + +def check_color_category_mismatch( + adata: AnnData, + column_name: str, +) -> str | None: + """ + Check if colors exist but don't match category count. + + Parameters + ---------- + adata + AnnData object + column_name + Name of the column to check + + Returns + ------- + Warning message if mismatch, None otherwise + """ + color_key = f"{column_name}_colors" + if color_key not in adata.uns: + return None + + colors = adata.uns[color_key] + + for df in (adata.obs, adata.var): + if column_name in df.columns and hasattr(df[column_name], "cat"): + n_cats = len(df[column_name].cat.categories) + if len(colors) != n_cats: + return f"Color mismatch: {len(colors)} colors for {n_cats} categories" + + return None + + +def escape_html(text: str) -> str: + """Escape HTML special characters.""" + return html.escape(str(text)) + + +def sanitize_for_id(text: str) -> str: + """Sanitize a string for use as an HTML id attribute.""" + # Replace non-alphanumeric chars with underscore + sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", str(text)) + # Ensure it starts with a letter + if sanitized and not sanitized[0].isalpha(): + sanitized = "id_" + sanitized + return sanitized + + +def truncate_string(text: str, max_length: int = 100) -> str: + """Truncate a string and add ellipsis if needed.""" + text = str(text) + if len(text) <= max_length: + return text + return text[: max_length - 3] + "..." + + +def format_memory_size(size_bytes: int | float) -> str: + """Format memory size in human-readable form.""" + if size_bytes < 0: + return "Unknown" + + for unit in ("B", "KB", "MB", "GB", "TB"): + if abs(size_bytes) < 1024: + if unit == "B": + return f"{int(size_bytes)} {unit}" + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024 + + return f"{size_bytes:.1f} PB" + + +def format_number(n: int | float) -> str: + """Format a number with thousand separators.""" + if isinstance(n, float): + if n == int(n): + n = int(n) + else: + return f"{n:,.2f}" + return f"{n:,}" + + +def get_anndata_version() -> str: + """Get the anndata version string.""" + try: + from importlib.metadata import version + + return version("anndata") + except Exception: + return "unknown" + + +def is_view(obj: Any) -> bool: + """Check if an object is a view (for AnnData-like objects).""" + return getattr(obj, "is_view", False) + + +def is_backed(obj: Any) -> bool: + """Check if an object is backed (for AnnData-like objects).""" + return getattr(obj, "isbacked", False) + + +def get_backing_info(obj: Any) -> dict[str, Any]: + """Get information about backing for an AnnData-like object.""" + if not is_backed(obj): + return {"backed": False} + + info = { + "backed": True, + "filename": str(getattr(obj, "filename", None)), + } + + # Try to get file status + file_obj = getattr(obj, "file", None) + if file_obj is not None: + info["is_open"] = getattr(file_obj, "is_open", None) + + # Detect format from filename + filename = info["filename"] + if filename: + if filename.endswith(".h5ad"): + info["format"] = "H5AD" + elif ".zarr" in filename: + info["format"] = "Zarr" + else: + info["format"] = "Unknown" + + return info + + +# Basic named colors for color detection +_NAMED_COLORS = frozenset( + { + "red", + "green", + "blue", + "yellow", + "cyan", + "magenta", + "black", + "white", + "gray", + "grey", + "orange", + "pink", + "purple", + "brown", + "navy", + "teal", + "olive", + "maroon", + "lime", + "aqua", + "silver", + "fuchsia", + # CSS color names (partial list) + "aliceblue", + "antiquewhite", + "aquamarine", + "azure", + "beige", + "bisque", + "blanchedalmond", + "blueviolet", + "burlywood", + "cadetblue", + "chartreuse", + "chocolate", + "coral", + "cornflowerblue", + "cornsilk", + "crimson", + "darkblue", + "darkcyan", + "darkgoldenrod", + "darkgray", + "darkgreen", + "darkkhaki", + "darkmagenta", + "darkolivegreen", + "darkorange", + "darkorchid", + "darkred", + "darksalmon", + "darkseagreen", + "darkslateblue", + "darkslategray", + "darkturquoise", + "darkviolet", + "deeppink", + "deepskyblue", + "dimgray", + "dodgerblue", + "firebrick", + "floralwhite", + "forestgreen", + "gainsboro", + "ghostwhite", + "gold", + "goldenrod", + "greenyellow", + "honeydew", + "hotpink", + "indianred", + "indigo", + "ivory", + "khaki", + "lavender", + "lavenderblush", + "lawngreen", + "lemonchiffon", + "lightblue", + "lightcoral", + "lightcyan", + "lightgoldenrodyellow", + "lightgray", + "lightgreen", + "lightpink", + "lightsalmon", + "lightseagreen", + "lightskyblue", + "lightslategray", + "lightsteelblue", + "lightyellow", + "limegreen", + "linen", + "mediumaquamarine", + "mediumblue", + "mediumorchid", + "mediumpurple", + "mediumseagreen", + "mediumslateblue", + "mediumspringgreen", + "mediumturquoise", + "mediumvioletred", + "midnightblue", + "mintcream", + "mistyrose", + "moccasin", + "navajowhite", + "oldlace", + "olivedrab", + "orangered", + "orchid", + "palegoldenrod", + "palegreen", + "paleturquoise", + "palevioletred", + "papayawhip", + "peachpuff", + "peru", + "plum", + "powderblue", + "rosybrown", + "royalblue", + "saddlebrown", + "salmon", + "sandybrown", + "seagreen", + "seashell", + "sienna", + "skyblue", + "slateblue", + "slategray", + "snow", + "springgreen", + "steelblue", + "tan", + "thistle", + "tomato", + "turquoise", + "violet", + "wheat", + "whitesmoke", + "yellowgreen", + } +) diff --git a/src/anndata/_settings.py b/src/anndata/_settings.py index 672245d75..495df2386 100644 --- a/src/anndata/_settings.py +++ b/src/anndata/_settings.py @@ -509,5 +509,39 @@ def validate_sparse_settings(val: Any, settings: SettingsManager) -> None: ) +# HTML representation settings +settings.register( + "repr_html_enabled", + default_value=True, + description="Whether to use rich HTML representation in Jupyter notebooks. Set to False to use plain text repr.", + validate=validate_bool, + get_from_env=check_and_get_bool, +) + +settings.register( + "repr_html_fold_threshold", + default_value=5, + description="Auto-fold sections in HTML repr when they have more than this many entries.", + validate=validate_int, + get_from_env=check_and_get_int, +) + +settings.register( + "repr_html_max_depth", + default_value=3, + description="Maximum recursion depth for nested AnnData objects in HTML repr.", + validate=validate_int, + get_from_env=check_and_get_int, +) + +settings.register( + "repr_html_max_items", + default_value=200, + description="Maximum number of items to show per section in HTML repr.", + validate=validate_int, + get_from_env=check_and_get_int, +) + + ################################################################################## ################################################################################## diff --git a/src/anndata/_settings.pyi b/src/anndata/_settings.pyi index 5b16b74fc..683e7f434 100644 --- a/src/anndata/_settings.pyi +++ b/src/anndata/_settings.pyi @@ -45,5 +45,9 @@ class _AnnDataSettingsManager(SettingsManager): min_rows_for_chunked_h5_copy: int = 1000 disallow_forward_slash_in_h5ad: bool = False auto_shard_zarr_v3: bool = False + repr_html_enabled: bool = True + repr_html_fold_threshold: int = 5 + repr_html_max_depth: int = 3 + repr_html_max_items: int = 200 settings: _AnnDataSettingsManager diff --git a/tests/test_repr_html.py b/tests/test_repr_html.py new file mode 100644 index 000000000..4d8652c40 --- /dev/null +++ b/tests/test_repr_html.py @@ -0,0 +1,1132 @@ +""" +Tests for HTML representation of AnnData objects. + +This module tests: +- Basic HTML generation for various AnnData configurations +- Formatter registry pattern and extensibility +- Type formatters for all supported types +- Settings integration +- HTML/CSS/JS validation +- Special features (colors, warnings, search) +- Completeness of representation + +Target coverage: >95% +""" + +from __future__ import annotations + +import re +from html.parser import HTMLParser +from string import ascii_letters +from typing import TYPE_CHECKING, Any + +import numpy as np +import pandas as pd +import pytest +import scipy.sparse as sp + +import anndata as ad +from anndata import AnnData + +# Check optional dependencies +try: + import dask.array as da + + HAS_DASK = True +except ImportError: + HAS_DASK = False + +try: + import cupy as cp + + HAS_CUPY = True +except ImportError: + HAS_CUPY = False + +try: + import awkward as ak + + HAS_AWKWARD = True +except ImportError: + HAS_AWKWARD = False + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def adata(): + """Basic AnnData for testing.""" + return AnnData( + np.random.randn(100, 50).astype(np.float32), + obs=pd.DataFrame( + {"batch": ["A", "B"] * 50}, 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)], + ), + ) + + +@pytest.fixture +def adata_full(): + """AnnData with all attributes populated.""" + n_obs, n_vars = 100, 50 + adata = AnnData( + sp.random(n_obs, n_vars, density=0.1, format="csr", dtype=np.float32), + obs=pd.DataFrame( + { + "batch": pd.Categorical(["A", "B"] * (n_obs // 2)), + "n_counts": np.random.randint(1000, 10000, n_obs), + "cell_type": pd.Categorical( + ["T", "B", "NK"] * (n_obs // 3) + ["T"] * (n_obs % 3) + ), + } + ), + var=pd.DataFrame( + { + "gene_name": [f"gene_{i}" for i in range(n_vars)], + "highly_variable": np.random.choice([True, False], n_vars), + } + ), + ) + adata.uns["neighbors"] = {"params": {"n_neighbors": 15}} + adata.uns["batch_colors"] = ["#FF0000", "#00FF00"] + adata.obsm["X_pca"] = np.random.randn(n_obs, 50).astype(np.float32) + adata.obsm["X_umap"] = np.random.randn(n_obs, 2).astype(np.float32) + adata.varm["PCs"] = np.random.randn(n_vars, 50).astype(np.float32) + adata.layers["raw"] = sp.random(n_obs, n_vars, density=0.1, format="csr") + adata.obsp["distances"] = sp.random(n_obs, n_obs, density=0.01, format="csr") + adata.varp["gene_corr"] = sp.random(n_vars, n_vars, density=0.1, format="csr") + return adata + + +@pytest.fixture +def adata_with_colors(): + """AnnData with color annotations.""" + adata = AnnData(np.zeros((10, 5))) + adata.obs["cluster"] = pd.Categorical(["A", "B", "C"] * 3 + ["A"]) + adata.uns["cluster_colors"] = ["#FF0000", "#00FF00", "#0000FF"] + return adata + + +@pytest.fixture +def adata_with_nested(): + """AnnData with nested AnnData in uns.""" + inner = AnnData(np.zeros((5, 3))) + outer = AnnData(np.zeros((10, 5))) + outer.uns["nested_adata"] = inner + return outer + + +@pytest.fixture +def adata_with_special_chars(): + """AnnData with special characters in names.""" + adata = AnnData(np.zeros((10, 5))) + adata.obs["col tags from HTML to simulate no-JS environment.""" + import re + return re.sub(r'', '', html, flags=re.DOTALL) + + def main(): """Generate visual test HTML file.""" print("Generating visual test cases...") @@ -209,7 +231,7 @@ def main(): adata_full = create_test_anndata() sections.append(( "1. Full AnnData (all features)", - adata_full._repr_html_() + adata_full._repr_html_(), )) # Test 2: Empty AnnData @@ -217,7 +239,7 @@ def main(): adata_empty = AnnData() sections.append(( "2. Empty AnnData", - adata_empty._repr_html_() + adata_empty._repr_html_(), )) # Test 3: Minimal AnnData @@ -225,7 +247,7 @@ def main(): adata_minimal = AnnData(np.zeros((10, 5))) sections.append(( "3. Minimal AnnData (just X matrix)", - adata_minimal._repr_html_() + adata_minimal._repr_html_(), )) # Test 4: View @@ -233,7 +255,7 @@ def main(): view = adata_full[0:20, 0:10] sections.append(( "4. AnnData View (subset)", - view._repr_html_() + view._repr_html_(), )) # Test 5: Dense matrix @@ -243,7 +265,7 @@ def main(): adata_dense.uns["cluster_colors"] = ["#e41a1c", "#377eb8", "#4daf4a", "#984ea3", "#ff7f00"] sections.append(( "5. Dense Matrix with Categories", - adata_dense._repr_html_() + adata_dense._repr_html_(), )) # Test 6: Many columns (collapsed sections) @@ -255,7 +277,7 @@ def main(): adata_many.obsm[f"X_embedding_{i}"] = np.random.randn(20, 2).astype(np.float32) sections.append(( "6. Many Columns (tests auto-folding)", - adata_many._repr_html_() + adata_many._repr_html_(), )) # Test 7: Special characters @@ -267,7 +289,7 @@ def main(): adata_special.uns["unicode_日本語"] = "japanese" sections.append(( "7. Special Characters (XSS/Unicode test)", - adata_special._repr_html_() + adata_special._repr_html_(), )) # Test 8: Dask array (if available) @@ -277,7 +299,7 @@ def main(): adata_dask = AnnData(X_dask) sections.append(( "8. Dask Array (lazy/chunked)", - adata_dask._repr_html_() + adata_dask._repr_html_(), )) # Test 9: Nested AnnData at depth @@ -291,7 +313,47 @@ def main(): outer.uns["level1"] = inner1 sections.append(( "9. Deeply Nested AnnData (tests max depth)", - outer._repr_html_() + outer._repr_html_(), + )) + + # Test 10: Many categories (tests truncation) + print(" 10. Many categories (tests category truncation)") + adata_many_cats = AnnData(np.zeros((100, 10))) + # 15 categories - should show only first 5 (default) with "...+10" + many_cat_values = [f"type_{i}" for i in range(15)] * (100 // 15) + ["type_0"] * (100 % 15) + adata_many_cats.obs["cell_type"] = pd.Categorical(many_cat_values) + # Add colors for the categories + adata_many_cats.uns["cell_type_colors"] = [ + "#e41a1c", "#377eb8", "#4daf4a", "#984ea3", "#ff7f00", + "#ffff33", "#a65628", "#f781bf", "#999999", "#66c2a5", + "#fc8d62", "#8da0cb", "#e78ac3", "#a6d854", "#ffd92f" + ] + # Also add a column with exactly max categories (5) + adata_many_cats.obs["batch"] = pd.Categorical(["A", "B", "C", "D", "E"] * 20) + adata_many_cats.uns["batch_colors"] = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd"] + sections.append(( + "10. Many Categories (tests truncation)", + adata_many_cats._repr_html_(), + "cell_type has 15 categories (should show 5 + '...+10'). batch has exactly 5 (should show all).", + )) + + # Test 11: No JavaScript (graceful degradation) + print(" 11. No JavaScript (graceful degradation)") + adata_nojs = AnnData(np.random.randn(30, 15).astype(np.float32)) + adata_nojs.obs["group"] = pd.Categorical(["X", "Y", "Z"] * 10) + adata_nojs.uns["group_colors"] = ["#e41a1c", "#377eb8", "#4daf4a"] + for i in range(8): + adata_nojs.obs[f"metric_{i}"] = np.random.randn(30) + adata_nojs.obsm["X_pca"] = np.random.randn(30, 10).astype(np.float32) + adata_nojs.layers["raw"] = np.random.randn(30, 15).astype(np.float32) + # Strip script tags to simulate no-JS environment + nojs_html = strip_script_tags(adata_nojs._repr_html_()) + sections.append(( + "11. No JavaScript (graceful degradation)", + nojs_html, + "This example has script tags removed to simulate environments where JS is disabled. " + "All content should be visible, sections should be expanded, and interactive buttons " + "(fold icons, copy buttons, search, expand) should be hidden.", )) # Generate HTML file From 1dd4f181c51319a09a7d5fee4bdd3e45015a85b6 Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 28 Nov 2025 13:19:36 -0800 Subject: [PATCH 010/194] cnter folding icon in html rep --- src/anndata/_repr/css.py | 6 ++++++ src/anndata/_repr/html.py | 13 ++++++++++--- src/anndata/_repr/javascript.py | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/anndata/_repr/css.py b/src/anndata/_repr/css.py index ba1ca3bb4..27d816d21 100644 --- a/src/anndata/_repr/css.py +++ b/src/anndata/_repr/css.py @@ -265,10 +265,16 @@ def get_css() -> str: } .anndata-repr .ad-fold-icon { + display: inline-flex; + align-items: center; + justify-content: center; width: 16px; + height: 16px; font-size: 10px; color: var(--ad-text-muted); transition: transform 0.15s; + transform-origin: center; + flex-shrink: 0; } .anndata-repr .ad-section.collapsed .ad-fold-icon { diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index 7cccacb00..d153f9858 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -874,8 +874,11 @@ def _render_section_header( ) name_style = "font-weight:600;" count_style = "font-size:11px;" - # Fold icon hidden by default, shown via JS - fold_style = "width:16px;font-size:10px;display:none;" + # Fold icon hidden by default, shown via JS - centered for proper rotation + fold_style = ( + "display:none;width:16px;height:16px;font-size:10px;" + "align-items:center;justify-content:center;transform-origin:center;flex-shrink:0;" + ) link_style = "margin-left:auto;padding:2px 6px;font-size:11px;text-decoration:none;" parts = [f'
'] @@ -895,7 +898,11 @@ def _render_empty_section(name: str) -> str: "display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;" "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;" ) - fold_style = "width:16px;font-size:10px;display:none;" + # Fold icon hidden by default, shown via JS - centered for proper rotation + fold_style = ( + "display:none;width:16px;height:16px;font-size:10px;" + "align-items:center;justify-content:center;transform-origin:center;flex-shrink:0;" + ) name_style = "font-weight:600;" count_style = "font-size:11px;" content_style = "padding:0;overflow:hidden;" diff --git a/src/anndata/_repr/javascript.py b/src/anndata/_repr/javascript.py index 5032e89b6..f0bab9fb9 100644 --- a/src/anndata/_repr/javascript.py +++ b/src/anndata/_repr/javascript.py @@ -41,7 +41,7 @@ def get_javascript(container_id: str) -> str: // Show interactive elements (hidden by default for no-JS graceful degradation) container.querySelectorAll('.ad-fold-icon').forEach(icon => { - icon.style.display = 'inline'; + icon.style.display = 'inline-flex'; }); container.querySelectorAll('.ad-copy-btn').forEach(btn => { btn.style.display = 'inline-flex'; From 3db23cd2d8d25119155e10d87c46bfbda2e39a08 Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 28 Nov 2025 13:26:45 -0800 Subject: [PATCH 011/194] max rows for counting n-unique in html rep --- src/anndata/_repr/__init__.py | 2 ++ src/anndata/_repr/html.py | 14 +++++++++----- src/anndata/_settings.py | 8 ++++++++ src/anndata/_settings.pyi | 1 + 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py index 5619d6d9d..e368cbafc 100644 --- a/src/anndata/_repr/__init__.py +++ b/src/anndata/_repr/__init__.py @@ -23,6 +23,7 @@ DEFAULT_MAX_STRING_LENGTH = 100 # Truncate strings longer than this DEFAULT_PREVIEW_ITEMS = 5 # Number of items to show in previews (first/last) DEFAULT_MAX_CATEGORIES = 5 # Max category values to display inline +DEFAULT_UNIQUE_LIMIT = 1_000_000 # Max rows to compute unique counts (0 to disable) # Documentation base URL DOCS_BASE_URL = "https://anndata.readthedocs.io/en/latest/" @@ -48,6 +49,7 @@ "DEFAULT_MAX_STRING_LENGTH", "DEFAULT_PREVIEW_ITEMS", "DEFAULT_MAX_CATEGORIES", + "DEFAULT_UNIQUE_LIMIT", "DOCS_BASE_URL", "SECTION_ORDER", # Main function diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index d153f9858..860152b40 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -23,6 +23,7 @@ DEFAULT_MAX_DEPTH, DEFAULT_MAX_ITEMS, DEFAULT_PREVIEW_ITEMS, + DEFAULT_UNIQUE_LIMIT, DOCS_BASE_URL, SECTION_ORDER, ) @@ -482,11 +483,14 @@ def _render_dataframe_entry( parts.append(f'...+{remaining}') elif hasattr(col, "nunique"): - try: - n_unique = col.nunique() - parts.append(f'({n_unique} unique)') - except Exception: - pass + # Skip nunique() for very large columns to avoid performance issues + unique_limit = _get_setting("repr_html_unique_limit", DEFAULT_UNIQUE_LIMIT) + if unique_limit > 0 and len(col) <= unique_limit: + try: + n_unique = col.nunique() + parts.append(f'({n_unique} unique)') + except Exception: + pass parts.append("") diff --git a/src/anndata/_settings.py b/src/anndata/_settings.py index 6c434cb65..a59fe8e54 100644 --- a/src/anndata/_settings.py +++ b/src/anndata/_settings.py @@ -550,6 +550,14 @@ def validate_sparse_settings(val: Any, settings: SettingsManager) -> None: get_from_env=check_and_get_int, ) +settings.register( + "repr_html_unique_limit", + default_value=1_000_000, + description="Maximum number of rows to compute unique counts for in HTML repr. Set to 0 to disable.", + validate=validate_int, + get_from_env=check_and_get_int, +) + ################################################################################## ################################################################################## diff --git a/src/anndata/_settings.pyi b/src/anndata/_settings.pyi index abe24a93f..dab64b16d 100644 --- a/src/anndata/_settings.pyi +++ b/src/anndata/_settings.pyi @@ -50,5 +50,6 @@ class _AnnDataSettingsManager(SettingsManager): repr_html_max_depth: int = 3 repr_html_max_items: int = 200 repr_html_max_categories: int = 5 + repr_html_unique_limit: int = 1_000_000 settings: _AnnDataSettingsManager From d5974f66401d3e9700724a7f52a0f16e35a2dcbe Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 28 Nov 2025 13:28:36 -0800 Subject: [PATCH 012/194] header coloring in html rep --- src/anndata/_repr/css.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/anndata/_repr/css.py b/src/anndata/_repr/css.py index 27d816d21..d99156bcc 100644 --- a/src/anndata/_repr/css.py +++ b/src/anndata/_repr/css.py @@ -127,7 +127,7 @@ def get_css() -> str: .anndata-repr .ad-type { font-weight: 600; font-size: 14px; - color: var(--ad-accent-color); + color: var(--ad-text-primary); } .anndata-repr .ad-shape { From 5cd1dd5b13ff713ecf444489f8f4f5473699cb40 Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 28 Nov 2025 13:31:12 -0800 Subject: [PATCH 013/194] max 20 categories in html rep --- src/anndata/_repr/__init__.py | 2 +- src/anndata/_settings.py | 2 +- src/anndata/_settings.pyi | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py index e368cbafc..597b990a1 100644 --- a/src/anndata/_repr/__init__.py +++ b/src/anndata/_repr/__init__.py @@ -22,7 +22,7 @@ DEFAULT_MAX_ITEMS = 200 # Maximum items to show per section DEFAULT_MAX_STRING_LENGTH = 100 # Truncate strings longer than this DEFAULT_PREVIEW_ITEMS = 5 # Number of items to show in previews (first/last) -DEFAULT_MAX_CATEGORIES = 5 # Max category values to display inline +DEFAULT_MAX_CATEGORIES = 20 # Max category values to display inline DEFAULT_UNIQUE_LIMIT = 1_000_000 # Max rows to compute unique counts (0 to disable) # Documentation base URL diff --git a/src/anndata/_settings.py b/src/anndata/_settings.py index a59fe8e54..993a34406 100644 --- a/src/anndata/_settings.py +++ b/src/anndata/_settings.py @@ -544,7 +544,7 @@ def validate_sparse_settings(val: Any, settings: SettingsManager) -> None: settings.register( "repr_html_max_categories", - default_value=5, + default_value=20, description="Maximum number of category values to display inline in HTML repr.", validate=validate_int, get_from_env=check_and_get_int, diff --git a/src/anndata/_settings.pyi b/src/anndata/_settings.pyi index dab64b16d..26043c987 100644 --- a/src/anndata/_settings.pyi +++ b/src/anndata/_settings.pyi @@ -49,7 +49,7 @@ class _AnnDataSettingsManager(SettingsManager): repr_html_fold_threshold: int = 5 repr_html_max_depth: int = 3 repr_html_max_items: int = 200 - repr_html_max_categories: int = 5 + repr_html_max_categories: int = 20 repr_html_unique_limit: int = 1_000_000 settings: _AnnDataSettingsManager From ef178c562097e605f90ce8c889bf262045537686 Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 28 Nov 2025 13:32:26 -0800 Subject: [PATCH 014/194] udpate many cats viz test of html rep --- tests/visual_inspect_repr_html.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index d78b30e79..d0ef6a1f0 100644 --- a/tests/visual_inspect_repr_html.py +++ b/tests/visual_inspect_repr_html.py @@ -319,22 +319,30 @@ def main(): # Test 10: Many categories (tests truncation) print(" 10. Many categories (tests category truncation)") adata_many_cats = AnnData(np.zeros((100, 10))) - # 15 categories - should show only first 5 (default) with "...+10" - many_cat_values = [f"type_{i}" for i in range(15)] * (100 // 15) + ["type_0"] * (100 % 15) + # 30 categories - should show only first 20 (default) with "...+10" + many_cat_values = [f"type_{i}" for i in range(30)] * (100 // 30) + [f"type_{i}" for i in range(100 % 30)] adata_many_cats.obs["cell_type"] = pd.Categorical(many_cat_values) # Add colors for the categories adata_many_cats.uns["cell_type_colors"] = [ "#e41a1c", "#377eb8", "#4daf4a", "#984ea3", "#ff7f00", "#ffff33", "#a65628", "#f781bf", "#999999", "#66c2a5", - "#fc8d62", "#8da0cb", "#e78ac3", "#a6d854", "#ffd92f" + "#fc8d62", "#8da0cb", "#e78ac3", "#a6d854", "#ffd92f", + "#e5c494", "#b3b3b3", "#1b9e77", "#d95f02", "#7570b3", + "#e7298a", "#66a61e", "#e6ab02", "#a6761d", "#666666", + "#8dd3c7", "#ffffb3", "#bebada", "#fb8072", "#80b1d3", + ] + # Also add a column with exactly max categories (20) + adata_many_cats.obs["batch"] = pd.Categorical([f"batch_{i}" for i in range(20)] * 5) + adata_many_cats.uns["batch_colors"] = [ + "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", + "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf", + "#aec7e8", "#ffbb78", "#98df8a", "#ff9896", "#c5b0d5", + "#c49c94", "#f7b6d2", "#c7c7c7", "#dbdb8d", "#9edae5", ] - # Also add a column with exactly max categories (5) - adata_many_cats.obs["batch"] = pd.Categorical(["A", "B", "C", "D", "E"] * 20) - adata_many_cats.uns["batch_colors"] = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd"] sections.append(( "10. Many Categories (tests truncation)", adata_many_cats._repr_html_(), - "cell_type has 15 categories (should show 5 + '...+10'). batch has exactly 5 (should show all).", + "cell_type has 30 categories (should show 20 + '...+10'). batch has exactly 20 (should show all).", )) # Test 11: No JavaScript (graceful degradation) From 11949af666f12a16b8d80c95c6ad27e94fd717c9 Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 28 Nov 2025 15:25:53 -0800 Subject: [PATCH 015/194] robust html rep for ad blocker --- src/anndata/_repr/css.py | 441 ++++++++++++++++-------------- src/anndata/_repr/html.py | 155 +++++------ src/anndata/_repr/javascript.py | 60 ++-- tests/visual_inspect_repr_html.py | 1 + 4 files changed, 344 insertions(+), 313 deletions(-) diff --git a/src/anndata/_repr/css.py b/src/anndata/_repr/css.py index d99156bcc..0afc5bb2b 100644 --- a/src/anndata/_repr/css.py +++ b/src/anndata/_repr/css.py @@ -24,37 +24,37 @@ def get_css() -> str: .anndata-repr { /* CSS Variables - Light mode (default) */ - --ad-bg-primary: #ffffff; - --ad-bg-secondary: #f8f9fa; - --ad-bg-tertiary: #e9ecef; - --ad-text-primary: #212529; - --ad-text-secondary: #6c757d; - --ad-text-muted: #adb5bd; - --ad-border-color: #dee2e6; - --ad-border-light: #e9ecef; - --ad-accent-color: #0d6efd; - --ad-accent-hover: #0b5ed7; - --ad-warning-color: #ffc107; - --ad-warning-bg: #fff3cd; - --ad-error-color: #dc3545; - --ad-error-bg: #f8d7da; - --ad-success-color: #198754; - --ad-info-color: #0dcaf0; - --ad-link-color: #0d6efd; - --ad-code-bg: #f8f9fa; - --ad-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - --ad-radius: 4px; - --ad-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; - --ad-font-size: 13px; - --ad-line-height: 1.4; + --anndata-bg-primary: #ffffff; + --anndata-bg-secondary: #f8f9fa; + --anndata-bg-tertiary: #e9ecef; + --anndata-text-primary: #212529; + --anndata-text-secondary: #6c757d; + --anndata-text-muted: #adb5bd; + --anndata-border-color: #dee2e6; + --anndata-border-light: #e9ecef; + --anndata-accent-color: #0d6efd; + --anndata-accent-hover: #0b5ed7; + --anndata-warning-color: #ffc107; + --anndata-warning-bg: #fff3cd; + --anndata-error-color: #dc3545; + --anndata-error-bg: #f8d7da; + --anndata-success-color: #198754; + --anndata-info-color: #0dcaf0; + --anndata-link-color: #0d6efd; + --anndata-code-bg: #f8f9fa; + --anndata-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + --anndata-radius: 4px; + --anndata-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + --anndata-font-size: 13px; + --anndata-line-height: 1.4; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: var(--ad-font-size); - line-height: var(--ad-line-height); - color: var(--ad-text-primary); - background: var(--ad-bg-primary); - border: 1px solid var(--ad-border-color); - border-radius: var(--ad-radius); + font-size: var(--anndata-font-size); + line-height: var(--anndata-line-height); + color: var(--anndata-text-primary); + background: var(--anndata-bg-primary); + border: 1px solid var(--anndata-border-color); + border-radius: var(--anndata-radius); padding: 0; margin: 8px 0; max-width: 100%; @@ -64,25 +64,25 @@ def get_css() -> str: /* Dark mode - via media query */ @media (prefers-color-scheme: dark) { .anndata-repr { - --ad-bg-primary: #1e1e1e; - --ad-bg-secondary: #252526; - --ad-bg-tertiary: #2d2d2d; - --ad-text-primary: #e0e0e0; - --ad-text-secondary: #a0a0a0; - --ad-text-muted: #707070; - --ad-border-color: #404040; - --ad-border-light: #333333; - --ad-accent-color: #58a6ff; - --ad-accent-hover: #79b8ff; - --ad-warning-color: #d29922; - --ad-warning-bg: #3d3200; - --ad-error-color: #f85149; - --ad-error-bg: #3d1a1a; - --ad-success-color: #3fb950; - --ad-info-color: #58a6ff; - --ad-link-color: #58a6ff; - --ad-code-bg: #2d2d2d; - --ad-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + --anndata-bg-primary: #1e1e1e; + --anndata-bg-secondary: #252526; + --anndata-bg-tertiary: #2d2d2d; + --anndata-text-primary: #e0e0e0; + --anndata-text-secondary: #a0a0a0; + --anndata-text-muted: #707070; + --anndata-border-color: #404040; + --anndata-border-light: #333333; + --anndata-accent-color: #58a6ff; + --anndata-accent-hover: #79b8ff; + --anndata-warning-color: #d29922; + --anndata-warning-bg: #3d3200; + --anndata-error-color: #f85149; + --anndata-error-bg: #3d1a1a; + --anndata-success-color: #3fb950; + --anndata-info-color: #58a6ff; + --anndata-link-color: #58a6ff; + --anndata-code-bg: #2d2d2d; + --anndata-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); } } @@ -92,51 +92,56 @@ def get_css() -> str: body.vscode-dark .anndata-repr, body[data-vscode-theme-kind="vscode-dark"] .anndata-repr, body.dark-mode .anndata-repr { - --ad-bg-primary: #1e1e1e; - --ad-bg-secondary: #252526; - --ad-bg-tertiary: #2d2d2d; - --ad-text-primary: #e0e0e0; - --ad-text-secondary: #a0a0a0; - --ad-text-muted: #707070; - --ad-border-color: #404040; - --ad-border-light: #333333; - --ad-accent-color: #58a6ff; - --ad-accent-hover: #79b8ff; - --ad-warning-color: #d29922; - --ad-warning-bg: #3d3200; - --ad-error-color: #f85149; - --ad-error-bg: #3d1a1a; - --ad-success-color: #3fb950; - --ad-info-color: #58a6ff; - --ad-link-color: #58a6ff; - --ad-code-bg: #2d2d2d; - --ad-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + --anndata-bg-primary: #1e1e1e; + --anndata-bg-secondary: #252526; + --anndata-bg-tertiary: #2d2d2d; + --anndata-text-primary: #e0e0e0; + --anndata-text-secondary: #a0a0a0; + --anndata-text-muted: #707070; + --anndata-border-color: #404040; + --anndata-border-light: #333333; + --anndata-accent-color: #58a6ff; + --anndata-accent-hover: #79b8ff; + --anndata-warning-color: #d29922; + --anndata-warning-bg: #3d3200; + --anndata-error-color: #f85149; + --anndata-error-bg: #3d1a1a; + --anndata-success-color: #3fb950; + --anndata-info-color: #58a6ff; + --anndata-link-color: #58a6ff; + --anndata-code-bg: #2d2d2d; + --anndata-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); } /* Header */ -.anndata-repr .ad-header { +.anndata-repr .anndata-hdr { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; - background: var(--ad-bg-secondary); - border-bottom: 1px solid var(--ad-border-color); + background: #f8f9fa; /* Fallback */ + background: var(--anndata-bg-secondary); + border-bottom: 1px solid #dee2e6; /* Fallback */ + border-bottom: 1px solid var(--anndata-border-color); } -.anndata-repr .ad-type { +.anndata-repr .adata-type { font-weight: 600; font-size: 14px; - color: var(--ad-text-primary); + color: #212529; /* Fallback */ + color: var(--anndata-text-primary); } -.anndata-repr .ad-shape { - font-family: var(--ad-font-mono); +.anndata-repr .adata-shape { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; /* Fallback */ + font-family: var(--anndata-font-mono); font-size: 12px; - color: var(--ad-text-secondary); + color: #6c757d; /* Fallback */ + color: var(--anndata-text-secondary); } -.anndata-repr .ad-badge { +.anndata-repr .adata-badge { display: inline-flex; align-items: center; gap: 4px; @@ -147,235 +152,257 @@ def get_css() -> str: white-space: nowrap; } -.anndata-repr .ad-badge-view { - background: var(--ad-info-color); +.anndata-repr .adata-badge-view { + background: var(--anndata-info-color); color: white; } -.anndata-repr .ad-badge-backed { - background: var(--ad-success-color); +.anndata-repr .adata-badge-backed { + background: var(--anndata-success-color); color: white; } -.anndata-repr .ad-badge-extension { - background: var(--ad-accent-color); +.anndata-repr .adata-badge-extension { + background: var(--anndata-accent-color); color: white; } /* Search box */ -.anndata-repr .ad-search { +.anndata-repr .adata-search { padding: 8px 12px; - background: var(--ad-bg-primary); - border-bottom: 1px solid var(--ad-border-light); + background: var(--anndata-bg-primary); + border-bottom: 1px solid var(--anndata-border-light); } -.anndata-repr .ad-search-input { +.anndata-repr .adata-search-input { width: 100%; max-width: 300px; padding: 6px 10px; font-size: 12px; - border: 1px solid var(--ad-border-color); - border-radius: var(--ad-radius); - background: var(--ad-bg-primary); - color: var(--ad-text-primary); + 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 .ad-search-input:focus { - border-color: var(--ad-accent-color); +.anndata-repr .adata-search-input:focus { + border-color: var(--anndata-accent-color); } -.anndata-repr .ad-search-input::placeholder { - color: var(--ad-text-muted); +.anndata-repr .adata-search-input::placeholder { + color: var(--anndata-text-muted); } -.anndata-repr .ad-filter-indicator { +.anndata-repr .adata-filter-indicator { display: none; margin-left: 8px; font-size: 11px; - color: var(--ad-accent-color); + color: var(--anndata-accent-color); } -.anndata-repr .ad-filter-indicator.active { +.anndata-repr .adata-filter-indicator.active { display: inline; } /* Metadata bar */ -.anndata-repr .ad-metadata { +.anndata-repr .adata-metadata { display: flex; flex-wrap: wrap; gap: 12px; padding: 6px 12px; font-size: 11px; - color: var(--ad-text-secondary); - background: var(--ad-bg-tertiary); - border-bottom: 1px solid var(--ad-border-light); + color: var(--anndata-text-secondary); + background: var(--anndata-bg-tertiary); + border-bottom: 1px solid var(--anndata-border-light); } -.anndata-repr .ad-metadata span { +.anndata-repr .adata-metadata span { white-space: nowrap; } /* Index preview */ -.anndata-repr .ad-index-preview { +.anndata-repr .adata-index-preview { padding: 8px 12px; font-size: 11px; - font-family: var(--ad-font-mono); - color: var(--ad-text-secondary); - background: var(--ad-bg-primary); - border-bottom: 1px solid var(--ad-border-light); + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; /* Fallback */ + font-family: var(--anndata-font-mono); + color: #6c757d; /* Fallback */ + color: var(--anndata-text-secondary); + background: #ffffff; /* Fallback */ + background: var(--anndata-bg-primary); + border-bottom: 1px solid #e9ecef; /* Fallback */ + border-bottom: 1px solid var(--anndata-border-light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.anndata-repr .ad-index-preview strong { - color: var(--ad-text-primary); +.anndata-repr .adata-index-preview strong { + color: #212529; /* Fallback */ + color: var(--anndata-text-primary); font-weight: 500; } /* Sections container */ -.anndata-repr .ad-sections { +.anndata-repr .anndata-secs { padding: 0; } /* Individual section */ -.anndata-repr .ad-section { - border-bottom: 1px solid var(--ad-border-light); +.anndata-repr .anndata-sec { + border-bottom: 1px solid #e9ecef; /* Fallback */ + border-bottom: 1px solid var(--anndata-border-light); } -.anndata-repr .ad-section:last-child { +.anndata-repr .anndata-sec:last-child { border-bottom: none; } -.anndata-repr .ad-section-header { +.anndata-repr .anndata-sechdr { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; user-select: none; - background: var(--ad-bg-primary); + background: #ffffff; /* Fallback */ + background: var(--anndata-bg-primary); transition: background-color 0.15s; } -.anndata-repr .ad-section-header:hover { - background: var(--ad-bg-secondary); +.anndata-repr .anndata-sechdr:hover { + background: #f8f9fa; /* Fallback */ + background: var(--anndata-bg-secondary); } -.anndata-repr .ad-fold-icon { +.anndata-repr .adata-fold-icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; font-size: 10px; - color: var(--ad-text-muted); + color: #adb5bd; /* Fallback */ + color: var(--anndata-text-muted); transition: transform 0.15s; transform-origin: center; flex-shrink: 0; } -.anndata-repr .ad-section.collapsed .ad-fold-icon { +.anndata-repr .anndata-sec.collapsed .adata-fold-icon { transform: rotate(-90deg); } -.anndata-repr .ad-section-name { +.anndata-repr .anndata-sec-name { font-weight: 600; - color: var(--ad-text-primary); + color: #212529; /* Fallback */ + color: var(--anndata-text-primary); } -.anndata-repr .ad-section-count { +.anndata-repr .anndata-sec-count { font-size: 11px; - color: var(--ad-text-secondary); + color: #6c757d; /* Fallback */ + color: var(--anndata-text-secondary); } -.anndata-repr .ad-help-link { +.anndata-repr .adata-help-link { margin-left: auto; padding: 2px 6px; font-size: 11px; - color: var(--ad-text-muted); + color: #adb5bd; /* Fallback */ + color: var(--anndata-text-muted); text-decoration: none; - border-radius: var(--ad-radius); + border-radius: 4px; /* Fallback */ + border-radius: var(--anndata-radius); transition: color 0.15s, background-color 0.15s; } -.anndata-repr .ad-help-link:hover { - color: var(--ad-accent-color); - background: var(--ad-bg-tertiary); +.anndata-repr .adata-help-link:hover { + color: #0d6efd; /* Fallback */ + color: var(--anndata-accent-color); + background: #e9ecef; /* Fallback */ + background: var(--anndata-bg-tertiary); } /* Section content */ -.anndata-repr .ad-section-content { +.anndata-repr .anndata-seccontent { padding: 0; overflow: hidden; transition: max-height 0.2s ease-out; } -.anndata-repr .ad-section.collapsed .ad-section-content { +.anndata-repr .anndata-sec.collapsed .anndata-seccontent { max-height: 0 !important; padding: 0; } /* Entries table */ -.anndata-repr .ad-table { +.anndata-repr .adata-table { width: 100%; border-collapse: collapse; font-size: 12px; } -.anndata-repr .ad-entry { - border-bottom: 1px solid var(--ad-border-light); +.anndata-repr .adata-entry { + border-bottom: 1px solid #e9ecef; /* Fallback */ + border-bottom: 1px solid var(--anndata-border-light); transition: background-color 0.1s; } -.anndata-repr .ad-entry:last-child { +.anndata-repr .adata-entry:last-child { border-bottom: none; } -.anndata-repr .ad-entry:hover { - background: var(--ad-bg-secondary); +.anndata-repr .adata-entry:hover { + background: #f8f9fa; /* Fallback */ + background: var(--anndata-bg-secondary); } -.anndata-repr .ad-entry.hidden { +.anndata-repr .adata-entry.hidden { display: none; } -.anndata-repr .ad-entry.warning { - background: var(--ad-warning-bg); +.anndata-repr .adata-entry.warning { + background: var(--anndata-warning-bg); } -.anndata-repr .ad-entry.error { - background: var(--ad-error-bg); +.anndata-repr .adata-entry.error { + background: var(--anndata-error-bg); } -.anndata-repr .ad-entry td { +.anndata-repr .adata-entry td { padding: 6px 12px; vertical-align: middle; } -.anndata-repr .ad-entry-name { - font-family: var(--ad-font-mono); +.anndata-repr .adata-entry-name { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; /* Fallback */ + font-family: var(--anndata-font-mono); font-weight: 500; - color: var(--ad-text-primary); + color: #212529; /* Fallback */ + color: var(--anndata-text-primary); white-space: nowrap; } -.anndata-repr .ad-entry-type { - font-family: var(--ad-font-mono); +.anndata-repr .adata-entry-type { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; /* Fallback */ + font-family: var(--anndata-font-mono); font-size: 11px; - color: var(--ad-text-secondary); + color: #6c757d; /* Fallback */ + color: var(--anndata-text-secondary); } -.anndata-repr .ad-entry-meta { +.anndata-repr .adata-entry-meta { font-size: 11px; - color: var(--ad-text-muted); + color: #adb5bd; /* Fallback */ + color: var(--anndata-text-muted); text-align: right; } /* Copy button */ -.anndata-repr .ad-copy-btn { +.anndata-repr .adata-copy-btn { display: inline-flex; align-items: center; justify-content: center; @@ -384,7 +411,7 @@ def get_css() -> str: margin-left: 4px; padding: 0; font-size: 11px; - color: var(--ad-text-muted); + color: var(--anndata-text-muted); background: transparent; border: none; border-radius: 3px; @@ -393,17 +420,17 @@ def get_css() -> str: transition: opacity 0.15s, color 0.15s, background-color 0.15s; } -.anndata-repr .ad-entry:hover .ad-copy-btn { +.anndata-repr .adata-entry:hover .adata-copy-btn { opacity: 1; } -.anndata-repr .ad-copy-btn:hover { - color: var(--ad-accent-color); - background: var(--ad-bg-tertiary); +.anndata-repr .adata-copy-btn:hover { + color: var(--anndata-accent-color); + background: var(--anndata-bg-tertiary); } -.anndata-repr .ad-copy-btn.copied { - color: var(--ad-success-color); +.anndata-repr .adata-copy-btn.copied { + color: var(--anndata-success-color); } /* Type-specific styling */ @@ -419,7 +446,7 @@ def get_css() -> str: .anndata-repr .dtype-anndata { color: #cf222e; font-weight: 600; } .anndata-repr .dtype-unknown { color: #6e7781; font-style: italic; } .anndata-repr .dtype-extension { color: #8250df; } -.anndata-repr .dtype-warning { color: var(--ad-warning-color); } +.anndata-repr .dtype-warning { color: var(--anndata-warning-color); } .anndata-repr .dtype-dask { color: #fb8500; } .anndata-repr .dtype-gpu { color: #76b900; } .anndata-repr .dtype-awkward { color: #e85d04; } @@ -466,133 +493,135 @@ def get_css() -> str: body.dark-mode .anndata-repr .dtype-anndata { color: #ff7b72; } /* Color swatches */ -.anndata-repr .ad-color-swatches { +.anndata-repr .adata-color-swatches { display: inline-flex; gap: 2px; margin-left: 6px; vertical-align: middle; } -.anndata-repr .ad-color-swatch { +.anndata-repr .adata-color-swatch { display: inline-block; width: 12px; height: 12px; border-radius: 2px; - border: 1px solid var(--ad-border-color); + border: 1px solid var(--anndata-border-color); } /* Warning indicator */ -.anndata-repr .ad-warning-icon { - color: var(--ad-warning-color); +.anndata-repr .adata-warning-icon { + color: var(--anndata-warning-color); margin-left: 4px; } /* Expandable nested content */ -.anndata-repr .ad-expand-btn { +.anndata-repr .adata-expand-btn { padding: 2px 8px; font-size: 11px; - color: var(--ad-accent-color); + color: var(--anndata-accent-color); background: transparent; - border: 1px solid var(--ad-accent-color); - border-radius: var(--ad-radius); + border: 1px solid var(--anndata-accent-color); + border-radius: var(--anndata-radius); cursor: pointer; transition: background-color 0.15s, color 0.15s; } -.anndata-repr .ad-expand-btn:hover { - background: var(--ad-accent-color); +.anndata-repr .adata-expand-btn:hover { + background: var(--anndata-accent-color); color: white; } /* Nested row (contains nested AnnData) */ -.anndata-repr .ad-nested-row { +.anndata-repr .adata-nested-row { display: none; } -.anndata-repr .ad-nested-row.expanded { +.anndata-repr .adata-nested-row.expanded { display: table-row; } -.anndata-repr .ad-nested-content { +.anndata-repr .adata-nested-content { padding: 8px 12px 8px 24px; - background: var(--ad-bg-secondary); - border-top: 1px solid var(--ad-border-light); + background: var(--anndata-bg-secondary); + border-top: 1px solid var(--anndata-border-light); } /* Nested AnnData */ -.anndata-repr .ad-nested-anndata { +.anndata-repr .adata-nested-anndata { margin: 8px 0; - border: 1px solid var(--ad-border-color); - border-radius: var(--ad-radius); + border: 1px solid var(--anndata-border-color); + border-radius: var(--anndata-radius); } /* X section (special) */ -.anndata-repr .ad-x-section { +.anndata-repr .adata-x-section { padding: 8px 12px; - background: var(--ad-bg-secondary); - border-bottom: 1px solid var(--ad-border-light); + background: var(--anndata-bg-secondary); + border-bottom: 1px solid var(--anndata-border-light); } -.anndata-repr .ad-x-info { +.anndata-repr .adata-x-info { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 12px; } -.anndata-repr .ad-x-info dt { - color: var(--ad-text-secondary); +.anndata-repr .adata-x-info dt { + color: var(--anndata-text-secondary); font-weight: 500; } -.anndata-repr .ad-x-info dd { +.anndata-repr .adata-x-info dd { margin: 0; - font-family: var(--ad-font-mono); - color: var(--ad-text-primary); + font-family: var(--anndata-font-mono); + color: var(--anndata-text-primary); } /* Truncation indicator */ -.anndata-repr .ad-truncated { +.anndata-repr .adata-truncated { padding: 8px 12px; font-size: 11px; - color: var(--ad-text-muted); + color: var(--anndata-text-muted); text-align: center; font-style: italic; } /* Max depth indicator */ -.anndata-repr .ad-max-depth { +.anndata-repr .adata-max-depth { padding: 8px 12px; font-size: 11px; - color: var(--ad-text-muted); - background: var(--ad-bg-tertiary); - border-radius: var(--ad-radius); + color: var(--anndata-text-muted); + background: var(--anndata-bg-tertiary); + border-radius: var(--anndata-radius); text-align: center; } /* Empty section indicator */ -.anndata-repr .ad-empty { +.anndata-repr .adata-empty { padding: 8px 12px; font-size: 11px; - color: var(--ad-text-muted); + color: var(--anndata-text-muted); font-style: italic; } /* Footer */ -.anndata-repr .ad-footer { - color: var(--ad-text-muted); - border-top: 1px solid var(--ad-border-light); +.anndata-repr .anndata-ftr { + color: var(--anndata-text-muted); + border-top: 1px solid var(--anndata-border-light); } /* Muted text helper */ -.anndata-repr .ad-text-muted { - color: var(--ad-text-muted); +.anndata-repr .adata-text-muted { + color: var(--anndata-text-muted); } /* X entry row */ -.anndata-repr .ad-x-entry { - border-bottom: 1px solid var(--ad-border-light); - color: var(--ad-text-secondary); +.anndata-repr .adata-x-entry { + border-bottom: 1px solid #e9ecef; /* Fallback */ + border-bottom: 1px solid var(--anndata-border-light); + color: #6c757d; /* Fallback */ + color: var(--anndata-text-secondary); } /* Tooltip */ @@ -611,7 +640,7 @@ def get_css() -> str: font-weight: normal; color: white; background: #333; - border-radius: var(--ad-radius); + border-radius: var(--anndata-radius); white-space: nowrap; z-index: 1000; pointer-events: none; diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index 860152b40..82c0adafd 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -135,14 +135,14 @@ def generate_repr_html( # Header (with search box integrated on the right) if show_header: - parts.append(_render_header(adata, show_search=show_search and depth == 0)) + parts.append(_render_header(adata, show_search=show_search and depth == 0, container_id=container_id)) # Index preview (only at top level) if depth == 0: parts.append(_render_index_preview(adata)) # Sections container - parts.append('
') + parts.append('
') # X as a simple entry (like layers) parts.append(_render_x_entry(adata, context)) @@ -170,7 +170,7 @@ def generate_repr_html( ) ) - parts.append("
") # ad-sections + parts.append("
") # adata-sections # Footer with metadata (only at top level) if depth == 0: @@ -190,7 +190,7 @@ def generate_repr_html( # ============================================================================= -def _render_header(adata: AnnData, *, show_search: bool = False) -> str: +def _render_header(adata: AnnData, *, show_search: bool = False, container_id: str = "") -> str: """Render the header with type, shape, badges, and optional search box.""" # Use inline styles for layout only - colors handled by CSS for dark mode support header_style = ( @@ -198,21 +198,21 @@ def _render_header(adata: AnnData, *, show_search: bool = False) -> str: "padding:8px 12px;" "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;" ) - parts = [f'
'] + parts = [f'
'] # Type name - allow for extension types type_name = type(adata).__name__ type_style = "font-weight:600;font-size:14px;" - parts.append(f'{escape_html(type_name)}') + parts.append(f'{escape_html(type_name)}') # Shape shape_str = f"{format_number(adata.n_obs)} obs × {format_number(adata.n_vars)} vars" shape_style = "font-family:ui-monospace,monospace;font-size:12px;" - parts.append(f'{shape_str}') + parts.append(f'{shape_str}') # Badges if is_view(adata): - parts.append('View') + parts.append('View') if is_backed(adata): backing = get_backing_info(adata) @@ -220,13 +220,13 @@ def _render_header(adata: AnnData, *, show_search: bool = False) -> str: format_str = backing.get("format", "") status = "Open" if backing.get("is_open") else "Closed" parts.append( - f'' + f'' f'📁 {format_str} ({status})' ) # Check for extension type (not standard AnnData) if type_name != "AnnData": - parts.append(f'{type_name}') + parts.append(f'{type_name}') # Search box on the right (spacer pushes it right) if show_search: @@ -234,12 +234,13 @@ def _render_header(adata: AnnData, *, show_search: bool = False) -> str: parts.append(f'') # Search input hidden by default (JS shows it) - filter indicator uses CSS .active class search_style = "display:none;padding:4px 8px;font-size:11px;border-radius:4px;outline:none;width:150px;" + search_id = f"{container_id}-search" if container_id else "anndata-search" parts.append( - f'' ) - # Filter indicator visibility controlled by CSS (.ad-filter-indicator.active) - no inline style - parts.append('') + # Filter indicator visibility controlled by CSS (.adata-filter-indicator.active) - no inline style + parts.append('') parts.append("
") return "\n".join(parts) @@ -252,7 +253,7 @@ def _render_footer(adata: AnnData) -> str: "font-size:10px;" "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;" ) - parts = [f'") # close entries grid + parts.append("
") # close section return "\n".join(parts) diff --git a/src/anndata/_repr/static/repr.css b/src/anndata/_repr/static/repr.css index 7c870aa96..976b1b262 100644 --- a/src/anndata/_repr/static/repr.css +++ b/src/anndata/_repr/static/repr.css @@ -92,10 +92,6 @@ /* --- JS-enabled overrides --- */ &.anndata-repr--js { - .anndata-section__table { - table-layout: fixed; - } - .anndata-entry__preview { overflow: hidden; text-overflow: ellipsis; @@ -487,13 +483,15 @@ overflow: hidden; } - .anndata-section__table { - width: 100%; - border-collapse: collapse; + .anndata-section__entries { + display: grid; + grid-template-columns: var(--anndata-name-col-width, 150px) var(--anndata-type-col-width, 180px) 1fr; font-size: 12px; + width: 100%; } .anndata-section__truncated { + grid-column: 1 / -1; padding: 8px 12px; font-size: 11px; color: var(--anndata-text-muted); @@ -511,6 +509,7 @@ /* --- Entries --- */ .anndata-entry { + grid-column: 1 / -1; transition: background-color 0.1s; &:nth-child(even) { @@ -533,16 +532,53 @@ background: var(--anndata-error-bg) !important; } - td { - padding: 6px 12px; - vertical-align: middle; - } - &:hover .anndata-entry__copy { opacity: 1; } } + /* Regular (non-expandable) entries use subgrid for column alignment */ + div.anndata-entry { + display: grid; + grid-template-columns: subgrid; + } + + /* Expandable entry summary — uses explicit columns (not subgrid) + so nested content below is a normal block child at full width */ + details.anndata-entry > summary.anndata-entry__summary { + display: grid; + grid-template-columns: var(--anndata-name-col-width, 150px) var(--anndata-type-col-width, 180px) 1fr; + list-style: none; + cursor: pointer; + position: relative; + + &::-webkit-details-marker { + display: none; + } + + /* Expand indicator arrow */ + &::after { + content: "\25B6"; /* ▶ */ + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + font-size: 8px; + color: var(--anndata-accent-color); + transition: transform 0.15s; + } + } + + /* When open, rotate the arrow */ + details.anndata-entry[open] > summary.anndata-entry__summary::after { + transform: translateY(-50%) rotate(90deg); + } + + /* Reserve space for the expand indicator */ + details.anndata-entry > summary.anndata-entry__summary > .anndata-entry__preview { + padding-right: 24px; + } + .anndata-entry__name { font-family: var( --anndata-font-mono, @@ -558,8 +594,9 @@ color: var(--anndata-text-primary, #212529); white-space: nowrap; text-align: left; - /* Fixed width for name column */ - width: var(--anndata-name-col-width, 150px); + padding: 6px 12px; + align-self: center; + min-width: 0; } .anndata-entry__name-inner { @@ -592,9 +629,8 @@ color: var(--anndata-text-secondary, #6c757d); text-align: left; white-space: nowrap; - /* Fixed width for consistent alignment (dtype names are predictable) */ - width: var(--anndata-type-col-width, 180px); - min-width: var(--anndata-type-col-width, 180px); + padding: 6px 12px; + align-self: center; } .anndata-entry__preview { @@ -604,7 +640,9 @@ /* Default: allow wrapping for graceful no-JS degradation */ white-space: normal; word-break: break-word; - /* Takes remaining space */ + padding: 6px 12px; + align-self: center; + min-width: 0; } /* Copy button */ @@ -686,41 +724,10 @@ margin-left: 2px; } - /* Expandable nested content */ - .anndata-entry__expand { - padding: 2px 8px; - font-size: 11px; - color: var(--anndata-accent-color); - background: transparent; - border: 1px solid var(--anndata-accent-color); - border-radius: var(--anndata-radius); - cursor: pointer; - transition: - background-color 0.15s, - color 0.15s; - vertical-align: middle; - margin-left: 4px; - - &:hover { - background: var(--anndata-accent-color); - color: white; - } - } - - .anndata-entry--nested { - display: none; - - &.anndata-entry--expanded { - display: table-row; - } - } - + /* Nested content area (inside expandable entries) */ .anndata-entry__nested-content { - /* Padding here doesn't accumulate because each nested-content is a fresh table cell */ padding: 12px; background: var(--anndata-bg-secondary); - /* Constrain width to prevent wide tables from expanding the layout */ - max-width: 1px; overflow: hidden; } @@ -728,9 +735,6 @@ display: block; overflow-x: auto; overflow-y: hidden; - /* Use width:0 + min-width:100% trick to prevent table from expanding container */ - width: 0; - min-width: 100%; padding: 12px; box-sizing: border-box; -webkit-overflow-scrolling: touch; @@ -789,7 +793,7 @@ } } - /* Nested AnnData wrapper - no extra padding */ + /* Nested AnnData wrapper — fill width, no extra padding */ &:has(> .anndata-entry__nested-anndata) { padding: 0; text-align: left; @@ -797,16 +801,16 @@ > .anndata-entry__nested-anndata { display: block; + width: 100%; margin: 0; - max-width: 100%; box-sizing: border-box; } } - /* Nested .anndata-repr should not add extra margin and must fit within parent */ + /* Nested .anndata-repr must fill its container */ .anndata-entry__nested-anndata > .anndata-repr { margin: 0; - max-width: 100%; + width: 100%; box-sizing: border-box; } diff --git a/src/anndata/_repr/static/repr.js b/src/anndata/_repr/static/repr.js index 9d9747d8b..cc0a640e4 100644 --- a/src/anndata/_repr/static/repr.js +++ b/src/anndata/_repr/static/repr.js @@ -12,9 +12,6 @@ container.querySelectorAll(".anndata-entry__copy").forEach((btn) => { container.querySelectorAll(".anndata-search__box").forEach((box) => { box.style.display = "inline-flex"; }); -container.querySelectorAll(".anndata-entry__expand").forEach((btn) => { - btn.style.display = "inline-block"; -}); container.querySelectorAll(".anndata-search__toggle").forEach((btn) => { btn.style.display = "inline-flex"; }); @@ -139,14 +136,11 @@ function filterEntries(query) { ".anndata-entry__nested-content", ); if (nestedContent) { - const nestedRow = nestedContent.closest( - ".anndata-entry--nested", + const expandableEntry = nestedContent.closest( + "details.anndata-entry", ); - if ( - nestedRow && - !nestedRow.classList.contains("anndata-entry--expanded") - ) { - nestedRow.classList.add("anndata-entry--expanded"); + if (expandableEntry && !expandableEntry.open) { + expandableEntry.open = true; } } } else { @@ -177,28 +171,30 @@ function filterEntries(query) { ); if (!nestedContainer) break; - // Find the parent row that contains this nested content - // Structure: tr.anndata-entry > tr.anndata-entry--nested > td.anndata-entry__nested-content - const nestedRow = nestedContainer.closest( - ".anndata-entry--nested", + // Find the parent entry that contains this nested content + // Structure: div.anndata-entry > details > .anndata-entry__nested-content + const parentEntry = nestedContainer.closest( + ".anndata-entry", ); - if (!nestedRow) break; + if (!parentEntry) break; - // The parent entry is the previous sibling row - const parentEntry = nestedRow.previousElementSibling; if ( - parentEntry && - parentEntry.classList.contains("anndata-entry") + parentEntry.classList.contains("anndata-entry--hidden") ) { - if ( - parentEntry.classList.contains("anndata-entry--hidden") - ) { - parentEntry.classList.remove("anndata-entry--hidden"); - totalMatches++; - } + parentEntry.classList.remove("anndata-entry--hidden"); + totalMatches++; } - // Continue searching from the parent's container - element = nestedRow.parentElement; + + // Open the expandable entry so nested content is visible + const expandableEntry = nestedContainer.closest( + "details.anndata-entry", + ); + if (expandableEntry && !expandableEntry.open) { + expandableEntry.open = true; + } + + // Continue searching from the parent entry's container + element = parentEntry.parentElement; } }); } @@ -297,38 +293,6 @@ container.querySelectorAll(".anndata-entry__copy").forEach((btn) => { }); }); -// Expand/collapse nested content -container.querySelectorAll(".anndata-entry__expand").forEach((btn) => { - btn.addEventListener("click", (e) => { - e.stopPropagation(); - - const entry = btn.closest(".anndata-entry"); - if (!entry) return; - - // The nested content is in a sibling - // which contains - const nestedRow = entry.nextElementSibling; - if ( - !nestedRow || - !nestedRow.classList.contains("anndata-entry--nested") - ) - return; - - const nestedContent = nestedRow.querySelector( - ".anndata-entry__nested-content", - ); - if (!nestedContent) return; - - const isExpanded = nestedRow.classList.toggle( - "anndata-entry--expanded", - ); - - btn.textContent = isExpanded ? "Collapse ▲" : "Expand ▼"; - btn.setAttribute("aria-expanded", isExpanded); - nestedRow.setAttribute("aria-hidden", !isExpanded); - }); -}); - // Helper to check if element is overflowing function isOverflowing(el) { return el.scrollWidth > el.clientWidth; @@ -349,8 +313,8 @@ function updateWrapButtonVisibility(btn, list, metaCell, wrappedClass) { // Factory function to set up wrap button handlers (DRY pattern for cats/cols buttons) function setupWrapButtons(buttonSelector, listSelector, wrappedClass) { container.querySelectorAll(buttonSelector).forEach((btn) => { - const typeCell = btn.closest(".anndata-entry__type"); - const metaCell = typeCell ? typeCell.nextElementSibling : null; + const entry = btn.closest(".anndata-entry"); + const metaCell = entry ? entry.querySelector(".anndata-entry__preview") : null; const list = metaCell ? metaCell.querySelector(listSelector) : null; // Initial visibility check @@ -400,8 +364,8 @@ function updateAllWrapButtons() { ], ].forEach(([btnSel, listSel, wrappedClass]) => { container.querySelectorAll(btnSel).forEach((btn) => { - const typeCell = btn.closest(".anndata-entry__type"); - const metaCell = typeCell ? typeCell.nextElementSibling : null; + const entry = btn.closest(".anndata-entry"); + const metaCell = entry ? entry.querySelector(".anndata-entry__preview") : null; const list = metaCell ? metaCell.querySelector(listSel) : null; updateWrapButtonVisibility(btn, list, metaCell, wrappedClass); }); diff --git a/src/anndata/_repr_constants.py b/src/anndata/_repr_constants.py index b37b3c5aa..91d9d0e1d 100644 --- a/src/anndata/_repr_constants.py +++ b/src/anndata/_repr_constants.py @@ -32,8 +32,8 @@ # Field name column width calculation constants # These values are empirically tuned for the default 13px monospace font CHAR_WIDTH_PX = 8 # Average character width for monospace at 13px font-size -COPY_BUTTON_PADDING_PX = 30 # Extra space for copy button and cell padding -MIN_FIELD_WIDTH_PX = 80 # Minimum column width to avoid cramped display +COPY_BUTTON_PADDING_PX = 54 # Extra space for copy button + cell padding (24px for grid border-box) +MIN_FIELD_WIDTH_PX = 104 # Minimum column width (includes 24px cell padding) DEFAULT_FIELD_WIDTH_PX = 100 # Default when no field names exist # Inline style for graceful degradation (hidden until JS enables). @@ -63,9 +63,6 @@ DF_COLS_PREVIEW_LIMIT = 5 # Max columns to show in compact preview DF_COLS_PREVIEW_MAX_LEN = 40 # Max total chars for column list string -# Table structure -ENTRY_TABLE_COLSPAN = 3 # Number of columns in entry table (name, type, preview) - # CSS class names for entry rows (BEM: anndata-entry block) CSS_ENTRY = "anndata-entry" CSS_ENTRY_NAME = "anndata-entry__name" @@ -74,7 +71,6 @@ CSS_TEXT_MUTED = "anndata-text--muted" CSS_TEXT_ERROR = "anndata-text--error" CSS_TEXT_WARNING = "anndata-text--warning" -CSS_NESTED_ROW = "anndata-entry--nested" CSS_NESTED_CONTENT = "anndata-entry__nested-content" CSS_NESTED_ANNDATA = "anndata-entry__nested-anndata" diff --git a/tests/repr/test_repr_ui.py b/tests/repr/test_repr_ui.py index d6d513d4c..45d366913 100644 --- a/tests/repr/test_repr_ui.py +++ b/tests/repr/test_repr_ui.py @@ -305,7 +305,7 @@ def test_visual_rendering_without_adblocker(self): assert "anndata-header__type" in html or "anndata" in html.lower() assert "anndata-entry" in html or "anndata-section" in html - assert "anndata-section__table" in html or "table" in html.lower() + assert "anndata-section__entries" in html or "anndata-entry" in html assert len(html) > 1000 diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index 19ba41cc3..7bd98aee5 100644 --- a/tests/visual_inspect_repr_html.py +++ b/tests/visual_inspect_repr_html.py @@ -1913,6 +1913,13 @@ def format(self, obj, context): adata_nojs.obs[f"metric_{i}"] = np.random.randn(30) adata_nojs.obsm["X_pca"] = np.random.randn(30, 10).astype(np.float32) adata_nojs.layers["raw"] = np.random.randn(30, 15).astype(np.float32) + # Add nested AnnData to test native
expand without JS + adata_nojs.uns["nested_adata"] = AnnData( + np.zeros((5, 3)), + obs=pd.DataFrame({"label": ["A", "B", "C", "D", "E"]}), + ) + # Add raw section to test raw rendering without JS + adata_nojs.raw = adata_nojs.copy() # Add a DataFrame with many columns to test column list wrapping without JS adata_nojs.obsm["cell_measurements"] = pd.DataFrame( { @@ -1947,8 +1954,10 @@ def format(self, obj, context): "This example has script tags removed to simulate environments where JS is disabled. " "All content should be visible, sections should be expanded, category lists and " "DataFrame column lists should wrap naturally to multiple lines, and interactive buttons " - "(fold icons, copy buttons, search, expand, wrap toggle) should be hidden. " - "The obsm 'cell_measurements' DataFrame has 20 columns to test column list wrapping.", + "(fold icons, copy buttons, search, wrap toggle) should be hidden. " + "The obsm 'cell_measurements' DataFrame has 20 columns to test column list wrapping. " + "Includes a nested AnnData in uns and a raw section — both use native <details> " + "for expand/collapse which works without JS.", )) # Test 14: Custom sections example using TreeData (if available) From 7cc08b9672723d0d44f8ad7e3e0d92307793f09c Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 20 Feb 2026 14:55:36 -0800 Subject: [PATCH 179/194] ruff corrections --- src/anndata/_repr/sections.py | 4 +--- src/anndata/_repr/static/repr.css | 14 ++++++++++---- src/anndata/_repr/static/repr.js | 16 ++++++++-------- src/anndata/_repr_constants.py | 4 +++- tests/repr/test_repr_core.py | 6 +++--- 5 files changed, 25 insertions(+), 19 deletions(-) diff --git a/src/anndata/_repr/sections.py b/src/anndata/_repr/sections.py index 7eb312183..25e957553 100644 --- a/src/anndata/_repr/sections.py +++ b/src/anndata/_repr/sections.py @@ -477,9 +477,7 @@ def _render_raw_section( # Single row with raw info type_str = f"{format_number(n_obs)} obs × {format_number(n_vars)} var" - parts.append( - render_entry_row_open("raw", "Raw", has_expandable_content=can_expand) - ) + parts.append(render_entry_row_open("raw", "Raw", has_expandable_content=can_expand)) parts.append(render_name_cell("raw")) type_cell_config = TypeCellConfig( type_name=type_str, diff --git a/src/anndata/_repr/static/repr.css b/src/anndata/_repr/static/repr.css index 976b1b262..525479cdb 100644 --- a/src/anndata/_repr/static/repr.css +++ b/src/anndata/_repr/static/repr.css @@ -485,7 +485,10 @@ .anndata-section__entries { display: grid; - grid-template-columns: var(--anndata-name-col-width, 150px) var(--anndata-type-col-width, 180px) 1fr; + grid-template-columns: var(--anndata-name-col-width, 150px) var( + --anndata-type-col-width, + 180px + ) 1fr; font-size: 12px; width: 100%; } @@ -547,7 +550,10 @@ so nested content below is a normal block child at full width */ details.anndata-entry > summary.anndata-entry__summary { display: grid; - grid-template-columns: var(--anndata-name-col-width, 150px) var(--anndata-type-col-width, 180px) 1fr; + grid-template-columns: var(--anndata-name-col-width, 150px) var( + --anndata-type-col-width, + 180px + ) 1fr; list-style: none; cursor: pointer; position: relative; @@ -575,7 +581,8 @@ } /* Reserve space for the expand indicator */ - details.anndata-entry > summary.anndata-entry__summary > .anndata-entry__preview { + details.anndata-entry > summary.anndata-entry__summary + > .anndata-entry__preview { padding-right: 24px; } @@ -1041,4 +1048,3 @@ color: var(--anndata-warning-color); } } - diff --git a/src/anndata/_repr/static/repr.js b/src/anndata/_repr/static/repr.js index cc0a640e4..c3e990f72 100644 --- a/src/anndata/_repr/static/repr.js +++ b/src/anndata/_repr/static/repr.js @@ -173,14 +173,10 @@ function filterEntries(query) { // Find the parent entry that contains this nested content // Structure: div.anndata-entry > details > .anndata-entry__nested-content - const parentEntry = nestedContainer.closest( - ".anndata-entry", - ); + const parentEntry = nestedContainer.closest(".anndata-entry"); if (!parentEntry) break; - if ( - parentEntry.classList.contains("anndata-entry--hidden") - ) { + if (parentEntry.classList.contains("anndata-entry--hidden")) { parentEntry.classList.remove("anndata-entry--hidden"); totalMatches++; } @@ -314,7 +310,9 @@ function updateWrapButtonVisibility(btn, list, metaCell, wrappedClass) { function setupWrapButtons(buttonSelector, listSelector, wrappedClass) { container.querySelectorAll(buttonSelector).forEach((btn) => { const entry = btn.closest(".anndata-entry"); - const metaCell = entry ? entry.querySelector(".anndata-entry__preview") : null; + const metaCell = entry + ? entry.querySelector(".anndata-entry__preview") + : null; const list = metaCell ? metaCell.querySelector(listSelector) : null; // Initial visibility check @@ -365,7 +363,9 @@ function updateAllWrapButtons() { ].forEach(([btnSel, listSel, wrappedClass]) => { container.querySelectorAll(btnSel).forEach((btn) => { const entry = btn.closest(".anndata-entry"); - const metaCell = entry ? entry.querySelector(".anndata-entry__preview") : null; + const metaCell = entry + ? entry.querySelector(".anndata-entry__preview") + : null; const list = metaCell ? metaCell.querySelector(listSel) : null; updateWrapButtonVisibility(btn, list, metaCell, wrappedClass); }); diff --git a/src/anndata/_repr_constants.py b/src/anndata/_repr_constants.py index 91d9d0e1d..5be704e10 100644 --- a/src/anndata/_repr_constants.py +++ b/src/anndata/_repr_constants.py @@ -32,7 +32,9 @@ # Field name column width calculation constants # These values are empirically tuned for the default 13px monospace font CHAR_WIDTH_PX = 8 # Average character width for monospace at 13px font-size -COPY_BUTTON_PADDING_PX = 54 # Extra space for copy button + cell padding (24px for grid border-box) +COPY_BUTTON_PADDING_PX = ( + 54 # Extra space for copy button + cell padding (24px for grid border-box) +) MIN_FIELD_WIDTH_PX = 104 # Minimum column width (includes 24px cell padding) DEFAULT_FIELD_WIDTH_PX = 100 # Default when no field names exist diff --git a/tests/repr/test_repr_core.py b/tests/repr/test_repr_core.py index fe62ba329..42187c33e 100644 --- a/tests/repr/test_repr_core.py +++ b/tests/repr/test_repr_core.py @@ -128,9 +128,9 @@ def test_dark_mode_css_present(self, adata): assert "light-dark(" in html, "CSS should use light-dark() for theming" assert "color-scheme:" in html, "CSS should declare color-scheme" # Theme selectors should still be present for explicit app themes - assert ( - "jp-Theme-Dark" in html or "[data-jp-theme-light" in html - ), "CSS should include Jupyter theme selectors" + assert "jp-Theme-Dark" in html or "[data-jp-theme-light" in html, ( + "CSS should include Jupyter theme selectors" + ) class TestBasicHTMLGeneration: From 44ed0b5ab99e56484639c800781501e840be575d Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 20 Feb 2026 17:00:32 -0800 Subject: [PATCH 180/194] fix: address review findings from table-to-grid migration - Add missing CSS rule for wrap button expansion on preview cells - Remove dead TypeCellConfig.has_expandable_content field - Fix zebra striping to skip hidden entries during search filtering - Add subgrid to CSS browser compat header comments - Fix inaccurate DOM structure comment in JS - Align DEFAULT_FIELD_WIDTH_PX with MIN_FIELD_WIDTH_PX (104) --- src/anndata/_repr/components.py | 3 --- src/anndata/_repr/core.py | 1 - src/anndata/_repr/static/repr.css | 12 ++++++++++-- src/anndata/_repr/static/repr.js | 2 +- src/anndata/_repr_constants.py | 4 +++- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/anndata/_repr/components.py b/src/anndata/_repr/components.py index 71f401502..fc6d0ecff 100644 --- a/src/anndata/_repr/components.py +++ b/src/anndata/_repr/components.py @@ -452,8 +452,6 @@ class TypeCellConfig: List of warning messages is_not_serializable Whether the data cannot be serialized to H5AD/Zarr - has_expandable_content - Whether to show expand button has_columns_list Whether to show columns wrap button has_categories_list @@ -486,7 +484,6 @@ class TypeCellConfig: tooltip: str = "" warnings: list[str] = field(default_factory=list) is_not_serializable: bool = False - has_expandable_content: bool = False has_columns_list: bool = False has_categories_list: bool = False append_type_html: bool = False diff --git a/src/anndata/_repr/core.py b/src/anndata/_repr/core.py index 776299b18..3546a0183 100644 --- a/src/anndata/_repr/core.py +++ b/src/anndata/_repr/core.py @@ -363,7 +363,6 @@ def render_formatted_entry( tooltip=output.tooltip, warnings=all_warnings, is_not_serializable=not output.is_serializable, - has_expandable_content=has_expandable_content, has_columns_list=has_columns_list, has_categories_list=has_categories, append_type_html=append_type_html, diff --git a/src/anndata/_repr/static/repr.css b/src/anndata/_repr/static/repr.css index 525479cdb..1b58e7535 100644 --- a/src/anndata/_repr/static/repr.css +++ b/src/anndata/_repr/static/repr.css @@ -1,7 +1,8 @@ /* AnnData HTML Representation Styles */ /* Scoped to .anndata-repr to avoid conflicts */ /* Uses native CSS nesting (Chrome 120+, Firefox 117+, Safari 17.2+) */ -/* Uses light-dark() (Chrome 123+, Firefox 120+, Safari 17.5+) */ +/* Uses subgrid (Chrome 117+, Firefox 71+, Safari 16+) */ +/* Uses light-dark() (Chrome 123+, Firefox 120+, Safari 17.5+) — the bottleneck */ .anndata-repr { /* Opt into light-dark(): responds to used color scheme, defaulting to light */ @@ -128,6 +129,13 @@ } } + /* Allow preview cell to expand when wrap button is toggled */ + .anndata-entry__preview.anndata-entry--expanded { + white-space: normal; + overflow: visible; + text-overflow: clip; + } + .anndata-categories__wrap, .anndata-columns__wrap { display: inline-block; @@ -515,7 +523,7 @@ grid-column: 1 / -1; transition: background-color 0.1s; - &:nth-child(even) { + &:nth-child(even of .anndata-entry:not(.anndata-entry--hidden)) { background: var(--anndata-bg-secondary); } diff --git a/src/anndata/_repr/static/repr.js b/src/anndata/_repr/static/repr.js index c3e990f72..0e9bb886d 100644 --- a/src/anndata/_repr/static/repr.js +++ b/src/anndata/_repr/static/repr.js @@ -172,7 +172,7 @@ function filterEntries(query) { if (!nestedContainer) break; // Find the parent entry that contains this nested content - // Structure: div.anndata-entry > details > .anndata-entry__nested-content + // Structure: details.anndata-entry > .anndata-entry__nested-content const parentEntry = nestedContainer.closest(".anndata-entry"); if (!parentEntry) break; diff --git a/src/anndata/_repr_constants.py b/src/anndata/_repr_constants.py index 5be704e10..48946eb26 100644 --- a/src/anndata/_repr_constants.py +++ b/src/anndata/_repr_constants.py @@ -36,7 +36,9 @@ 54 # Extra space for copy button + cell padding (24px for grid border-box) ) MIN_FIELD_WIDTH_PX = 104 # Minimum column width (includes 24px cell padding) -DEFAULT_FIELD_WIDTH_PX = 100 # Default when no field names exist +DEFAULT_FIELD_WIDTH_PX = ( + 104 # Default when no field names exist (matches MIN_FIELD_WIDTH_PX) +) # Inline style for graceful degradation (hidden until JS enables). # JS sets different display values per element, so this must stay inline. From 7eba3a49bdf1161de378f61eb8c84dd9ba333d5c Mon Sep 17 00:00:00 2001 From: Dominik Date: Fri, 20 Feb 2026 23:25:29 -0800 Subject: [PATCH 181/194] docs: rename release note to match PR number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 675.feature.md → 2236.feat.md --- docs/release-notes/{675.feature.md => 2236.feat.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/release-notes/{675.feature.md => 2236.feat.md} (100%) diff --git a/docs/release-notes/675.feature.md b/docs/release-notes/2236.feat.md similarity index 100% rename from docs/release-notes/675.feature.md rename to docs/release-notes/2236.feat.md From 0200ac5969f439a9e4f40aaefbe87be78aa99c8e Mon Sep 17 00:00:00 2001 From: Dominik Date: Mon, 23 Mar 2026 09:53:22 -0700 Subject: [PATCH 182/194] feat: add no-CSS fallback message for untrusted notebooks JupyterLab strips ", "", html, flags=re.DOTALL) + stripped = re.sub(r"]*>.*?", "", stripped, flags=re.DOTALL) + + # Text fallback should remain (no style hiding it) + assert '
' in stripped
+        # Rich HTML still has display:none inline
+        assert "display:none" in stripped
+
+    def test_nested_repr_no_fallback(self):
+        """Nested AnnData (depth > 0) should not have text fallback."""
+        from anndata._repr.html import generate_repr_html
+
+        adata = AnnData(np.zeros((5, 3)))
+        html = generate_repr_html(adata, depth=1)
+        assert "anndata-repr-fallback" not in html
+        assert "display:none" not in html
+
+
 class TestColumnWidthSettings:
     """Test column width calculation settings."""
 
diff --git a/tests/repr/test_repr_robustness.py b/tests/repr/test_repr_robustness.py
index 88f2cbac4..9ed24528b 100644
--- a/tests/repr/test_repr_robustness.py
+++ b/tests/repr/test_repr_robustness.py
@@ -1285,8 +1285,10 @@ def test_uns_with_many_keys_truncated(self, validate_html) -> None:
         # data-key, data-copy, visible text, tooltip)
         key_count = html.count("key_")
         assert key_count < 1200, f"Too many keys in HTML: {key_count}"
-        # Also verify that key_0299 (the last one) is NOT shown since it's beyond max_items=200
-        assert "key_0299" not in html, "Last key should not be shown (truncation)"
+        # Also verify that key_0299 (the last one) is NOT shown in the rich HTML
+        # (it may appear in the text fallback 
, so check only the rich portion)
+        rich_html = html.split('
str: return re.sub(r"", "", html, flags=re.DOTALL) +def strip_style_and_script_tags(html: str) -> str: + """Remove ", "", html, flags=re.DOTALL) + html = re.sub(r"", "", html, flags=re.DOTALL) + return html + + def main(): # noqa: PLR0915, PLR0912 """Generate visual test HTML file.""" print("Generating visual test cases...") @@ -2042,6 +2051,41 @@ def format(self, obj, context): "for expand/collapse which works without JS.", )) + # Test 13b: No CSS (GitHub / untrusted notebook fallback) + print(" 13b. No CSS (GitHub / untrusted notebook fallback)") + adata_nocss = AnnData( + sp.random(500, 200, density=0.1, format="csr", dtype=np.float32), + obs=pd.DataFrame({ + "cell_type": pd.Categorical(["T-cell", "B-cell", "NK", "Mono", "DC"] * 100), + "patient": pd.Categorical([f"P{i}" for i in range(50)] * 10), + "score": np.random.rand(500), + }), + var=pd.DataFrame({"gene_name": [f"gene_{i}" for i in range(200)]}), + ) + adata_nocss.obsm["X_pca"] = np.random.randn(500, 20).astype(np.float32) + adata_nocss.uns["method"] = "scran" + nocss_html = strip_style_and_script_tags(adata_nocss._repr_html_()) + # Wrap in an iframe (srcdoc) so it's fully isolated from the page's CSS. + # Without isolation, the ", "", html, flags=re.DOTALL) - stripped = re.sub(r"]*>.*?", "", stripped, flags=re.DOTALL) + assert "anndata-repr__hint-nocss" in html + # No inline display:none on the hint — visible by default + assert 'hint-nocss" style="display:none"' not in html - # Text fallback should remain (no style hiding it) - assert '
' in stripped
-        # Rich HTML still has display:none inline
-        assert "display:none" in stripped
+    def test_nojs_hint_hidden_by_default(self, adata):
+        """No-JS hint should be hidden by default (shown by CSS, hidden by JS)."""
+        html = adata._repr_html_()
+        assert 'anndata-repr__hint-nojs" style="display:none"' in html
 
-    def test_nested_repr_no_fallback(self):
-        """Nested AnnData (depth > 0) should not have text fallback."""
-        from anndata._repr.html import generate_repr_html
+    def test_category_separators(self):
+        """Category items should have comma separators for no-CSS readability."""
+        adata = AnnData(np.zeros((10, 3)))
+        adata.obs["cat"] = pd.Categorical(["A", "B"] * 5)
+        html = adata._repr_html_()
+        assert "anndata-categories__sep" in html
 
-        adata = AnnData(np.zeros((5, 3)))
-        html = generate_repr_html(adata, depth=1)
-        assert "anndata-repr-fallback" not in html
-        assert "display:none" not in html
+    def test_structure_survives_style_stripping(self, adata):
+        """When CSS/JS are stripped, the HTML structure is still intact."""
+        html = adata._repr_html_()
+        stripped = re.sub(r"]*>.*?", "", html, flags=re.DOTALL)
+        stripped = re.sub(r"]*>.*?", "", stripped, flags=re.DOTALL)
+
+        # Rich HTML is visible (no display:none)
+        assert '
for folding + assert " None: # data-key, data-copy, visible text, tooltip) key_count = html.count("key_") assert key_count < 1200, f"Too many keys in HTML: {key_count}" - # Also verify that key_0299 (the last one) is NOT shown in the rich HTML - # (it may appear in the text fallback
, so check only the rich portion)
-        rich_html = html.split('
blocks from other test cases would style this too. import html as html_mod @@ -2081,9 +2071,9 @@ def format(self, obj, context): iframe_html, "Simulates GitHub's notebook renderer which strips <style> and <script> tags. " "Rendered in an iframe for CSS isolation. " - "You should see the plain text repr (like repr(adata)) in a <pre> block. " - "The rich HTML is hidden via inline display:none that survives style stripping. " - "This is the xarray-style dual-representation pattern.", + "The rich HTML degrades gracefully: inline <span> cells keep entries on one line, " + "monospace font, CSS variable column widths, and comma-separated categories. " + "Sections fold/unfold via native <details>/<summary>.", )) # Test 14: Custom sections example using TreeData (if available) From 8f58f261976eb36b1ecf489f734d0cb39ad34f4a Mon Sep 17 00:00:00 2001 From: Dominik Date: Wed, 15 Apr 2026 12:15:05 -0700 Subject: [PATCH 190/194] refactor: adopt iter_outer for section iteration Use anndata.utils.iter_outer from #2372 as the canonical source for standard section iteration in the HTML repr. Drop the redundant SECTION_ORDER tuple; display order now follows iter_outer. - _render_all_sections iterates (name, elem) pairs from iter_outer and passes elem to downstream renderers, avoiding a second getattr (which would trigger another file open/close cycle on backed AnnData). - _render_dataframe_section, _render_mapping_section, _render_uns_section, _render_raw_section now take the elem directly. - _detect_unknown_sections and _get_custom_sections_by_position use a local STANDARD_SECTIONS frozenset for name-only membership checks so they don't pay iter_outer's per-yield I/O just to get names. - Drop unused adata param from _render_uns_entry. Display order changes to X, obs, var, obsm, varm, obsp, varp, layers, uns, raw (uns moves from position 4 to position 9). --- src/anndata/_repr/__init__.py | 25 --------------- src/anndata/_repr/html.py | 57 ++++++++++++++++------------------ src/anndata/_repr/sections.py | 35 +++++++++------------ src/anndata/_repr_constants.py | 50 +++++++++++++++-------------- 4 files changed, 67 insertions(+), 100 deletions(-) diff --git a/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py index 1b61850be..6c39c6394 100644 --- a/src/anndata/_repr/__init__.py +++ b/src/anndata/_repr/__init__.py @@ -322,16 +322,6 @@ def _repr_html_(self): DEFAULT_TYPE_WIDTH, DEFAULT_UNIQUE_LIMIT, NOT_SERIALIZABLE_MSG, - SECTION_LAYERS, - SECTION_OBS, - SECTION_OBSM, - SECTION_OBSP, - SECTION_RAW, - SECTION_UNS, - SECTION_VAR, - SECTION_VARM, - SECTION_VARP, - SECTION_X, ) # Documentation base URL @@ -356,20 +346,6 @@ def get_section_doc_url(section: str) -> str: return f"{DOCS_BASE_URL}generated/anndata.AnnData.{section}.html" -# Section order for display -SECTION_ORDER = ( - SECTION_X, - SECTION_OBS, - SECTION_VAR, - SECTION_UNS, - SECTION_OBSM, - SECTION_VARM, - SECTION_LAYERS, - SECTION_OBSP, - SECTION_VARP, - SECTION_RAW, -) - # Import main functionality # Inline styles for graceful degradation (from single source of truth) from .._repr_constants import STYLE_HIDDEN # noqa: E402 @@ -428,7 +404,6 @@ def get_section_doc_url(section: str) -> str: "DEFAULT_TYPE_WIDTH", "DOCS_BASE_URL", "get_section_doc_url", - "SECTION_ORDER", "NOT_SERIALIZABLE_MSG", # CSS dtype constants for custom formatters "CSS_DTYPE_NDARRAY", diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index cce67efa5..c50630c11 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -33,12 +33,8 @@ DEFAULT_PREVIEW_ITEMS, DEFAULT_TYPE_WIDTH, DEFAULT_UNIQUE_LIMIT, - SECTION_OBS, - SECTION_ORDER, - SECTION_RAW, - SECTION_VAR, - SECTION_X, ) +from ..utils import iter_outer from .components import ( render_badge, render_search_box, @@ -88,6 +84,7 @@ COPY_BUTTON_PADDING_PX, DEFAULT_FIELD_WIDTH_PX, MIN_FIELD_WIDTH_PX, + STANDARD_SECTIONS, ) from . import formatters as _formatters # noqa: F401 @@ -100,21 +97,14 @@ def _collect_all_field_names(adata: AnnData) -> list[str]: (uns, obsm, varm, layers, obsp, varp) plus any registered custom sections. """ all_names: list[str] = [] - skip_sections = {SECTION_X, SECTION_RAW} # Single items, not collections - # Standard sections from SECTION_ORDER - for section in SECTION_ORDER: - if section in skip_sections: + for section, attr in iter_outer(adata): + if section in {"X", "raw"} or attr is None: continue try: - attr = getattr(adata, section, None) - if attr is None: - continue - # obs/var are DataFrames - use column names - if section in (SECTION_OBS, SECTION_VAR): + if section in {"obs", "var"}: if hasattr(attr, "columns"): all_names.extend(attr.columns.tolist()) - # Other sections are mappings - use keys elif hasattr(attr, "keys"): all_names.extend(attr.keys()) except Exception: # noqa: BLE001 @@ -122,8 +112,8 @@ def _collect_all_field_names(adata: AnnData) -> list[str]: # Registered custom sections (e.g., TreeData's obst/vart) for section_name in formatter_registry.get_registered_sections(): - if section_name in SECTION_ORDER: - continue # Already handled + if section_name in STANDARD_SECTIONS: + continue try: attr = getattr(adata, section_name, None) if attr is not None and hasattr(attr, "keys"): @@ -357,8 +347,8 @@ def _render_all_sections( parts: list[str] = [] custom_sections_after = _get_custom_sections_by_position(adata) - for section in SECTION_ORDER: - parts.append(_render_section(adata, section, context)) + for section, elem in iter_outer(adata): + parts.append(_render_section(adata, section, elem, context)) # Render custom sections after this section if section in custom_sections_after: @@ -374,7 +364,7 @@ def _render_all_sections( for section_formatter in custom_sections_after[None] ) - # Detect and show unknown sections (mapping-like attributes not in SECTION_ORDER) + # Detect and show unknown sections (mapping-like attributes not surfaced by iter_outer) unknown_sections = _detect_unknown_sections(adata) if unknown_sections: parts.append(_render_unknown_sections(unknown_sections)) @@ -385,21 +375,26 @@ def _render_all_sections( def _render_section( adata: AnnData, section: str, + elem: object, context: FormatterContext, ) -> str: - """Render a single standard section.""" - from .._repr_constants import SECTION_RAW, SECTION_UNS + """Render a single standard section. + ``elem`` is the value yielded by ``iter_outer`` for this section. We pass it + through to the per-section renderers so they don't need a second ``getattr`` + (which would re-open the backing file on backed AnnData, since ``iter_outer`` + closes it after each yield). + """ try: - if section == SECTION_X: + if section == "X": return render_x_entry(adata, context) - if section == SECTION_RAW: - return _render_raw_section(adata, context) - if section in (SECTION_OBS, SECTION_VAR): - return _render_dataframe_section(adata, section, context) - if section == SECTION_UNS: - return _render_uns_section(adata, context) - return _render_mapping_section(adata, section, context) + if section == "raw": + return _render_raw_section(elem, context) + if section in ("obs", "var"): + return _render_dataframe_section(section, elem, context) + if section == "uns": + return _render_uns_section(elem, context) + return _render_mapping_section(section, elem, context) except Exception as e: # noqa: BLE001 # Show error instead of hiding the section return _render_error_entry(section, str(e)) @@ -424,7 +419,7 @@ def _get_custom_sections_by_position( continue # Skip standard sections (they're handled separately) - if section_name in SECTION_ORDER: + if section_name in STANDARD_SECTIONS: continue # Check if this section should be shown for this object diff --git a/src/anndata/_repr/sections.py b/src/anndata/_repr/sections.py index c1aab5610..46861c226 100644 --- a/src/anndata/_repr/sections.py +++ b/src/anndata/_repr/sections.py @@ -111,12 +111,11 @@ def _render_entry_row( def _render_dataframe_section( - adata: AnnData, section: str, + df: pd.DataFrame, context: FormatterContext, ) -> str: """Render obs or var section.""" - df: pd.DataFrame = getattr(adata, section) n_cols = len(df.columns) # Doc URL and tooltip for this section @@ -157,12 +156,11 @@ def _render_dataframe_section( def _render_mapping_section( - adata: AnnData, section: str, + mapping: object, context: FormatterContext, ) -> str: """Render obsm, varm, layers, obsp, varp sections.""" - mapping = getattr(adata, section, None) if mapping is None: return "" @@ -206,11 +204,10 @@ def _render_mapping_section( def _render_uns_section( - adata: AnnData, + uns: object, context: FormatterContext, ) -> str: """Render the uns section with special handling.""" - uns = adata.uns # Get count without creating full list (O(1) for dict) n_items = len(uns) @@ -228,7 +225,7 @@ def _render_uns_section( rows.append(render_truncation_indicator(n_items - context.max_items)) break value = uns[key] - rows.append(_render_uns_entry(adata, key, value, context)) + rows.append(_render_uns_entry(key, value, context)) return render_section( "uns", @@ -241,7 +238,6 @@ def _render_uns_section( def _render_uns_entry( - adata: AnnData, key: str, value: object, context: FormatterContext, @@ -285,20 +281,21 @@ def _render_uns_entry( def _detect_unknown_sections(adata: AnnData) -> list[tuple[str, str]]: - """Detect mapping-like attributes that aren't in SECTION_ORDER. + """Detect mapping-like attributes not surfaced by ``iter_outer``. Returns list of (attr_name, type_description) tuples for unknown sections. """ from collections.abc import Mapping - from . import SECTION_ORDER + from .._repr_constants import STANDARD_SECTIONS - # Skip sections we render + internal/meta attributes (not data slots) - # See INTERNAL_ANNDATA_ATTRS docstring for why this is explicit, not introspected - known = set(SECTION_ORDER) | INTERNAL_ANNDATA_ATTRS + # Skip standard sections + internal/meta attributes. + # STANDARD_SECTIONS mirrors iter_outer's names without triggering its file I/O. + # See INTERNAL_ANNDATA_ATTRS docstring for why the internal list is explicit. + known = STANDARD_SECTIONS | INTERNAL_ANNDATA_ATTRS - # Also exclude sections that have registered custom formatters - # (including those with should_show=False that suppress display) + # Also exclude sections with registered custom formatters + # (including should_show=False ones that suppress display). known |= set(formatter_registry.get_registered_sections()) unknown = [] @@ -440,7 +437,7 @@ def _get_raw_meta_parts(raw: object) -> list[str]: def _render_raw_section( - adata: AnnData, + raw: object, context: FormatterContext, ) -> str: """Render the raw section as a single expandable row. @@ -456,7 +453,6 @@ def _render_raw_section( 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 "" @@ -571,12 +567,11 @@ def _generate_raw_repr_html( parts.append(_render_error_entry("X", str(e))) # var section (like AnnData's var) - # _render_dataframe_section expects an object with a .var attribute try: if hasattr(raw, "var") and raw.var is not None and len(raw.var.columns) > 0: # Raw doesn't have the same structure as AnnData, so clear adata_ref var_context = replace(context, adata_ref=None, section="var") - parts.append(_render_dataframe_section(raw, "var", var_context)) + parts.append(_render_dataframe_section("var", raw.var, var_context)) except Exception as e: # noqa: BLE001 parts.append(_render_error_entry("var", str(e))) @@ -584,7 +579,7 @@ def _generate_raw_repr_html( try: if hasattr(raw, "varm") and raw.varm is not None and len(raw.varm) > 0: varm_context = replace(context, adata_ref=None, section="varm") - parts.append(_render_mapping_section(raw, "varm", varm_context)) + parts.append(_render_mapping_section("varm", raw.varm, varm_context)) except Exception as e: # noqa: BLE001 parts.append(_render_error_entry("varm", str(e))) diff --git a/src/anndata/_repr_constants.py b/src/anndata/_repr_constants.py index 48946eb26..81cbe9820 100644 --- a/src/anndata/_repr_constants.py +++ b/src/anndata/_repr_constants.py @@ -115,7 +115,7 @@ CSS_COLORS_SWATCH = "anndata-colors__swatch" CSS_COLORS_SWATCH_INVALID = "anndata-colors__swatch--invalid" -# Section names (canonical strings used for data-section attributes and keys) +# Section name constants (canonical strings used for data-section attributes and dispatch) SECTION_X = "X" SECTION_OBS = "obs" SECTION_VAR = "var" @@ -127,32 +127,34 @@ SECTION_VARP = "varp" SECTION_RAW = "raw" +# Canonical set of standard section names. +# Mirrors the list iterated by `anndata.utils.iter_outer`. Kept as a constant +# (rather than calling iter_outer with values discarded) because iter_outer +# performs a file-open/close cycle per attribute on backed AnnData objects. +# Use this for name-only membership checks; use iter_outer when you need values. +STANDARD_SECTIONS = frozenset({ + SECTION_X, + SECTION_OBS, + SECTION_VAR, + SECTION_UNS, + SECTION_OBSM, + SECTION_VARM, + SECTION_LAYERS, + SECTION_OBSP, + SECTION_VARP, + SECTION_RAW, +}) + # Internal AnnData attributes to skip when detecting unknown sections. # -# Why explicit list instead of introspection? -# ------------------------------------------- -# We intentionally maintain this list manually rather than using introspection -# (e.g., inspect.getmembers(AnnData, property)) because: -# -# 1. NEW data slots added to AnnData should appear in "unknown sections" until -# a proper renderer is implemented. Introspection would hide them. -# -# 2. This list contains only internal/meta attributes that are NOT data slots: -# - Shape/size metadata (shape, n_obs, n_vars) -# - Index accessors (obs_names, var_names) -# - File/backing info (filename, file, isbacked) -# - View status (is_view, isview) -# - Transpose accessor (T) -# -# What else is skipped (not in this list)? -# ---------------------------------------- -# - Data sections (obs, var, uns, obsm, etc.) via SECTION_ORDER -# - Custom sections registered via SectionFormatter (e.g., obst, vart from TreeData) -# - Methods are filtered by `not callable()` at runtime +# Standard data sections (obs, var, uns, obsm, etc.) are discovered via +# `anndata.utils.iter_outer`. Custom sections come from registered +# SectionFormatter extensions. This frozenset lists non-data attributes +# that would otherwise appear as "unknown sections" — shape/size metadata, +# index accessors, file/backing info, and the transpose accessor. # -# When to update this list: -# - Add entries when AnnData gains new internal/meta properties (not data slots) -# - Do NOT add new data slots here - they should appear in "unknown" until rendered +# Keep NEW data slots OUT of this list. They should surface as "unknown" +# until a proper renderer is implemented. INTERNAL_ANNDATA_ATTRS = frozenset({ # Shape/size metadata "shape", From 47066f2340bd690bc2e1773ded78834cabad482c Mon Sep 17 00:00:00 2001 From: Dominik Date: Wed, 15 Apr 2026 12:27:41 -0700 Subject: [PATCH 191/194] refactor: collect section names during iter_outer iteration Drop the STANDARD_SECTIONS frozenset (which mirrored iter_outer's internal name list). Instead, materialize iter_outer once at the top of _render_all_sections and reuse the collected names for the membership checks in _get_custom_sections_by_position and _detect_unknown_sections. Same in _collect_all_field_names: the names come from the iter_outer loop that was already running for column/key collection. Removes the maintenance burden of keeping a separate constant in sync with iter_outer's internal list. --- src/anndata/_repr/html.py | 23 ++++++++++++++++------- src/anndata/_repr/sections.py | 14 ++++++++------ src/anndata/_repr_constants.py | 18 ------------------ tests/repr/test_repr_sections.py | 8 ++++++-- 4 files changed, 30 insertions(+), 33 deletions(-) diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index c50630c11..9cae219cb 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -22,6 +22,7 @@ DEFAULT_MAX_README_SIZE, TOOLTIP_TRUNCATE_LENGTH, ) +from ..utils import iter_outer from . import ( DEFAULT_FOLD_THRESHOLD, DEFAULT_MAX_CATEGORIES, @@ -34,7 +35,6 @@ DEFAULT_TYPE_WIDTH, DEFAULT_UNIQUE_LIMIT, ) -from ..utils import iter_outer from .components import ( render_badge, render_search_box, @@ -84,7 +84,6 @@ COPY_BUTTON_PADDING_PX, DEFAULT_FIELD_WIDTH_PX, MIN_FIELD_WIDTH_PX, - STANDARD_SECTIONS, ) from . import formatters as _formatters # noqa: F401 @@ -97,8 +96,10 @@ def _collect_all_field_names(adata: AnnData) -> list[str]: (uns, obsm, varm, layers, obsp, varp) plus any registered custom sections. """ all_names: list[str] = [] + standard_sections: set[str] = set() for section, attr in iter_outer(adata): + standard_sections.add(section) if section in {"X", "raw"} or attr is None: continue try: @@ -112,7 +113,7 @@ def _collect_all_field_names(adata: AnnData) -> list[str]: # Registered custom sections (e.g., TreeData's obst/vart) for section_name in formatter_registry.get_registered_sections(): - if section_name in STANDARD_SECTIONS: + if section_name in standard_sections: continue try: attr = getattr(adata, section_name, None) @@ -345,9 +346,16 @@ def _render_all_sections( ) -> list[str]: """Render all standard and custom sections.""" parts: list[str] = [] - custom_sections_after = _get_custom_sections_by_position(adata) + # Materialize iter_outer once. On backed AnnData each yield reopens/closes + # the backing file, so we pay that cost once and reuse the names downstream. + sections = list(iter_outer(adata)) + standard_section_names = {name for name, _ in sections} + + custom_sections_after = _get_custom_sections_by_position( + adata, standard_section_names + ) - for section, elem in iter_outer(adata): + for section, elem in sections: parts.append(_render_section(adata, section, elem, context)) # Render custom sections after this section @@ -365,7 +373,7 @@ def _render_all_sections( ) # Detect and show unknown sections (mapping-like attributes not surfaced by iter_outer) - unknown_sections = _detect_unknown_sections(adata) + unknown_sections = _detect_unknown_sections(adata, standard_section_names) if unknown_sections: parts.append(_render_unknown_sections(unknown_sections)) @@ -402,6 +410,7 @@ def _render_section( def _get_custom_sections_by_position( adata: object, + standard_section_names: set[str], ) -> dict[str | None, list[SectionFormatter]]: """ Get registered custom section formatters grouped by their position. @@ -419,7 +428,7 @@ def _get_custom_sections_by_position( continue # Skip standard sections (they're handled separately) - if section_name in STANDARD_SECTIONS: + if section_name in standard_section_names: continue # Check if this section should be shown for this object diff --git a/src/anndata/_repr/sections.py b/src/anndata/_repr/sections.py index 46861c226..407b14265 100644 --- a/src/anndata/_repr/sections.py +++ b/src/anndata/_repr/sections.py @@ -280,19 +280,21 @@ def _render_uns_entry( # ----------------------------------------------------------------------------- -def _detect_unknown_sections(adata: AnnData) -> list[tuple[str, str]]: +def _detect_unknown_sections( + adata: AnnData, standard_section_names: set[str] +) -> list[tuple[str, str]]: """Detect mapping-like attributes not surfaced by ``iter_outer``. + ``standard_section_names`` is the set of names already yielded by + ``iter_outer`` for this AnnData (collected by the caller so we don't + re-iterate it here, since each yield reopens the backing file). + Returns list of (attr_name, type_description) tuples for unknown sections. """ from collections.abc import Mapping - from .._repr_constants import STANDARD_SECTIONS - - # Skip standard sections + internal/meta attributes. - # STANDARD_SECTIONS mirrors iter_outer's names without triggering its file I/O. # See INTERNAL_ANNDATA_ATTRS docstring for why the internal list is explicit. - known = STANDARD_SECTIONS | INTERNAL_ANNDATA_ATTRS + known = standard_section_names | INTERNAL_ANNDATA_ATTRS # Also exclude sections with registered custom formatters # (including should_show=False ones that suppress display). diff --git a/src/anndata/_repr_constants.py b/src/anndata/_repr_constants.py index 81cbe9820..9c4d0bfbe 100644 --- a/src/anndata/_repr_constants.py +++ b/src/anndata/_repr_constants.py @@ -127,24 +127,6 @@ SECTION_VARP = "varp" SECTION_RAW = "raw" -# Canonical set of standard section names. -# Mirrors the list iterated by `anndata.utils.iter_outer`. Kept as a constant -# (rather than calling iter_outer with values discarded) because iter_outer -# performs a file-open/close cycle per attribute on backed AnnData objects. -# Use this for name-only membership checks; use iter_outer when you need values. -STANDARD_SECTIONS = frozenset({ - SECTION_X, - SECTION_OBS, - SECTION_VAR, - SECTION_UNS, - SECTION_OBSM, - SECTION_VARM, - SECTION_LAYERS, - SECTION_OBSP, - SECTION_VARP, - SECTION_RAW, -}) - # Internal AnnData attributes to skip when detecting unknown sections. # # Standard data sections (obs, var, uns, obsm, etc.) are discovered via diff --git a/tests/repr/test_repr_sections.py b/tests/repr/test_repr_sections.py index 93eaa5b47..8e3e518aa 100644 --- a/tests/repr/test_repr_sections.py +++ b/tests/repr/test_repr_sections.py @@ -841,19 +841,23 @@ def test_render_unknown_sections(self): def test_detect_unknown_sections_empty(self): """Test _detect_unknown_sections returns empty for standard AnnData.""" from anndata._repr.sections import _detect_unknown_sections + from anndata.utils import iter_outer adata = AnnData(np.zeros((5, 3))) - unknown = _detect_unknown_sections(adata) + names = {name for name, _ in iter_outer(adata)} + unknown = _detect_unknown_sections(adata, names) assert unknown == [] def test_detect_unknown_sections_with_custom_attr(self): """Test _detect_unknown_sections detects custom attributes.""" from anndata._repr.sections import _detect_unknown_sections + from anndata.utils import iter_outer adata = AnnData(np.zeros((5, 3))) # Add a custom public attribute (not starting with _) object.__setattr__(adata, "custom_data", [1, 2, 3]) - unknown = _detect_unknown_sections(adata) + names = {name for name, _ in iter_outer(adata)} + unknown = _detect_unknown_sections(adata, names) # May or may not detect depending on implementation assert isinstance(unknown, list) From e141f4b31c2ba279be6db302f995105a15014298 Mon Sep 17 00:00:00 2001 From: Phil Schaf Date: Mon, 20 Apr 2026 10:29:23 +0200 Subject: [PATCH 192/194] style: some basic style --- .vscode/settings.json | 6 +- biome.jsonc | 14 +- src/anndata/_repr/css.py | 4 +- src/anndata/_repr/javascript.py | 4 +- src/anndata/_repr/static/repr.js | 443 +++++++++++++++---------------- src/anndata/_repr/utils.py | 4 +- 6 files changed, 242 insertions(+), 233 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 6d97737f8..c1fb938ff 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "[python][toml][json][jsonc]": { + "[python][javascript][toml][json][jsonc]": { "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.organizeImports": "explicit", @@ -12,7 +12,7 @@ "[toml]": { "editor.defaultFormatter": "tamasfe.even-better-toml", }, - "[json][jsonc]": { + "[javascript][json][jsonc]": { "editor.defaultFormatter": "biomejs.biome", }, "python.analysis.typeCheckingMode": "basic", @@ -25,4 +25,6 @@ ], "python.terminal.activateEnvironment": true, "python.analysis.include": ["src/**/*", "ci/scripts/**/*", "tests/**/*"], + "python-envs.defaultEnvManager": "ms-python.python:venv", + "python-envs.defaultPackageManager": "ms-python.python:pip", } diff --git a/biome.jsonc b/biome.jsonc index 3c34a2071..3f3c15941 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,6 +1,18 @@ { - "$schema": "https://biomejs.dev/schemas/2.1.1/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.12/schema.json", "formatter": { "useEditorconfig": true }, + "linter": { + "rules": { + "complexity": { + "noForEach": "on", + }, + }, + }, + "javascript": { + "formatter": { + "semicolons": "asNeeded", + }, + }, "overrides": [ { "includes": ["./.vscode/*.json", "**/*.jsonc", "**/asv.conf.json"], diff --git a/src/anndata/_repr/css.py b/src/anndata/_repr/css.py index 9980136fe..04c1645d2 100644 --- a/src/anndata/_repr/css.py +++ b/src/anndata/_repr/css.py @@ -2,11 +2,11 @@ from __future__ import annotations -from functools import lru_cache +from functools import cache from importlib.resources import files -@lru_cache(maxsize=1) +@cache def get_css() -> str: """Get the complete CSS for the HTML representation. diff --git a/src/anndata/_repr/javascript.py b/src/anndata/_repr/javascript.py index ffec566ed..d626c3338 100644 --- a/src/anndata/_repr/javascript.py +++ b/src/anndata/_repr/javascript.py @@ -14,11 +14,11 @@ from __future__ import annotations -from functools import lru_cache +from functools import cache from importlib.resources import files -@lru_cache(maxsize=1) +@cache def _load_js_content() -> str: """Load main JS content from static file (cached).""" return files("anndata._repr.static").joinpath("repr.js").read_text(encoding="utf-8") diff --git a/src/anndata/_repr/static/repr.js b/src/anndata/_repr/static/repr.js index 4a6462a69..49954fe78 100644 --- a/src/anndata/_repr/static/repr.js +++ b/src/anndata/_repr/static/repr.js @@ -3,340 +3,335 @@ // The {container_id} placeholder is replaced at runtime. // Mark container as JS-enabled (shows interactive elements) -container.classList.add("anndata-repr--js"); +container.classList.add("anndata-repr--js") // Show interactive elements (hidden by default for no-JS graceful degradation) -container.querySelectorAll(".anndata-entry__copy").forEach((btn) => { - btn.style.display = "inline-flex"; -}); -container.querySelectorAll(".anndata-search__box").forEach((box) => { - box.style.display = "inline-flex"; -}); -container.querySelectorAll(".anndata-search__toggle").forEach((btn) => { - btn.style.display = "inline-flex"; -}); +for (const btn of container.querySelectorAll(".anndata-entry__copy")) { + btn.style.display = "inline-flex" +} +for (const box of container.querySelectorAll(".anndata-search__box")) { + box.style.display = "inline-flex" +} +for (const btn of container.querySelectorAll(".anndata-search__toggle")) { + btn.style.display = "inline-flex" +} // Filter indicator is shown via CSS .active class, no need to set display here // Hide the no-JS hint now that JavaScript is running -container.querySelectorAll(".anndata-repr__hint-nojs").forEach((el) => { - el.style.display = "none"; -}); +for (const el of container.querySelectorAll(".anndata-repr__hint-nojs")) { + el.style.display = "none" +} // Section collapse is handled natively by
/ elements. // No JS needed for section toggle — the browser handles open/close state. // Search/filter functionality -const searchBox = container.querySelector(".anndata-search__box"); -const searchInput = container.querySelector(".anndata-search__input"); -const filterIndicator = container.querySelector(".anndata-search__indicator"); -const caseToggle = container.querySelector(".anndata-search__toggle--case"); -const regexToggle = container.querySelector(".anndata-search__toggle--regex"); +const searchBox = container.querySelector(".anndata-search__box") +const searchInput = container.querySelector(".anndata-search__input") +const filterIndicator = container.querySelector(".anndata-search__indicator") +const caseToggle = container.querySelector(".anndata-search__toggle--case") +const regexToggle = container.querySelector(".anndata-search__toggle--regex") // Search state -let caseSensitive = false; -let useRegex = false; +let caseSensitive = false +let useRegex = false if (searchInput) { - let debounceTimer; + let debounceTimer const triggerFilter = () => { - clearTimeout(debounceTimer); + clearTimeout(debounceTimer) debounceTimer = setTimeout(() => { - filterEntries(searchInput.value.trim()); - }, 150); - }; + filterEntries(searchInput.value.trim()) + }, 150) + } - searchInput.addEventListener("input", triggerFilter); + searchInput.addEventListener("input", triggerFilter) // Clear on Escape searchInput.addEventListener("keydown", (e) => { if (e.key === "Escape") { - searchInput.value = ""; - filterEntries(""); + searchInput.value = "" + filterEntries("") } - }); + }) // Toggle button handlers if (caseToggle) { caseToggle.addEventListener("click", (e) => { - e.stopPropagation(); - caseSensitive = !caseSensitive; - caseToggle.classList.toggle("anndata--active", caseSensitive); - caseToggle.setAttribute("aria-pressed", caseSensitive); - triggerFilter(); - }); + e.stopPropagation() + caseSensitive = !caseSensitive + caseToggle.classList.toggle("anndata--active", caseSensitive) + caseToggle.setAttribute("aria-pressed", caseSensitive) + triggerFilter() + }) } if (regexToggle) { regexToggle.addEventListener("click", (e) => { - e.stopPropagation(); - useRegex = !useRegex; - regexToggle.classList.toggle("anndata--active", useRegex); - regexToggle.setAttribute("aria-pressed", useRegex); - triggerFilter(); - }); + e.stopPropagation() + useRegex = !useRegex + regexToggle.classList.toggle("anndata--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 (!query) return true if (useRegex) { try { - const flags = caseSensitive ? "" : "i"; - const regex = new RegExp(query, flags); + const flags = caseSensitive ? "" : "i" + const regex = new RegExp(query, flags) if (searchBox) - searchBox.classList.remove("anndata-search__box--error"); - return regex.test(text); - } catch (e) { + searchBox.classList.remove("anndata-search__box--error") + return regex.test(text) + } catch { // Invalid regex - show error state but don't crash - if (searchBox) - searchBox.classList.add("anndata-search__box--error"); - return false; + if (searchBox) searchBox.classList.add("anndata-search__box--error") + return false } } else { - if (searchBox) searchBox.classList.remove("anndata-search__box--error"); + if (searchBox) searchBox.classList.remove("anndata-search__box--error") if (caseSensitive) { - return text.includes(query); + return text.includes(query) } else { - return text.toLowerCase().includes(query.toLowerCase()); + return text.toLowerCase().includes(query.toLowerCase()) } } } function filterEntries(query) { - let totalMatches = 0; - let totalEntries = 0; + let totalMatches = 0 + let totalEntries = 0 // First pass: mark all entries as hidden or not based on direct match - const entries = container.querySelectorAll(".anndata-entry"); - const directMatches = new Set(); + const entries = container.querySelectorAll(".anndata-entry") + const directMatches = new Set() - entries.forEach((entry) => { - totalEntries++; + for (const entry of entries) { + totalEntries++ - const key = entry.dataset.key || ""; - const dtype = entry.dataset.dtype || ""; - const text = entry.textContent; + const key = entry.dataset.key || "" + const dtype = entry.dataset.dtype || "" + const text = entry.textContent const matches = !query || matchesQuery(key, query) || matchesQuery(dtype, query) || - matchesQuery(text, query); + matchesQuery(text, query) if (matches) { - directMatches.add(entry); - entry.classList.remove("anndata-entry--hidden"); - totalMatches++; + directMatches.add(entry) + entry.classList.remove("anndata-entry--hidden") + totalMatches++ // Expand parent sections to show match - const section = entry.closest(".anndata-section"); + const section = entry.closest(".anndata-section") if (section && !section.open) { - section.open = true; + section.open = true } // Expand nested content if match is inside nested area const nestedContent = entry.closest( ".anndata-entry__nested-content", - ); + ) if (nestedContent) { const expandableEntry = nestedContent.closest( "details.anndata-entry", - ); + ) if (expandableEntry && !expandableEntry.open) { - expandableEntry.open = true; + expandableEntry.open = true } } } else { - entry.classList.add("anndata-entry--hidden"); + entry.classList.add("anndata-entry--hidden") } - }); + } // Second pass: if a nested entry matches, show all ancestor entry rows // This ensures that when searching for something inside a nested AnnData, // all parent rows remain visible so the user can expand them to see the match if (query) { - directMatches.forEach((matchedEntry) => { + for (const matchedEntry of directMatches) { // Walk up the DOM tree to find and show all parent entry rows // Safety limit prevents infinite loops (max nesting depth is typically 3) - let element = matchedEntry; - let iterations = 0; - const maxIterations = 20; + let element = matchedEntry + let iterations = 0 + const maxIterations = 20 while ( element && element !== container && iterations < maxIterations ) { - iterations++; + iterations++ // Check if we're inside a nested content container const nestedContainer = element.closest( ".anndata-entry__nested-content", - ); - if (!nestedContainer) break; + ) + if (!nestedContainer) break // Find the parent entry that contains this nested content // Structure: details.anndata-entry > .anndata-entry__nested-content - const parentEntry = nestedContainer.closest(".anndata-entry"); - if (!parentEntry) break; + const parentEntry = nestedContainer.closest(".anndata-entry") + if (!parentEntry) break if (parentEntry.classList.contains("anndata-entry--hidden")) { - parentEntry.classList.remove("anndata-entry--hidden"); - totalMatches++; + parentEntry.classList.remove("anndata-entry--hidden") + totalMatches++ } // Open the expandable entry so nested content is visible const expandableEntry = nestedContainer.closest( "details.anndata-entry", - ); + ) if (expandableEntry && !expandableEntry.open) { - expandableEntry.open = true; + expandableEntry.open = true } // Continue searching from the parent entry's container - element = parentEntry.parentElement; + element = parentEntry.parentElement } - }); + } } // Also filter X entries in nested AnnData (they use anndata-x__entry class, not anndata-entry) // This prevents orphaned X rows from showing when their sibling entries are hidden if (query) { - container - .querySelectorAll( - ".anndata-entry__nested-content .anndata-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( - ".anndata-entry:not(.anndata-entry--hidden)", - ); - xEntry.style.display = hasVisibleEntries ? "" : "none"; - } - }); + for (const xEntry of container.querySelectorAll( + ".anndata-entry__nested-content .anndata-x__entry", + )) { + // Check if the nested AnnData has any visible entries + const nestedRepr = xEntry.closest(".anndata-repr") + if (nestedRepr) { + const hasVisibleEntries = nestedRepr.querySelector( + ".anndata-entry:not(.anndata-entry--hidden)", + ) + xEntry.style.display = hasVisibleEntries ? "" : "none" + } + } } else { // Reset X entries when no query - container - .querySelectorAll( - ".anndata-entry__nested-content .anndata-x__entry", - ) - .forEach((xEntry) => { - xEntry.style.display = ""; - }); + for (const xEntry of container.querySelectorAll( + ".anndata-entry__nested-content .anndata-x__entry", + )) { + xEntry.style.display = "" + } } // Update filter indicator if (filterIndicator) { if (query) { - filterIndicator.classList.add("anndata--active"); - filterIndicator.textContent = `Showing ${totalMatches} of ${totalEntries}`; + filterIndicator.classList.add("anndata--active") + filterIndicator.textContent = `Showing ${totalMatches} of ${totalEntries}` } else { - filterIndicator.classList.remove("anndata--active"); + filterIndicator.classList.remove("anndata--active") } } // Hide sections with no visible entries - container.querySelectorAll(".anndata-section").forEach((section) => { + for (const section of container.querySelectorAll(".anndata-section")) { const visibleEntries = section.querySelectorAll( ".anndata-entry:not(.anndata-entry--hidden)", - ); + ) if (query && visibleEntries.length === 0) { - section.style.display = "none"; + section.style.display = "none" } else { - section.style.display = ""; + section.style.display = "" } - }); + } } // Copy to clipboard -container.querySelectorAll(".anndata-entry__copy").forEach((btn) => { +for (const btn of container.querySelectorAll(".anndata-entry__copy")) { btn.addEventListener("click", async (e) => { - e.stopPropagation(); + e.stopPropagation() - const text = btn.dataset.copy; - if (!text) return; + const text = btn.dataset.copy + if (!text) return try { - await navigator.clipboard.writeText(text); + await navigator.clipboard.writeText(text) // Visual feedback (icon turns green via CSS) - btn.classList.add("anndata-entry__copy--copied"); + btn.classList.add("anndata-entry__copy--copied") setTimeout( () => btn.classList.remove("anndata-entry__copy--copied"), 1500, - ); - } catch (err) { + ) + } catch { // Fallback for older browsers - const textarea = document.createElement("textarea"); - textarea.value = text; - textarea.style.position = "fixed"; - textarea.style.opacity = "0"; - document.body.appendChild(textarea); - textarea.select(); + const textarea = document.createElement("textarea") + textarea.value = text + textarea.style.position = "fixed" + textarea.style.opacity = "0" + document.body.appendChild(textarea) + textarea.select() try { - document.execCommand("copy"); - btn.classList.add("anndata-entry__copy--copied"); + document.execCommand("copy") + btn.classList.add("anndata-entry__copy--copied") setTimeout( () => btn.classList.remove("anndata-entry__copy--copied"), 1500, - ); + ) } catch (e) { - console.error("Copy failed:", e); + console.error("Copy failed:", e) } - document.body.removeChild(textarea); + document.body.removeChild(textarea) } - }); -}); + }) +} // Helper to check if element is overflowing function isOverflowing(el) { - return el.scrollWidth > el.clientWidth; + return el.scrollWidth > el.clientWidth } // Helper to update wrap button visibility based on overflow function updateWrapButtonVisibility(btn, list, metaCell, wrappedClass) { if (!list || !metaCell) { - btn.style.display = "none"; - return; + btn.style.display = "none" + return } // Show button only if content is overflowing or currently wrapped - const isWrapped = list.classList.contains(wrappedClass); - const overflows = isOverflowing(metaCell); - btn.style.display = overflows || isWrapped ? "inline" : "none"; + const isWrapped = list.classList.contains(wrappedClass) + const overflows = isOverflowing(metaCell) + btn.style.display = overflows || isWrapped ? "inline" : "none" } // Factory function to set up wrap button handlers (DRY pattern for cats/cols buttons) function setupWrapButtons(buttonSelector, listSelector, wrappedClass) { - container.querySelectorAll(buttonSelector).forEach((btn) => { - const entry = btn.closest(".anndata-entry"); + for (const btn of container.querySelectorAll(buttonSelector)) { + const entry = btn.closest(".anndata-entry") const metaCell = entry ? entry.querySelector(".anndata-entry__preview") - : null; - const list = metaCell ? metaCell.querySelector(listSelector) : null; + : null + const list = metaCell ? metaCell.querySelector(listSelector) : null // Initial visibility check - updateWrapButtonVisibility(btn, list, metaCell, wrappedClass); + updateWrapButtonVisibility(btn, list, metaCell, wrappedClass) btn.addEventListener("click", (e) => { - e.stopPropagation(); - if (!list || !metaCell) return; + e.stopPropagation() + if (!list || !metaCell) return - const isWrapped = list.classList.toggle(wrappedClass); - metaCell.classList.toggle("anndata-entry--expanded", isWrapped); - btn.textContent = isWrapped ? "▲" : "▼"; + const isWrapped = list.classList.toggle(wrappedClass) + metaCell.classList.toggle("anndata-entry--expanded", isWrapped) + btn.textContent = isWrapped ? "▲" : "▼" btn.title = isWrapped ? "Collapse to single line" - : "Expand to multi-line view"; + : "Expand to multi-line view" // Always show button when wrapped - btn.style.display = "inline"; - }); - }); + btn.style.display = "inline" + }) + } } // Set up wrap buttons for categories and columns lists @@ -344,17 +339,17 @@ setupWrapButtons( ".anndata-categories__wrap", ".anndata-categories", "anndata-categories--wrapped", -); +) setupWrapButtons( ".anndata-columns__wrap", ".anndata-columns", "anndata-columns--wrapped", -); +) // Update button visibility on container resize (works for JupyterLab panes too) // Uses the same selector pairs as setupWrapButtons for consistency function updateAllWrapButtons() { - [ + for (const [btnSel, listSel, wrappedClass] of [ [ ".anndata-categories__wrap", ".anndata-categories", @@ -365,114 +360,114 @@ function updateAllWrapButtons() { ".anndata-columns", "anndata-columns--wrapped", ], - ].forEach(([btnSel, listSel, wrappedClass]) => { - container.querySelectorAll(btnSel).forEach((btn) => { - const entry = btn.closest(".anndata-entry"); + ]) { + for (const btn of container.querySelectorAll(btnSel)) { + const entry = btn.closest(".anndata-entry") const metaCell = entry ? entry.querySelector(".anndata-entry__preview") - : null; - const list = metaCell ? metaCell.querySelector(listSel) : null; - updateWrapButtonVisibility(btn, list, metaCell, wrappedClass); - }); - }); + : null + const list = metaCell ? metaCell.querySelector(listSel) : null + updateWrapButtonVisibility(btn, list, metaCell, wrappedClass) + } + } } // Use ResizeObserver for robust resize detection (pane resizes, not just window) if (typeof ResizeObserver !== "undefined") { - let resizeTimer; + let resizeTimer const resizeObserver = new ResizeObserver(() => { - clearTimeout(resizeTimer); - resizeTimer = setTimeout(updateAllWrapButtons, 100); - }); - resizeObserver.observe(container); + clearTimeout(resizeTimer) + resizeTimer = setTimeout(updateAllWrapButtons, 100) + }) + resizeObserver.observe(container) } else { // Fallback for older browsers - let resizeTimer; + let resizeTimer window.addEventListener("resize", () => { - clearTimeout(resizeTimer); - resizeTimer = setTimeout(updateAllWrapButtons, 100); - }); + clearTimeout(resizeTimer) + resizeTimer = setTimeout(updateAllWrapButtons, 100) + }) } // README modal functionality -const readmeIcon = container.querySelector(".anndata-readme__icon"); +const readmeIcon = container.querySelector(".anndata-readme__icon") if (readmeIcon) { // Ensure accessibility attributes - readmeIcon.setAttribute("role", "button"); - readmeIcon.setAttribute("tabindex", "0"); - readmeIcon.setAttribute("aria-label", "View README"); + readmeIcon.setAttribute("role", "button") + readmeIcon.setAttribute("tabindex", "0") + readmeIcon.setAttribute("aria-label", "View README") readmeIcon.addEventListener("click", (e) => { - e.stopPropagation(); - const readmeContent = readmeIcon.dataset.readme; - if (!readmeContent) return; + e.stopPropagation() + const readmeContent = readmeIcon.dataset.readme + if (!readmeContent) return // Create modal overlay - const overlay = document.createElement("div"); - overlay.className = "anndata-readme__overlay"; + const overlay = document.createElement("div") + overlay.className = "anndata-readme__overlay" // Create modal with accessibility attributes // Use container.id to make IDs unique across multiple cells - const modalTitleId = container.id + "-readme-modal-title"; - const modal = document.createElement("div"); - modal.className = "anndata-readme__modal"; - modal.setAttribute("role", "dialog"); - modal.setAttribute("aria-modal", "true"); - modal.setAttribute("aria-labelledby", modalTitleId); + const modalTitleId = `${container.id}-readme-modal-title` + const modal = document.createElement("div") + modal.className = "anndata-readme__modal" + modal.setAttribute("role", "dialog") + modal.setAttribute("aria-modal", "true") + modal.setAttribute("aria-labelledby", modalTitleId) // Header - const header = document.createElement("div"); - header.className = "anndata-readme__header"; - header.innerHTML = '

README

'; + const header = document.createElement("div") + header.className = "anndata-readme__header" + header.innerHTML = `

README

` - const closeBtn = document.createElement("button"); - closeBtn.className = "anndata-readme__close"; - closeBtn.textContent = "×"; - closeBtn.setAttribute("aria-label", "Close"); - header.appendChild(closeBtn); + const closeBtn = document.createElement("button") + closeBtn.className = "anndata-readme__close" + closeBtn.textContent = "×" + closeBtn.setAttribute("aria-label", "Close") + header.appendChild(closeBtn) // Content — plain text (no markdown parsing, XSS-safe via textContent) - const content = document.createElement("div"); - content.className = "anndata-readme__content"; - const pre = document.createElement("pre"); - pre.textContent = readmeContent; - content.appendChild(pre); + const content = document.createElement("div") + content.className = "anndata-readme__content" + const pre = document.createElement("pre") + pre.textContent = readmeContent + content.appendChild(pre) - modal.appendChild(header); - modal.appendChild(content); - overlay.appendChild(modal); + modal.appendChild(header) + modal.appendChild(content) + overlay.appendChild(modal) // Add to container (scoped styles apply) - container.appendChild(overlay); + container.appendChild(overlay) // Close handlers const closeModal = () => { - overlay.remove(); - }; + overlay.remove() + } - closeBtn.addEventListener("click", closeModal); + closeBtn.addEventListener("click", closeModal) overlay.addEventListener("click", (e) => { - if (e.target === overlay) closeModal(); - }); + if (e.target === overlay) closeModal() + }) // Escape key closes modal const escHandler = (e) => { if (e.key === "Escape") { - closeModal(); - document.removeEventListener("keydown", escHandler); + closeModal() + document.removeEventListener("keydown", escHandler) } - }; - document.addEventListener("keydown", escHandler); + } + document.addEventListener("keydown", escHandler) // Focus trap - closeBtn.focus(); - }); + closeBtn.focus() + }) // Keyboard accessibility for the icon readmeIcon.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - readmeIcon.click(); + e.preventDefault() + readmeIcon.click() } - }); + }) } diff --git a/src/anndata/_repr/utils.py b/src/anndata/_repr/utils.py index c4dec635c..2a87f2595 100644 --- a/src/anndata/_repr/utils.py +++ b/src/anndata/_repr/utils.py @@ -671,10 +671,10 @@ def _load_css_colors() -> frozenset[str]: ------- frozenset of lowercase color names """ - from functools import lru_cache + from functools import cache from importlib.resources import files - @lru_cache(maxsize=1) + @cache def _load() -> frozenset[str]: content = ( files("anndata._repr.static") From 33ac844f07b1a6858da0cac392e556bb524097d0 Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 11:05:29 -0700 Subject: [PATCH 193/194] fix: avoid `id="..."` in JS source and install repr JS once per page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related robustness fixes to the inlined repr JS: 1. `repr.js`: replace the last remaining `innerHTML = \`

…\`` in the README modal with plain DOM construction (`createElement`/`textContent`/`appendChild`). The literal `id="${modalTitleId}"` substring inside the inlined JS source was being regex-matched by the Jupyter-compatibility tests as a duplicate HTML ID attribute across cells. Using DOM APIs removes the problematic substring entirely and matches the surrounding code style. 2. `javascript.py`: wrap the per-container initialisation body in an `install-once` guard. Every cell still ships the full source so any cell stays self-sufficient across deletion, reorder, or notebook reopen, but only the first to execute actually installs `window.anndataRepr`; subsequent cells reuse the installed `init(container)`. Addresses the runtime-redundancy concern without giving up per-cell portability. --- src/anndata/_repr/javascript.py | 16 +++++++++++++--- src/anndata/_repr/static/repr.js | 6 +++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/anndata/_repr/javascript.py b/src/anndata/_repr/javascript.py index d626c3338..a86057a38 100644 --- a/src/anndata/_repr/javascript.py +++ b/src/anndata/_repr/javascript.py @@ -28,6 +28,11 @@ def get_javascript(container_id: str) -> str: """ Get the JavaScript code for a specific container. + Each rendered repr ships the full source so that any cell is + self-sufficient (surviving deletion, reorder, or notebook reopen), + but only the first to execute installs ``window.anndataRepr`` — + subsequent cells reuse the installed ``init`` for their own container. + Parameters ---------- container_id @@ -40,10 +45,15 @@ def get_javascript(container_id: str) -> str: js_content = _load_js_content() return f"""""" diff --git a/src/anndata/_repr/static/repr.js b/src/anndata/_repr/static/repr.js index 49954fe78..e202be002 100644 --- a/src/anndata/_repr/static/repr.js +++ b/src/anndata/_repr/static/repr.js @@ -418,7 +418,11 @@ if (readmeIcon) { // Header const header = document.createElement("div") header.className = "anndata-readme__header" - header.innerHTML = `

README

` + + const title = document.createElement("h3") + title.id = modalTitleId + title.textContent = "README" + header.appendChild(title) const closeBtn = document.createElement("button") closeBtn.className = "anndata-readme__close" From d274fc723dce55d4fffb27747ecbaa836e7d5ede Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 15:03:34 -0700 Subject: [PATCH 194/194] refactor(repr): iterate AnnDataElem directly; fix nested copy-button hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace iter_outer as the section-iteration source in the repr, and derive the canonical section list from get_literal_members(AnnDataElem). Why not iter_outer: iter_outer yields (name, getattr(adata, name)) pairs, and propagates the first exception it hits — so a single broken section (corrupt aligned mapping, subclass with a crashing property, etc.) terminates the generator mid-iteration. _repr_html_'s top-level except catches that, the repr returns None, and the whole cell output disappears. The adversarial "Evil AnnData" case hit this. Iterating get_literal_members(AnnDataElem) ourselves and doing the getattr inside each section's try/except isolates the failure: a broken section renders as an error placeholder and the remaining sections still appear. iter_outer stays for callers that want strict semantics (AnnData.__str__, to_memory, _reduce, I/O). Also: use the same Literal as the single source of truth wherever the repr previously derived a set of section names from iter_outer (_detect_unknown_sections, _get_custom_sections_by_position). Those helpers now compute the set locally and no longer need the caller to thread it through. CSS fix: .anndata-entry:hover .anndata-entry__copy propagated the :hover state to every ancestor entry, revealing every ancestor's copy button when a deeply-nested row was hovered. Scope the trigger to the entry's own row: the entry itself for plain div rows (which never contain nested entries) and the for expandable rows (nested children live in .anndata-entry__nested-content, outside the summary). --- src/anndata/_repr/html.py | 48 +++++++++++++++---------------- src/anndata/_repr/sections.py | 14 ++++----- src/anndata/_repr/static/repr.css | 16 +++++++++-- tests/repr/test_repr_sections.py | 8 ++---- 4 files changed, 44 insertions(+), 42 deletions(-) diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index 9cae219cb..6dc30c4f3 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -22,7 +22,8 @@ DEFAULT_MAX_README_SIZE, TOOLTIP_TRUNCATE_LENGTH, ) -from ..utils import iter_outer +from .._types import AnnDataElem +from ..utils import get_literal_members from . import ( DEFAULT_FOLD_THRESHOLD, DEFAULT_MAX_CATEGORIES, @@ -96,19 +97,23 @@ def _collect_all_field_names(adata: AnnData) -> list[str]: (uns, obsm, varm, layers, obsp, varp) plus any registered custom sections. """ all_names: list[str] = [] - standard_sections: set[str] = set() + standard_sections = set(get_literal_members(AnnDataElem)) - for section, attr in iter_outer(adata): - standard_sections.add(section) - if section in {"X", "raw"} or attr is None: + for section in get_literal_members(AnnDataElem): + if section in {"X", "raw"}: continue try: + attr = getattr(adata, section) + if attr is None: + continue if section in {"obs", "var"}: if hasattr(attr, "columns"): all_names.extend(attr.columns.tolist()) elif hasattr(attr, "keys"): all_names.extend(attr.keys()) except Exception: # noqa: BLE001 + # Broken section — skip for width calculation, error placeholder is + # rendered separately by _render_section. pass # Registered custom sections (e.g., TreeData's obst/vart) @@ -346,17 +351,10 @@ def _render_all_sections( ) -> list[str]: """Render all standard and custom sections.""" parts: list[str] = [] - # Materialize iter_outer once. On backed AnnData each yield reopens/closes - # the backing file, so we pay that cost once and reuse the names downstream. - sections = list(iter_outer(adata)) - standard_section_names = {name for name, _ in sections} - - custom_sections_after = _get_custom_sections_by_position( - adata, standard_section_names - ) + custom_sections_after = _get_custom_sections_by_position(adata) - for section, elem in sections: - parts.append(_render_section(adata, section, elem, context)) + for section in get_literal_members(AnnDataElem): + parts.append(_render_section(adata, section, context)) # Render custom sections after this section if section in custom_sections_after: @@ -372,8 +370,8 @@ def _render_all_sections( for section_formatter in custom_sections_after[None] ) - # Detect and show unknown sections (mapping-like attributes not surfaced by iter_outer) - unknown_sections = _detect_unknown_sections(adata, standard_section_names) + # Detect and show unknown sections (attributes not in AnnDataElem) + unknown_sections = _detect_unknown_sections(adata) if unknown_sections: parts.append(_render_unknown_sections(unknown_sections)) @@ -383,19 +381,21 @@ def _render_all_sections( def _render_section( adata: AnnData, section: str, - elem: object, context: FormatterContext, ) -> str: """Render a single standard section. - ``elem`` is the value yielded by ``iter_outer`` for this section. We pass it - through to the per-section renderers so they don't need a second ``getattr`` - (which would re-open the backing file on backed AnnData, since ``iter_outer`` - closes it after each yield). + Attribute access happens inside the try/except so a broken section (one + whose ``getattr`` raises — e.g. a corrupt aligned mapping or a subclass + with a crashing property) renders as an error placeholder instead of + aborting the whole repr. This is why we iterate section names directly + via ``get_literal_members(AnnDataElem)`` rather than delegating to + ``iter_outer``, which propagates the first exception it hits. """ try: if section == "X": return render_x_entry(adata, context) + elem = getattr(adata, section) if section == "raw": return _render_raw_section(elem, context) if section in ("obs", "var"): @@ -405,12 +405,11 @@ def _render_section( return _render_mapping_section(section, elem, context) except Exception as e: # noqa: BLE001 # Show error instead of hiding the section - return _render_error_entry(section, str(e)) + return _render_error_entry(section, f"{type(e).__name__}: {e}") def _get_custom_sections_by_position( adata: object, - standard_section_names: set[str], ) -> dict[str | None, list[SectionFormatter]]: """ Get registered custom section formatters grouped by their position. @@ -421,6 +420,7 @@ def _get_custom_sections_by_position( from collections import defaultdict result = defaultdict(list) + standard_section_names = set(get_literal_members(AnnDataElem)) for section_name in formatter_registry.get_registered_sections(): formatter = formatter_registry.get_section_formatter(section_name) diff --git a/src/anndata/_repr/sections.py b/src/anndata/_repr/sections.py index 407b14265..866c2d9e1 100644 --- a/src/anndata/_repr/sections.py +++ b/src/anndata/_repr/sections.py @@ -32,6 +32,8 @@ ERROR_TRUNCATE_LENGTH, INTERNAL_ANNDATA_ATTRS, ) +from .._types import AnnDataElem +from ..utils import get_literal_members from . import ( get_section_doc_url, ) @@ -280,21 +282,15 @@ def _render_uns_entry( # ----------------------------------------------------------------------------- -def _detect_unknown_sections( - adata: AnnData, standard_section_names: set[str] -) -> list[tuple[str, str]]: - """Detect mapping-like attributes not surfaced by ``iter_outer``. - - ``standard_section_names`` is the set of names already yielded by - ``iter_outer`` for this AnnData (collected by the caller so we don't - re-iterate it here, since each yield reopens the backing file). +def _detect_unknown_sections(adata: AnnData) -> list[tuple[str, str]]: + """Detect mapping-like attributes not surfaced by the standard section list. Returns list of (attr_name, type_description) tuples for unknown sections. """ from collections.abc import Mapping # See INTERNAL_ANNDATA_ATTRS docstring for why the internal list is explicit. - known = standard_section_names | INTERNAL_ANNDATA_ATTRS + known = set(get_literal_members(AnnDataElem)) | INTERNAL_ANNDATA_ATTRS # Also exclude sections with registered custom formatters # (including should_show=False ones that suppress display). diff --git a/src/anndata/_repr/static/repr.css b/src/anndata/_repr/static/repr.css index 887c5b6fd..4c49bb19c 100644 --- a/src/anndata/_repr/static/repr.css +++ b/src/anndata/_repr/static/repr.css @@ -558,10 +558,20 @@ &.error { background: var(--anndata-error-bg) !important; } + } - &:hover .anndata-entry__copy { - opacity: 1; - } + /* Copy button visibility on hover. + Using `.anndata-entry:hover` would propagate from any nested child row + up through every ancestor entry, revealing every ancestor's copy button + when a deeply-nested row is hovered. Scope the trigger to: + - the entry itself for plain (non-expandable) rows, and + - the for expandable rows, which doesn't contain nested + child entries (those live in `.anndata-entry__nested-content`). */ + div.anndata-entry:hover > .anndata-entry__name .anndata-entry__copy, + details.anndata-entry + > summary.anndata-entry__summary:hover + .anndata-entry__copy { + opacity: 1; } /* Regular (non-expandable) entries use subgrid for column alignment */ diff --git a/tests/repr/test_repr_sections.py b/tests/repr/test_repr_sections.py index 8e3e518aa..93eaa5b47 100644 --- a/tests/repr/test_repr_sections.py +++ b/tests/repr/test_repr_sections.py @@ -841,23 +841,19 @@ def test_render_unknown_sections(self): def test_detect_unknown_sections_empty(self): """Test _detect_unknown_sections returns empty for standard AnnData.""" from anndata._repr.sections import _detect_unknown_sections - from anndata.utils import iter_outer adata = AnnData(np.zeros((5, 3))) - names = {name for name, _ in iter_outer(adata)} - unknown = _detect_unknown_sections(adata, names) + unknown = _detect_unknown_sections(adata) assert unknown == [] def test_detect_unknown_sections_with_custom_attr(self): """Test _detect_unknown_sections detects custom attributes.""" from anndata._repr.sections import _detect_unknown_sections - from anndata.utils import iter_outer adata = AnnData(np.zeros((5, 3))) # Add a custom public attribute (not starting with _) object.__setattr__(adata, "custom_data", [1, 2, 3]) - names = {name for name, _ in iter_outer(adata)} - unknown = _detect_unknown_sections(adata, names) + unknown = _detect_unknown_sections(adata) # May or may not detect depending on implementation assert isinstance(unknown, list)