diff --git a/src/anndata/_core/anndata.py b/src/anndata/_core/anndata.py index 95a222384..39b099e4b 100644 --- a/src/anndata/_core/anndata.py +++ b/src/anndata/_core/anndata.py @@ -599,9 +599,13 @@ def _repr_html_(self) -> str | None: from anndata._repr import generate_repr_html return generate_repr_html(self) - except Exception: # noqa: BLE001 + except Exception as e: # noqa: BLE001 # Intentional broad catch: HTML repr should never crash the notebook - # Fall back to text repr if HTML generation fails + # Fall back to text repr if HTML generation fails, but log the error + warn( + f"HTML repr failed, falling back to text repr: {e}", + UserWarning, + ) return None def __eq__(self, other): diff --git a/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py index ea378af8a..7de920bec 100644 --- a/src/anndata/_repr/__init__.py +++ b/src/anndata/_repr/__init__.py @@ -100,12 +100,128 @@ def get_entries(self, obj, context): ) for k, v in obj.obst.items() ] + +Building Custom _repr_html_ +--------------------------- +For packages with AnnData-adjacent objects (like SpatialData, MuData) that need +their own ``_repr_html_``, you can reuse anndata's CSS, JavaScript, and helpers. + +**Basic structure**:: + + from anndata._repr import get_css, get_javascript + + + class MyData: + def _repr_html_(self): + container_id = f"mydata-{id(self)}" + return f''' + {get_css()} +
+
+ MyData + 100 items +
+
+ +
+
+ {get_javascript(container_id)} + ''' + +**CSS classes** (stable, can be used directly): + + - ``anndata-repr``: Main container (required for JS and styling) + - ``anndata-hdr``: Header row (flexbox, contains type/shape/badges) + - ``anndata-ftr``: Footer row (version, memory info) + - ``adata-type``: Type name span in header + - ``adata-shape``: Shape/dimensions span in header + - ``adata-sections``: Container for all sections + - ``adata-section``: Individual section wrapper + - ``adata-section-header``: Section header (clickable to fold) + - ``adata-section-content``: Section content (rows) + +**CSS variables** (set on ``.anndata-repr`` element): + + - ``--anndata-name-col-width``: Width of name column (default: 150px) + - ``--anndata-type-col-width``: Width of type column (default: 220px) + +**Using render helpers** for consistent section rendering:: + + from anndata._repr import ( + get_css, + get_javascript, + render_section, + render_formatted_entry, + render_badge, + render_search_box, + FormattedEntry, + FormattedOutput, + ) + + + def _repr_html_(self): + container_id = f"mydata-{id(self)}" + parts = [get_css()] + + # Header + parts.append(f''' +
+
+ MyData + {render_badge("Zarr", "adata-badge-backed")} + + {render_search_box(container_id)} +
+
+ ''') + + # Build section entries + entries = [] + for key, value in self.items.items(): + entry = FormattedEntry( + key=key, + output=FormattedOutput( + type_name=f"array {value.shape}", + css_class="dtype-ndarray", + ), + ) + entries.append(render_formatted_entry(entry)) + + # Render section + parts.append( + render_section( + "items", + "\\n".join(entries), + n_items=len(self.items), + ) + ) + + parts.append("
") + parts.append(get_javascript(container_id)) + return "\\n".join(parts) + +**Embedding nested AnnData** with full interactivity:: + + from anndata._repr import generate_repr_html, FormattedEntry, FormattedOutput + + nested_html = generate_repr_html(adata, depth=1, max_depth=3) + entry = FormattedEntry( + key="table", + output=FormattedOutput( + type_name=f"AnnData ({adata.n_obs} x {adata.n_vars})", + html_content=nested_html, + is_expandable=True, + ), + ) + +**Complete example**: See ``MockSpatialData`` in ``tests/visual_inspect_repr_html.py`` +for a full implementation with images, labels, points, shapes, and nested tables. """ from __future__ import annotations # Import constants from dedicated module (single source of truth) -from anndata._repr.constants import ( +from .constants import ( DEFAULT_FOLD_THRESHOLD, DEFAULT_MAX_CATEGORIES, DEFAULT_MAX_DEPTH, @@ -115,6 +231,7 @@ def get_entries(self, obj, context): DEFAULT_PREVIEW_ITEMS, DEFAULT_TYPE_WIDTH, DEFAULT_UNIQUE_LIMIT, + NOT_SERIALIZABLE_MSG, ) # Documentation base URL @@ -135,8 +252,29 @@ def get_entries(self, obj, context): ) # Import main functionality -from anndata._repr.html import generate_repr_html # noqa: E402 -from anndata._repr.registry import ( # noqa: E402 +# Inline styles for graceful degradation (from single source of truth) +from .constants import STYLE_HIDDEN # noqa: E402 + +# Building blocks for packages that want to create their own _repr_html_ +# These allow reusing anndata's styling while building custom representations +from .css import get_css # noqa: E402 + +# HTML rendering helpers for building custom sections +# UI component helpers (search box, fold icon, badges, etc.) +from .formatters import check_column_name # noqa: E402 +from .html import ( # noqa: E402 + generate_repr_html, + render_badge, + render_copy_button, + render_fold_icon, + render_formatted_entry, + render_header_badges, + render_search_box, + render_section, + render_warning_icon, +) +from .javascript import get_javascript # noqa: E402 +from .registry import ( # noqa: E402 UNS_TYPE_HINT_KEY, FormattedEntry, FormattedOutput, @@ -150,6 +288,11 @@ def get_entries(self, obj, context): formatter_registry, register_formatter, ) +from .utils import ( # noqa: E402 + escape_html, + format_memory_size, + format_number, +) __all__ = [ # noqa: RUF022 # organized by category, not alphabetically # Constants @@ -164,6 +307,7 @@ def get_entries(self, obj, context): "DEFAULT_TYPE_WIDTH", "DOCS_BASE_URL", "SECTION_ORDER", + "NOT_SERIALIZABLE_MSG", # Main function "generate_repr_html", # Registry for extensibility @@ -178,4 +322,22 @@ def get_entries(self, obj, context): # Type hint extraction (for tagged data in uns) "extract_uns_type_hint", "UNS_TYPE_HINT_KEY", + # Building blocks for custom _repr_html_ implementations + "get_css", + "get_javascript", + "escape_html", + "format_number", + "format_memory_size", + "render_section", + "render_formatted_entry", + "STYLE_HIDDEN", + # UI component helpers + "render_search_box", + "render_fold_icon", + "render_copy_button", + "render_badge", + "render_header_badges", + "render_warning_icon", + # Validation helpers + "check_column_name", ] diff --git a/src/anndata/_repr/constants.py b/src/anndata/_repr/constants.py index 659d23f28..9ced60d7f 100644 --- a/src/anndata/_repr/constants.py +++ b/src/anndata/_repr/constants.py @@ -20,3 +20,11 @@ # Column widths (pixels) DEFAULT_MAX_FIELD_WIDTH = 400 # Max width for field name column DEFAULT_TYPE_WIDTH = 220 # Width for type column + +# Inline styles for graceful degradation (hidden until JS enables) +STYLE_HIDDEN = "display:none;" +STYLE_SECTION_CONTENT = "padding:0;overflow:hidden;" +STYLE_SECTION_TABLE = "width:100%;border-collapse:collapse;" + +# Warning messages +NOT_SERIALIZABLE_MSG = "Not serializable to H5AD/Zarr" diff --git a/src/anndata/_repr/css.py b/src/anndata/_repr/css.py index d69b816df..88172880d 100644 --- a/src/anndata/_repr/css.py +++ b/src/anndata/_repr/css.py @@ -357,23 +357,32 @@ def get_css() -> str: border-bottom: 1px solid var(--anndata-border-light); } -.anndata-repr .adata-search-input { - width: 100%; +/* Search box container with inline toggle buttons */ +.anndata-repr .adata-search-box { + display: inline-flex; + align-items: center; max-width: 300px; - padding: 6px 10px; - font-size: 12px; border: 1px solid var(--anndata-border-color); border-radius: var(--anndata-radius); background: var(--anndata-bg-primary); - color: var(--anndata-text-primary); - outline: none; transition: border-color 0.15s; } -.anndata-repr .adata-search-input:focus { +.anndata-repr .adata-search-box:focus-within { border-color: var(--anndata-accent-color); } +.anndata-repr .adata-search-input { + flex: 1; + min-width: 120px; + padding: 6px 8px; + font-size: 12px; + border: none; + background: transparent; + color: var(--anndata-text-primary); + outline: none; +} + .anndata-repr .adata-search-input::placeholder { color: var(--anndata-text-muted); } @@ -389,6 +398,53 @@ def get_css() -> str: display: inline; } +/* Search toggle buttons (case sensitive, regex) - inside search box */ +.anndata-repr .adata-search-toggles { + display: flex; + gap: 1px; + padding-right: 4px; + border-left: 1px solid var(--anndata-border-light); + margin-left: 4px; + padding-left: 4px; +} + +.anndata-repr .adata-search-toggle { + display: none; /* Hidden until JS enables */ + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 3px; + background: transparent; + color: var(--anndata-text-muted); + font-size: 10px; + font-family: var(--anndata-font-mono); + font-weight: 600; + cursor: pointer; + transition: all 0.15s; +} + +.anndata-repr .adata-search-toggle:hover { + background: var(--anndata-bg-secondary); + color: var(--anndata-text-primary); +} + +.anndata-repr .adata-search-toggle.active { + background: var(--anndata-accent-color); + color: white; +} + +/* Regex error indicator */ +.anndata-repr .adata-search-box.regex-error { + border-color: #dc3545; +} + +.anndata-repr .adata-search-box.regex-error .adata-search-input { + background: rgba(220, 53, 69, 0.05); +} + /* Metadata bar */ .anndata-repr .adata-metadata { display: flex; diff --git a/src/anndata/_repr/formatters.py b/src/anndata/_repr/formatters.py index a8cb21786..c54659c22 100644 --- a/src/anndata/_repr/formatters.py +++ b/src/anndata/_repr/formatters.py @@ -22,12 +22,12 @@ import numpy as np import pandas as pd -from anndata._repr.registry import ( +from .registry import ( FormattedOutput, TypeFormatter, formatter_registry, ) -from anndata._repr.utils import ( +from .utils import ( format_number, is_serializable, ) @@ -35,12 +35,112 @@ if TYPE_CHECKING: from typing import Any - from anndata._repr.registry import FormatterContext + from .registry import FormatterContext -# ============================================================================= -# NumPy Formatters -# ============================================================================= +def check_column_name(name: str) -> tuple[bool, str, bool]: + """Check if a column name is valid for HDF5/Zarr serialization. + + Returns (is_valid, reason, is_hard_error). + is_hard_error=True means it fails NOW, False means it's a deprecation warning. + """ + if not isinstance(name, str): + return False, f"Non-string name ({type(name).__name__})", True + # Slashes will be disallowed in h5 stores (FutureWarning) + if "/" in name: + return False, "Contains '/' (deprecated)", False + return True, "", False + + +def _check_array_has_writer(array: Any) -> bool: + """Check if an array type has a registered IO writer. + + This uses the actual IO registry, making it future-proof: if a writer + is registered for a new type (e.g., datetime64), this will detect it. + """ + try: + from .._io.specs.registry import _REGISTRY + + _REGISTRY.get_spec(array) + return True + except (KeyError, TypeError): + return False + + +def _check_series_backing_array(series: pd.Series) -> tuple[bool, str]: + """Check if a Series' backing array type can be serialized. + + Uses the IO registry to check the underlying array. This is future-proof: + if anndata adds support for datetime64/timedelta64/etc, this will detect it. + + Returns (is_serializable, reason_if_not). + """ + # Standard numpy dtypes are always serializable (no registry check needed) + # This covers: float16/32/64, int8/16/32/64, uint*, bool, complex*, bytes, str + if series.dtype.kind in ("f", "i", "u", "b", "c", "S", "U"): + return True, "" + + # Get the backing array for extension dtypes + backing_array = series.array + + # NumpyExtensionArray wraps numpy arrays - check the underlying numpy array + if type(backing_array).__name__ == "NumpyExtensionArray": + # The underlying numpy array is serializable + return True, "" + + # For other extension arrays (DatetimeArray, ArrowStringArray, etc.), + # check the IO registry. This is future-proof: if anndata adds support + # for datetime64, the registry will have a writer and this returns True. + if _check_array_has_writer(backing_array): + return True, "" + + # No writer registered - provide a helpful message + dtype_name = str(series.dtype) + return False, f"{dtype_name} not serializable" + + +def _check_series_serializability(series: pd.Series) -> tuple[bool, str]: + """ + Check if an object-dtype Series contains serializable values. + + For object dtype columns, checks the first non-null value to determine + if the column can be written to H5AD/Zarr. Uses anndata's actual IO + mechanism to test serializability. + + Parameters + ---------- + series + Pandas Series with object dtype + + Returns + ------- + tuple of (is_serializable, reason_if_not) + """ + if len(series) == 0: + return True, "" + + # Get first non-null value + first_valid_idx = series.first_valid_index() + if first_valid_idx is None: + return True, "" # All null + + value = series.loc[first_valid_idx] + + # Object dtype columns with non-string/numeric values are problematic + # Check if value is a type that anndata can serialize in a DataFrame column + if isinstance(value, (list, tuple)): + # Lists/tuples in DataFrame columns are not directly serializable + # (they work in uns as arrays, but not as DataFrame cell values) + # NOTE: If https://github.com/scverse/anndata/issues/1923 is resolved, + # lists of strings may become serializable - update this check accordingly + return False, f"Contains {type(value).__name__}" + elif isinstance(value, dict): + return False, "Contains dict" + elif not isinstance(value, str | bytes | np.generic | int | float | bool): + # Custom objects are not serializable + return False, f"Contains {type(value).__name__}" + + return True, "" class NumpyArrayFormatter(TypeFormatter): @@ -107,11 +207,6 @@ def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: ) -# ============================================================================= -# SciPy Sparse Formatters -# ============================================================================= - - class SparseMatrixFormatter(TypeFormatter): """ Formatter for scipy.sparse matrices and arrays. @@ -204,11 +299,6 @@ def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: ) -# ============================================================================= -# Pandas Formatters -# ============================================================================= - - class DataFrameFormatter(TypeFormatter): """Formatter for pandas.DataFrame. @@ -302,6 +392,23 @@ def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: dtype_str = str(series.dtype) css_class = _get_pandas_dtype_css_class(series.dtype) + # Check serializability using the IO registry (future-proof) + is_serial = True + warnings = [] + + # For non-object dtypes, check if the backing array has a registered writer + # This is future-proof: if anndata adds datetime64 support, this will detect it + if series.dtype != np.dtype("object"): + is_serial, reason = _check_series_backing_array(series) + if not is_serial: + warnings.append(reason) + + # Object dtype columns need value-level checking + elif len(series) > 0: + is_serial, reason = _check_series_serializability(series) + if not is_serial: + warnings.append(reason) + return FormattedOutput( type_name=f"{dtype_str}", css_class=css_class, @@ -309,7 +416,8 @@ def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: "length": len(series), "dtype": dtype_str, }, - is_serializable=True, + is_serializable=is_serial, + warnings=warnings, ) @@ -342,11 +450,6 @@ def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: ) -# ============================================================================= -# Dask Array Formatter -# ============================================================================= - - class DaskArrayFormatter(TypeFormatter): """Formatter for dask.array.Array.""" @@ -381,11 +484,6 @@ def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: ) -# ============================================================================= -# CuPy Array Formatter -# ============================================================================= - - class CuPyArrayFormatter(TypeFormatter): """Formatter for cupy.ndarray (GPU arrays).""" @@ -415,11 +513,6 @@ def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: ) -# ============================================================================= -# Awkward Array Formatter -# ============================================================================= - - class AwkwardArrayFormatter(TypeFormatter): """Formatter for awkward.Array (ragged/jagged arrays).""" @@ -450,11 +543,6 @@ def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: ) -# ============================================================================= -# Array-API Compatible Array Formatter -# ============================================================================= - - class ArrayAPIFormatter(TypeFormatter): """ Formatter for Array-API compatible arrays (JAX, PyTorch, TensorFlow, etc.). @@ -544,11 +632,6 @@ def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: ) -# ============================================================================= -# AnnData Formatter (for nested AnnData in .uns) -# ============================================================================= - - class AnnDataFormatter(TypeFormatter): """Formatter for nested AnnData objects.""" @@ -575,11 +658,6 @@ def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: ) -# ============================================================================= -# Python Built-in Type Formatters -# ============================================================================= - - class NoneFormatter(TypeFormatter): """Formatter for None.""" @@ -710,11 +788,6 @@ def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: ) -# ============================================================================= -# Color List Formatter -# ============================================================================= - - class ColorListFormatter(TypeFormatter): """Special formatter for color lists (*_colors in .uns).""" @@ -729,11 +802,6 @@ def format(self, obj: Any, context: FormatterContext) -> FormattedOutput: raise NotImplementedError -# ============================================================================= -# Helper Functions -# ============================================================================= - - def _get_dtype_css_class(dtype: np.dtype) -> str: """Get CSS class for a numpy dtype.""" kind = dtype.kind @@ -768,11 +836,6 @@ def _get_pandas_dtype_css_class(dtype) -> str: return "dtype-object" -# ============================================================================= -# Register all formatters -# ============================================================================= - - def _register_builtin_formatters() -> None: """Register all built-in formatters with the global registry.""" formatters = [ diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index e13979b78..f7320edc1 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -16,7 +16,7 @@ import numpy as np -from anndata._repr import ( +from . import ( DEFAULT_FOLD_THRESHOLD, DEFAULT_MAX_CATEGORIES, DEFAULT_MAX_DEPTH, @@ -29,14 +29,20 @@ DOCS_BASE_URL, SECTION_ORDER, ) -from anndata._repr.css import get_css -from anndata._repr.javascript import get_javascript -from anndata._repr.registry import ( +from .constants import ( + NOT_SERIALIZABLE_MSG, + STYLE_HIDDEN, + STYLE_SECTION_CONTENT, + STYLE_SECTION_TABLE, +) +from .css import get_css +from .javascript import get_javascript +from .registry import ( FormatterContext, extract_uns_type_hint, formatter_registry, ) -from anndata._repr.utils import ( +from .utils import ( check_color_category_mismatch, escape_html, format_memory_size, @@ -57,34 +63,52 @@ import pandas as pd from anndata import AnnData - from anndata._repr.registry import FormattedEntry, FormattedOutput + + from .registry import FormattedEntry, FormattedOutput # Import formatters to register them (side-effect import) -import anndata._repr.formatters # noqa: F401 +from . import formatters as _formatters # noqa: F401 +from .formatters import check_column_name # Approximate character width in pixels for monospace font at 13px CHAR_WIDTH_PX = 8 -# ============================================================================= -# Inline styles for graceful degradation -# ============================================================================= # CSS classes in css.py provide full styling with dark mode support. # These inline styles are minimal fallbacks that ensure basic readability # if CSS fails to load (e.g., email clients, restrictive embeds). # Colors and theming are intentionally CSS-only to support dark mode. +# STYLE_HIDDEN, STYLE_SECTION_CONTENT, STYLE_SECTION_TABLE are imported +# from constants.py (single source of truth). -# Hidden elements - JS reveals these; must be inline to work without CSS -STYLE_HIDDEN = "display:none;" - -# Section content and tables - basic layout fallback -STYLE_SECTION_CONTENT = "padding:0;overflow:hidden;" -STYLE_SECTION_TABLE = "width:100%;border-collapse:collapse;" STYLE_SECTION_INFO = "padding:6px 12px;" # Category color dot - needs inline for dynamic background color STYLE_CAT_DOT = "width:8px;height:8px;border-radius:50%;display:inline-block;" +def render_warning_icon(warnings: list[str], *, is_error: bool = False) -> str: + """Render warning icon with tooltip if there are warnings or errors. + + Parameters + ---------- + warnings + List of warning messages to show in tooltip. + is_error + If True, prepends "Not serializable to H5AD/Zarr" to warnings. + + Returns + ------- + HTML string for warning icon, or empty string if no warnings/error. + """ + if not warnings and not is_error: + return "" + all_warnings = list(warnings) + if is_error: + all_warnings.insert(0, NOT_SERIALIZABLE_MSG) + title = escape_html("; ".join(all_warnings)) + return f'(!)' + + def _calculate_field_name_width(adata: AnnData, max_width: int) -> int: """ Calculate the optimal field name column width based on longest field name. @@ -102,9 +126,13 @@ def _calculate_field_name_width(adata: AnnData, max_width: int) -> int: # Mapping sections (uns, obsm, varm, layers, obsp, varp) for attr in ("uns", "obsm", "varm", "layers", "obsp", "varp"): - mapping = getattr(adata, attr, None) - if mapping is not None: - all_names.extend(mapping.keys()) + try: + mapping = getattr(adata, attr, None) + if mapping is not None: + all_names.extend(mapping.keys()) + except Exception: # noqa: BLE001 + # Skip sections that fail to access (will show error during rendering) + pass if not all_names: return 100 # Minimum default @@ -241,11 +269,6 @@ def generate_repr_html( return "\n".join(parts) -# ============================================================================= -# Custom Section Support -# ============================================================================= - - def _render_all_sections( adata: AnnData, context: FormatterContext, @@ -297,6 +320,11 @@ def _render_all_sections( for section_formatter in custom_sections_after[None] ) + # Detect and show unknown sections (mapping-like attributes not in SECTION_ORDER) + unknown_sections = _detect_unknown_sections(adata) + if unknown_sections: + parts.append(_render_unknown_sections(unknown_sections)) + return parts @@ -310,15 +338,25 @@ def _render_section( max_depth: int, ) -> str: """Render a single standard section.""" - if section == "raw": - return _render_raw_section(adata, context, fold_threshold, max_items) - if section in ("obs", "var"): - return _render_dataframe_section( + try: + if section == "X": + return _render_x_entry(adata, context) + if section == "raw": + return _render_raw_section(adata, context, fold_threshold, max_items) + if section in ("obs", "var"): + return _render_dataframe_section( + adata, section, context, fold_threshold, max_items + ) + if section == "uns": + return _render_uns_section( + adata, context, fold_threshold, max_items, max_depth + ) + return _render_mapping_section( adata, section, context, fold_threshold, max_items ) - if section == "uns": - return _render_uns_section(adata, context, fold_threshold, max_items, max_depth) - return _render_mapping_section(adata, section, context, fold_threshold, max_items) + except Exception as e: # noqa: BLE001 + # Show error instead of hiding the section + return _render_error_entry(section, str(e)) def _get_custom_sections_by_position(adata: Any) -> dict[str | None, list]: @@ -368,7 +406,7 @@ def _render_custom_section( entries = formatter.get_entries(adata, context) except Exception as e: # noqa: BLE001 # Intentional broad catch: custom formatters shouldn't crash the entire repr - from anndata._warnings import warn + from .._warnings import warn warn( f"Custom section formatter '{formatter.section_name}' failed: {e}", @@ -380,42 +418,82 @@ def _render_custom_section( return "" n_items = len(entries) - should_collapse = n_items > fold_threshold - section_name = formatter.section_name - display_name = getattr(formatter, "display_name", section_name) - doc_url = getattr(formatter, "doc_url", None) - tooltip = getattr(formatter, "tooltip", "") - - parts = [ - f'
' - ] - - # Header - parts.append( - _render_section_header(display_name, f"({n_items} items)", doc_url, tooltip) - ) - - # Content - parts.append(f'
') - parts.append(f'') + # Render entries (with truncation) + rows = [] for i, entry in enumerate(entries): if i >= max_items: - parts.append(_render_truncation_indicator(n_items - max_items)) + rows.append(_render_truncation_indicator(n_items - max_items)) break - parts.append(_render_formatted_entry(entry, section_name)) + rows.append(render_formatted_entry(entry, section_name)) + + # Use render_section for consistent structure + return render_section( + getattr(formatter, "display_name", section_name), + "\n".join(rows), + n_items=n_items, + doc_url=getattr(formatter, "doc_url", None), + tooltip=getattr(formatter, "tooltip", ""), + should_collapse=n_items > fold_threshold, + section_id=section_name, + ) - parts.append("
") - parts.append("
") # anndata-seccontent - parts.append("
") # anndata-sec - return "\n".join(parts) +def render_formatted_entry(entry: FormattedEntry, section: str = "") -> str: + """ + Render a FormattedEntry as a table row. + + This is a public API for packages building their own _repr_html_. + It provides the same flexibility as internal code by accepting + FormattedEntry/FormattedOutput objects. + + Parameters + ---------- + entry + A FormattedEntry containing the key and FormattedOutput + section + Optional section name (for future use) + + Returns + ------- + HTML string for the table row(s) + Examples + -------- + :: -def _render_formatted_entry(entry: FormattedEntry, section: str) -> str: - """Render a FormattedEntry from a custom section formatter.""" + from . import ( + FormattedEntry, + FormattedOutput, + render_formatted_entry, + ) + + entry = FormattedEntry( + key="my_array", + output=FormattedOutput( + type_name="ndarray (100, 50) float32", + css_class="dtype-ndarray", + tooltip="My custom array", + warnings=["Some warning"], + ), + ) + html = render_formatted_entry(entry) + + With expandable nested content:: + + nested_html = generate_repr_html(adata, depth=1) + entry = FormattedEntry( + key="cell_table", + output=FormattedOutput( + type_name="AnnData (150 × 30)", + css_class="dtype-anndata", + html_content=nested_html, + is_expandable=True, + ), + ) + html = render_formatted_entry(entry) + """ output = entry.output entry_class = "adata-entry" @@ -439,12 +517,7 @@ def _render_formatted_entry(entry: FormattedEntry, section: str) -> str: parts.append( f'{escape_html(output.type_name)}' ) - if output.warnings or not output.is_serializable: - warnings_list = output.warnings.copy() - if not output.is_serializable: - warnings_list.insert(0, "Not serializable to H5AD/Zarr") - title = escape_html("; ".join(warnings_list)) - parts.append(f'(!)') + parts.append(render_warning_icon(list(output.warnings), not output.is_serializable)) if has_expandable_content: parts.append( @@ -458,8 +531,11 @@ def _render_formatted_entry(entry: FormattedEntry, section: str) -> str: parts.append("") - # Meta (empty for custom sections, or could be customized) - parts.append('') + # Meta column (for data previews, dimensions, etc.) + parts.append('') + if output.meta_content: + parts.append(output.meta_content) + parts.append("") parts.append("") @@ -474,9 +550,181 @@ def _render_formatted_entry(entry: FormattedEntry, section: str) -> str: return "\n".join(parts) -# ============================================================================= -# Name Cell Renderer -# ============================================================================= +def render_search_box(container_id: str = "") -> str: + """ + Render a search box with filter indicator and search mode toggles. + + The search box is hidden by default and shown when JavaScript is enabled. + It filters entries across all sections by key, type, or content. + Includes toggle buttons for case-sensitive search and regex mode. + + Parameters + ---------- + container_id + Unique ID for the container (used for label association) + + Returns + ------- + HTML string for the search box + + Example + ------- + >>> parts = ['
'] + >>> parts.append('SpatialData') + >>> parts.append('') # Spacer + >>> parts.append(render_search_box(container_id)) + >>> parts.append("
") + """ + search_id = f"{container_id}-search" if container_id else "anndata-search" + return ( + f'' + f'' + f'' + f'' + f'' + f"" + f"" + f'' + ) + + +def render_fold_icon() -> str: + """ + Render a fold/expand icon for section headers. + + The icon is hidden by default and shown when JavaScript is enabled. + It rotates when the section is collapsed. + + Returns + ------- + HTML string for the fold icon + + Example + ------- + >>> parts = ['
'] + >>> parts.append(render_fold_icon()) + >>> parts.append('images') + >>> parts.append("
") + """ + return f'' + + +def render_copy_button(text: str, tooltip: str = "Copy") -> str: + """ + Render a copy-to-clipboard button. + + The button is hidden by default and shown when JavaScript is enabled. + When clicked, it copies the specified text to the clipboard. + + Parameters + ---------- + text + The text to copy when clicked + tooltip + Tooltip text (default: "Copy") + + Returns + ------- + HTML string for the copy button + + Example + ------- + >>> html = f"{name}{render_copy_button(name, 'Copy name')}" + """ + escaped_text = escape_html(text) + escaped_tooltip = escape_html(tooltip) + return ( + f'' + ) + + +def render_badge( + text: str, + variant: str = "", + tooltip: str = "", +) -> str: + """ + Render a badge (pill-shaped label). + + Parameters + ---------- + text + Badge text + variant + Variant class for styling. Built-in variants: + - "" (default gray) + - "adata-badge-view" (blue, for views) + - "adata-badge-backed" (orange, for backed mode) + - "adata-badge-sparse" (green, for sparse matrices) + - "adata-badge-dask" (purple, for Dask arrays) + - "adata-badge-extension" (for extension types) + tooltip + Tooltip text on hover + + Returns + ------- + HTML string for the badge + + Example + ------- + >>> render_badge("Zarr", "adata-badge-backed", "Backed by Zarr store") + """ + escaped_text = escape_html(text) + title_attr = f' title="{escape_html(tooltip)}"' if tooltip else "" + # Always include base class, optionally add variant + css_class = f"adata-badge {variant}".strip() if variant else "adata-badge" + return f'{escaped_text}' + + +def render_header_badges( + *, + is_view: bool = False, + is_backed: bool = False, + backing_path: str | None = None, + backing_format: str | None = None, +) -> str: + """ + Render standard header badges for view/backed status. + + Parameters + ---------- + is_view + Whether this is a view + is_backed + Whether this is backed by a file + backing_path + Path to the backing file (for tooltip) + backing_format + Format of the backing file ("H5AD", "Zarr", etc.) + + Returns + ------- + HTML string with badges + + Example + ------- + >>> badges = render_header_badges( + ... is_backed=True, + ... backing_path="/data/sample.zarr", + ... backing_format="Zarr", + ... ) + """ + parts = [] + if is_view: + parts.append( + render_badge("View", "adata-badge-view", "This is a view of another object") + ) + if is_backed: + tooltip = f"Backed by {backing_path}" if backing_path else "Backed mode" + label = backing_format or "Backed" + parts.append(render_badge(label, "adata-badge-backed", tooltip)) + return "".join(parts) def _render_name_cell(name: str) -> str: @@ -490,18 +738,12 @@ def _render_name_cell(name: str) -> str: f'' f'
' f'{escaped_name}' - f'' + f"{render_copy_button(name, 'Copy name')}" f"
" f"" ) -# ============================================================================= -# Section Renderers -# ============================================================================= - - def _render_header( adata: AnnData, *, show_search: bool = False, container_id: str = "" ) -> str: @@ -516,19 +758,16 @@ def _render_header( shape_str = f"{format_number(adata.n_obs)} obs × {format_number(adata.n_vars)} vars" parts.append(f'{shape_str}') - # Badges + # Badges - use render_badge() helper if is_view(adata): - parts.append('View') + parts.append(render_badge("View", "adata-badge-view")) if is_backed(adata): backing = get_backing_info(adata) filename = backing.get("filename", "") format_str = backing.get("format", "") status = "Open" if backing.get("is_open") else "Closed" - parts.append( - f'' - f"{format_str} ({status})" - ) + parts.append(render_badge(f"{format_str} ({status})", "adata-badge-backed")) # Inline file path (full path, no truncation) if filename: path_style = ( @@ -543,9 +782,7 @@ def _render_header( # Check for extension type (not standard AnnData) if type_name != "AnnData": - parts.append( - f'{type_name}' - ) + parts.append(render_badge(type_name, "adata-badge-extension")) # README icon if uns["README"] exists with a string readme_content = adata.uns.get("README") if hasattr(adata, "uns") else None @@ -566,17 +803,10 @@ def _render_header( f"" ) - # Search box on the right (spacer pushes it right) + # Search box on the right (spacer pushes it right) - use render_search_box() helper if show_search: parts.append('') - # Search input hidden by default (JS shows it) - filter indicator uses CSS .active class - search_id = f"{container_id}-search" if container_id else "anndata-search" - parts.append( - f'' - ) - # Filter indicator visibility controlled by CSS (.adata-filter-indicator.active) - no inline style - parts.append('') + parts.append(render_search_box(container_id)) parts.append("") return "\n".join(parts) @@ -638,9 +868,12 @@ def _format_index_preview(index: pd.Index, name: str) -> str: return ", ".join(items) -def _render_x_entry(adata: AnnData, context: FormatterContext) -> str: - """Render X as a single compact entry row.""" - X = adata.X +def _render_x_entry(obj: Any, context: FormatterContext) -> str: + """Render X as a single compact entry row. + + Works with both AnnData and Raw objects. + """ + X = obj.X parts = ['
'] parts.append("X") @@ -664,8 +897,8 @@ def _render_x_entry(adata: AnnData, context: FormatterContext) -> str: if "chunks" in output.details: type_parts.append(f"chunks={output.details['chunks']}") - # Backed info - if is_backed(adata): + # Backed info (only for AnnData, not Raw) + if is_backed(obj): type_parts.append("on disk") type_str = " · ".join(type_parts) @@ -693,38 +926,24 @@ def _render_dataframe_section( if n_cols == 0: return _render_empty_section(section, doc_url, tooltip) - # Should this section be collapsed? (only via JS, default is expanded) - should_collapse = n_cols > fold_threshold - - # Section - no inline colors to allow dark mode CSS to work - parts = [ - f'
' - ] - - # Header - parts.append( - _render_section_header(section, f"({n_cols} columns)", doc_url, tooltip) - ) - - # Content - always visible by default (JS can hide it) - parts.append(f'
') - parts.append(f'') - - # Render each column + # Render entries (with truncation) + rows = [] for i, col_name in enumerate(df.columns): if i >= max_items: - parts.append(_render_truncation_indicator(n_cols - max_items)) + rows.append(_render_truncation_indicator(n_cols - max_items)) break - col = df[col_name] - parts.append(_render_dataframe_entry(adata, section, col_name, col, context)) - - parts.append("
") - parts.append("
") # anndata-seccontent - parts.append("
") # anndata-sec - - return "\n".join(parts) + rows.append(_render_dataframe_entry(adata, section, col_name, col, context)) + + return render_section( + section, + "\n".join(rows), + n_items=n_cols, + doc_url=doc_url, + tooltip=tooltip, + should_collapse=n_cols > fold_threshold, + count_str=f"({n_cols} columns)", + ) def _render_category_list( @@ -787,6 +1006,13 @@ def _render_dataframe_entry( if should_warn: entry_warnings.append(warn_msg) + # Check column name validity (issue #321) + name_valid, name_reason, name_hard_error = check_column_name(col_name) + name_error = False + if not name_valid: + entry_warnings.append(name_reason) + name_error = name_hard_error + # Check for color mismatch color_warning = check_color_category_mismatch(adata, col_name) if color_warning: @@ -797,10 +1023,12 @@ def _render_dataframe_entry( # Copy button hidden by default - JS shows it - # Add warning class if needed (CSS handles color) + # Add warning/error class (CSS handles color - error is red like in uns) entry_class = "adata-entry" if entry_warnings: entry_class += " warning" + if not output.is_serializable or name_error: + entry_class += " error" # Build row parts = [ @@ -822,9 +1050,8 @@ def _render_dataframe_entry( parts.append( f'{escape_html(output.type_name)}' ) - if entry_warnings: - title = escape_html("; ".join(entry_warnings)) - parts.append(f'(!)') + is_error = not output.is_serializable or name_error + parts.append(render_warning_icon(entry_warnings, is_error)) # Add wrap button for categories in the type column if is_categorical and n_cats > 0: @@ -867,42 +1094,31 @@ def _render_mapping_section( if n_items == 0: return _render_empty_section(section, doc_url, tooltip) - should_collapse = n_items > fold_threshold - - # Section - no inline colors to allow dark mode CSS to work - parts = [ - f'
' - ] - - # Header - parts.append( - _render_section_header(section, f"({n_items} items)", doc_url, tooltip) - ) - - # Content - always visible by default - parts.append(f'
') - parts.append(f'') - + # Render entries (with truncation) + rows = [] for i, key in enumerate(keys): if i >= max_items: - parts.append(_render_truncation_indicator(n_items - max_items)) + rows.append(_render_truncation_indicator(n_items - max_items)) break - value = mapping[key] - parts.append(_render_mapping_entry(key, value, context, section)) - - parts.append("
") - parts.append("
") - parts.append("
") - - return "\n".join(parts) + rows.append(_render_mapping_entry(key, value, context, section)) + + return render_section( + section, + "\n".join(rows), + n_items=n_items, + doc_url=doc_url, + tooltip=tooltip, + should_collapse=n_items > fold_threshold, + ) def _render_type_cell( output: FormattedOutput, *, has_expandable_content: bool, + extra_warnings: list[str] | None = None, + key_hard_error: bool = False, ) -> list[str]: """Render the type cell for a mapping entry.""" parts = [''] @@ -910,12 +1126,9 @@ def _render_type_cell( parts.append( f'{escape_html(output.type_name)}' ) - if output.warnings or not output.is_serializable: - entry_warnings = output.warnings.copy() - if not output.is_serializable: - entry_warnings.insert(0, "Not serializable to H5AD/Zarr") - title = escape_html("; ".join(entry_warnings)) - parts.append(f'(!)') + all_warnings = (extra_warnings or []) + list(output.warnings) + has_error = not output.is_serializable or key_hard_error + parts.append(render_warning_icon(all_warnings, has_error)) if has_expandable_content: parts.append( @@ -971,13 +1184,17 @@ def _render_mapping_entry( """Render a single mapping entry.""" output = formatter_registry.format_value(value, context) - # Button styles (hidden by default - JS shows them) + # Check key name validity (issue #321) + key_valid, key_reason, key_hard_error = check_column_name(key) + key_warnings = [] + if not key_valid: + key_warnings.append(key_reason) # Build class list for CSS styling entry_class = "adata-entry" - if output.warnings: + if output.warnings or key_warnings: entry_class += " warning" - if not output.is_serializable: + if not output.is_serializable or key_hard_error: entry_class += " error" has_expandable_content = output.html_content and output.is_expandable @@ -992,7 +1209,12 @@ def _render_mapping_entry( # Type cell parts.extend( - _render_type_cell(output, has_expandable_content=has_expandable_content) + _render_type_cell( + output, + has_expandable_content=has_expandable_content, + extra_warnings=key_warnings, + key_hard_error=key_hard_error, + ) ) # Meta cell @@ -1030,34 +1252,23 @@ def _render_uns_section( if n_items == 0: return _render_empty_section("uns", doc_url, tooltip) - should_collapse = n_items > fold_threshold - - # Section - use data-should-collapse for JS to handle (consistent with other sections) - parts = [ - f'
' - ] - - # Header - parts.append(_render_section_header("uns", f"({n_items} items)", doc_url, tooltip)) - - # Content - parts.append(f'
') - parts.append(f'') - + # Render entries (with truncation) + rows = [] for i, key in enumerate(keys): if i >= max_items: - parts.append(_render_truncation_indicator(n_items - max_items)) + rows.append(_render_truncation_indicator(n_items - max_items)) break - value = uns[key] - parts.append(_render_uns_entry(adata, key, value, context, max_depth)) - - parts.append("
") - parts.append("
") - parts.append("
") - - return "\n".join(parts) + rows.append(_render_uns_entry(adata, key, value, context, max_depth)) + + return render_section( + "uns", + "\n".join(rows), + n_items=n_items, + doc_url=doc_url, + tooltip=tooltip, + should_collapse=n_items > fold_threshold, + ) def _render_uns_entry( @@ -1133,12 +1344,16 @@ def _render_uns_entry_with_preview( if preview_note: preview = f"{preview_note} {preview}" if preview else preview_note - # Inline styles for layout - colors via CSS + # Check key name validity + key_valid, key_reason, key_hard_error = check_column_name(key) + all_warnings = list(output.warnings) + if not key_valid: + all_warnings.append(key_reason) entry_class = "adata-entry" - if output.warnings: + if all_warnings: entry_class += " warning" - if not output.is_serializable: + if not output.is_serializable or key_hard_error: entry_class += " error" parts = [ @@ -1153,12 +1368,8 @@ def _render_uns_entry_with_preview( parts.append( f'{escape_html(output.type_name)}' ) - if output.warnings or not output.is_serializable: - warn_list = output.warnings.copy() - if not output.is_serializable: - warn_list.insert(0, "Not serializable to H5AD/Zarr") - title = escape_html("; ".join(warn_list)) - parts.append(f'(!)') + is_error = not output.is_serializable or key_hard_error + parts.append(render_warning_icon(all_warnings, is_error)) parts.append("") # Meta - value preview @@ -1260,15 +1471,19 @@ def _render_uns_entry_with_custom_html(key: str, output: FormattedOutput) -> str The output should have html_content set. """ - # Inline styles for layout - type_label = output.type_name + # Check key name validity + key_valid, key_reason, key_hard_error = check_column_name(key) + all_warnings = list(output.warnings) + if not key_valid: + all_warnings.append(key_reason) + # Build class list for CSS styling entry_class = "adata-entry" - if output.warnings: + if all_warnings: entry_class += " warning" - if not output.is_serializable: + if not output.is_serializable or key_hard_error: entry_class += " error" parts = [ @@ -1281,12 +1496,8 @@ def _render_uns_entry_with_custom_html(key: str, output: FormattedOutput) -> str # Type parts.append('') parts.append(f'{escape_html(type_label)}') - if output.warnings or not output.is_serializable: - entry_warnings = output.warnings.copy() - if not output.is_serializable: - entry_warnings.insert(0, "Not serializable to H5AD/Zarr") - title = escape_html("; ".join(entry_warnings)) - parts.append(f'(!)') + is_error = not output.is_serializable or key_hard_error + parts.append(render_warning_icon(all_warnings, is_error)) parts.append("") # Meta - custom HTML content @@ -1391,63 +1602,337 @@ def _render_nested_anndata_entry( return "\n".join(parts) +def _detect_unknown_sections(adata) -> list[tuple[str, str]]: + """Detect mapping-like attributes that aren't in SECTION_ORDER. + + Returns list of (attr_name, type_description) tuples for unknown sections. + """ + from collections.abc import Mapping + + # Known sections and internal attributes to skip + known = set(SECTION_ORDER) | { + # Internal/meta attributes + "shape", + "n_obs", + "n_vars", + "obs_names", + "var_names", + "filename", + "file", + "is_view", + "isbacked", + "isview", + "T", + # Methods (not data) + "obs_keys", + "var_keys", + "uns_keys", + "obsm_keys", + "varm_keys", + } + + # Also exclude sections that have registered custom formatters + # (including those with should_show=False that suppress display) + known |= set(formatter_registry.get_registered_sections()) + + unknown = [] + for attr in dir(adata): + # Skip private, known, and callable attributes + if attr.startswith("_") or attr in known: + continue + + try: + val = getattr(adata, attr) + # Check if it's a data container (mapping-like or has keys()) + if isinstance(val, Mapping) or ( + hasattr(val, "keys") + and hasattr(val, "__getitem__") + and not callable(val) + ): + # Get type description + type_name = type(val).__name__ + try: + n_items = len(val) + type_desc = f"{type_name} ({n_items} items)" + except Exception: # noqa: BLE001 + type_desc = type_name + unknown.append((attr, type_desc)) + except Exception: # noqa: BLE001 + # If we can't even access the attribute, note it as inaccessible + unknown.append((attr, "inaccessible")) + + return unknown + + +def _render_unknown_sections(unknown_sections: list[tuple[str, str]]) -> str: + """Render a section showing unknown/unrecognized attributes.""" + parts = [ + '
' + ] + parts.append('
') + parts.append(render_fold_icon()) + parts.append('other') + parts.append(f'({len(unknown_sections)})') + parts.append("
") + + parts.append(f'
') + parts.append(f'') + + for attr_name, type_desc in unknown_sections: + parts.append(f'') + parts.append(_render_name_cell(attr_name)) + parts.append('") + parts.append('') + parts.append("") + + parts.append("
') + parts.append( + f'' + f"{escape_html(type_desc)}" + ) + parts.append("
") + parts.append("
") + parts.append("
") + + return "\n".join(parts) + + +def _render_error_entry(section: str, error: str) -> str: + """Render an error indicator for a section that failed to render.""" + error_escaped = escape_html(str(error)[:200]) # Truncate long errors + return f""" +
+
+ {render_fold_icon()} + {escape_html(section)} + (error) +
+
+
+ Failed to render: {error_escaped} +
+
+
+""" + + +def _safe_get_attr(obj, attr: str, default="?"): + """Safely get an attribute with fallback.""" + try: + val = getattr(obj, attr, None) + return val if val is not None else default + except Exception: # noqa: BLE001 + return default + + +def _get_raw_meta_parts(raw) -> list[str]: + """Build meta info parts for raw section.""" + meta_parts = [] + try: + if hasattr(raw, "var") and raw.var is not None and len(raw.var.columns) > 0: + meta_parts.append(f"var: {len(raw.var.columns)} cols") + except Exception: # noqa: BLE001 + pass + try: + if hasattr(raw, "varm") and raw.varm is not None and len(raw.varm) > 0: + meta_parts.append(f"varm: {len(raw.varm)}") + except Exception: # noqa: BLE001 + pass + return meta_parts + + def _render_raw_section( adata: AnnData, context: FormatterContext, fold_threshold: int, max_items: int, ) -> str: - """Render the raw section.""" + """Render the raw section as a single expandable row. + + The raw section shows unprocessed data that was saved before filtering/normalization. + It contains raw.X (the matrix), raw.var (variable annotations), and raw.varm + (multi-dimensional variable annotations). + + Unlike the main AnnData, raw shares obs with the parent but has its own var + (which may have more variables than the filtered main data). + + Rendered as a single row with an expand button (no section header). + When expanded, shows a full AnnData-like repr for Raw contents (X, var, varm). + The depth parameter prevents infinite recursion. + """ raw = getattr(adata, "raw", None) if raw is None: return "" - # Raw section always starts collapsed (use data-should-collapse for consistency) - parts = ['
'] + # Safely get dimensions with fallbacks + n_obs = _safe_get_attr(raw, "n_obs", "?") + n_vars = _safe_get_attr(raw, "n_vars", "?") + max_depth = context.max_depth - # Header - doc_url = f"{DOCS_BASE_URL}generated/anndata.AnnData.raw.html" - n_vars = getattr(raw, "n_vars", "?") - parts.append( - _render_section_header( - "raw", - f"(n_vars = {format_number(n_vars)})", - doc_url, - "Raw data (original unprocessed)", - ) - ) + # Check if we can expand (same logic as nested AnnData) + can_expand = context.depth < max_depth - 1 - # Content - parts.append(f'
') + # Build meta info string safely + meta_parts = _get_raw_meta_parts(raw) + meta_text = ", ".join(meta_parts) if meta_parts else "" - # raw.X info - if hasattr(raw, "X") and raw.X is not None: - output = formatter_registry.format_value(raw.X, context) - parts.append( - f'
raw.X: {escape_html(output.type_name)}
' - ) + # Single row container (like a minimal section with just one entry) + parts = ['
'] + parts.append(f'') - # raw.var columns - if hasattr(raw, "var") and len(raw.var.columns) > 0: + # Single row with raw info and expand button + parts.append('') + parts.append(_render_name_cell("raw")) + parts.append('") + parts.append(f'') + parts.append("") - # raw.varm - if hasattr(raw, "varm") and len(raw.varm) > 0: - parts.append( - f'
raw.varm: {len(raw.varm)} items
' + # Nested content (hidden by default, shown on expand) + if can_expand: + parts.append('') + parts.append('") + parts.append("") + + parts.append("
') + # Show just dimensions - "raw" is already in the name cell + type_str = f"{format_number(n_obs)} obs × {format_number(n_vars)} var" + parts.append(f'{escape_html(type_str)}') + if can_expand: parts.append( - f'
raw.var: {len(raw.var.columns)} columns
' + f'' ) + parts.append("
') + parts.append('
') + + nested_html = _generate_raw_repr_html( + raw, + depth=context.depth + 1, + max_depth=max_depth, + fold_threshold=fold_threshold, + max_items=max_items, ) + parts.append(nested_html) - parts.append("
") + parts.append("") + parts.append("
") parts.append("
") return "\n".join(parts) -# ============================================================================= -# Helper Functions -# ============================================================================= +def _generate_raw_repr_html( + raw, + depth: int = 0, + max_depth: int | None = None, + fold_threshold: int | None = None, + max_items: int | None = None, +) -> str: + """Generate HTML repr for a Raw object. + + This renders X, var, and varm sections similar to AnnData, + but without obs, obsm, layers, obsp, varp, uns, or raw sections. + + Parameters + ---------- + raw + Raw object to render + depth + Current nesting depth + max_depth + Maximum nesting depth (defaults to settings or DEFAULT_MAX_DEPTH) + fold_threshold + Number of items before a section auto-folds (defaults to settings or DEFAULT_FOLD_THRESHOLD) + max_items + Maximum items to display per section (defaults to settings or DEFAULT_MAX_ITEMS) + """ + # Use configured settings with fallback to defaults + if max_depth is None: + max_depth = _get_setting("repr_html_max_depth", default=DEFAULT_MAX_DEPTH) + if fold_threshold is None: + fold_threshold = _get_setting( + "repr_html_fold_threshold", default=DEFAULT_FOLD_THRESHOLD + ) + if max_items is None: + max_items = _get_setting("repr_html_max_items", default=DEFAULT_MAX_ITEMS) + from .registry import FormatterContext + + # Safely get dimensions + n_obs = _safe_get_attr(raw, "n_obs", "?") + n_vars = _safe_get_attr(raw, "n_vars", "?") + + context = FormatterContext( + depth=depth, + max_depth=max_depth, + adata_ref=None, + ) + + parts = [] + + # Container with header showing Raw shape + container_id = f"raw-repr-{id(raw)}" + parts.append(f'
') + + # Header for Raw - same structure as AnnData header + parts.append('
') + parts.append('Raw') + shape_str = f"{format_number(n_obs)} obs × {format_number(n_vars)} var" + parts.append(f'{shape_str}') + parts.append("
") + + # X section - show matrix info (with error handling) + try: + if hasattr(raw, "X") and raw.X is not None: + parts.append(_render_x_entry(raw, context)) + except Exception as e: # noqa: BLE001 + parts.append(_render_error_entry("X", str(e))) + + # var section (like AnnData's var) + # _render_dataframe_section expects an object with a .var attribute + try: + if hasattr(raw, "var") and raw.var is not None and len(raw.var.columns) > 0: + var_context = FormatterContext( + depth=depth, + max_depth=max_depth, + adata_ref=None, + section="var", + ) + parts.append( + _render_dataframe_section( + raw, # Pass raw object, not raw.var + "var", + var_context, + fold_threshold=fold_threshold, + max_items=max_items, + ) + ) + except Exception as e: # noqa: BLE001 + parts.append(_render_error_entry("var", str(e))) + + # varm section (like AnnData's varm) + try: + if hasattr(raw, "varm") and raw.varm is not None and len(raw.varm) > 0: + varm_context = FormatterContext( + depth=depth, + max_depth=max_depth, + adata_ref=None, + section="varm", + ) + parts.append( + _render_mapping_section( + raw, # Pass raw object, not raw.varm + "varm", + varm_context, + fold_threshold=fold_threshold, + max_items=max_items, + ) + ) + except Exception as e: # noqa: BLE001 + parts.append(_render_error_entry("varm", str(e))) + + parts.append("
") + + return "\n".join(parts) def _render_section_header( @@ -1458,7 +1943,7 @@ def _render_section_header( ) -> str: """Render a section header - colors handled by CSS for dark mode support.""" parts = ['
'] - parts.append(f'') + parts.append(render_fold_icon()) # Use helper for fold icon parts.append(f'{escape_html(name)}') parts.append(f'{escape_html(count_str)}') if doc_url: @@ -1480,10 +1965,13 @@ def _render_empty_section( if doc_url: help_link = f'?' + # Use render_fold_icon() helper for consistency + fold_icon = render_fold_icon() + return f"""
- + {fold_icon} {escape_html(name)} (empty) {help_link} @@ -1531,3 +2019,97 @@ def _get_setting(name: str, *, default: Any) -> Any: return getattr(settings, name, default) except (ImportError, AttributeError): return default + + +def render_section( # noqa: PLR0913 + name: str, + entries_html: str, + *, + n_items: int, + doc_url: str | None = None, + tooltip: str = "", + should_collapse: bool = False, + section_id: str | None = None, + count_str: str | None = None, +) -> str: + """ + Render a complete section with header and content. + + This is a public API for packages building their own _repr_html_. + It is also used internally for consistency. + + Parameters + ---------- + name + Display name for the section header (e.g., 'images', 'tables') + entries_html + HTML content for the section body (table rows) + n_items + Number of items (used for empty check and default count string) + doc_url + URL for the help link (? icon) + tooltip + Tooltip text for the help link + should_collapse + Whether this section should start collapsed + section_id + ID for the section in data-section attribute (defaults to name) + count_str + Custom count string for header (defaults to "(N items)") + + Returns + ------- + HTML string for the complete section + + Examples + -------- + :: + + from . import ( + FormattedEntry, + FormattedOutput, + render_formatted_entry, + ) + + rows = [] + for key, info in items.items(): + entry = FormattedEntry( + key=key, + output=FormattedOutput( + type_name=info["type"], css_class="dtype-ndarray" + ), + ) + rows.append(render_formatted_entry(entry)) + + html = render_section( + "images", + "\\n".join(rows), + n_items=len(items), + doc_url="https://docs.example.com/images", + tooltip="Image data", + ) + """ + if section_id is None: + section_id = name + + if n_items == 0: + return _render_empty_section(name, doc_url, tooltip) + + if count_str is None: + count_str = f"({n_items} items)" + + parts = [ + f'
' + ] + + # Header + parts.append(_render_section_header(name, count_str, doc_url, tooltip)) + + # Content + parts.append(f'
') + parts.append(f'') + parts.append(entries_html) + parts.append("
") + + return "\n".join(parts) diff --git a/src/anndata/_repr/javascript.py b/src/anndata/_repr/javascript.py index 241644b2e..c9baef926 100644 --- a/src/anndata/_repr/javascript.py +++ b/src/anndata/_repr/javascript.py @@ -11,7 +11,7 @@ from __future__ import annotations -from anndata._repr.markdown import get_markdown_parser_js +from .markdown import get_markdown_parser_js def get_javascript(container_id: str) -> str: @@ -52,12 +52,15 @@ def get_javascript(container_id: str) -> str: container.querySelectorAll('.adata-copy-btn').forEach(btn => { btn.style.display = 'inline-flex'; }); - container.querySelectorAll('.adata-search-input').forEach(input => { - input.style.display = 'inline-block'; + container.querySelectorAll('.adata-search-box').forEach(box => { + box.style.display = 'inline-flex'; }); container.querySelectorAll('.adata-expand-btn').forEach(btn => { btn.style.display = 'inline-block'; }); + container.querySelectorAll('.adata-search-toggle').forEach(btn => { + btn.style.display = 'inline-flex'; + }); // Filter indicator is shown via CSS .active class, no need to set display here // Apply initial collapse state from data attributes @@ -110,18 +113,27 @@ def get_javascript(container_id: str) -> str: }); // Search/filter functionality + const searchBox = container.querySelector('.adata-search-box'); const searchInput = container.querySelector('.adata-search-input'); const filterIndicator = container.querySelector('.adata-filter-indicator'); + const caseToggle = container.querySelector('.adata-toggle-case'); + const regexToggle = container.querySelector('.adata-toggle-regex'); + + // Search state + let caseSensitive = false; + let useRegex = false; if (searchInput) { let debounceTimer; - searchInput.addEventListener('input', (e) => { + const triggerFilter = () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { - filterEntries(e.target.value.toLowerCase().trim()); + filterEntries(searchInput.value.trim()); }, 150); - }); + }; + + searchInput.addEventListener('input', triggerFilter); // Clear on Escape searchInput.addEventListener('keydown', (e) => { @@ -130,6 +142,51 @@ def get_javascript(container_id: str) -> str: filterEntries(''); } }); + + // Toggle button handlers + if (caseToggle) { + caseToggle.addEventListener('click', (e) => { + e.stopPropagation(); + caseSensitive = !caseSensitive; + caseToggle.classList.toggle('active', caseSensitive); + caseToggle.setAttribute('aria-pressed', caseSensitive); + triggerFilter(); + }); + } + + if (regexToggle) { + regexToggle.addEventListener('click', (e) => { + e.stopPropagation(); + useRegex = !useRegex; + regexToggle.classList.toggle('active', useRegex); + regexToggle.setAttribute('aria-pressed', useRegex); + triggerFilter(); + }); + } + } + + // Helper: test if text matches query (respects case sensitivity and regex mode) + function matchesQuery(text, query) { + if (!query) return true; + if (useRegex) { + try { + const flags = caseSensitive ? '' : 'i'; + const regex = new RegExp(query, flags); + if (searchBox) searchBox.classList.remove('regex-error'); + return regex.test(text); + } catch (e) { + // Invalid regex - show error state but don't crash + if (searchBox) searchBox.classList.add('regex-error'); + return false; + } + } else { + if (searchBox) searchBox.classList.remove('regex-error'); + if (caseSensitive) { + return text.includes(query); + } else { + return text.toLowerCase().includes(query.toLowerCase()); + } + } } function filterEntries(query) { @@ -143,17 +200,11 @@ def get_javascript(container_id: str) -> str: entries.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 key = entry.dataset.key || ''; + const dtype = entry.dataset.dtype || ''; + const text = entry.textContent; - const matches = key.includes(query) || dtype.includes(query) || text.includes(query); + const matches = !query || matchesQuery(key, query) || matchesQuery(dtype, query) || matchesQuery(text, query); if (matches) { directMatches.add(entry); @@ -166,10 +217,13 @@ def get_javascript(container_id: str) -> str: section.classList.remove('collapsed'); } - // Expand nested content if match is inside + // Expand nested content if match is inside nested area const nestedContent = entry.closest('.adata-nested-content'); - if (nestedContent && !nestedContent.classList.contains('expanded')) { - nestedContent.classList.add('expanded'); + if (nestedContent) { + const nestedRow = nestedContent.closest('.adata-nested-row'); + if (nestedRow && !nestedRow.classList.contains('expanded')) { + nestedRow.classList.add('expanded'); + } } } else { entry.classList.add('hidden'); @@ -212,6 +266,24 @@ def get_javascript(container_id: str) -> str: }); } + // Also filter X entries in nested AnnData (they use adata-x-entry class, not adata-entry) + // This prevents orphaned X rows from showing when their sibling entries are hidden + if (query) { + container.querySelectorAll('.adata-nested-content .adata-x-entry').forEach(xEntry => { + // Check if the nested AnnData has any visible entries + const nestedRepr = xEntry.closest('.anndata-repr'); + if (nestedRepr) { + const hasVisibleEntries = nestedRepr.querySelector('.adata-entry:not(.hidden)'); + xEntry.style.display = hasVisibleEntries ? '' : 'none'; + } + }); + } else { + // Reset X entries when no query + container.querySelectorAll('.adata-nested-content .adata-x-entry').forEach(xEntry => { + xEntry.style.display = ''; + }); + } + // Update filter indicator if (filterIndicator) { if (query) { diff --git a/src/anndata/_repr/registry.py b/src/anndata/_repr/registry.py index 88403895b..7f012578a 100644 --- a/src/anndata/_repr/registry.py +++ b/src/anndata/_repr/registry.py @@ -75,7 +75,10 @@ class FormattedOutput: """Child entries for expandable types""" html_content: str | None = None - """Custom HTML content (for special visualizations like colors)""" + """Custom HTML content (for expandable nested content or type column)""" + + meta_content: str | None = None + """HTML content for the meta column (data previews, dimensions, etc.)""" is_expandable: bool = False """Whether this entry can be expanded""" @@ -207,6 +210,10 @@ class SectionFormatter(ABC): are formatted. This allows packages like TreeData, MuData, SpatialData to add custom sections (e.g., obst, vart, mod, spatial). + A single SectionFormatter can handle multiple sections by setting + ``section_names`` (tuple). Use ``should_show()`` returning False to + suppress sections entirely (they won't appear in "other" either). + Example usage:: from anndata._repr import ( @@ -234,14 +241,40 @@ def get_entries(self, obj, context): ) entries.append(FormattedEntry(key=key, output=output)) return entries + + Example - suppress multiple sections:: + + @register_formatter + class SuppressInternalSections(SectionFormatter): + section_names = ("obsmap", "varmap", "axis") + + @property + def section_name(self) -> str: + return self.section_names[0] + + def should_show(self, obj) -> bool: + return False # Never show + + def get_entries(self, obj, context): + return [] """ @property @abstractmethod def section_name(self) -> str: - """Name of the section this formatter handles.""" + """Primary name of the section this formatter handles.""" ... + @property + def section_names(self) -> tuple[str, ...]: + """ + All section names this formatter handles. + + Override this to handle multiple sections with one formatter. + Defaults to a tuple containing just section_name. + """ + return (self.section_name,) + @property def display_name(self) -> str: """Display name (defaults to section_name).""" @@ -363,8 +396,9 @@ def register_type_formatter(self, formatter: TypeFormatter) -> None: self._type_formatters.sort(key=lambda f: -f.priority) def register_section_formatter(self, formatter: SectionFormatter) -> None: - """Register a section formatter.""" - self._section_formatters[formatter.section_name] = formatter + """Register a section formatter for all its section_names.""" + for name in formatter.section_names: + self._section_formatters[name] = formatter def unregister_type_formatter(self, formatter: TypeFormatter) -> bool: """Unregister a type formatter. Returns True if found and removed.""" @@ -396,7 +430,7 @@ def format_value(self, obj: Any, context: FormatterContext) -> FormattedOutput: return formatter.format(obj, context) except Exception as e: # noqa: BLE001 # Intentional broad catch: formatters shouldn't crash the entire repr - from anndata._warnings import warn + from .._warnings import warn warn( f"Formatter {type(formatter).__name__} failed for " @@ -421,10 +455,6 @@ def get_registered_sections(self) -> list[str]: formatter_registry = FormatterRegistry() -# ============================================================================= -# Type hint extraction for tagged data in uns -# ============================================================================= - # Type hint key used in uns dicts to indicate custom rendering UNS_TYPE_HINT_KEY = "__anndata_repr__" diff --git a/src/anndata/_repr/utils.py b/src/anndata/_repr/utils.py index 14ee5d7a9..0af8414ac 100644 --- a/src/anndata/_repr/utils.py +++ b/src/anndata/_repr/utils.py @@ -33,7 +33,7 @@ def _check_serializable_single(obj: Any) -> tuple[bool, str]: # Use the actual IO registry try: - from anndata._io.specs.registry import _REGISTRY + from .._io.specs.registry import _REGISTRY _REGISTRY.get_spec(obj) return True, "" @@ -177,7 +177,7 @@ def is_color_list(key: str, value: Any) -> bool: ------- True if this appears to be a color list """ - if not key.endswith("_colors"): + if not isinstance(key, str) or not key.endswith("_colors"): return False if not isinstance(value, (list, np.ndarray, tuple)): return False @@ -207,6 +207,10 @@ def get_matching_column_colors( ------- List of color strings if colors exist and match, None otherwise """ + # Handle objects without .uns (e.g., Raw) + if not hasattr(adata, "uns"): + return None + color_key = f"{column_name}_colors" if color_key not in adata.uns: return None @@ -215,9 +219,9 @@ def get_matching_column_colors( # Find the column in obs or var col = None - if column_name in adata.obs.columns: + if hasattr(adata, "obs") and column_name in adata.obs.columns: col = adata.obs[column_name] - elif column_name in adata.var.columns: + elif hasattr(adata, "var") and column_name in adata.var.columns: col = adata.var[column_name] if col is None: @@ -244,7 +248,7 @@ def check_color_category_mismatch( Parameters ---------- adata - AnnData object + AnnData object (or object with .uns attribute) column_name Name of the column to check @@ -252,6 +256,10 @@ def check_color_category_mismatch( ------- Warning message if mismatch, None otherwise """ + # Handle objects without .uns (e.g., Raw) + if not hasattr(adata, "uns"): + return None + color_key = f"{column_name}_colors" if color_key not in adata.uns: return None diff --git a/tests/test_repr_html.py b/tests/test_repr_html.py index 22969f3ca..4099ddaf7 100644 --- a/tests/test_repr_html.py +++ b/tests/test_repr_html.py @@ -2995,6 +2995,321 @@ def test_dataframe_long_column_names_truncation(self): assert "meta_preview_full" in result.details +class TestSeriesFormatterNonSerializable: + """Tests for Series formatter detecting non-serializable object dtype columns.""" + + def test_custom_object_in_obs_column_not_serializable(self): + """Test that custom objects in obs columns are flagged as non-serializable. + + Uses a custom class (not a list) to ensure this test remains valid + even if list serialization is added in the future (issue #1923). + """ + from anndata._repr.formatters import SeriesFormatter + from anndata._repr.registry import FormatterContext + + class CustomObject: + """A custom class that will never be serializable.""" + + # Create a Series with custom objects + series = pd.Series([CustomObject(), CustomObject(), CustomObject()]) + assert series.dtype == np.dtype("object") + + formatter = SeriesFormatter() + result = formatter.format(series, FormatterContext()) + + assert not result.is_serializable + assert len(result.warnings) > 0 + assert "CustomObject" in result.warnings[0] + + def test_list_in_obs_column_not_serializable(self): + """Test that lists in obs columns are flagged as non-serializable. + + NOTE: This test may need updating if #1923 adds list serialization. + See: https://github.com/scverse/anndata/issues/1923 + """ + from anndata._repr.formatters import SeriesFormatter + from anndata._repr.registry import FormatterContext + + series = pd.Series([["a", "b"], ["c"], ["d", "e", "f"]]) + assert series.dtype == np.dtype("object") + + formatter = SeriesFormatter() + result = formatter.format(series, FormatterContext()) + + assert not result.is_serializable + assert len(result.warnings) > 0 + assert "list" in result.warnings[0] + + def test_string_obs_column_is_serializable(self): + """Test that string object columns are serializable.""" + from anndata._repr.formatters import SeriesFormatter + from anndata._repr.registry import FormatterContext + + series = pd.Series(["a", "b", "c"], dtype=object) + + formatter = SeriesFormatter() + result = formatter.format(series, FormatterContext()) + + assert result.is_serializable + assert len(result.warnings) == 0 + + def test_empty_series_is_serializable(self): + """Test that empty object dtype series is considered serializable.""" + from anndata._repr.formatters import SeriesFormatter + from anndata._repr.registry import FormatterContext + + series = pd.Series([], dtype=object) + + formatter = SeriesFormatter() + result = formatter.format(series, FormatterContext()) + + assert result.is_serializable + + def test_list_column_detected_and_not_serializable(self, tmp_path): + """Repr detects list columns as non-serializable, and they actually fail. + + MAINTAINER NOTE: If issue #1923 is resolved and lists become serializable, + update _check_series_serializability() in formatters.py to remove the + list/tuple check, or make it conditional on list content. + """ + adata = ad.AnnData(X=np.eye(3)) + adata.obs["list_col"] = [["a", "b"], ["c"], ["d"]] + + # Verify lists still fail to serialize + try: + adata.write_h5ad(tmp_path / "test.h5ad") + pytest.fail( + "List serialization now works! " + "Update _check_series_serializability() in formatters.py." + ) + except (TypeError, Exception): + pass # Expected - lists not serializable + + # Repr should detect and warn + html = adata._repr_html_() + assert "list" in html + assert "(!)" in html + + def test_custom_object_detected_and_not_serializable(self, tmp_path): + """Repr detects custom objects as non-serializable, and they actually fail. + + NOTE: Custom objects should never become serializable without explicit + registration. If this test fails, check if anndata added a generic + object serialization mechanism. + """ + + class CustomObject: + pass + + adata = ad.AnnData(X=np.eye(3)) + adata.obs["custom"] = [CustomObject(), CustomObject(), CustomObject()] + + # Verify custom objects still fail to serialize + try: + adata.write_h5ad(tmp_path / "test.h5ad") + pytest.fail( + "Custom object serialization now works! " + "Update _check_series_serializability() in formatters.py." + ) + except (TypeError, Exception): + pass # Expected - custom objects not serializable + + # Repr should detect and warn + html = adata._repr_html_() + assert "CustomObject" in html + assert "(!)" in html + + def test_non_ascii_column_no_warning_and_serializes(self, tmp_path): + """Repr does not warn for non-ASCII (it's valid), and it serializes. + + MAINTAINER NOTE: If non-ASCII stops working, add a warning in + check_column_name() in formatters.py. But UTF-8 should always work. + """ + adata = ad.AnnData(X=np.eye(3)) + adata.obs["gène_名前"] = ["a", "b", "c"] + + # Verify non-ASCII still serializes fine + path = tmp_path / "test.h5ad" + adata.write_h5ad(path) + adata2 = ad.read_h5ad(path) + assert "gène_名前" in adata2.obs.columns, ( + "Non-ASCII serialization broke! If this is intentional, " + "add warning in check_column_name() in formatters.py." + ) + + # Repr should NOT warn (non-ASCII is valid UTF-8) + html = adata._repr_html_() + assert "gène_名前" in html + assert "Not serializable" not in html + + def test_tuple_column_name_detected_and_not_serializable(self, tmp_path): + """Repr detects non-string column names, and they actually fail. + + NOTE: Non-string column names (tuples, ints) should never become + serializable - HDF5/Zarr keys must be strings. If this changes, + update check_column_name() in formatters.py. + """ + adata = ad.AnnData(X=np.eye(3)) + adata.obs[("a", "b")] = [1, 2, 3] + + # Verify non-string names still fail to serialize + try: + adata.write_h5ad(tmp_path / "test.h5ad") + pytest.fail( + "Non-string column name serialization now works! " + "Update check_column_name() in formatters.py." + ) + except (TypeError, Exception): + pass # Expected - non-string names not serializable + + # Repr should detect and warn + html = adata._repr_html_() + assert "Non-string" in html + assert "(!)" in html + + def test_datetime64_column_detected_and_not_serializable(self, tmp_path): + """Repr detects datetime64 columns as non-serializable (issue #455). + + MAINTAINER NOTE: If this test fails because datetime64 serialization + was added to anndata, update SeriesFormatter in formatters.py to + remove the datetime64 warning check. + """ + adata = ad.AnnData(X=np.eye(3)) + adata.obs["date"] = pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"]) + + # Verify datetime64 still fails to serialize + # If this starts passing, datetime64 support was added - update repr accordingly + try: + path = tmp_path / "test_datetime.h5ad" + adata.write_h5ad(path) + # If we get here, datetime64 is now serializable - repr should stop warning + pytest.fail( + "datetime64 serialization now works! " + "Update SeriesFormatter in formatters.py to remove the datetime64 warning." + ) + except Exception: + pass # Expected - datetime64 not serializable + + # Repr should detect and warn about it + html = adata._repr_html_() + assert "datetime64" in html, "Repr should show datetime64 dtype" + assert "(!)" in html, "Repr should show warning icon for datetime64" + + def test_timedelta64_column_detected_and_not_serializable(self, tmp_path): + """Repr detects timedelta64 columns as non-serializable. + + MAINTAINER NOTE: If this test fails because timedelta64 serialization + was added to anndata, update SeriesFormatter in formatters.py to + remove the timedelta64 warning check. + """ + adata = ad.AnnData(X=np.eye(3)) + adata.obs["duration"] = pd.to_timedelta(["1 days", "2 days", "3 days"]) + + # Verify timedelta64 still fails to serialize + try: + path = tmp_path / "test_timedelta.h5ad" + adata.write_h5ad(path) + pytest.fail( + "timedelta64 serialization now works! " + "Update SeriesFormatter in formatters.py to remove the timedelta64 warning." + ) + except Exception: + pass # Expected - timedelta64 not serializable + + # Repr should detect and warn about it + html = adata._repr_html_() + assert "timedelta64" in html, "Repr should show timedelta64 dtype" + assert "(!)" in html, "Repr should show warning icon for timedelta64" + + +class TestColumnNameValidation: + """Tests for column name validation (issue #321).""" + + def test_check_column_name_valid(self): + """Test that valid column names pass.""" + from anndata._repr.formatters import check_column_name + + valid, _, _ = check_column_name("gene_name") + assert valid + valid, _, _ = check_column_name("gène_名前") # Non-ASCII is fine + assert valid + + def test_check_column_name_slash(self): + """Test slashes are flagged as warning (not hard error - still works for now).""" + from anndata._repr.formatters import check_column_name + + valid, reason, is_hard_error = check_column_name("path/to/gene") + assert not valid + assert "/" in reason + assert not is_hard_error # Deprecation warning, not hard error + + def test_check_column_name_non_string(self): + """Test non-string names are flagged as hard error.""" + from anndata._repr.formatters import check_column_name + + valid, reason, is_hard_error = check_column_name(("a", "b")) + assert not valid + assert "Non-string" in reason + assert is_hard_error # Fails NOW + + def test_slash_column_name_warns_but_still_serializes(self, tmp_path): + """Test slash in column names: warns (yellow) but still works for now. + + MAINTAINER NOTE: When slashes become hard errors (FutureWarning fulfilled), + update check_column_name() in formatters.py to return is_hard_error=True + for slashes, and update the CSS class from yellow to red. + """ + adata = ad.AnnData(X=np.eye(3)) + adata.obs["path/gene"] = ["a", "b", "c"] + + # Currently still serializes (with FutureWarning) + path = tmp_path / "test_slash.h5ad" + import warnings + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + try: + adata.write_h5ad(path) + # If we get here, slashes still work + serializes = True + except Exception: + # Slashes now fail - update repr to show red instead of yellow + serializes = False + pytest.fail( + "Slash in column names now fails! " + "Update check_column_name() in formatters.py: " + "set is_hard_error=True for slashes." + ) + + if serializes: + # Verify it's still a FutureWarning (yellow in repr) + adata2 = ad.read_h5ad(path) + assert "path/gene" in adata2.obs.columns + + # Repr should show yellow warning (not red error) + html = adata._repr_html_() + assert "deprecated" in html or "/" in html + + def test_mapping_sections_warn_for_invalid_keys(self): + """Test that layers/obsm/etc warn for invalid key names.""" + adata = ad.AnnData(X=np.eye(3)) + adata.layers[("tuple", "key")] = np.eye(3) # Non-string - error + adata.obsm["path/embed"] = np.random.randn(3, 2) # Slash - warning + + html = adata._repr_html_() + assert "Non-string" in html + assert "deprecated" in html + + def test_uns_warns_for_invalid_keys(self): + """Test that uns warns for invalid key names.""" + adata = ad.AnnData(X=np.eye(3)) + adata.uns[("tuple", "key")] = "value" # Non-string - error + + html = adata._repr_html_() + assert "Non-string" in html + assert "Not serializable" in html + + class TestMockCuPyArrayFormatter: """Tests for CuPy array formatter using mock objects.""" @@ -4245,3 +4560,386 @@ def test_readme_icon_accessibility(self): assert 'role="button"' in html assert 'tabindex="0"' in html assert 'aria-label="View README"' in html + + +# ============================================================================= +# Test render_header_badges Helper +# ============================================================================= + + +class TestRenderHeaderBadges: + """Tests for the render_header_badges public helper.""" + + def test_no_badges(self): + """Test render_header_badges with no flags set.""" + from anndata._repr import render_header_badges + + html = render_header_badges() + assert html == "" + + def test_view_badge_only(self): + """Test render_header_badges with view flag.""" + from anndata._repr import render_header_badges + + html = render_header_badges(is_view=True) + assert "View" in html + assert "adata-badge-view" in html + assert "This is a view" in html + + def test_backed_badge_only(self): + """Test render_header_badges with backed flag.""" + from anndata._repr import render_header_badges + + html = render_header_badges(is_backed=True) + assert "Backed" in html + assert "adata-badge-backed" in html + + def test_backed_badge_with_format(self): + """Test render_header_badges with backing format.""" + from anndata._repr import render_header_badges + + html = render_header_badges(is_backed=True, backing_format="Zarr") + assert "Zarr" in html + assert "adata-badge-backed" in html + + def test_backed_badge_with_path(self): + """Test render_header_badges with backing path in tooltip.""" + from anndata._repr import render_header_badges + + html = render_header_badges( + is_backed=True, + backing_path="/data/sample.h5ad", + backing_format="H5AD", + ) + assert "H5AD" in html + assert "/data/sample.h5ad" in html + + def test_both_badges(self): + """Test render_header_badges with both view and backed.""" + from anndata._repr import render_header_badges + + html = render_header_badges( + is_view=True, + is_backed=True, + backing_format="Zarr", + ) + assert "View" in html + assert "Zarr" in html + assert "adata-badge-view" in html + assert "adata-badge-backed" in html + + +# ============================================================================= +# Test Error Handling in HTML Rendering +# ============================================================================= + + +class TestErrorHandling: + """Tests for error handling in HTML rendering.""" + + def test_error_entry_display(self): + """Test that error entries are displayed with error styling.""" + from anndata._repr.html import _render_error_entry + + html = _render_error_entry("bad_key", "Something went wrong") + assert "bad_key" in html + assert "Something went wrong" in html + assert "adata-error" in html or "error" in html.lower() + + def test_formatter_exception_caught(self): + """Test that formatter exceptions don't crash the repr.""" + from anndata._repr import ( + TypeFormatter, + formatter_registry, + ) + + class CrashingFormatter(TypeFormatter): + priority = 1000 # High priority to be checked first + + def can_format(self, obj): + return isinstance(obj, dict) and obj.get("__crash__") + + def format(self, obj, context): + msg = "Intentional crash" + raise RuntimeError(msg) + + formatter = CrashingFormatter() + formatter_registry.register_type_formatter(formatter) + + try: + # Create AnnData with data that triggers the crashing formatter + adata = AnnData(np.zeros((5, 3))) + adata.uns["test"] = {"__crash__": True, "data": "value"} + + # Should not raise, should fall back gracefully + import warnings + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + html = adata._repr_html_() + # Should have generated HTML despite the crash + assert "anndata-repr" in html + # Should have warned about the formatter failure + assert any("CrashingFormatter" in str(warning.message) for warning in w) + finally: + formatter_registry.unregister_type_formatter(formatter) + + def test_section_formatter_exception_caught(self): + """Test that section formatter exceptions don't crash the repr.""" + from anndata._repr import ( + SectionFormatter, + register_formatter, + ) + + class CrashingSectionFormatter(SectionFormatter): + @property + def section_name(self): + return "crashing_section" + + def should_show(self, obj): + return True + + def get_entries(self, obj, context): + msg = "Section formatter crash" + raise RuntimeError(msg) + + formatter = CrashingSectionFormatter() + register_formatter(formatter) + + try: + adata = AnnData(np.zeros((5, 3))) + import warnings + + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + html = adata._repr_html_() + # Should still generate HTML + assert "anndata-repr" in html + finally: + # Clean up - remove from registry + from anndata._repr import formatter_registry + + if "crashing_section" in formatter_registry._section_formatters: + del formatter_registry._section_formatters["crashing_section"] + + +# ============================================================================= +# Test Unknown Sections Detection +# ============================================================================= + + +class TestUnknownSectionsDetection: + """Tests for detecting and displaying unknown sections.""" + + def test_standard_sections_not_in_other(self): + """Test that standard sections don't appear in 'other'.""" + adata = AnnData( + np.zeros((10, 5)), + obs=pd.DataFrame({"col": range(10)}), + var=pd.DataFrame({"gene": range(5)}), + ) + adata.obsm["X_pca"] = np.zeros((10, 2)) + adata.uns["neighbors"] = {"key": "value"} + + html = adata._repr_html_() + + # Standard sections should be present + assert 'data-section="obs"' in html + assert 'data-section="obsm"' in html + assert 'data-section="uns"' in html + + # Should NOT have an "other" section for standard attributes + # (other section only appears for truly unknown attributes) + assert 'data-section="other"' not in html or "unknown" not in html.lower() + + def test_unknown_attribute_in_other(self): + """Test that unknown attributes appear in 'other' section.""" + adata = AnnData(np.zeros((10, 5))) + # Add a non-standard attribute + adata._custom_attr = {"special": "data"} + + html = adata._repr_html_() + + # The repr should still work + assert "anndata-repr" in html + + def test_registered_section_not_in_other(self): + """Test that registered custom sections don't appear in 'other'.""" + from anndata._repr import ( + FormattedEntry, + FormattedOutput, + SectionFormatter, + formatter_registry, + register_formatter, + ) + + class CustomSectionFormatter(SectionFormatter): + @property + def section_name(self): + return "custom_test_section" + + def should_show(self, obj): + return hasattr(obj, "uns") + + def get_entries(self, obj, context): + return [ + FormattedEntry( + key="test_entry", + output=FormattedOutput(type_name="test"), + ) + ] + + formatter = CustomSectionFormatter() + register_formatter(formatter) + + try: + adata = AnnData(np.zeros((5, 3))) + html = adata._repr_html_() + + # Custom section should be present + assert 'data-section="custom_test_section"' in html + assert "test_entry" in html + finally: + # Clean up + if "custom_test_section" in formatter_registry._section_formatters: + del formatter_registry._section_formatters["custom_test_section"] + + def test_suppressed_section_not_in_other(self): + """Test that suppressed sections (should_show=False) don't appear in 'other'.""" + from anndata._repr import ( + SectionFormatter, + formatter_registry, + register_formatter, + ) + + class SuppressedSectionFormatter(SectionFormatter): + @property + def section_name(self): + return "suppressed_section" + + def should_show(self, obj): + return False # Never show + + def get_entries(self, obj, context): + return [] + + formatter = SuppressedSectionFormatter() + register_formatter(formatter) + + try: + adata = AnnData(np.zeros((5, 3))) + html = adata._repr_html_() + + # Suppressed section should NOT appear + assert "suppressed_section" not in html + # And should not be in "other" either + assert "suppressed_section" not in html + finally: + # Clean up + if "suppressed_section" in formatter_registry._section_formatters: + del formatter_registry._section_formatters["suppressed_section"] + + +# ============================================================================= +# Test Public API Exports +# ============================================================================= + + +class TestPublicAPIExports: + """Tests that all documented public API is actually importable.""" + + def test_css_js_exports(self): + """Test CSS and JS helper exports.""" + from anndata._repr import get_css, get_javascript + + css = get_css() + assert "