diff --git a/docs/release-notes/2236.feat.md b/docs/release-notes/2236.feat.md index 38093264d..096a078a3 100644 --- a/docs/release-notes/2236.feat.md +++ b/docs/release-notes/2236.feat.md @@ -1 +1 @@ -Add rich HTML representation for {class}`~anndata.AnnData` objects in Jupyter notebooks with foldable sections, search/filter, category color visualization, dark mode support, and configurable settings via {attr}`anndata.settings` +Add rich HTML representation for {class}`~anndata.AnnData` objects in Jupyter notebooks with foldable sections, search/filter, category color visualization, dark mode support, and configurable settings via {attr}`anndata.settings`. Rendering is built on {mod}`jinja2` and {mod}`markupsafe` with autoescape-by-default; extension packages register {class}`~anndata._repr.TypeFormatter` / {class}`~anndata._repr.SectionFormatter` subclasses that return {class}`~anndata._repr.FormattedOutput` with {class}`markupsafe.Markup`-typed HTML fragments (`preview_markup`, `type_markup`, `expanded_markup`). diff --git a/pyproject.toml b/pyproject.toml index 8a7a0cab0..9fd97ff0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,8 @@ dependencies = [ "zarr >=3.1", "typing-extensions; python_version<'3.13'", "scverse-misc>=0.0.3", + "jinja2>=3.1", + "markupsafe>=3.0", ] dynamic = [ "version" ] diff --git a/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py index 6c39c6394..7f7b5a93f 100644 --- a/src/anndata/_repr/__init__.py +++ b/src/anndata/_repr/__init__.py @@ -68,6 +68,8 @@ Example - format by Python type:: + from markupsafe import Markup + from anndata._repr import register_formatter, TypeFormatter, FormattedOutput @@ -82,10 +84,27 @@ def format(self, obj, context): return FormattedOutput( type_name=f"MyArray {obj.shape}", css_class="anndata-dtype--myarray", - # preview_html provides HTML for the preview column (rightmost) - preview_html=f'({obj.n_items} items)', + # ``preview_markup`` takes trusted HTML. Build it with + # ``Markup('{}').format(value)`` — MarkupSafe + # autoescapes each non-``Markup`` arg at that boundary. + preview_markup=Markup( + '{} items' + ).format(obj.n_items), ) + **Preview contract**: if no custom HTML is needed, prefer the plain-text + ``preview`` field (autoescaped end-to-end). Use ``preview_markup`` when + you need custom structure. Three valid idioms: + + - ``Markup('{}').format(value)`` — standard MarkupSafe pattern; + each non-``Markup`` arg is autoescaped. + - ``Markup(obj._repr_html_())`` — wrap trusted HTML from another package. + - ``get_macros().my_macro(value)`` — invoke a Jinja macro directly, which + also benefits from the engine's NUL-scrub finalize hook. + + Never build HTML via ``Markup(f'...{value}...')`` — the f-string substitutes + ``value`` before ``Markup`` sees it, bypassing autoescape. + **Error handling**: Formatters can signal errors in two ways: 1. **Raise an exception** - The registry catches it, emits a warning with the @@ -97,10 +116,12 @@ def format(self, obj, context): ``FormattedOutput(error="reason")`` directly. The row will be highlighted red and the error shown in the preview column. - When ``error`` is set, it takes precedence over ``preview`` and ``preview_html``. + When ``error`` is set, it takes precedence over ``preview`` and ``preview_markup``. Example - format by embedded type hint (for tagged data in uns):: + from markupsafe import Markup + from anndata._repr import register_formatter, TypeFormatter, FormattedOutput from anndata._repr import extract_uns_type_hint @@ -118,7 +139,9 @@ def format(self, obj, context): hint, data = extract_uns_type_hint(obj) return FormattedOutput( type_name="config", - preview_html="Custom config preview", + preview_markup=Markup( + '{}' + ).format(data.get("name", "(unnamed)")), ) Data structure for type hints (works in any section):: @@ -277,7 +300,7 @@ def _repr_html_(self): parts.append( render_section( "items", - "\\n".join(entries), + Markup("\\n").join(entries), n_items=len(self.items), ) ) @@ -290,12 +313,12 @@ def _repr_html_(self): from anndata._repr import generate_repr_html, FormattedEntry, FormattedOutput - nested_html = generate_repr_html(adata, depth=1, max_depth=3) + # generate_repr_html already returns Markup; no Markup(...) wrap needed. entry = FormattedEntry( key="table", output=FormattedOutput( type_name=f"AnnData ({adata.n_obs} x {adata.n_vars})", - expanded_html=nested_html, # Collapsible content below the row + expanded_markup=generate_repr_html(adata, depth=1, max_depth=3), ), ) @@ -321,7 +344,6 @@ def _repr_html_(self): DEFAULT_PREVIEW_ITEMS, DEFAULT_TYPE_WIDTH, DEFAULT_UNIQUE_LIMIT, - NOT_SERIALIZABLE_MSG, ) # Documentation base URL @@ -346,10 +368,6 @@ def get_section_doc_url(section: str) -> str: return f"{DOCS_BASE_URL}generated/anndata.AnnData.{section}.html" -# Import main functionality -# Inline styles for graceful degradation (from single source of truth) -from .._repr_constants import STYLE_HIDDEN # noqa: E402 - # Building blocks for packages that want to create their own _repr_html_ # These allow reusing anndata's styling while building custom representations from .components import ( # noqa: E402 @@ -361,6 +379,7 @@ def get_section_doc_url(section: str) -> str: render_warning_icon, ) from .css import get_css # noqa: E402 +from .environment import get_macros # noqa: E402 from .html import ( # noqa: E402 generate_repr_html, render_formatted_entry, @@ -384,7 +403,6 @@ def get_section_doc_url(section: str) -> str: # HTML rendering helpers for building custom sections from .utils import ( # noqa: E402 - escape_html, format_memory_size, format_number, validate_key, @@ -402,9 +420,6 @@ def get_section_doc_url(section: str) -> str: "DEFAULT_UNIQUE_LIMIT", "DEFAULT_MAX_FIELD_WIDTH", "DEFAULT_TYPE_WIDTH", - "DOCS_BASE_URL", - "get_section_doc_url", - "NOT_SERIALIZABLE_MSG", # CSS dtype constants for custom formatters "CSS_DTYPE_NDARRAY", "CSS_DTYPE_ANNDATA", @@ -425,12 +440,11 @@ def get_section_doc_url(section: str) -> str: # Building blocks for custom _repr_html_ implementations "get_css", "get_javascript", - "escape_html", + "get_macros", "format_number", "format_memory_size", "render_section", "render_formatted_entry", - "STYLE_HIDDEN", # UI component helpers "render_search_box", "render_copy_button", diff --git a/src/anndata/_repr/components.py b/src/anndata/_repr/components.py index db2530117..8c6f22213 100644 --- a/src/anndata/_repr/components.py +++ b/src/anndata/_repr/components.py @@ -17,13 +17,11 @@ from dataclasses import dataclass, field -from .._repr_constants import ( - CSS_ENTRY, - CSS_TEXT_MUTED, - NOT_SERIALIZABLE_MSG, - STYLE_HIDDEN, -) -from .utils import escape_html, sanitize_css_color +from markupsafe import Markup + +from .._repr_constants import CSS_ENTRY +from .environment import get_macros +from .utils import sanitize_css_color def render_entry_row_open( @@ -34,7 +32,7 @@ def render_entry_row_open( is_error: bool = False, has_expandable_content: bool = False, extra_classes: str = "", -) -> str: +) -> Markup: """Render the opening tag for an entry row. For regular entries, returns ``
``. @@ -58,9 +56,8 @@ def render_entry_row_open( Returns ------- - Opening tag(s) with class and data attributes + ``Markup`` HTML for the opening tag(s) with class and data attributes. """ - # Build CSS class string classes = [CSS_ENTRY] if extra_classes: classes.append(extra_classes) @@ -69,21 +66,12 @@ def render_entry_row_open( if is_error: classes.append("error") css_class = " ".join(classes) - - escaped_key = escape_html(key) - escaped_dtype = escape_html(dtype) - - if has_expandable_content: - return ( - f'
' - f'' - ) - return f'
' + return Markup(get_macros().row_open(key, dtype, css_class, has_expandable_content)) def render_warning_icon( warnings: list[str], *, is_not_serializable: bool = False -) -> str: +) -> Markup: """Render warning icon with tooltip if there are warnings or serialization issues. Parameters @@ -95,29 +83,12 @@ def render_warning_icon( Returns ------- - HTML string for warning icon, or empty string if no warnings. + ``Markup`` HTML for warning icon, or empty ``Markup`` if no warnings. """ - if not warnings and not is_not_serializable: - return "" - - # Build the tooltip message - if is_not_serializable: - if warnings: - # "Not serializable: reason1; reason2" - reasons = "; ".join(warnings) - title = f"{NOT_SERIALIZABLE_MSG}: {reasons}" - else: - # Just "Not serializable to H5AD/Zarr" - title = NOT_SERIALIZABLE_MSG - else: - # Independent warnings joined with ";" - title = "; ".join(warnings) - - title = escape_html(title) - return f'(!)' - - -def render_search_box(container_id: str = "") -> str: + return Markup(get_macros().warning_icon(warnings or [], is_not_serializable)) + + +def render_search_box(container_id: str = "") -> Markup: """ Render a search box with filter indicator and search mode toggles. @@ -132,35 +103,13 @@ def render_search_box(container_id: str = "") -> str: Returns ------- - HTML string for the search box - - Example - ------- - >>> container_id = "spatialdata-123" - >>> parts = ['
'] - >>> parts.append('SpatialData') - >>> parts.append('') # Spacer - >>> parts.append(render_search_box(container_id)) - >>> parts.append("
") + ``Markup`` HTML for the search box. """ search_id = f"{container_id}-search" if container_id else "anndata-search" - return ( - f'' - f'' - f'' - f'' - f'' - f"" - f"" - f'' - ) + return Markup(get_macros().search_box(search_id)) -def render_copy_button(text: str, tooltip: str = "Copy") -> str: +def render_copy_button(text: str, tooltip: str = "Copy") -> Markup: """ Render a copy-to-clipboard button. @@ -176,51 +125,45 @@ def render_copy_button(text: str, tooltip: str = "Copy") -> str: Returns ------- - HTML string for the copy button + ``Markup`` HTML for the copy button Example ------- >>> name = "gene_expression" >>> html = f"{name}{render_copy_button(name, 'Copy name')}" """ - escaped_text = escape_html(text) - escaped_tooltip = escape_html(tooltip) - return ( - f'' - ) + return Markup(get_macros().copy_button(text, tooltip)) -def _render_wrap_button(css_class: str) -> str: +def _render_wrap_button(css_class: str) -> Markup: """Render a wrap toggle button with the specified CSS class. Internal helper used by render_categories_wrap_button and render_columns_wrap_button. """ - return f'' + return Markup(get_macros().wrap_button(css_class)) -def render_categories_wrap_button() -> str: +def render_categories_wrap_button() -> Markup: """Render a button to toggle category list between single-line and multi-line. Returns ------- - HTML string for the wrap button (▼ expands, ▲ collapses) + ``Markup`` HTML for the wrap button (▼ expands, ▲ collapses) """ return _render_wrap_button("anndata-categories__wrap") -def render_columns_wrap_button() -> str: +def render_columns_wrap_button() -> Markup: """Render a button to toggle column list between single-line and multi-line. Returns ------- - HTML string for the wrap button (▼ expands, ▲ collapses) + ``Markup`` HTML for the wrap button (▼ expands, ▲ collapses) """ return _render_wrap_button("anndata-columns__wrap") -def render_muted_span(text: str) -> str: +def render_muted_span(text: str) -> Markup: """Render text in a muted span (gray color). Parameters @@ -230,12 +173,29 @@ def render_muted_span(text: str) -> str: Returns ------- - HTML string with muted styling + ``Markup`` HTML with muted styling """ - return f'{escape_html(text)}' + return Markup(get_macros().muted_span(text)) -def render_nested_content(html_content: str) -> str: +def render_filepath_span(path: str, style: str = "") -> Markup: + """Render a ```` for backed/lazy files. + + Parameters + ---------- + path + File path to display (autoescaped). + style + Optional inline style (trusted CSS, not escaped). + + Returns + ------- + ``Markup`` HTML for the filepath span. + """ + return Markup(get_macros().filepath_span(path, style)) + + +def render_nested_content(html_content: str | Markup) -> Markup: """Render nested/expanded content inside an expandable entry. The entry must have been opened with ``has_expandable_content=True`` @@ -246,25 +206,22 @@ def render_nested_content(html_content: str) -> str: Parameters ---------- html_content - The HTML content to display when expanded + The trusted HTML (``Markup`` preferred; ``str`` is wrapped) to + display when expanded. Returns ------- - HTML closing the summary and wrapping nested content + ``Markup`` HTML closing the summary and wrapping nested content. """ - return ( - f"
" - f'
' - f'
{html_content}
' - f"
" - ) + body = html_content if isinstance(html_content, Markup) else Markup(html_content) + return Markup(get_macros().nested_content(body)) def render_badge( text: str, variant: str = "", tooltip: str = "", -) -> str: +) -> Markup: """ Render a badge (pill-shaped label). @@ -285,17 +242,13 @@ def render_badge( Returns ------- - HTML string for the badge + ``Markup`` HTML for the badge Example ------- >>> badge = render_badge("Zarr", "anndata-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"anndata-badge {variant}".strip() if variant else "anndata-badge" - return f'{escaped_text}' + return Markup(get_macros().badge(text, variant, tooltip)) def render_header_badges( @@ -305,7 +258,7 @@ def render_header_badges( is_lazy: bool = False, backing_path: str | None = None, backing_format: str | None = None, -) -> str: +) -> Markup: """ Render standard header badges for view/backed/lazy status. @@ -324,7 +277,7 @@ def render_header_badges( Returns ------- - HTML string with badges + ``Markup`` HTML with badges Example ------- @@ -334,7 +287,7 @@ def render_header_badges( ... backing_format="Zarr", ... ) """ - parts = [] + parts: list[Markup] = [] if is_view: parts.append( render_badge( @@ -351,10 +304,10 @@ def render_header_badges( "Lazy", "anndata-badge--lazy", "Lazy loading (experimental read_lazy)" ) ) - return "".join(parts) + return Markup("").join(parts) -def render_name_cell(name: str) -> str: +def render_name_cell(name: str) -> Markup: """Render a name cell with copy button and tooltip for truncated names. The structure uses flexbox so the copy button stays visible even when @@ -367,17 +320,9 @@ def render_name_cell(name: str) -> str: Returns ------- - HTML string for the cell div + ``Markup`` HTML for the cell span. """ - escaped_name = escape_html(name) - return ( - f'' - f'' - f'{escaped_name}' - f"{render_copy_button(name, 'Copy name')}" - f"" - f"" - ) + return Markup(get_macros().name_cell(name)) def render_category_list( @@ -386,7 +331,7 @@ def render_category_list( max_cats: int, *, n_hidden: int = 0, -) -> str: +) -> Markup: """Render a list of category values with optional color dots. Parameters @@ -405,32 +350,16 @@ def render_category_list( ------- HTML string for the category list """ - parts = [''] - for i, cat in enumerate(categories[:max_cats]): - if i > 0: - parts.append(', ') - cat_name = escape_html(str(cat)) - color = colors[i] if colors and i < len(colors) else None - parts.append('') - if color: - # Sanitize color to prevent CSS injection - safe_color = sanitize_css_color(str(color)) - if safe_color: - parts.append( - f'' - ) - # Skip color dot if color is invalid/unsafe - parts.append(f"{cat_name}") - parts.append("") - - # Calculate total hidden: from max_cats truncation + lazy truncation + visible = categories[:max_cats] + items: list[tuple[str, str | None]] = [] + for i, cat in enumerate(visible): + raw_color = colors[i] if colors and i < len(colors) else None + safe_color = sanitize_css_color(str(raw_color)) if raw_color else None + items.append((str(cat), safe_color)) + hidden_from_max_cats = max(0, len(categories) - max_cats) total_hidden = hidden_from_max_cats + n_hidden - - if total_hidden > 0: - parts.append(f'...+{total_hidden}') - parts.append("") - return "".join(parts) + return Markup(get_macros().category_list(items, total_hidden)) @dataclass @@ -446,7 +375,7 @@ class TypeCellConfig: The type name to display (e.g., "ndarray (100, 50) float32") css_class CSS class for the type span (e.g., "anndata-dtype--ndarray") - type_html + type_markup Optional custom HTML content for the type cell tooltip Optional tooltip for the type label @@ -458,8 +387,8 @@ class TypeCellConfig: Whether to show columns wrap button has_categories_list Whether to show categories wrap button - append_type_html - If True, type_html is appended below type_name instead of replacing it + append_type_markup + If True, type_markup is appended below type_name instead of replacing it Examples -------- @@ -482,33 +411,33 @@ class TypeCellConfig: type_name: str css_class: str - type_html: str | None = None + type_markup: Markup | None = None tooltip: str = "" warnings: list[str] = field(default_factory=list) is_not_serializable: bool = False has_columns_list: bool = False has_categories_list: bool = False - append_type_html: bool = False + append_type_markup: bool = False -def render_entry_type_cell(config: TypeCellConfig) -> str: +def render_entry_type_cell(config: TypeCellConfig) -> Markup: """Render the type cell for an entry row. This is a unified helper that handles all type cell variations: - Type label with optional tooltip - - Custom type_html (as replacement or appended content) + - Custom type_markup (as replacement or appended content) - Warning icon - Expand/wrap buttons - The type_html and append_type_html config fields control content rendering: + The type_markup and append_type_markup config fields control content rendering: - 1. No type_html: Shows type_name in a styled span + 1. No type_markup: Shows type_name in a styled span ``type_name`` - 2. type_html with append_type_html=False (default): type_html REPLACES type_name + 2. type_markup with append_type_markup=False (default): type_markup REPLACES type_name Used for fully custom type content (e.g., category swatches instead of text) - 3. type_html with append_type_html=True: type_html is shown BELOW type_name + 3. type_markup with append_type_markup=True: type_markup is shown BELOW type_name Used to add extra content while keeping the type label (e.g., showing category list below "categorical" label) @@ -519,66 +448,27 @@ def render_entry_type_cell(config: TypeCellConfig) -> str: Returns ------- - HTML string for the complete type cell - - Examples - -------- - >>> config = TypeCellConfig( - ... type_name="ndarray (100, 50) float32", - ... css_class="anndata-dtype--ndarray", - ... tooltip="Dense array", - ... ) - >>> html = render_entry_type_cell(config) + ``Markup`` HTML for the complete type cell. """ - type_name = config.type_name - css_class = config.css_class - type_html = config.type_html - tooltip = config.tooltip - warnings = config.warnings - is_not_serializable = config.is_not_serializable - has_columns_list = config.has_columns_list - has_categories_list = config.has_categories_list - append_type_html = config.append_type_html - - parts = [ - '' - ] - - # Type content: handle different cases - if type_html and not append_type_html: - # type_html replaces the type label entirely - parts.append(type_html) - elif tooltip: - parts.append( - f'' - f"{escape_html(type_name)}" + return Markup( + get_macros().type_cell( + type_name=config.type_name, + css_class=config.css_class, + type_markup=config.type_markup, + tooltip=config.tooltip, + all_warnings=config.warnings, + is_not_serializable=config.is_not_serializable, + has_columns_list=config.has_columns_list, + has_categories_list=config.has_categories_list, + append_type_markup=config.append_type_markup, ) - else: - parts.append(f'{escape_html(type_name)}') - - # Warning icon - parts.append( - render_warning_icon(warnings or [], is_not_serializable=is_not_serializable) ) - # Wrap buttons - if has_columns_list: - parts.append(render_columns_wrap_button()) - if has_categories_list: - parts.append(render_categories_wrap_button()) - - # Appended type_html (for custom inline rendering below the type) - if type_html and append_type_html: - parts.append(f'{type_html}') - - parts.append("") - return "".join(parts) - def render_entry_preview_cell( - preview_html: str | None = None, + preview_markup: Markup | None = None, preview_text: str | None = None, -) -> str: +) -> Markup: """Render the preview cell (third column) for an entry row. Formatters are responsible for producing complete preview content. @@ -586,23 +476,18 @@ def render_entry_preview_cell( Parameters ---------- - preview_html - Raw HTML content for preview (highest priority) + preview_markup + Trusted HTML (``Markup``) for preview (highest priority). preview_text - Plain text preview (will be escaped and muted) + Plain text preview (autoescaped and muted). Returns ------- - HTML string for the preview cell + ``Markup`` HTML for the preview cell. """ - parts = [ - '' - ] - - if preview_html: - parts.append(preview_html) - elif preview_text: - parts.append(render_muted_span(preview_text)) - - parts.append("") - return "".join(parts) + return Markup( + get_macros().preview_cell( + preview_markup=preview_markup, + preview_text=preview_text, + ) + ) diff --git a/src/anndata/_repr/core.py b/src/anndata/_repr/core.py index 3546a0183..1bb086571 100644 --- a/src/anndata/_repr/core.py +++ b/src/anndata/_repr/core.py @@ -13,22 +13,15 @@ from typing import TYPE_CHECKING +from markupsafe import Markup + from .._repr_constants import ( CSS_DTYPE_CATEGORY, CSS_DTYPE_DATAFRAME, - CSS_TEXT_ERROR, - CSS_TEXT_MUTED, -) -from .components import ( - TypeCellConfig, - render_entry_preview_cell, - render_entry_row_open, - render_entry_type_cell, - render_name_cell, - render_nested_content, ) +from .environment import get_env, get_macros from .registry import formatter_registry -from .utils import escape_html, format_number +from .utils import format_number if TYPE_CHECKING: from .registry import FormattedEntry, FormatterContext @@ -36,7 +29,7 @@ def render_section( # noqa: PLR0913 name: str, - entries_html: str, + entries: Markup, *, n_items: int, doc_url: str | None = None, @@ -44,7 +37,8 @@ def render_section( # noqa: PLR0913 should_collapse: bool = False, section_id: str | None = None, count_str: str | None = None, -) -> str: + extra_classes: str = "", +) -> Markup: """ Render a complete section with header and content. @@ -55,8 +49,10 @@ def render_section( # noqa: PLR0913 ---------- name Display name for the section header (e.g., 'images', 'tables') - entries_html - HTML content for the section body (table rows) + entries + Trusted HTML (``markupsafe.Markup``) for the section body. + Typically produced by joining per-entry ``Markup`` values, + e.g. ``Markup("\\n").join(render_formatted_entry(e) for e in entries)``. n_items Number of items (used for empty check and default count string) doc_url @@ -72,12 +68,14 @@ def render_section( # noqa: PLR0913 Returns ------- - HTML string for the complete section + ``Markup`` HTML for the complete section. Examples -------- :: + from markupsafe import Markup + from anndata._repr import ( CSS_DTYPE_NDARRAY, FormattedEntry, @@ -86,19 +84,21 @@ def render_section( # noqa: PLR0913 render_section, ) - rows = [] - for key, info in items.items(): - entry = FormattedEntry( - key=key, - output=FormattedOutput( - type_name=info["type"], css_class=CSS_DTYPE_NDARRAY - ), + rows = [ + render_formatted_entry( + FormattedEntry( + key=key, + output=FormattedOutput( + type_name=info["type"], css_class=CSS_DTYPE_NDARRAY + ), + ) ) - rows.append(render_formatted_entry(entry)) + for key, info in items.items() + ] html = render_section( "images", - "\\n".join(rows), + Markup("\\n").join(rows), n_items=len(items), doc_url="https://docs.example.com/images", tooltip="Image data", @@ -106,78 +106,38 @@ def render_section( # noqa: PLR0913 """ 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)" - - open_attr = " open" if not should_collapse else "" - parts = [ - f'
' - ] - - # Header - parts.append(_render_section_header(name, count_str, doc_url, tooltip)) - - # Content - parts.append('
') - parts.append('
') - parts.append(entries_html) - parts.append("
") - - return "\n".join(parts) - - -def _render_section_header( - name: str, - count_str: str, - doc_url: str | None, - tooltip: str, -) -> str: - """Render a section header as - native disclosure triangle replaces fold icon.""" - parts = [""] - parts.append(f'{escape_html(name)}') - parts.append( - f'{escape_html(count_str)}' - ) - if doc_url: - parts.append( - f'?' + count_str = "(empty)" if n_items == 0 else f"({n_items} items)" + + return Markup( + get_env() + .get_template("section.j2") + .render( + name=name, + count_str=count_str, + doc_url=doc_url, + tooltip=tooltip, + should_collapse=should_collapse, + section_id=section_id, + n_items=n_items, + entries=entries, + extra_classes=extra_classes, ) - parts.append("") - return "\n".join(parts) + ) def render_empty_section( name: str, doc_url: str | None = None, tooltip: str = "", -) -> str: +) -> Markup: """Render an empty section indicator.""" - # Build help link if doc_url provided - help_link = "" - if doc_url: - help_link = f'?' - - return f""" -
- - {escape_html(name)} - (empty) - {help_link} - -
-
No entries
-
-
-""" + return render_section(name, Markup(""), n_items=0, doc_url=doc_url, tooltip=tooltip) -def render_truncation_indicator(remaining: int) -> str: +def render_truncation_indicator(remaining: int) -> Markup: """Render a truncation indicator.""" - return f'
... and {format_number(remaining)} more
' + return Markup(get_macros().truncation_indicator(format_number(remaining))) def get_section_tooltip(section: str) -> str: @@ -196,43 +156,45 @@ def get_section_tooltip(section: str) -> str: return tooltips.get(section, "") -def render_x_entry(obj: object, context: FormatterContext) -> str: +def render_x_entry(obj: object, context: FormatterContext) -> Markup: """Render X as a single compact entry row. Works with AnnData, Raw, and any object with an X attribute. Handles missing or broken X attributes gracefully. """ - parts = ['
'] - parts.append("X") + state = "ok" + type_name = "" + css_class = "" + error_msg = "" try: X = obj.X except Exception as e: # noqa: BLE001 - # Handle missing or broken X attribute gracefully + state = "attribute_error" error_msg = f"error: {type(e).__name__}" - parts.append( - f'({escape_html(error_msg)})' - ) - parts.append("
") - return "\n".join(parts) - - if X is None: - parts.append("None") else: - # Format the X matrix (formatter includes all info like sparsity, on disk, etc.) - try: - output = formatter_registry.format_value(X, context) - parts.append( - f'{escape_html(output.type_name)}' - ) - except Exception as e: # noqa: BLE001 - error_msg = f"error formatting: {type(e).__name__}" - parts.append( - f'({escape_html(error_msg)})' - ) - - parts.append("
") - return "\n".join(parts) + if X is None: + state = "none" + else: + try: + output = formatter_registry.format_value(X, context) + type_name = output.type_name + css_class = output.css_class + except Exception as e: # noqa: BLE001 + state = "format_error" + error_msg = f"error formatting: {type(e).__name__}" + + return Markup( + get_env() + .get_template("x_entry.j2") + .render( + x_label="X", + state=state, + type_name=type_name, + css_class=css_class, + error_msg=error_msg, + ) + ) def render_formatted_entry( @@ -240,9 +202,9 @@ def render_formatted_entry( section: str = "", *, extra_warnings: list[str] | None = None, - append_type_html: bool = False, + append_type_markup: bool = False, preview_note: str | None = None, -) -> str: +) -> Markup: """ Render a FormattedEntry as a table row. @@ -257,15 +219,15 @@ def render_formatted_entry( Optional section name (used for meta column rendering) extra_warnings Additional warnings to display (e.g., key validation warnings) - append_type_html - If True, append type_html below type_name instead of replacing it. + append_type_markup + If True, append type_markup below type_name instead of replacing it. Used for mapping entries (obsm, varm, etc.) to show extra content. preview_note Optional note to prepend to preview text (for type hints in uns) Returns ------- - HTML string for the table row(s) + ``Markup`` HTML for the table row(s) Examples -------- @@ -292,13 +254,13 @@ def render_formatted_entry( With expandable nested content:: - nested_html = generate_repr_html(adata, depth=1) + # generate_repr_html already returns Markup — no wrap needed. entry = FormattedEntry( key="cell_table", output=FormattedOutput( type_name="AnnData (150 × 30)", css_class=CSS_DTYPE_ANNDATA, - expanded_html=nested_html, + expanded_markup=generate_repr_html(adata, depth=1), ), ) html = render_formatted_entry(entry) @@ -325,78 +287,45 @@ def render_formatted_entry( html = render_formatted_entry(entry) """ output = entry.output - extra_warnings = extra_warnings or [] - - # Compute entry CSS classes - # Both hard errors and serialization issues get red background - all_warnings = extra_warnings + list(output.warnings) + all_warnings = (extra_warnings or []) + list(output.warnings) has_error = output.error is not None or not output.is_serializable - - has_expandable_content = output.expanded_html is not None - # Detect wrap button needs from output css_class + has_expandable_content = output.expanded_markup is not None has_categories = output.css_class == CSS_DTYPE_CATEGORY and bool( - output.preview_html + output.preview_markup ) has_columns_list = output.css_class == CSS_DTYPE_DATAFRAME and bool( - output.preview_html - ) - - # Build row using consolidated helper - parts = [ - render_entry_row_open( - entry.key, - output.type_name, - has_warnings=bool(all_warnings), - is_error=has_error, - has_expandable_content=has_expandable_content, - ) - ] - - # Name cell - parts.append(render_name_cell(entry.key)) - - # Type cell - type_cell_config = TypeCellConfig( - type_name=output.type_name, - css_class=output.css_class, - type_html=output.type_html if append_type_html else None, - tooltip=output.tooltip, - warnings=all_warnings, - is_not_serializable=not output.is_serializable, - has_columns_list=has_columns_list, - has_categories_list=has_categories, - append_type_html=append_type_html, + output.preview_markup ) - parts.append(render_entry_type_cell(type_cell_config)) - # Preview cell - # Error takes precedence over preview/preview_html - preview_html = output.preview_html + preview_markup = output.preview_markup preview_text = output.preview - - if output.error and not preview_html: - # Generate error preview if error is set but no preview_html provided - error_text = escape_html(output.error) - preview_html = f'{error_text}' + if output.error and not preview_markup: + preview_markup = Markup(get_macros().error_preview(output.error)) if preview_note and preview_text: preview_text = f"{preview_note} {preview_text}" elif preview_note: preview_text = preview_note - parts.append( - render_entry_preview_cell( - preview_html=preview_html, + rendered = ( + get_env() + .get_template("entry.j2") + .render( + entry_key=entry.key, + type_name=output.type_name, + css_class=output.css_class, + type_markup=output.type_markup, + tooltip=output.tooltip, + all_warnings=all_warnings, + is_not_serializable=not output.is_serializable, + has_error=has_error, + has_expandable_content=has_expandable_content, + has_columns_list=has_columns_list, + has_categories_list=has_categories, + append_type_markup=append_type_markup, + preview_markup=preview_markup, preview_text=preview_text, + expanded_markup=output.expanded_markup, ) ) - - # Expandable entries use
/; render_nested_content - # closes the and adds the nested content div. - if has_expandable_content: - parts.append(render_nested_content(output.expanded_html)) - parts.append("
") - else: - parts.append("") - - return "\n".join(parts) + return Markup(rendered) diff --git a/src/anndata/_repr/environment.py b/src/anndata/_repr/environment.py new file mode 100644 index 000000000..68d55da57 --- /dev/null +++ b/src/anndata/_repr/environment.py @@ -0,0 +1,76 @@ +""" +Jinja2 Environment for the AnnData HTML repr. + +The repr renders by feeding structured values (plus pre-produced HTML +fragments wrapped in ``markupsafe.Markup``) into templates loaded from +``anndata._repr.templates``. Autoescape is on by default: + +- Plain ``str`` values are escaped (``<``, ``>``, ``&``, ``"``, ``'``). +- ``Markup`` values pass through verbatim because Jinja recognises the type. + +The trust contract is therefore typed rather than conventional: data arrives +as ``str`` and gets escaped; HTML arrives as ``Markup`` and is trusted. +""" + +from __future__ import annotations + +from functools import cache + +from jinja2 import Environment, PackageLoader, select_autoescape + +from .._repr_constants import ( + CSS_COLORS, + CSS_COLORS_SWATCH, + CSS_COLORS_SWATCH_INVALID, + CSS_DTYPE_UNKNOWN, + CSS_NESTED_ANNDATA, + CSS_TEXT_ERROR, + CSS_TEXT_MUTED, + CSS_TEXT_WARNING, + NOT_SERIALIZABLE_MSG, + STYLE_HIDDEN, +) + + +def _scrub_nulls(value): + # Null bytes in user data break HTML parsers; replace pre-escape. + # Markup values pass through unchanged. + if isinstance(value, str) and not hasattr(value, "__html__"): + return value.replace("\x00", "\ufffd") + return value + + +@cache +def get_env() -> Environment: + env = Environment( + loader=PackageLoader("anndata._repr", "templates"), + autoescape=select_autoescape(default=True, default_for_string=True), + trim_blocks=True, + lstrip_blocks=True, + finalize=_scrub_nulls, + ) + env.globals.update( + CSS_COLORS=CSS_COLORS, + CSS_COLORS_SWATCH=CSS_COLORS_SWATCH, + CSS_COLORS_SWATCH_INVALID=CSS_COLORS_SWATCH_INVALID, + CSS_DTYPE_UNKNOWN=CSS_DTYPE_UNKNOWN, + CSS_NESTED_ANNDATA=CSS_NESTED_ANNDATA, + CSS_TEXT_ERROR=CSS_TEXT_ERROR, + CSS_TEXT_MUTED=CSS_TEXT_MUTED, + CSS_TEXT_WARNING=CSS_TEXT_WARNING, + NOT_SERIALIZABLE_MSG=NOT_SERIALIZABLE_MSG, + STYLE_HIDDEN=STYLE_HIDDEN, + ) + return env + + +@cache +def get_macros(): + """Cached handle to the macros module from ``_macros.j2``. + + Extension packages can invoke any macro as ``get_macros().name(args)``. + Macro calls render through this module's Jinja environment, so + arguments flow through autoescape + the NUL-scrub finalize hook — the + safest way to build custom HTML from user data. + """ + return get_env().get_template("_macros.j2").module diff --git a/src/anndata/_repr/formatters.py b/src/anndata/_repr/formatters.py index 8407455f3..fb3c2836d 100644 --- a/src/anndata/_repr/formatters.py +++ b/src/anndata/_repr/formatters.py @@ -21,12 +21,10 @@ import numpy as np import pandas as pd +from markupsafe import Markup from .._repr_constants import ( COLOR_PREVIEW_LIMIT, - CSS_COLORS, - CSS_COLORS_SWATCH, - CSS_COLORS_SWATCH_INVALID, CSS_DTYPE_ANNDATA, CSS_DTYPE_ARRAY_API, CSS_DTYPE_AWKWARD, @@ -42,11 +40,10 @@ CSS_DTYPE_STRING, CSS_DTYPE_TPU, CSS_DTYPE_UNKNOWN, - CSS_NESTED_ANNDATA, - CSS_TEXT_MUTED, ) from ..compat import has_xp -from .components import render_category_list +from .components import render_category_list, render_muted_span +from .environment import get_macros from .lazy import get_lazy_categorical_info, is_lazy_column from .registry import ( FormattedOutput, @@ -56,7 +53,6 @@ from .utils import ( check_color_category_mismatch, check_invalid_colors, - escape_html, format_invalid_colors_warning, format_number, get_categories_for_display, @@ -393,30 +389,31 @@ def format(self, obj: pd.DataFrame, context: FormatterContext) -> FormattedOutpu n_rows, n_cols = len(df), len(df.columns) cols = list(df.columns) - # Build preview_html with column list for obsm/varm sections + # Build preview_markup with column list for obsm/varm sections # Uses anndata-columns class for CSS truncation and JS wrap button - preview_html = None + preview_markup: Markup | None = None if n_cols > 0 and context.section in ("obsm", "varm"): - col_str = ", ".join(escape_html(str(c)) for c in cols) - preview_html = f'[{col_str}]' + preview_markup = Markup( + get_macros().columns_preview([str(c) for c in cols]) + ) # Check if expandable _repr_html_ is enabled expand_dataframes = get_setting("repr_html_dataframe_expand", default=False) - expanded_html = None + expanded_markup: Markup | None = None if expand_dataframes and n_rows > 0 and n_cols > 0: # Use pandas _repr_html_() for native Jupyter-style output # Respects pd.options.display settings (max_rows, max_columns, etc.) # Intentional broad catch: _repr_html_() can fail in many ways # (memory, recursion, custom dtypes, etc.) - gracefully degrade with contextlib.suppress(Exception): - expanded_html = df._repr_html_() + expanded_markup = Markup(df._repr_html_()) return FormattedOutput( type_name=f"DataFrame ({format_number(n_rows)} × {format_number(n_cols)})", css_class=CSS_DTYPE_DATAFRAME, - expanded_html=expanded_html, - preview_html=preview_html, + expanded_markup=expanded_markup, + preview_markup=preview_markup, is_serializable=True, ) @@ -542,8 +539,8 @@ def format( # noqa: PLR0912 else f"category ({n_categories})" ) - # Build preview_html with category list and colors - preview_html = None + # Build preview_markup with category list and colors + preview_markup: Markup | None = None error = None if context.section in ("obs", "var") and context.key is not None: try: @@ -555,11 +552,9 @@ def format( # noqa: PLR0912 if len(categories) == 0: # Metadata-only mode or no categories: show just count if n_total is not None: - preview_html = f'({n_total} categories)' + preview_markup = render_muted_span(f"({n_total} categories)") else: - preview_html = ( - f'(categories)' - ) + preview_markup = render_muted_span("(categories)") else: # Get colors for categories colors = None @@ -582,7 +577,7 @@ def format( # noqa: PLR0912 if (n_total and was_truncated) else 0 ) - preview_html = render_category_list( + preview_markup = render_category_list( categories, colors, context.max_categories, n_hidden=n_hidden ) except Exception as e: # noqa: BLE001 @@ -615,7 +610,7 @@ def format( # noqa: PLR0912 return FormattedOutput( type_name=type_name, css_class=CSS_DTYPE_CATEGORY, - preview_html=preview_html, + preview_markup=preview_markup, is_serializable=True, warnings=warnings, error=error, @@ -887,7 +882,7 @@ def format(self, obj: object, context: FormatterContext) -> FormattedOutput: shape_str = f"{format_number(obj.n_obs)} × {format_number(obj.n_vars)}" # type: ignore[attr-defined] # Generate expanded HTML if within depth limit - expanded_html = None + expanded_markup: Markup | None = None if context.depth < context.max_depth - 1: # Lazy import to avoid circular dependency from .html import generate_repr_html @@ -899,13 +894,15 @@ def format(self, obj: object, context: FormatterContext) -> FormattedOutput: show_header=True, show_search=False, ) - expanded_html = f'
{nested_html}
' + expanded_markup = Markup( + get_macros().nested_anndata_wrapper(Markup(nested_html)) + ) return FormattedOutput( type_name=f"AnnData ({shape_str})", css_class=CSS_DTYPE_ANNDATA, tooltip="Nested AnnData object", - expanded_html=expanded_html, + expanded_markup=expanded_markup, is_serializable=True, ) @@ -1032,29 +1029,24 @@ def format(self, obj: list, context: FormatterContext) -> FormattedOutput: n_colors = len(colors) # Build color swatch HTML with sanitized colors, counting invalid ones - swatches = [] + swatches: list[Markup] = [] invalid_count = 0 for color in colors[:COLOR_PREVIEW_LIMIT]: # Sanitize color to prevent CSS injection - safe_color = sanitize_css_color(str(color)) + label = str(color) + safe_color = sanitize_css_color(label) if safe_color: swatches.append( - f'' + Markup(get_macros().color_swatch(safe_color, label, valid=True)) ) else: - # Invalid/unsafe color - show as text only, no style invalid_count += 1 swatches.append( - f'?""" + Markup(get_macros().color_swatch("", label, valid=False)) ) - if n_colors > COLOR_PREVIEW_LIMIT: - swatches.append( - f'+{n_colors - COLOR_PREVIEW_LIMIT}' - ) + overflow = max(0, n_colors - COLOR_PREVIEW_LIMIT) - preview_html = f'{"".join(swatches)}' + preview_markup = Markup(get_macros().color_preview(swatches, overflow)) # Build warnings list (only for colors within preview limit) warnings = [] @@ -1067,7 +1059,7 @@ def format(self, obj: list, context: FormatterContext) -> FormattedOutput: return FormattedOutput( type_name=f"colors ({n_colors})", css_class=CSS_DTYPE_OBJECT, - preview_html=preview_html, + preview_markup=preview_markup, is_serializable=True, warnings=warnings, ) diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index 6dc30c4f3..80956e8e5 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -11,15 +11,22 @@ from __future__ import annotations +import re import uuid from typing import TYPE_CHECKING +from markupsafe import Markup + from .._repr_constants import ( + CHAR_WIDTH_PX, + COPY_BUTTON_PADDING_PX, CSS_BADGE_BACKED, CSS_BADGE_EXTENSION, CSS_BADGE_LAZY, CSS_BADGE_VIEW, + DEFAULT_FIELD_WIDTH_PX, DEFAULT_MAX_README_SIZE, + MIN_FIELD_WIDTH_PX, TOOLTIP_TRUNCATE_LENGTH, ) from .._types import AnnDataElem @@ -36,8 +43,12 @@ DEFAULT_TYPE_WIDTH, DEFAULT_UNIQUE_LIMIT, ) +from . import ( + formatters as _formatters, # noqa: F401 # side-effect: register built-in formatters +) from .components import ( render_badge, + render_filepath_span, render_search_box, ) from .core import ( @@ -47,6 +58,7 @@ render_x_entry, ) from .css import get_css +from .environment import get_env, get_macros from .javascript import get_javascript from .lazy import get_lazy_backing_info, is_lazy_adata from .registry import ( @@ -63,7 +75,6 @@ _render_uns_section, ) from .utils import ( - escape_html, format_index_preview, format_memory_size, format_number, @@ -79,14 +90,11 @@ from .registry import SectionFormatter -# Import formatters to register them (side-effect import) -from .._repr_constants import ( - CHAR_WIDTH_PX, - COPY_BUTTON_PADDING_PX, - DEFAULT_FIELD_WIDTH_PX, - MIN_FIELD_WIDTH_PX, -) -from . import formatters as _formatters # noqa: F401 + +# container_id is interpolated verbatim into a ' + ESCAPED = "<script>alert("xss")</script>" + + def _register_and_render(self, *, field: str) -> str: + """Register a one-off TypeFormatter that sets ``field`` to bare str and render.""" + from anndata._repr.registry import ( + FormattedOutput, + TypeFormatter, + register_formatter, + ) + + attack = self.ATTACK + + class _ContractViolator(TypeFormatter): + priority = 10000 # beat every built-in formatter + + def can_format(self, obj, context): + return isinstance(obj, _ContractViolator._Sentinel) + + def format(self, obj, context): + # Bare str — violates the Markup | None contract. + return FormattedOutput(type_name="violator", **{field: attack}) + + class _Sentinel: + pass + + formatter = _ContractViolator() + register_formatter(formatter) + try: + adata = AnnData(np.zeros((2, 2))) + adata.uns["evil"] = _ContractViolator._Sentinel() + return adata._repr_html_() + finally: + formatter_registry.unregister_type_formatter(formatter) + + def test_bare_str_preview_markup_is_escaped(self) -> None: + html = self._register_and_render(field="preview_markup") + assert self.ATTACK not in html, ( + "bare str preview_markup leaked raw — autoescape failed" + ) + assert self.ESCAPED in html + + def test_bare_str_type_markup_is_escaped(self) -> None: + html = self._register_and_render(field="type_markup") + assert self.ATTACK not in html + assert self.ESCAPED in html + + def test_bare_str_expanded_markup_is_escaped(self) -> None: + html = self._register_and_render(field="expanded_markup") + assert self.ATTACK not in html + assert self.ESCAPED in html + + def test_markup_wrapped_preview_passes_through(self) -> None: + """Positive control: ``Markup`` input flows through autoescape verbatim.""" + from markupsafe import Markup + + from anndata._repr.registry import ( + FormattedOutput, + TypeFormatter, + register_formatter, + ) + + safe_html = 'hello' + + class _TrustedFormatter(TypeFormatter): + priority = 10000 + + def can_format(self, obj, context): + return isinstance(obj, _TrustedFormatter._Sentinel) + + def format(self, obj, context): + return FormattedOutput( + type_name="trusted", + preview_markup=Markup(safe_html), + ) + + class _Sentinel: + pass + + formatter = _TrustedFormatter() + register_formatter(formatter) + try: + adata = AnnData(np.zeros((2, 2))) + adata.uns["ok"] = _TrustedFormatter._Sentinel() + html = adata._repr_html_() + assert safe_html in html, ( + "Markup-wrapped preview did not pass through verbatim" + ) + finally: + formatter_registry.unregister_type_formatter(formatter) + + def test_section_formatter_render_html_is_trusted(self) -> None: + """Document the ecosystem-extension contract: ``SectionFormatter.render_html`` + output is trusted verbatim. + + ``_render_custom_section`` wraps ``render_html`` return values in + ``Markup(...)`` — that is a trust assertion, not a sanitizer. An + extension author who returns a bare ``str`` with embedded HTML is + responsible for the contents being safe. This test pins the current + behavior so any future shift to autoescape is an explicit decision. + """ + from anndata._repr.registry import ( + FormattedEntry, + FormattedOutput, + SectionFormatter, + formatter_registry, + ) + + raw_html = '
trusted raw html
' + + class _TrustedSection(SectionFormatter): + @property + def section_name(self) -> str: + return "_trusted_section" + + def should_show(self, obj): + return True + + def get_entries(self, obj, context): + return [ + FormattedEntry(key="ignored", output=FormattedOutput(type_name="x")) + ] + + def render_html(self, obj, context): + # Bare str — anndata trusts this verbatim per the extension contract. + return raw_html + + formatter = _TrustedSection() + formatter_registry.register_section_formatter(formatter) + try: + adata = AnnData(np.zeros((2, 2))) + html = adata._repr_html_() + assert raw_html in html, ( + "render_html(str) must be passed through unmodified — " + "trust boundary is the SectionFormatter contract" + ) + finally: + # No public unregister for section formatters; pop from internal dict. + for name in formatter.section_names: + formatter_registry._section_formatters.pop(name, None) diff --git a/tests/repr/test_repr_utils.py b/tests/repr/test_repr_utils.py index e3b500927..e959dac36 100644 --- a/tests/repr/test_repr_utils.py +++ b/tests/repr/test_repr_utils.py @@ -181,14 +181,6 @@ def test_get_matching_column_colors_no_uns_key(self): class TestFormatting: """Tests for formatting utilities.""" - def test_escape_html(self): - """Test HTML escaping.""" - from anndata._repr.utils import escape_html - - assert escape_html("' - return f"
{escape_html(val)}
" + return Markup("
{}
").format(val) try: html = adata._repr_html_() diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index 4bbd5da7e..ee9ab9c68 100644 --- a/tests/visual_inspect_repr_html.py +++ b/tests/visual_inspect_repr_html.py @@ -34,6 +34,7 @@ import numpy as np import pandas as pd import scipy.sparse as sp +from markupsafe import Markup import anndata as ad @@ -49,7 +50,6 @@ from anndata._repr import ( # noqa: E402 FormattedOutput, TypeFormatter, - escape_html, extract_uns_type_hint, register_formatter, ) @@ -219,7 +219,7 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: type_name=f"DiGraph ({n_nodes} nodes, {n_leaves} leaves)", css_class="anndata-dtype--tree", tooltip=f"Phylogenetic tree with {n_nodes} total nodes", - expanded_html=svg_html, + expanded_markup=Markup(svg_html), ) entries.append(FormattedEntry(key=key, output=output)) return entries @@ -258,7 +258,7 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: type_name=f"DiGraph ({n_nodes} nodes, {n_leaves} leaves)", css_class="anndata-dtype--tree", tooltip=f"Phylogenetic tree with {n_nodes} total nodes", - expanded_html=svg_html, + expanded_markup=Markup(svg_html), ) entries.append(FormattedEntry(key=key, output=output)) return entries @@ -276,7 +276,7 @@ class TreeMetadataSectionFormatter(SectionFormatter): Renders like the X entry — a single non-foldable line showing key=value pairs for label, alignment, and allow_overlap. - All values are escaped via ``escape_html(repr(val))``. + All values are escaped via ``Markup(...).format(...)``. """ @property @@ -319,11 +319,9 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: entries.append(FormattedEntry(key=label, output=output)) return entries - def render_html(self, obj, context: FormatterContext) -> str: + def render_html(self, obj, context: FormatterContext) -> Markup: """Render as a compact line instead of a foldable section.""" - from anndata._repr.utils import escape_html - - pairs = [] + pairs: list[Markup] = [] for attr, label in [ ("_tree_label", "label"), ("_alignment", "alignment"), @@ -332,16 +330,17 @@ def render_html(self, obj, context: FormatterContext) -> str: val = getattr(obj, attr, None) if val is not None: pairs.append( - f'{label}=' - f"{escape_html(repr(val))}" + Markup( + '{label}={val}' + ).format(label=label, val=repr(val)) ) - summary = "   ".join(pairs) - return ( + summary = Markup("   ").join(pairs) + return Markup( '
' - f"tree" - f"{summary}" + "tree" + "{summary}" "
" - ) + ).format(summary=summary) except (ImportError, AttributeError): # AttributeError can occur on Python 3.14+ with incompatible networkx versions @@ -433,7 +432,7 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: type_name=f"AnnData ({shape_str})", css_class="anndata-dtype--anndata", tooltip=f"Modality: {mod_name}", - expanded_html=nested_html if can_expand else None, + expanded_markup=Markup(nested_html) if can_expand else None, is_serializable=True, ) entries.append(FormattedEntry(key=mod_name, output=output)) @@ -469,7 +468,6 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]: FormatterRegistry, SectionFormatter, TypeFormatter, - escape_html, format_number, get_css, get_javascript, @@ -575,9 +573,11 @@ def _build_header(self, container_id: str) -> str: ) ) parts.append( - f'' - f"{escape_html(self.path)}" + Markup( + '' + "{path}" + ).format(path=self.path) ) # Search box using render_search_box() helper @@ -620,10 +620,12 @@ def _build_coordinate_systems_preview(self) -> str: for cs_name in self.coordinate_systems: tooltip = f"Elements: {elements_str}" cs_parts.append( - f'' - f"'{escape_html(cs_name)}'" + Markup( + '' + "'{cs_name}'" + ).format(tooltip=tooltip, cs_name=cs_name) ) parts.append(", ".join(cs_parts)) @@ -640,7 +642,7 @@ def _build_images_section(self) -> str: for name, info in self.images.items(): # Build meta content (dimensions info) for the META column dims_str = ", ".join(info.get("dims", ["y", "x"])) - meta = f'[{dims_str}]' + meta = Markup(f'[{dims_str}]') # Create a FormattedEntry with FormattedOutput entry = FormattedEntry( @@ -648,16 +650,19 @@ def _build_images_section(self) -> str: output=FormattedOutput( type_name=f"DataArray {info['shape']} {info['dtype']}", css_class="anndata-dtype--ndarray", - preview_html=meta, # Content in preview column (rightmost) + preview_markup=meta, # Content in preview column (rightmost) ), ) # render_formatted_entry() creates the table row HTML rows.append(render_formatted_entry(entry)) - # render_section() wraps rows in a collapsible section + # render_section() wraps rows in a collapsible section. + # Use Markup("\n").join so the joined result stays Markup — a + # plain str.join(...) would yield a bare str that Jinja autoescapes + # when render_section interpolates it. return render_section( "images", - "\n".join(rows), + Markup("\n").join(rows), n_items=len(self.images), tooltip="Image data (xarray.DataArray)", ) @@ -667,21 +672,21 @@ def _build_labels_section(self) -> str: rows = [] for name, info in self.labels.items(): dims_str = ", ".join(info.get("dims", ["y", "x"])) - meta = f'[{dims_str}]' + meta = Markup(f'[{dims_str}]') entry = FormattedEntry( key=name, output=FormattedOutput( type_name=f"Labels {info['shape']} {info['dtype']}", css_class="anndata-dtype--ndarray", - preview_html=meta, + preview_markup=meta, ), ) rows.append(render_formatted_entry(entry)) return render_section( "labels", - "\n".join(rows), + Markup("\n").join(rows), n_items=len(self.labels), tooltip="Segmentation masks (xarray.DataArray)", ) @@ -690,21 +695,23 @@ def _build_points_section(self) -> str: """Build points section.""" rows = [] for name, info in self.points.items(): - meta = f'{info["n_dims"]}D coordinates' + meta = Markup( + f'{info["n_dims"]}D coordinates' + ) entry = FormattedEntry( key=name, output=FormattedOutput( type_name=f"dask.DataFrame ({format_number(info['n_points'])} × {info['n_dims']})", css_class="anndata-dtype--dataframe", - preview_html=meta, + preview_markup=meta, ), ) rows.append(render_formatted_entry(entry)) return render_section( "points", - "\n".join(rows), + Markup("\n").join(rows), n_items=len(self.points), tooltip="Point annotations (dask.DataFrame)", ) @@ -713,21 +720,23 @@ def _build_shapes_section(self) -> str: """Build shapes section.""" rows = [] for name, info in self.shapes.items(): - meta = f'{info["geometry_type"]}' + meta = Markup( + f'{info["geometry_type"]}' + ) entry = FormattedEntry( key=name, output=FormattedOutput( type_name=f"GeoDataFrame ({format_number(info['n_shapes'])} shapes)", css_class="anndata-dtype--dataframe", - preview_html=meta, + preview_markup=meta, ), ) rows.append(render_formatted_entry(entry)) return render_section( "shapes", - "\n".join(rows), + Markup("\n").join(rows), n_items=len(self.shapes), tooltip="Vector shapes (geopandas.GeoDataFrame)", ) @@ -750,20 +759,21 @@ def _build_tables_section(self) -> str: show_search=False, ) - # FormattedOutput with expanded_html makes it collapsible + # FormattedOutput with expanded_markup makes it collapsible entry = FormattedEntry( key=name, output=FormattedOutput( type_name=f"AnnData ({adata.n_obs} × {adata.n_vars})", css_class="anndata-dtype--anndata", - expanded_html=nested_html, # Makes the nested content collapsible + # Makes the nested content collapsible + expanded_markup=Markup(nested_html), ), ) rows.append(render_formatted_entry(entry)) return render_section( "tables", - "\n".join(rows), + Markup("\n").join(rows), n_items=len(self.tables), tooltip="Annotation tables (AnnData)", ) @@ -794,7 +804,7 @@ def _build_custom_sections(self) -> str: rows = [render_formatted_entry(entry) for entry in entries] section_html = render_section( formatter.section_name, - "\n".join(rows), + Markup("\n").join(rows), n_items=len(entries), tooltip=getattr(formatter, "tooltip", ""), ) @@ -1940,7 +1950,8 @@ def format(self, obj, context): return FormattedOutput( type_name="analysis history", - preview_html="".join(html_parts), # Use preview_html for inline preview + # preview_markup takes Markup — join the pre-escaped fragments + preview_markup=Markup("".join(html_parts)), ) adata_uns = AnnData(np.zeros((10, 5))) @@ -2330,7 +2341,7 @@ def format(self, obj, context): "" @@ -3217,7 +3228,7 @@ def format(self, obj, context): This produces a FormattedOutput with: - type_name: "category[registry] (n)" instead of just "category (n)" - - preview_html: Category values with validation indicators + - preview_markup: Category values with validation indicators - tooltip: Shows ontology ID and validation status - warnings: If unmapped values exist """ @@ -3236,24 +3247,27 @@ def format(self, obj, context): # Build preview with validation status categories = list(obj.cat.categories[:5]) + cat_spans = [ + Markup( + '{}' + ).format(str(c)) + for c in categories + ] + cat_html = Markup(", ").join(cat_spans) + if n_cats > 5: + cat_html += Markup(' ...+{}').format( + n_cats - 5 + ) if validated: # All values mapped - show green checkmark - cat_html = ", ".join( - f'{escape_html(str(c))}' - for c in categories + cat_html += Markup( + ' ' ) - if n_cats > 5: - cat_html += f' ...+{n_cats - 5}' - cat_html += ' ' else: # Some unmapped values - show warning - cat_html = ", ".join( - f'{escape_html(str(c))}' - for c in categories - ) - if n_cats > 5: - cat_html += f' ...+{n_cats - 5}' - cat_html += f' ⚠ {unmapped_count} unmapped' + cat_html += Markup( + ' ⚠ {n} unmapped' + ).format(n=unmapped_count) # Build tooltip with full metadata tooltip_parts = [f"Registry: {registry}"] @@ -3267,7 +3281,7 @@ def format(self, obj, context): type_name=type_name, css_class="anndata-dtype--category", tooltip="\n".join(tooltip_parts), - preview_html=cat_html, + preview_markup=Markup(cat_html), warnings=[] if validated else [f"{unmapped_count} values not mapped to ontology"],