-
▼
+ {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 "