From 2eef28fbc2f37ee52b48395f917d312653061eeb Mon Sep 17 00:00:00 2001 From: Dominik Date: Mon, 20 Apr 2026 14:19:02 -0700 Subject: [PATCH 01/22] feat(repr): POC Jinja + Markup middle-ground for outer template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes the top-level repr through a single autoescape-enabled Jinja template and wraps existing formatter-produced HTML fragments in markupsafe.Markup at the boundary. Formatter internals (formatters.py, registry.py, components.py, sections.py, core.py) are untouched. The safety contract at the outer template: - plain-str values (container_id, depth, style) are autoescaped by default - Markup-wrapped fragments (header, sections, css, js, hints) pass through Adds jinja2>=3.1 and markupsafe>=3.0 to dependencies. Adds a minimal Environment module and one outer anndata.j2 template. The existing tests/visual_inspect_repr_html.py visual harness runs cleanly against this branch and produces the full 26-scenario comparison artifact. Repr test suite: 614 passed, 1 skipped — zero regressions. --- pyproject.toml | 2 + src/anndata/_repr/environment.py | 35 ++++++++++ src/anndata/_repr/html.py | 88 +++++++++++++------------- src/anndata/_repr/templates/anndata.j2 | 18 ++++++ 4 files changed, 100 insertions(+), 43 deletions(-) create mode 100644 src/anndata/_repr/environment.py create mode 100644 src/anndata/_repr/templates/anndata.j2 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/environment.py b/src/anndata/_repr/environment.py new file mode 100644 index 000000000..8382147b9 --- /dev/null +++ b/src/anndata/_repr/environment.py @@ -0,0 +1,35 @@ +""" +Jinja2 Environment for the AnnData HTML repr (middle-ground POC). + +This module wires Jinja2 into the existing repr pipeline in a minimal way: + +- A single autoescape-enabled ``Environment`` loads templates from + ``anndata._repr.templates``. +- The existing formatter machinery still produces HTML fragments as strings; + the top-level renderer wraps those fragments in ``markupsafe.Markup`` at the + boundary so they pass through autoescape verbatim. +- Any additional values injected directly into the outer template (container + id, depth, inline style, etc.) are autoescaped by default, which closes the + "forgot to call ``html.escape()``" class of bug for those specific + insertions. + +This is deliberately narrow in scope. It illustrates the trust contract +(``Markup`` = trusted, ``str`` = untrusted) without rewriting the per-type +formatters. +""" + +from __future__ import annotations + +from functools import cache + +from jinja2 import Environment, PackageLoader, select_autoescape + + +@cache +def get_env() -> Environment: + return Environment( + loader=PackageLoader("anndata._repr", "templates"), + autoescape=select_autoescape(default=True, default_for_string=True), + trim_blocks=True, + lstrip_blocks=True, + ) diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index 9cae219cb..114e54414 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -14,6 +14,10 @@ import uuid from typing import TYPE_CHECKING +from markupsafe import Markup + +from .environment import get_env + from .._repr_constants import ( CSS_BADGE_BACKED, CSS_BADGE_EXTENSION, @@ -267,13 +271,6 @@ def generate_repr_html( # noqa: PLR0913 # Generate unique container ID container_id = _container_id or f"anndata-repr-{uuid.uuid4().hex[:8]}" - # Build HTML parts - parts = [] - - # CSS and JS only at top level - if depth == 0: - parts.append(get_css()) - # Calculate field name column width based on content max_field_width = get_setting( "repr_html_max_field_width", default=DEFAULT_MAX_FIELD_WIDTH @@ -283,61 +280,66 @@ def generate_repr_html( # noqa: PLR0913 # Get type column width from settings type_width = get_setting("repr_html_type_width", default=DEFAULT_TYPE_WIDTH) - # Container with computed column widths as CSS variables. - # Inline font-family:monospace provides readable fallback when CSS is stripped - # (GitHub, untrusted notebooks). CSS overrides with its own font stack. - # Inline min-width on cells + CSS custom properties give column alignment - # even without a stylesheet. - style = f"font-family: monospace; --anndata-name-col-width: {field_width}px; --anndata-type-col-width: {type_width}px;" - parts.append( - f'
' + # Computed column widths as CSS variables. Inline font-family:monospace + # provides a readable fallback when CSS is stripped (GitHub, untrusted + # notebooks). + style = ( + f"font-family: monospace; " + f"--anndata-name-col-width: {field_width}px; " + f"--anndata-type-col-width: {type_width}px;" ) - # Header (with search box integrated on the right) + # Gather already-rendered HTML fragments and mark them as trusted Markup. + # Each of these is produced by existing formatter/renderer code; wrapping + # at this boundary is the trust assertion the POC illustrates. + header_html: Markup | None = None if show_header: - parts.append( + header_html = Markup( _render_header( - adata, show_search=show_search and depth == 0, container_id=container_id + adata, + show_search=show_search and depth == 0, + container_id=container_id, ) ) - # Index preview (only at top level) - if depth == 0: - parts.append(_render_index_preview(adata)) - - # Sections container - parts.append('
') - parts.extend(_render_all_sections(adata, context)) - parts.append("
") # anndata-repr__sections - - # Footer with metadata (only at top level) + index_preview_html: Markup | None = None + footer_html: Markup | None = None + hints_html: Markup | None = None + css_html: Markup | None = None + javascript_html: Markup | None = None if depth == 0: - parts.append(_render_footer(adata)) - # Degradation hints: visible only when CSS or JS is missing. - # No-CSS hint: visible by default, hidden by CSS. - parts.append( + index_preview_html = Markup(_render_index_preview(adata)) + footer_html = Markup(_render_footer(adata)) + hints_html = Markup( '
' "Styled representation available in Jupyter and trusted notebooks " "(colors, search, type highlighting)." "
" - ) - # No-JS hint: hidden by default (no-CSS case already has its own hint), - # shown by CSS (for static HTML with styles but no JS), - # hidden again by JS on init. - parts.append( '" ) + css_html = Markup(get_css()) + javascript_html = Markup(get_javascript(container_id)) - parts.append("
") # anndata-repr - - # JavaScript (only at top level) - if depth == 0: - parts.append(get_javascript(container_id)) + sections_markup = [Markup(s) for s in _render_all_sections(adata, context)] - return "\n".join(parts) + # Render the outer template. `container_id`, `depth`, and `style` are + # plain strings and get autoescaped by the engine; the Markup-wrapped + # fragments pass through verbatim. + return get_env().get_template("anndata.j2").render( + container_id=container_id, + depth=depth, + style=style, + css=css_html, + header=header_html, + index_preview=index_preview_html, + sections=sections_markup, + footer=footer_html, + hints=hints_html, + javascript=javascript_html, + ) def _render_all_sections( diff --git a/src/anndata/_repr/templates/anndata.j2 b/src/anndata/_repr/templates/anndata.j2 new file mode 100644 index 000000000..a3d202251 --- /dev/null +++ b/src/anndata/_repr/templates/anndata.j2 @@ -0,0 +1,18 @@ +{# Outer AnnData repr template (middle-ground POC). + All fragments (header, sections, footer, css, js) are rendered by the + existing Python code and arrive here as Markup — they pass through + autoescape verbatim. + The remaining interpolations ({{ container_id }}, {{ depth }}, + {{ style }}) are user-adjacent values; Jinja autoescapes them by default. +#} +{% if css %}{{ css }}{% endif %} +
+ {% if header %}{{ header }}{% endif %} + {% if index_preview %}{{ index_preview }}{% endif %} +
+ {% for section in sections %}{{ section }}{% endfor %} +
+ {% if footer %}{{ footer }}{% endif %} + {% if hints %}{{ hints }}{% endif %} +
+{% if javascript %}{{ javascript }}{% endif %} From fa4402ee0aa02367a285afb3388281aef060e680 Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 11:12:29 -0700 Subject: [PATCH 02/22] feat(repr): migrate section scaffolding to Jinja (phase A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the f-string-assembled section frame in ``core.py`` with ``templates/section.j2``. One template covers both the normal ``
``-with-entries shape and the empty-state placeholder. - ``render_section`` now renders through the template and returns ``Markup``. - ``render_empty_section`` delegates to ``render_section(n_items=0, …)``. - ``render_truncation_indicator`` now returns ``Markup``. - Constants used inside templates (``CSS_TEXT_ERROR``, ``CSS_TEXT_MUTED``, ``NOT_SERIALIZABLE_MSG``, ``STYLE_HIDDEN``) are exposed as environment globals so templates can reference them symbolically. Transition: ``render_section(entries_html=…)`` accepts ``str`` or ``Markup``. Bare ``str`` is wrapped in ``Markup`` at the boundary so existing callers that still produce raw HTML fragments are preserved. Entry-level rendering will migrate in a follow-up; at that point internal callers will pass ``Markup`` directly. --- src/anndata/_repr/core.py | 91 ++++++++------------------ src/anndata/_repr/environment.py | 44 ++++++++----- src/anndata/_repr/templates/section.j2 | 33 ++++++++++ 3 files changed, 88 insertions(+), 80 deletions(-) create mode 100644 src/anndata/_repr/templates/section.j2 diff --git a/src/anndata/_repr/core.py b/src/anndata/_repr/core.py index 3546a0183..cbca728a6 100644 --- a/src/anndata/_repr/core.py +++ b/src/anndata/_repr/core.py @@ -13,6 +13,8 @@ from typing import TYPE_CHECKING +from markupsafe import Markup + from .._repr_constants import ( CSS_DTYPE_CATEGORY, CSS_DTYPE_DATAFRAME, @@ -27,6 +29,7 @@ render_name_cell, render_nested_content, ) +from .environment import get_env from .registry import formatter_registry from .utils import escape_html, format_number @@ -36,7 +39,7 @@ def render_section( # noqa: PLR0913 name: str, - entries_html: str, + entries_html: str | Markup, *, n_items: int, doc_url: str | None = None, @@ -44,7 +47,7 @@ def render_section( # noqa: PLR0913 should_collapse: bool = False, section_id: str | None = None, count_str: str | None = None, -) -> str: +) -> Markup: """ Render a complete section with header and content. @@ -56,7 +59,8 @@ 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) + HTML content for the section body (table rows). ``Markup`` passes + through; ``str`` is escaped. n_items Number of items (used for empty check and default count string) doc_url @@ -72,7 +76,7 @@ def render_section( # noqa: PLR0913 Returns ------- - HTML string for the complete section + ``Markup`` HTML for the complete section. Examples -------- @@ -106,78 +110,41 @@ 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)}' + # Existing internal callers produce trusted HTML fragments as ``str``. + # Autoescape would break that, so we wrap bare ``str`` in ``Markup``; + # callers that already pass ``Markup`` are a no-op through the constructor. + entries = entries_html if isinstance(entries_html, Markup) else Markup(entries_html) + + rendered = 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, ) - if doc_url: - parts.append( - f'?' - ) - parts.append("") - return "\n".join(parts) + return Markup(rendered) 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, "", 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( + f'
... and {format_number(remaining)} more
' + ) def get_section_tooltip(section: str) -> str: diff --git a/src/anndata/_repr/environment.py b/src/anndata/_repr/environment.py index 8382147b9..6b507f483 100644 --- a/src/anndata/_repr/environment.py +++ b/src/anndata/_repr/environment.py @@ -1,21 +1,15 @@ """ -Jinja2 Environment for the AnnData HTML repr (middle-ground POC). - -This module wires Jinja2 into the existing repr pipeline in a minimal way: - -- A single autoescape-enabled ``Environment`` loads templates from - ``anndata._repr.templates``. -- The existing formatter machinery still produces HTML fragments as strings; - the top-level renderer wraps those fragments in ``markupsafe.Markup`` at the - boundary so they pass through autoescape verbatim. -- Any additional values injected directly into the outer template (container - id, depth, inline style, etc.) are autoescaped by default, which closes the - "forgot to call ``html.escape()``" class of bug for those specific - insertions. - -This is deliberately narrow in scope. It illustrates the trust contract -(``Markup`` = trusted, ``str`` = untrusted) without rewriting the per-type -formatters. +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 @@ -24,12 +18,26 @@ from jinja2 import Environment, PackageLoader, select_autoescape +from .._repr_constants import ( + CSS_TEXT_ERROR, + CSS_TEXT_MUTED, + NOT_SERIALIZABLE_MSG, + STYLE_HIDDEN, +) + @cache def get_env() -> Environment: - return Environment( + env = Environment( loader=PackageLoader("anndata._repr", "templates"), autoescape=select_autoescape(default=True, default_for_string=True), trim_blocks=True, lstrip_blocks=True, ) + env.globals.update( + CSS_TEXT_ERROR=CSS_TEXT_ERROR, + CSS_TEXT_MUTED=CSS_TEXT_MUTED, + NOT_SERIALIZABLE_MSG=NOT_SERIALIZABLE_MSG, + STYLE_HIDDEN=STYLE_HIDDEN, + ) + return env diff --git a/src/anndata/_repr/templates/section.j2 b/src/anndata/_repr/templates/section.j2 new file mode 100644 index 000000000..5fe805a2a --- /dev/null +++ b/src/anndata/_repr/templates/section.j2 @@ -0,0 +1,33 @@ +{# One-size-fits-all section frame. + Non-empty sections get
/ with the entries body. + Empty sections render a muted "No entries" placeholder. + All user-facing strings (name, count_str, tooltip) are autoescaped; + doc_url is autoescaped inside the href attribute. + `entries` is expected to be Markup (pre-rendered HTML). +#} +{%- macro section_header(name, count_str, doc_url, tooltip) -%} + + {{ name }} + {{ count_str }} + {%- if doc_url %} + ? + {%- endif %} + +{%- endmacro -%} +{%- if n_items == 0 -%} +
+ {{ section_header(name, '(empty)', doc_url, tooltip) }} +
+
No entries
+
+
+{%- else -%} +
+ {{ section_header(name, count_str, doc_url, tooltip) }} +
+
+ {{ entries }} +
+
+
+{%- endif -%} From a4f940ef6c6074d86e95537f5d31bfcfe7fa56d7 Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 11:25:12 -0700 Subject: [PATCH 03/22] feat(repr): migrate entry rendering to Jinja (phase B) Adds templates/_macros.j2 (badge, copy_button, muted_span, warning_icon, wrap_button) and templates/entry.j2 for one-shot row rendering. render_formatted_entry now returns Markup via entry.j2 instead of assembling sub-cells in Python. Small component helpers (render_badge, render_copy_button, render_muted_span, render_warning_icon, wrap-button helpers) delegate to the new macros and return Markup. render_header_badges composes its parts via Markup.join. environment.get_env() now uses a finalize callback to scrub NUL bytes from str values before autoescape, preserving the scrubbing previously done in utils.escape_html on the Python render path. Public API is stable; return types tightened from str to Markup (Markup subclasses str, so callers using string ops still work). --- src/anndata/_repr/components.py | 85 +++++++++-------------- src/anndata/_repr/core.py | 96 +++++++++----------------- src/anndata/_repr/environment.py | 9 +++ src/anndata/_repr/templates/_macros.j2 | 41 +++++++++++ src/anndata/_repr/templates/entry.j2 | 51 ++++++++++++++ 5 files changed, 165 insertions(+), 117 deletions(-) create mode 100644 src/anndata/_repr/templates/_macros.j2 create mode 100644 src/anndata/_repr/templates/entry.j2 diff --git a/src/anndata/_repr/components.py b/src/anndata/_repr/components.py index db2530117..bd1d5fde0 100644 --- a/src/anndata/_repr/components.py +++ b/src/anndata/_repr/components.py @@ -16,16 +16,24 @@ from __future__ import annotations from dataclasses import dataclass, field +from functools import cache + +from markupsafe import Markup from .._repr_constants import ( CSS_ENTRY, CSS_TEXT_MUTED, - NOT_SERIALIZABLE_MSG, STYLE_HIDDEN, ) +from .environment import get_env from .utils import escape_html, sanitize_css_color +@cache +def _macros(): + return get_env().get_template("_macros.j2").module + + def render_entry_row_open( key: str, dtype: str, @@ -83,7 +91,7 @@ def render_entry_row_open( 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,26 +103,9 @@ 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'(!)' + return Markup(_macros().warning_icon(warnings or [], is_not_serializable)) def render_search_box(container_id: str = "") -> str: @@ -160,7 +151,7 @@ def render_search_box(container_id: str = "") -> str: ) -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 +167,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(_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(_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,9 +215,9 @@ 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(_macros().muted_span(text)) def render_nested_content(html_content: str) -> str: @@ -264,7 +249,7 @@ def render_badge( text: str, variant: str = "", tooltip: str = "", -) -> str: +) -> Markup: """ Render a badge (pill-shaped label). @@ -285,17 +270,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(_macros().badge(text, variant, tooltip)) def render_header_badges( @@ -305,7 +286,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 +305,7 @@ def render_header_badges( Returns ------- - HTML string with badges + ``Markup`` HTML with badges Example ------- @@ -334,7 +315,7 @@ def render_header_badges( ... backing_format="Zarr", ... ) """ - parts = [] + parts: list[Markup] = [] if is_view: parts.append( render_badge( @@ -351,7 +332,7 @@ 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: @@ -386,7 +367,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 @@ -430,7 +411,7 @@ def render_category_list( if total_hidden > 0: parts.append(f'...+{total_hidden}') parts.append("") - return "".join(parts) + return Markup("".join(parts)) @dataclass diff --git a/src/anndata/_repr/core.py b/src/anndata/_repr/core.py index cbca728a6..60a140cfe 100644 --- a/src/anndata/_repr/core.py +++ b/src/anndata/_repr/core.py @@ -21,14 +21,6 @@ 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 from .registry import formatter_registry from .utils import escape_html, format_number @@ -209,7 +201,7 @@ def render_formatted_entry( extra_warnings: list[str] | None = None, append_type_html: bool = False, preview_note: str | None = None, -) -> str: +) -> Markup: """ Render a FormattedEntry as a table row. @@ -232,7 +224,7 @@ def render_formatted_entry( Returns ------- - HTML string for the table row(s) + ``Markup`` HTML for the table row(s) Examples -------- @@ -292,15 +284,9 @@ 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_categories = output.css_class == CSS_DTYPE_CATEGORY and bool( output.preview_html ) @@ -308,62 +294,42 @@ def render_formatted_entry( 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, - ) - parts.append(render_entry_type_cell(type_cell_config)) - - # Preview cell - # Error takes precedence over preview/preview_html preview_html = output.preview_html 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}' + preview_html = Markup( + f'{escape_html(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, - preview_text=preview_text, - ) + # Pre-rendered HTML fragments (produced by Python formatters) arrive as + # plain str; wrap in Markup so the template trust contract holds. + type_html = Markup(output.type_html) if output.type_html else None + if isinstance(preview_html, str) and not isinstance(preview_html, Markup): + preview_html = Markup(preview_html) + expanded_html = ( + Markup(output.expanded_html) if output.expanded_html else None ) - # 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) + rendered = get_env().get_template("entry.j2").render( + entry_key=entry.key, + type_name=output.type_name, + css_class=output.css_class, + type_html=type_html, + 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_html=append_type_html, + preview_html=preview_html, + preview_text=preview_text, + expanded_html=expanded_html, + ) + return Markup(rendered) diff --git a/src/anndata/_repr/environment.py b/src/anndata/_repr/environment.py index 6b507f483..d236d5f41 100644 --- a/src/anndata/_repr/environment.py +++ b/src/anndata/_repr/environment.py @@ -26,6 +26,14 @@ ) +def _scrub_nulls(value): + # Null bytes in user data break HTML parsers; replace pre-escape (mirrors + # utils.escape_html). 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( @@ -33,6 +41,7 @@ def get_env() -> Environment: autoescape=select_autoescape(default=True, default_for_string=True), trim_blocks=True, lstrip_blocks=True, + finalize=_scrub_nulls, ) env.globals.update( CSS_TEXT_ERROR=CSS_TEXT_ERROR, diff --git a/src/anndata/_repr/templates/_macros.j2 b/src/anndata/_repr/templates/_macros.j2 new file mode 100644 index 000000000..8581d7c04 --- /dev/null +++ b/src/anndata/_repr/templates/_macros.j2 @@ -0,0 +1,41 @@ +{# Reusable small HTML components used by entry.j2 and (later) other templates. + + Trust contract (see environment.py): + - plain str args are autoescaped by Jinja inside `{{ … }}` + - Markup args pass through verbatim + - Globals CSS_TEXT_MUTED, NOT_SERIALIZABLE_MSG, STYLE_HIDDEN come from the + Environment.globals; do not hardcode their string values. +#} + +{%- macro badge(text, variant='', tooltip='') -%} +{%- if variant -%} +{{ text }} +{%- else -%} +{{ text }} +{%- endif -%} +{%- endmacro -%} + +{%- macro copy_button(text, tooltip='Copy') -%} + +{%- endmacro -%} + +{%- macro muted_span(text) -%} +{{ text }} +{%- endmacro -%} + +{%- macro warning_icon(warnings, is_not_serializable=false) -%} +{%- if warnings or is_not_serializable -%} +{%- if is_not_serializable and warnings -%} +{%- set title = NOT_SERIALIZABLE_MSG ~ ': ' ~ warnings | join('; ') -%} +{%- elif is_not_serializable -%} +{%- set title = NOT_SERIALIZABLE_MSG -%} +{%- else -%} +{%- set title = warnings | join('; ') -%} +{%- endif -%} +(!) +{%- endif -%} +{%- endmacro -%} + +{%- macro wrap_button(css_class) -%} + +{%- endmacro -%} diff --git a/src/anndata/_repr/templates/entry.j2 b/src/anndata/_repr/templates/entry.j2 new file mode 100644 index 000000000..d63a2511b --- /dev/null +++ b/src/anndata/_repr/templates/entry.j2 @@ -0,0 +1,51 @@ +{# Renders one entry row. + + Trust contract: + - `entry_key`, `type_name`, `css_class`, `tooltip`, `preview_text` and items in + `all_warnings` are plain str and autoescaped inside `{{ … }}`. + - `type_html`, `preview_html`, `expanded_html` arrive as Markup (produced by + Python formatters) and pass through verbatim. +#} +{% from '_macros.j2' import copy_button, muted_span, warning_icon, wrap_button %} + +{%- set entry_classes = 'anndata-entry' -%} +{%- if all_warnings %}{%- set entry_classes = entry_classes ~ ' warning' -%}{%- endif -%} +{%- if has_error %}{%- set entry_classes = entry_classes ~ ' error' -%}{%- endif -%} + +{%- macro name_cell(entry_key) -%} +{{ entry_key }}{{ copy_button(entry_key, 'Copy name') }} +{%- endmacro -%} + +{%- macro type_cell() -%} + +{%- if type_html and not append_type_html -%} +{{ type_html }} +{%- elif tooltip -%} +{{ type_name }} +{%- else -%} +{{ type_name }} +{%- endif -%} +{{ warning_icon(all_warnings, is_not_serializable) }} +{%- if has_columns_list %}{{ wrap_button('anndata-columns__wrap') }}{% endif -%} +{%- if has_categories_list %}{{ wrap_button('anndata-categories__wrap') }}{% endif -%} +{%- if type_html and append_type_html -%} +{{ type_html }} +{%- endif -%} + +{%- endmacro -%} + +{%- macro preview_cell() -%} + +{%- if preview_html -%} +{{ preview_html }} +{%- elif preview_text -%} +{{ muted_span(preview_text) }} +{%- endif -%} + +{%- endmacro -%} + +{%- if has_expandable_content -%} +
{{ name_cell(entry_key) }}{{ type_cell() }}{{ preview_cell() }}
{{ expanded_html }}
+{%- else -%} +
{{ name_cell(entry_key) }}{{ type_cell() }}{{ preview_cell() }}
+{%- endif -%} From 9a7f27358fd7c5490937ecbd071324921a8610ae Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 12:31:46 -0700 Subject: [PATCH 04/22] =?UTF-8?q?feat(repr):=20wrap=20section=20HTML=20in?= =?UTF-8?q?=20Markup=20(phase=20C=20=E2=80=94=20sections.py)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section-level HTML assemblers (_render_unknown_sections, _render_error_entry, _render_raw_section, and the per-attr section renderers) now return Markup. Internal "\n".join(parts) stays but the result is wrapped at the return boundary. Return type annotations updated from str to Markup. No behavioral change — existing escape_html discipline is preserved; the Markup wrap makes the trust claim explicit and unblocks the phase-C finalization that removes the transitional str→Markup wrap in render_formatted_entry. --- src/anndata/_repr/sections.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/anndata/_repr/sections.py b/src/anndata/_repr/sections.py index 407b14265..548201bec 100644 --- a/src/anndata/_repr/sections.py +++ b/src/anndata/_repr/sections.py @@ -26,6 +26,8 @@ from dataclasses import replace from typing import TYPE_CHECKING +from markupsafe import Markup + from .._repr_constants import ( CSS_DTYPE_ANNDATA, CSS_DTYPE_UNKNOWN, @@ -76,7 +78,7 @@ def _render_entry_row( *, append_type_html: bool = False, preview_note: str | None = None, -) -> str: +) -> Markup: """Render an entry row for DataFrame, mapping, or uns sections. Key validation is handled by FormatterRegistry.format_value() via context.key, @@ -114,7 +116,7 @@ def _render_dataframe_section( section: str, df: pd.DataFrame, context: FormatterContext, -) -> str: +) -> Markup: """Render obs or var section.""" n_cols = len(df.columns) @@ -159,10 +161,10 @@ def _render_mapping_section( section: str, mapping: object, context: FormatterContext, -) -> str: +) -> Markup: """Render obsm, varm, layers, obsp, varp sections.""" if mapping is None: - return "" + return Markup("") # Get count without creating full list (O(1) for most mappings) n_items = len(mapping) @@ -206,7 +208,7 @@ def _render_mapping_section( def _render_uns_section( uns: object, context: FormatterContext, -) -> str: +) -> Markup: """Render the uns section with special handling.""" # Get count without creating full list (O(1) for dict) n_items = len(uns) @@ -241,7 +243,7 @@ def _render_uns_entry( key: str, value: object, context: FormatterContext, -) -> str: +) -> Markup: """Render a single uns entry with special type handling. Rendering priority: @@ -329,7 +331,7 @@ def _detect_unknown_sections( return unknown -def _render_unknown_sections(unknown_sections: list[tuple[str, str]]) -> str: +def _render_unknown_sections(unknown_sections: list[tuple[str, str]]) -> Markup: """Render a section showing unknown/unrecognized attributes.""" parts = [ '
' @@ -360,16 +362,16 @@ def _render_unknown_sections(unknown_sections: list[tuple[str, str]]) -> str: parts.append("") parts.append("
") - return "\n".join(parts) + return Markup("\n".join(parts)) -def _render_error_entry(section: str, error: str) -> str: +def _render_error_entry(section: str, error: str) -> Markup: """Render an error indicator for a section that failed to render.""" error_str = str(error) if len(error_str) > ERROR_TRUNCATE_LENGTH: error_str = error_str[:ERROR_TRUNCATE_LENGTH] + "..." error_escaped = escape_html(error_str) - return f""" + return Markup(f"""
{escape_html(section)} @@ -381,7 +383,7 @@ def _render_error_entry(section: str, error: str) -> str:
-""" +""") # ----------------------------------------------------------------------------- @@ -441,7 +443,7 @@ def _get_raw_meta_parts(raw: object) -> list[str]: def _render_raw_section( raw: object, context: FormatterContext, -) -> str: +) -> Markup: """Render the raw section as a single expandable row. The raw section shows unprocessed data that was saved before filtering/normalization. @@ -456,7 +458,7 @@ def _render_raw_section( The depth parameter prevents infinite recursion. """ if raw is None: - return "" + return Markup("") # Safely get dimensions with fallbacks n_obs = _safe_get_attr(raw, "n_obs", "?") @@ -497,13 +499,13 @@ def _render_raw_section( parts.append("") # close entries grid parts.append("") # close section - return "\n".join(parts) + return Markup("\n".join(parts)) def _generate_raw_repr_html( raw, context: FormatterContext, -) -> str: +) -> Markup: """Generate HTML repr for a Raw object. This renders X, var, and varm sections similar to AnnData, @@ -587,4 +589,4 @@ def _generate_raw_repr_html( parts.append("") - return "\n".join(parts) + return Markup("\n".join(parts)) From 95ddde97e4dcfb5e11fa98dd2a4e16f2e3672182 Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 12:32:06 -0700 Subject: [PATCH 05/22] =?UTF-8?q?feat(repr):=20wrap=20formatter=20HTML=20i?= =?UTF-8?q?n=20Markup=20(phase=20C=20=E2=80=94=20formatters.py)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TypeFormatters that produce preview_html / expanded_html / type_html now return Markup at construction (not str). The dataclass types were already tightened in phase B; this tightens the values. No behavioral change — each fragment already used escape_html on user data and is safe HTML; the Markup wrap makes the trust claim explicit and allows phase C finalization to remove the transitional str→Markup wrap currently applied in render_formatted_entry. --- src/anndata/_repr/formatters.py | 39 +++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/src/anndata/_repr/formatters.py b/src/anndata/_repr/formatters.py index 8407455f3..8fb573993 100644 --- a/src/anndata/_repr/formatters.py +++ b/src/anndata/_repr/formatters.py @@ -21,6 +21,7 @@ import numpy as np import pandas as pd +from markupsafe import Markup from .._repr_constants import ( COLOR_PREVIEW_LIMIT, @@ -395,22 +396,22 @@ def format(self, obj: pd.DataFrame, context: FormatterContext) -> FormattedOutpu # Build preview_html with column list for obsm/varm sections # Uses anndata-columns class for CSS truncation and JS wrap button - preview_html = None + preview_html: 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_html = Markup(f'[{col_str}]') # Check if expandable _repr_html_ is enabled expand_dataframes = get_setting("repr_html_dataframe_expand", default=False) - expanded_html = None + expanded_html: 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_html = Markup(df._repr_html_()) return FormattedOutput( type_name=f"DataFrame ({format_number(n_rows)} × {format_number(n_cols)})", @@ -543,7 +544,7 @@ def format( # noqa: PLR0912 ) # Build preview_html with category list and colors - preview_html = None + preview_html: Markup | None = None error = None if context.section in ("obs", "var") and context.key is not None: try: @@ -555,9 +556,11 @@ 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_html = Markup( + f'({n_total} categories)' + ) else: - preview_html = ( + preview_html = Markup( f'(categories)' ) else: @@ -887,19 +890,23 @@ 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_html: Markup | None = None if context.depth < context.max_depth - 1: # Lazy import to avoid circular dependency from .html import generate_repr_html - nested_html = generate_repr_html( - obj, # type: ignore[arg-type] - depth=context.depth + 1, - max_depth=context.max_depth, - show_header=True, - show_search=False, + nested_html = Markup( + generate_repr_html( + obj, # type: ignore[arg-type] + depth=context.depth + 1, + max_depth=context.max_depth, + show_header=True, + show_search=False, + ) + ) + expanded_html = Markup( + f'
{nested_html}
' ) - expanded_html = f'
{nested_html}
' return FormattedOutput( type_name=f"AnnData ({shape_str})", @@ -1054,7 +1061,7 @@ def format(self, obj: list, context: FormatterContext) -> FormattedOutput: f'+{n_colors - COLOR_PREVIEW_LIMIT}' ) - preview_html = f'{"".join(swatches)}' + preview_html = Markup(f'{"".join(swatches)}') # Build warnings list (only for colors within preview limit) warnings = [] From 42f655f1e0cc8bc61fa5443601d3cec1fa0cb381 Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 12:34:17 -0700 Subject: [PATCH 06/22] refactor(repr): tighten component + FormattedOutput types to Markup (phase C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FormattedOutput.preview_html / type_html / expanded_html are now typed ``Markup | None`` (was ``str | None``). Component helpers that were still returning ``str`` — render_entry_row_open, render_search_box, render_nested_content, render_name_cell, render_entry_type_cell, render_entry_preview_cell — now return ``Markup``. Their composition sites use ``Markup("").join(...)`` so the return value is a real ``Markup`` instance rather than a plain string that happens to contain HTML. TypeCellConfig.type_html is also typed ``Markup | None``. No behavioral change: every site already used escape_html() around user data. Tightening the types makes the trust boundary enforceable at the annotation level and removes the ambiguity of "is this str safe HTML or not?" Paired with the formatter/section wraps in 95ddde97 and 9a7f2735, this lets the next commit remove the transitional str→Markup wrap in render_formatted_entry. --- src/anndata/_repr/components.py | 113 +++++++++++++++----------------- src/anndata/_repr/registry.py | 23 ++++--- 2 files changed, 66 insertions(+), 70 deletions(-) diff --git a/src/anndata/_repr/components.py b/src/anndata/_repr/components.py index bd1d5fde0..a22c54c0a 100644 --- a/src/anndata/_repr/components.py +++ b/src/anndata/_repr/components.py @@ -42,7 +42,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 ``
``. @@ -66,9 +66,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) @@ -82,11 +81,13 @@ def render_entry_row_open( escaped_dtype = escape_html(dtype) if has_expandable_content: - return ( + return Markup( f'
' f'' ) - return f'
' + return Markup( + f'
' + ) def render_warning_icon( @@ -108,7 +109,7 @@ def render_warning_icon( return Markup(_macros().warning_icon(warnings or [], is_not_serializable)) -def render_search_box(container_id: str = "") -> str: +def render_search_box(container_id: str = "") -> Markup: """ Render a search box with filter indicator and search mode toggles. @@ -123,19 +124,12 @@ 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 ( + search_id = escape_html( + f"{container_id}-search" if container_id else "anndata-search" + ) + return Markup( f'' f' Markup: return Markup(_macros().muted_span(text)) -def render_nested_content(html_content: str) -> str: +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`` @@ -231,16 +225,18 @@ 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 ( + body = html_content if isinstance(html_content, Markup) else Markup(html_content) + return Markup( f"
" f'
' - f'
{html_content}
' + f'
{body}
' f"
" ) @@ -335,7 +331,7 @@ def render_header_badges( 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 @@ -348,10 +344,10 @@ 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 ( + return Markup( f'' f'' f'{escaped_name}' @@ -463,7 +459,7 @@ class TypeCellConfig: type_name: str css_class: str - type_html: str | None = None + type_html: Markup | None = None tooltip: str = "" warnings: list[str] = field(default_factory=list) is_not_serializable: bool = False @@ -472,7 +468,7 @@ class TypeCellConfig: append_type_html: 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: @@ -500,16 +496,7 @@ 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 @@ -521,45 +508,47 @@ def render_entry_type_cell(config: TypeCellConfig) -> str: has_categories_list = config.has_categories_list append_type_html = config.append_type_html - parts = [ - '' + parts: list[str | Markup] = [ + Markup( + '' + ) ] - # 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)}" + Markup( + f'' + f"{escape_html(type_name)}" + ) ) else: - parts.append(f'{escape_html(type_name)}') + parts.append( + Markup(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( + Markup(f'{type_html}') + ) - parts.append("") - return "".join(parts) + parts.append(Markup("")) + return Markup("").join(parts) def render_entry_preview_cell( - preview_html: str | None = None, + preview_html: 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. @@ -568,16 +557,18 @@ def render_entry_preview_cell( Parameters ---------- preview_html - Raw HTML content for preview (highest priority) + 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 = [ - '' + parts: list[str | Markup] = [ + Markup( + '' + ) ] if preview_html: @@ -585,5 +576,5 @@ def render_entry_preview_cell( elif preview_text: parts.append(render_muted_span(preview_text)) - parts.append("") - return "".join(parts) + parts.append(Markup("")) + return Markup("").join(parts) diff --git a/src/anndata/_repr/registry.py b/src/anndata/_repr/registry.py index c09a7cb04..7ad20c1eb 100644 --- a/src/anndata/_repr/registry.py +++ b/src/anndata/_repr/registry.py @@ -50,6 +50,8 @@ def format(self, obj, context): from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING +from markupsafe import Markup + if TYPE_CHECKING: from typing import TypeGuard @@ -129,9 +131,10 @@ class FormattedOutput: Always used for data-dtype attribute (search/filter). Auto-escaped. Defaults to 'unknown' for resilience when type extraction fails.""" - type_html: str | None = None - """Optional. Raw HTML to render in type column instead of type_name. - If provided, replaces the visual rendering but type_name still used for data-dtype.""" + type_html: Markup | None = None + """Optional. Trusted HTML (``markupsafe.Markup``) to render in the type + column instead of ``type_name``. If provided, replaces the visual + rendering but ``type_name`` is still used for the ``data-dtype`` attribute.""" css_class: str = CSS_DTYPE_UNKNOWN """CSS class for styling the type column.""" @@ -146,13 +149,15 @@ class FormattedOutput: """Optional. Plain text for preview column (rightmost). Auto-escaped. Mutually exclusive with preview_html.""" - preview_html: str | None = None - """Optional. Raw HTML for preview column (e.g., category pills with colors). - Takes precedence over preview if both provided (with warning).""" + preview_html: Markup | None = None + """Optional. Trusted HTML (``markupsafe.Markup``) for the preview column + (e.g., category pills with colors). Takes precedence over ``preview`` if + both provided (with warning).""" - expanded_html: str | None = None - """Optional. Raw HTML for expandable content shown in collapsible row below. - If provided, an 'Expand ▼' button is added to the type column.""" + expanded_html: Markup | None = None + """Optional. Trusted HTML (``markupsafe.Markup``) for expandable content + shown in the collapsible row below. If provided, an 'Expand ▼' button is + added to the type column.""" is_serializable: bool = True """Whether this type can be serialized to H5AD/Zarr.""" From 10aed2ed5c73534c02846666686dd5b71d2a658a Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 12:37:23 -0700 Subject: [PATCH 07/22] =?UTF-8?q?refactor(repr):=20remove=20transitional?= =?UTF-8?q?=20str=E2=86=92Markup=20wrap=20(phase=20C=20finalization)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B added an implicit Markup wrap in render_formatted_entry so that formatters still returning str preview_html / type_html / expanded_html could flow through entry.j2 without being autoescaped. With all internal formatters (95ddde97), sections (9a7f2735), and components (42f655f1) now returning Markup, the wrap is dead weight — remove it. Also wraps the two fallback-formatter preview_html f-strings in registry.py in Markup(), and updates the module docstring example to show the Markup(...) idiom at the formatter boundary. FormattedOutput.preview_html / type_html / expanded_html are now Markup-typed end-to-end: every internal producer returns Markup, the dataclass stores Markup, and entry.j2 passes it through autoescape verbatim. Extension packages producing HTML must now wrap at the formatter boundary (documented in the module docstring). --- src/anndata/_repr/core.py | 13 ++----------- src/anndata/_repr/registry.py | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/anndata/_repr/core.py b/src/anndata/_repr/core.py index 60a140cfe..6c290dfb4 100644 --- a/src/anndata/_repr/core.py +++ b/src/anndata/_repr/core.py @@ -306,20 +306,11 @@ def render_formatted_entry( elif preview_note: preview_text = preview_note - # Pre-rendered HTML fragments (produced by Python formatters) arrive as - # plain str; wrap in Markup so the template trust contract holds. - type_html = Markup(output.type_html) if output.type_html else None - if isinstance(preview_html, str) and not isinstance(preview_html, Markup): - preview_html = Markup(preview_html) - expanded_html = ( - Markup(output.expanded_html) if output.expanded_html else None - ) - rendered = get_env().get_template("entry.j2").render( entry_key=entry.key, type_name=output.type_name, css_class=output.css_class, - type_html=type_html, + type_html=output.type_html, tooltip=output.tooltip, all_warnings=all_warnings, is_not_serializable=not output.is_serializable, @@ -330,6 +321,6 @@ def render_formatted_entry( append_type_html=append_type_html, preview_html=preview_html, preview_text=preview_text, - expanded_html=expanded_html, + expanded_html=output.expanded_html, ) return Markup(rendered) diff --git a/src/anndata/_repr/registry.py b/src/anndata/_repr/registry.py index 7ad20c1eb..501d5127a 100644 --- a/src/anndata/_repr/registry.py +++ b/src/anndata/_repr/registry.py @@ -9,6 +9,8 @@ Usage for extending to new types: + from markupsafe import Markup + from anndata._repr import register_formatter, TypeFormatter, FormattedOutput # Format by Python type (e.g., custom array in obsm) @@ -21,8 +23,11 @@ def format(self, obj, context): return FormattedOutput( type_name=f"MyArray {obj.shape}", css_class="anndata-dtype--myarray", - # preview_html for rightmost column (data preview, counts, etc.) - preview_html=f'({obj.n_items} items)', + # preview_html is typed Markup — wrap the trusted fragment + # at construction so the template renders it verbatim. + preview_html=Markup( + f'({obj.n_items} items)' + ), ) # Format by embedded type hint (e.g., tagged data in uns) @@ -40,7 +45,7 @@ def format(self, obj, context): hint, data = extract_uns_type_hint(obj) return FormattedOutput( type_name="config", - preview_html='Custom config preview', + preview_html=Markup("Custom config preview"), ) """ @@ -614,9 +619,11 @@ def format( # noqa: PLR0912, PLR0915 if all_errors: try: error_text = escape_html(", ".join(all_errors)) - preview_html = f'{error_text}' + preview_html = Markup( + f'{error_text}' + ) except Exception: # noqa: BLE001 - preview_html = f'Error' + preview_html = Markup(f'Error') else: # No errors - check if unknown type warning needed try: From 89d3c983cb8a103877865b91107dd7665fee3832 Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 12:58:23 -0700 Subject: [PATCH 08/22] test(repr): wrap ecosystem-example HTML in Markup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-phase-C, FormattedOutput.preview_html / expanded_html are typed Markup | None and entry.j2 autoescapes bare str. The ecosystem examples in tests/visual_inspect_repr_html.py were still passing raw str for: - TreeData ObstSectionFormatter / VartSectionFormatter: expanded_html was the SVG tree string from _render_tree_svg - TreeMetadataSectionFormatter.render_html: returned raw str - MuData ModSectionFormatter: expanded_html was generate_repr_html output - SpatialData: preview_html for images / labels / points / shapes, expanded_html for the nested AnnData in tables - Uns TypeFormatter custom preview - Ontology extensibility TypeFormatter preview_html Each site now wraps its HTML in markupsafe.Markup so the trust claim is explicit — matches the public contract now that the type tightening is end-to-end. Bug this fixes: "gene_ontology DiGraph (54 nodes, 45 leaves)" placeholder and the nested AnnData inside SpatialData tables were being rendered as escaped HTML source text instead of their intended markup. --- tests/visual_inspect_repr_html.py | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index 4bbd5da7e..8c2b0b992 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 @@ -219,7 +220,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_html=Markup(svg_html), ) entries.append(FormattedEntry(key=key, output=output)) return entries @@ -258,7 +259,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_html=Markup(svg_html), ) entries.append(FormattedEntry(key=key, output=output)) return entries @@ -319,7 +320,7 @@ 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 @@ -336,7 +337,7 @@ def render_html(self, obj, context: FormatterContext) -> str: f"{escape_html(repr(val))}" ) summary = "   ".join(pairs) - return ( + return Markup( '
' f"tree" f"{summary}" @@ -433,7 +434,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_html=Markup(nested_html) if can_expand else None, is_serializable=True, ) entries.append(FormattedEntry(key=mod_name, output=output)) @@ -640,7 +641,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( @@ -667,7 +668,7 @@ 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, @@ -690,7 +691,9 @@ 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, @@ -713,7 +716,9 @@ 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, @@ -756,7 +761,8 @@ def _build_tables_section(self) -> str: 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_html=Markup(nested_html), ), ) rows.append(render_formatted_entry(entry)) @@ -1940,7 +1946,8 @@ def format(self, obj, context): return FormattedOutput( type_name="analysis history", - preview_html="".join(html_parts), # Use preview_html for inline preview + # preview_html takes Markup — join the pre-escaped fragments + preview_html=Markup("".join(html_parts)), ) adata_uns = AnnData(np.zeros((10, 5))) @@ -3267,7 +3274,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_html=Markup(cat_html), warnings=[] if validated else [f"{unmapped_count} values not mapped to ontology"], From 7b822e3322b64df6aff348c61993e9194484cd0a Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 13:06:10 -0700 Subject: [PATCH 09/22] refactor(repr): rename *_html FormattedOutput fields to *_markup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Jinja migration typed these fields as ``markupsafe.Markup | None``; the ``_html`` suffix was a leftover from the plain-``str`` era and misdescribed their contract — a bare string flowing into a field named ``preview_html`` looked fine to ecosystem authors but was silently autoescaped at the template boundary. Renames (on FormattedOutput, TypeCellConfig, entry.j2, and every call site and docstring): - preview_html → preview_markup - type_html → type_markup - expanded_html → expanded_markup - append_type_html → append_type_markup (bool flag that mirrors the renamed field) - index_preview_html (local in html.py) → index_preview_markup The plain-text siblings (``preview``, ``type_name``, ``tooltip``) keep their names — the bare-name / ``_markup``-suffix pair now cleanly reflects the type contract: autoescaped str vs trusted Markup. Also wraps every bare-string assignment to the renamed fields in ``Markup(...)``: four docstring examples in __init__.py / registry.py / core.py, and three test assignments in test_repr_registry.py / test_repr_formatters.py that were previously passing through the autoescape path and being rendered as escaped HTML text. No backwards-compat shims: nothing has been released. --- src/anndata/_repr/__init__.py | 19 +++++-- src/anndata/_repr/components.py | 40 +++++++------- src/anndata/_repr/core.py | 80 ++++++++++++++++------------ src/anndata/_repr/formatters.py | 38 ++++++------- src/anndata/_repr/html.py | 33 ++++++------ src/anndata/_repr/registry.py | 53 +++++++++--------- src/anndata/_repr/sections.py | 14 ++--- src/anndata/_repr/templates/entry.j2 | 16 +++--- tests/repr/test_repr_formatters.py | 49 +++++++++-------- tests/repr/test_repr_registry.py | 9 ++-- tests/visual_inspect_repr_html.py | 28 +++++----- 11 files changed, 207 insertions(+), 172 deletions(-) diff --git a/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py index 6c39c6394..c4df5db5a 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,8 +84,11 @@ 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 is typed Markup — wrap trusted HTML at + # the formatter boundary so autoescape lets it through. + preview_markup=Markup( + f'({obj.n_items} items)' + ), ) **Error handling**: Formatters can signal errors in two ways: @@ -97,10 +102,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 +125,7 @@ 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("Custom config preview"), ) Data structure for type hints (works in any section):: @@ -288,6 +295,8 @@ def _repr_html_(self): **Embedding nested AnnData** with full interactivity:: + from markupsafe import Markup + from anndata._repr import generate_repr_html, FormattedEntry, FormattedOutput nested_html = generate_repr_html(adata, depth=1, max_depth=3) @@ -295,7 +304,7 @@ def _repr_html_(self): 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=Markup(nested_html), # Collapsible content below the row ), ) diff --git a/src/anndata/_repr/components.py b/src/anndata/_repr/components.py index a22c54c0a..cc35e03f0 100644 --- a/src/anndata/_repr/components.py +++ b/src/anndata/_repr/components.py @@ -423,7 +423,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 @@ -435,8 +435,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 -------- @@ -459,13 +459,13 @@ class TypeCellConfig: type_name: str css_class: str - type_html: Markup | 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) -> Markup: @@ -473,19 +473,19 @@ def render_entry_type_cell(config: TypeCellConfig) -> Markup: 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) @@ -500,13 +500,13 @@ def render_entry_type_cell(config: TypeCellConfig) -> Markup: """ type_name = config.type_name css_class = config.css_class - type_html = config.type_html + type_markup = config.type_markup 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 + append_type_markup = config.append_type_markup parts: list[str | Markup] = [ Markup( @@ -514,8 +514,8 @@ def render_entry_type_cell(config: TypeCellConfig) -> Markup: ) ] - if type_html and not append_type_html: - parts.append(type_html) + if type_markup and not append_type_markup: + parts.append(type_markup) elif tooltip: parts.append( Markup( @@ -536,9 +536,9 @@ def render_entry_type_cell(config: TypeCellConfig) -> Markup: if has_categories_list: parts.append(render_categories_wrap_button()) - if type_html and append_type_html: + if type_markup and append_type_markup: parts.append( - Markup(f'{type_html}') + Markup(f'{type_markup}') ) parts.append(Markup("")) @@ -546,7 +546,7 @@ def render_entry_type_cell(config: TypeCellConfig) -> Markup: def render_entry_preview_cell( - preview_html: Markup | None = None, + preview_markup: Markup | None = None, preview_text: str | None = None, ) -> Markup: """Render the preview cell (third column) for an entry row. @@ -556,7 +556,7 @@ def render_entry_preview_cell( Parameters ---------- - preview_html + preview_markup Trusted HTML (``Markup``) for preview (highest priority). preview_text Plain text preview (autoescaped and muted). @@ -571,8 +571,8 @@ def render_entry_preview_cell( ) ] - if preview_html: - parts.append(preview_html) + if preview_markup: + parts.append(preview_markup) elif preview_text: parts.append(render_muted_span(preview_text)) diff --git a/src/anndata/_repr/core.py b/src/anndata/_repr/core.py index 6c290dfb4..3f56d8a74 100644 --- a/src/anndata/_repr/core.py +++ b/src/anndata/_repr/core.py @@ -110,15 +110,19 @@ def render_section( # noqa: PLR0913 # callers that already pass ``Markup`` are a no-op through the constructor. entries = entries_html if isinstance(entries_html, Markup) else Markup(entries_html) - rendered = 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, + rendered = ( + 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, + ) ) return Markup(rendered) @@ -199,7 +203,7 @@ 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, ) -> Markup: """ @@ -216,8 +220,8 @@ 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) @@ -251,13 +255,15 @@ def render_formatted_entry( With expandable nested content:: + from markupsafe import Markup + nested_html = generate_repr_html(adata, depth=1) entry = FormattedEntry( key="cell_table", output=FormattedOutput( type_name="AnnData (150 × 30)", css_class=CSS_DTYPE_ANNDATA, - expanded_html=nested_html, + expanded_markup=Markup(nested_html), ), ) html = render_formatted_entry(entry) @@ -286,18 +292,18 @@ def render_formatted_entry( output = entry.output 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 + 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 + output.preview_markup ) - preview_html = output.preview_html + preview_markup = output.preview_markup preview_text = output.preview - if output.error and not preview_html: - preview_html = Markup( + if output.error and not preview_markup: + preview_markup = Markup( f'{escape_html(output.error)}' ) @@ -306,21 +312,25 @@ def render_formatted_entry( elif preview_note: preview_text = preview_note - rendered = get_env().get_template("entry.j2").render( - entry_key=entry.key, - type_name=output.type_name, - css_class=output.css_class, - type_html=output.type_html, - 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_html=append_type_html, - preview_html=preview_html, - preview_text=preview_text, - expanded_html=output.expanded_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, + ) ) return Markup(rendered) diff --git a/src/anndata/_repr/formatters.py b/src/anndata/_repr/formatters.py index 8fb573993..b3b036f75 100644 --- a/src/anndata/_repr/formatters.py +++ b/src/anndata/_repr/formatters.py @@ -394,30 +394,30 @@ 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: Markup | None = 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 = Markup(f'[{col_str}]') + preview_markup = Markup(f'[{col_str}]') # Check if expandable _repr_html_ is enabled expand_dataframes = get_setting("repr_html_dataframe_expand", default=False) - expanded_html: Markup | None = 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 = Markup(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, ) @@ -543,8 +543,8 @@ def format( # noqa: PLR0912 else f"category ({n_categories})" ) - # Build preview_html with category list and colors - preview_html: Markup | None = 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: @@ -556,11 +556,11 @@ 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 = Markup( + preview_markup = Markup( f'({n_total} categories)' ) else: - preview_html = Markup( + preview_markup = Markup( f'(categories)' ) else: @@ -585,7 +585,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 @@ -618,7 +618,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, @@ -890,7 +890,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: Markup | None = 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 @@ -904,7 +904,7 @@ def format(self, obj: object, context: FormatterContext) -> FormattedOutput: show_search=False, ) ) - expanded_html = Markup( + expanded_markup = Markup( f'
{nested_html}
' ) @@ -912,7 +912,7 @@ def format(self, obj: object, context: FormatterContext) -> 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, ) @@ -1061,7 +1061,9 @@ def format(self, obj: list, context: FormatterContext) -> FormattedOutput: f'+{n_colors - COLOR_PREVIEW_LIMIT}' ) - preview_html = Markup(f'{"".join(swatches)}') + preview_markup = Markup( + f'{"".join(swatches)}' + ) # Build warnings list (only for colors within preview limit) warnings = [] @@ -1074,7 +1076,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 114e54414..dd0cc3bcc 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -16,8 +16,6 @@ from markupsafe import Markup -from .environment import get_env - from .._repr_constants import ( CSS_BADGE_BACKED, CSS_BADGE_EXTENSION, @@ -50,6 +48,7 @@ render_x_entry, ) from .css import get_css +from .environment import get_env from .javascript import get_javascript from .lazy import get_lazy_backing_info, is_lazy_adata from .registry import ( @@ -302,13 +301,13 @@ def generate_repr_html( # noqa: PLR0913 ) ) - index_preview_html: Markup | None = None + index_preview_markup: Markup | None = None footer_html: Markup | None = None hints_html: Markup | None = None css_html: Markup | None = None javascript_html: Markup | None = None if depth == 0: - index_preview_html = Markup(_render_index_preview(adata)) + index_preview_markup = Markup(_render_index_preview(adata)) footer_html = Markup(_render_footer(adata)) hints_html = Markup( '
' @@ -328,17 +327,21 @@ def generate_repr_html( # noqa: PLR0913 # Render the outer template. `container_id`, `depth`, and `style` are # plain strings and get autoescaped by the engine; the Markup-wrapped # fragments pass through verbatim. - return get_env().get_template("anndata.j2").render( - container_id=container_id, - depth=depth, - style=style, - css=css_html, - header=header_html, - index_preview=index_preview_html, - sections=sections_markup, - footer=footer_html, - hints=hints_html, - javascript=javascript_html, + return ( + get_env() + .get_template("anndata.j2") + .render( + container_id=container_id, + depth=depth, + style=style, + css=css_html, + header=header_html, + index_preview=index_preview_markup, + sections=sections_markup, + footer=footer_html, + hints=hints_html, + javascript=javascript_html, + ) ) diff --git a/src/anndata/_repr/registry.py b/src/anndata/_repr/registry.py index 501d5127a..b81258583 100644 --- a/src/anndata/_repr/registry.py +++ b/src/anndata/_repr/registry.py @@ -23,9 +23,9 @@ def format(self, obj, context): return FormattedOutput( type_name=f"MyArray {obj.shape}", css_class="anndata-dtype--myarray", - # preview_html is typed Markup — wrap the trusted fragment + # preview_markup is typed Markup — wrap the trusted fragment # at construction so the template renders it verbatim. - preview_html=Markup( + preview_markup=Markup( f'({obj.n_items} items)' ), ) @@ -45,7 +45,7 @@ def format(self, obj, context): hint, data = extract_uns_type_hint(obj) return FormattedOutput( type_name="config", - preview_html=Markup("Custom config preview"), + preview_markup=Markup("Custom config preview"), ) """ @@ -85,33 +85,36 @@ class FormattedOutput: ┌─────────────┬────────────────────────────┬─────────────────┐ │ Name │ Type │ Preview │ ├─────────────┼────────────────────────────┼─────────────────┤ - │ (from key) │ type_html or type_name │ preview_html or │ + │ (from key) │ type_markup or type_name │ preview_markup or │ │ │ + warnings + [Expand ▼] │ preview (text) │ └─────────────┴────────────────────────────┴─────────────────┘ - │ (if expanded_html provided and clicked) + │ (if expanded_markup provided and clicked) ▼ ┌─────────────────────────────────────────────────┐ - │ expanded_html content (collapsible row) │ + │ expanded_markup content (collapsible row) │ └─────────────────────────────────────────────────┘ Field precedence rules ---------------------- Some fields have precedence relationships when multiple are provided: - **Type column** (``type_name`` vs ``type_html``): + **Type column** (``type_name`` vs ``type_markup``): - ``type_name`` is always required and used for search/filter (data-dtype) - - If ``type_html`` is provided, it replaces the visual display + - If ``type_markup`` is provided, it replaces the visual display - ``type_name`` is still used for the data-dtype attribute regardless - **Preview column** (``preview`` vs ``preview_html``): - - If ``preview_html`` is provided, it is used (raw HTML) + **Preview column** (``preview`` vs ``preview_markup``): + - If ``preview_markup`` is provided, it is used (raw HTML) - Otherwise, ``preview`` is used as plain text (auto-escaped) - A warning is logged if both are provided Field naming convention ----------------------- - - ``*_html`` fields contain raw HTML (caller responsible for escaping) - - Other string fields are plain text (auto-escaped when rendered) + - ``*_markup`` fields carry trusted HTML as ``markupsafe.Markup`` + (caller responsible for escaping at the boundary); they pass through + Jinja autoescape verbatim. + - Plain string fields (``type_name``, ``preview``, ``tooltip``, etc.) + are autoescaped when rendered. Available CSS classes --------------------- @@ -136,7 +139,7 @@ class FormattedOutput: Always used for data-dtype attribute (search/filter). Auto-escaped. Defaults to 'unknown' for resilience when type extraction fails.""" - type_html: Markup | None = None + type_markup: Markup | None = None """Optional. Trusted HTML (``markupsafe.Markup``) to render in the type column instead of ``type_name``. If provided, replaces the visual rendering but ``type_name`` is still used for the ``data-dtype`` attribute.""" @@ -152,14 +155,14 @@ class FormattedOutput: preview: str | None = None """Optional. Plain text for preview column (rightmost). Auto-escaped. - Mutually exclusive with preview_html.""" + Mutually exclusive with preview_markup.""" - preview_html: Markup | None = None + preview_markup: Markup | None = None """Optional. Trusted HTML (``markupsafe.Markup``) for the preview column (e.g., category pills with colors). Takes precedence over ``preview`` if both provided (with warning).""" - expanded_html: Markup | None = None + expanded_markup: Markup | None = None """Optional. Trusted HTML (``markupsafe.Markup``) for expandable content shown in the collapsible row below. If provided, an 'Expand ▼' button is added to the type column.""" @@ -171,7 +174,7 @@ class FormattedOutput: """Hard error message. If set, row is highlighted red and error shown in preview. **Precedence**: If ``error`` is set, it takes precedence over ``preview`` and - ``preview_html`` - the error message is displayed instead of any preview content. + ``preview_markup`` - the error message is displayed instead of any preview content. Used for: formatter failures, key validation errors, property access failures. Ecosystem packages can set this explicitly or just raise (caught by registry).""" @@ -607,9 +610,9 @@ def format( # noqa: PLR0912, PLR0915 except Exception: # noqa: BLE001 pass - # === Build preview_html for errors === + # === Build preview_markup for errors === # SECURITY: All text must be HTML-escaped to prevent XSS - preview_html = None + preview_markup = None warnings: list[str] = [] # Add serialization reason to warnings if not serializable @@ -619,11 +622,11 @@ def format( # noqa: PLR0912, PLR0915 if all_errors: try: error_text = escape_html(", ".join(all_errors)) - preview_html = Markup( + preview_markup = Markup( f'{error_text}' ) except Exception: # noqa: BLE001 - preview_html = Markup(f'Error') + preview_markup = Markup(f'Error') else: # No errors - check if unknown type warning needed try: @@ -636,7 +639,7 @@ def format( # noqa: PLR0912, PLR0915 if not is_extension: warnings.append(f"Unknown type: {full_name}") warning_text = escape_html(f"Unknown type: {full_name}") - preview_html = ( + preview_markup = ( f'{warning_text}' ) except Exception: # noqa: BLE001 @@ -668,7 +671,7 @@ def format( # noqa: PLR0912, PLR0915 css_class=css_class, tooltip=tooltip, warnings=warnings, - preview_html=preview_html, + preview_markup=preview_markup, is_serializable=is_serial, error=error, ) @@ -967,6 +970,8 @@ def extract_uns_type_hint(value: object) -> tuple[str | None, object]: 1. In your package (e.g., mypackage/__init__.py), register a TypeFormatter:: + from markupsafe import Markup + from anndata._repr import ( register_formatter, TypeFormatter, @@ -989,7 +994,7 @@ def format(self, obj, context): # Render your custom visualization return FormattedOutput( type_name="mytype", - preview_html="Custom rendering", + preview_markup=Markup("Custom rendering"), ) 2. When the user imports your package, the formatter is registered diff --git a/src/anndata/_repr/sections.py b/src/anndata/_repr/sections.py index 548201bec..ae3f1f725 100644 --- a/src/anndata/_repr/sections.py +++ b/src/anndata/_repr/sections.py @@ -76,7 +76,7 @@ def _render_entry_row( key: str, output: FormattedOutput, *, - append_type_html: bool = False, + append_type_markup: bool = False, preview_note: str | None = None, ) -> Markup: """Render an entry row for DataFrame, mapping, or uns sections. @@ -90,8 +90,8 @@ def _render_entry_row( Entry key/name to display output FormattedOutput from a TypeFormatter (already includes key validation) - append_type_html - If True, append type_html below type_name (for mapping entries) + append_type_markup + If True, append type_markup below type_name (for mapping entries) preview_note Optional note to prepend to preview (for type hints in uns) @@ -102,7 +102,7 @@ def _render_entry_row( entry = FormattedEntry(key=key, output=output) return render_formatted_entry( entry, - append_type_html=append_type_html, + append_type_markup=append_type_markup, preview_note=preview_note, ) @@ -188,7 +188,7 @@ def _render_mapping_section( value = mapping[key] key_context = replace(section_context, key=key) output = formatter_registry.format_value(value, key_context) - rows.append(_render_entry_row(key, output, append_type_html=True)) + rows.append(_render_entry_row(key, output, append_type_markup=True)) return render_section( section, @@ -257,8 +257,8 @@ def _render_uns_entry( # 1. Try formatter first - handles type hints, color lists, AnnData output = formatter_registry.format_value(value, key_context) - # If a custom formatter produced preview_html, use it directly - if output.preview_html: + # If a custom formatter produced preview_markup, use it directly + if output.preview_markup: return _render_entry_row(key, output) # 2. Check for unhandled type hint (basic formatter matched, not custom) diff --git a/src/anndata/_repr/templates/entry.j2 b/src/anndata/_repr/templates/entry.j2 index d63a2511b..b760692ff 100644 --- a/src/anndata/_repr/templates/entry.j2 +++ b/src/anndata/_repr/templates/entry.j2 @@ -3,7 +3,7 @@ Trust contract: - `entry_key`, `type_name`, `css_class`, `tooltip`, `preview_text` and items in `all_warnings` are plain str and autoescaped inside `{{ … }}`. - - `type_html`, `preview_html`, `expanded_html` arrive as Markup (produced by + - `type_markup`, `preview_markup`, `expanded_markup` arrive as Markup (produced by Python formatters) and pass through verbatim. #} {% from '_macros.j2' import copy_button, muted_span, warning_icon, wrap_button %} @@ -18,8 +18,8 @@ {%- macro type_cell() -%} -{%- if type_html and not append_type_html -%} -{{ type_html }} +{%- if type_markup and not append_type_markup -%} +{{ type_markup }} {%- elif tooltip -%} {{ type_name }} {%- else -%} @@ -28,16 +28,16 @@ {{ warning_icon(all_warnings, is_not_serializable) }} {%- if has_columns_list %}{{ wrap_button('anndata-columns__wrap') }}{% endif -%} {%- if has_categories_list %}{{ wrap_button('anndata-categories__wrap') }}{% endif -%} -{%- if type_html and append_type_html -%} -{{ type_html }} +{%- if type_markup and append_type_markup -%} +{{ type_markup }} {%- endif -%} {%- endmacro -%} {%- macro preview_cell() -%} -{%- if preview_html -%} -{{ preview_html }} +{%- if preview_markup -%} +{{ preview_markup }} {%- elif preview_text -%} {{ muted_span(preview_text) }} {%- endif -%} @@ -45,7 +45,7 @@ {%- endmacro -%} {%- if has_expandable_content -%} -
{{ name_cell(entry_key) }}{{ type_cell() }}{{ preview_cell() }}
{{ expanded_html }}
+
{{ name_cell(entry_key) }}{{ type_cell() }}{{ preview_cell() }}
{{ expanded_markup }}
{%- else -%}
{{ name_cell(entry_key) }}{{ type_cell() }}{{ preview_cell() }}
{%- endif -%} diff --git a/tests/repr/test_repr_formatters.py b/tests/repr/test_repr_formatters.py index de7a66347..3ce2b9607 100644 --- a/tests/repr/test_repr_formatters.py +++ b/tests/repr/test_repr_formatters.py @@ -14,6 +14,7 @@ import pandas as pd import pytest import scipy.sparse as sp +from markupsafe import Markup from anndata import AnnData @@ -290,9 +291,9 @@ def test_dataframe_formatter(self): assert "3 × 2" in result.type_name result_obsm = formatter.format(df, FormatterContext(section="obsm")) - assert result_obsm.preview_html is not None - assert "a" in result_obsm.preview_html - assert "b" in result_obsm.preview_html + assert result_obsm.preview_markup is not None + assert "a" in result_obsm.preview_markup + assert "b" in result_obsm.preview_markup def test_dataframe_formatter_expandable(self): """Test DataFrameFormatter with expandable to_html enabled.""" @@ -305,14 +306,14 @@ def test_dataframe_formatter_expandable(self): df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}) result = formatter.format(df, ctx) - assert result.expanded_html is None + assert result.expanded_markup is None original = anndata.settings.repr_html_dataframe_expand try: anndata.settings.repr_html_dataframe_expand = True result_expanded = formatter.format(df, ctx) - assert result_expanded.expanded_html is not None - assert "Inline Preview
' + ), ) formatter = InlineHtmlFormatter() @@ -835,7 +838,7 @@ def format(self, obj, context): return FormattedOutput( type_name="TreeData (3 nodes)", css_class="anndata-dtype--tree", - expanded_html=tree_html, + expanded_markup=tree_html, ) formatter = ExpandableHtmlFormatter() @@ -1009,29 +1012,29 @@ def test_array_api_formatter_obsm_preview(self): result = formatter.format(arr, FormatterContext(section="varm")) assert result.preview == "(15 columns)" - def test_dataframe_obsm_preview_html(self): - """Test DataFrameFormatter shows column list in obsm preview_html.""" + def test_dataframe_obsm_preview_markup(self): + """Test DataFrameFormatter shows column list in obsm preview_markup.""" from anndata._repr.formatters import DataFrameFormatter from anndata._repr.registry import FormatterContext formatter = DataFrameFormatter() df = pd.DataFrame({"col_a": [1, 2, 3], "col_b": [4, 5, 6], "col_c": [7, 8, 9]}) - # No preview_html outside obsm/varm + # No preview_markup outside obsm/varm result = formatter.format(df, FormatterContext(section="uns")) - assert result.preview_html is None + assert result.preview_markup is None # Shows column names in obsm result = formatter.format(df, FormatterContext(section="obsm")) - assert result.preview_html is not None - assert "col_a" in result.preview_html - assert "col_b" in result.preview_html - assert "col_c" in result.preview_html + assert result.preview_markup is not None + assert "col_a" in result.preview_markup + assert "col_b" in result.preview_markup + assert "col_c" in result.preview_markup # Shows column names in varm result = formatter.format(df, FormatterContext(section="varm")) - assert result.preview_html is not None - assert "col_a" in result.preview_html + assert result.preview_markup is not None + assert "col_a" in result.preview_markup def test_1d_arrays_no_preview(self): """Test that 1D arrays don't show column preview in obsm/varm.""" diff --git a/tests/repr/test_repr_registry.py b/tests/repr/test_repr_registry.py index a4af35eec..609759ee6 100644 --- a/tests/repr/test_repr_registry.py +++ b/tests/repr/test_repr_registry.py @@ -11,6 +11,7 @@ import numpy as np import pytest +from markupsafe import Markup from anndata import AnnData @@ -546,7 +547,7 @@ def format(self, obj, context): items = data.get("data", {}) return FormattedOutput( type_name="test config", - preview_html=f'Items: {len(items)}', + preview_markup=f'Items: {len(items)}', ) formatter = TestConfigFormatter() @@ -814,7 +815,7 @@ def get_entries(self, obj, context: FormatterContext): output=FormattedOutput( type_name="Expandable", css_class="test", - expanded_html="
Expanded content here
", + expanded_markup=Markup("
Expanded content here
"), ), ), ] @@ -857,7 +858,9 @@ def get_entries(self, obj, context: FormatterContext): output=FormattedOutput( type_name="Inline", css_class="test", - preview_html="Preview content", + preview_markup=Markup( + "Preview content" + ), ), ), ] diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index 8c2b0b992..69c009bf0 100644 --- a/tests/visual_inspect_repr_html.py +++ b/tests/visual_inspect_repr_html.py @@ -220,7 +220,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=Markup(svg_html), + expanded_markup=Markup(svg_html), ) entries.append(FormattedEntry(key=key, output=output)) return entries @@ -259,7 +259,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=Markup(svg_html), + expanded_markup=Markup(svg_html), ) entries.append(FormattedEntry(key=key, output=output)) return entries @@ -434,7 +434,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=Markup(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)) @@ -649,7 +649,7 @@ 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 @@ -675,7 +675,7 @@ def _build_labels_section(self) -> str: 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)) @@ -700,7 +700,7 @@ def _build_points_section(self) -> str: 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)) @@ -725,7 +725,7 @@ def _build_shapes_section(self) -> str: 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)) @@ -755,14 +755,14 @@ 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", # Makes the nested content collapsible - expanded_html=Markup(nested_html), + expanded_markup=Markup(nested_html), ), ) rows.append(render_formatted_entry(entry)) @@ -1946,8 +1946,8 @@ def format(self, obj, context): return FormattedOutput( type_name="analysis history", - # preview_html takes Markup — join the pre-escaped fragments - preview_html=Markup("".join(html_parts)), + # preview_markup takes Markup — join the pre-escaped fragments + preview_markup=Markup("".join(html_parts)), ) adata_uns = AnnData(np.zeros((10, 5))) @@ -2337,7 +2337,7 @@ def format(self, obj, context): "
    " "
  • get_css() / get_javascript() - reuse styling and interactivity
  • " "
  • render_section() - create collapsible sections (images, labels, points, shapes, tables)
  • " - "
  • render_formatted_entry() with preview_html - table rows with preview column
  • " + "
  • render_formatted_entry() with preview_markup - table rows with preview column
  • " "
  • generate_repr_html() - embed nested AnnData (see 'tables' section)
  • " "
  • FormatterRegistry - custom 'transforms' section added via SectionFormatter
  • " "
" @@ -3224,7 +3224,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 """ @@ -3274,7 +3274,7 @@ def format(self, obj, context): type_name=type_name, css_class="anndata-dtype--category", tooltip="\n".join(tooltip_parts), - preview_html=Markup(cat_html), + preview_markup=Markup(cat_html), warnings=[] if validated else [f"{unmapped_count} values not mapped to ontology"], From 5dd1de702207bf2b8fd90fbc69713a60ba930648 Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 13:39:56 -0700 Subject: [PATCH 10/22] =?UTF-8?q?refactor(repr):=20tighten=20remaining=20s?= =?UTF-8?q?tr=E2=86=92Markup=20boundaries=20(review=20batch=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stragglers from the phase-C review that kept a few str return types and transitional wraps alive: - ``core.py::render_x_entry`` → returns ``Markup`` (was ``str``); parts list is typed ``list[Markup]`` and joined with ``Markup("\n").join``. - ``html.py``: ``_render_header`` / ``_render_footer`` / ``_render_index_preview`` / ``_render_max_depth_indicator`` → ``Markup``. Their callers in ``generate_repr_html`` drop the transitional ``Markup(...)`` wraps. - ``html.py::_render_all_sections`` → ``list[Markup]``; drop the ``[Markup(s) for s in …]`` comprehension at the caller. - ``html.py::_render_section`` / ``_render_custom_section`` → ``Markup`` (the latter wraps ``formatter.render_html(…)`` output so extension packages can still return plain ``str``). - ``html.py::generate_repr_html`` → ``Markup``. This removes a redundant ``Markup(Markup(...))`` double-wrap in ``AnnDataFormatter``'s nested repr construction. - ``formatters.py::AnnDataFormatter.format`` — drop the inner redundant ``Markup(...)`` now that ``generate_repr_html`` returns ``Markup``. - ``_render_footer`` uses ``Markup('{}').format(value)`` for safe plain-text interpolations (version string, memory size) instead of redundant ``escape_html`` on strings that can't contain HTML chars. - ``html.py``: moved the side-effect ``from . import formatters`` next to the other first-party imports (was below ``TYPE_CHECKING``). Also wraps two bare-string assignments surfaced by the rename: - ``tests/repr/test_repr_registry.py:550`` — ``preview_markup=f'…'`` → ``Markup(f'…')`` - ``tests/repr/test_repr_formatters.py:841`` — ``expanded_markup=tree_html`` where ``tree_html`` was a bare triple-quoted string → ``Markup(...)`` Drops "POC" / "middle-ground" language from ``anndata.j2`` and ``core.py`` now that the migration has landed. --- src/anndata/_repr/core.py | 37 +++--- src/anndata/_repr/formatters.py | 14 +-- src/anndata/_repr/html.py | 160 +++++++++++-------------- src/anndata/_repr/templates/anndata.j2 | 10 +- tests/repr/test_repr_formatters.py | 4 +- tests/repr/test_repr_registry.py | 4 +- 6 files changed, 105 insertions(+), 124 deletions(-) diff --git a/src/anndata/_repr/core.py b/src/anndata/_repr/core.py index 3f56d8a74..4726f4e40 100644 --- a/src/anndata/_repr/core.py +++ b/src/anndata/_repr/core.py @@ -105,9 +105,8 @@ def render_section( # noqa: PLR0913 if count_str is None: count_str = f"({n_items} items)" - # Existing internal callers produce trusted HTML fragments as ``str``. - # Autoescape would break that, so we wrap bare ``str`` in ``Markup``; - # callers that already pass ``Markup`` are a no-op through the constructor. + # Callers that produced HTML as plain ``str`` (legacy path) are implicitly + # trusted; ``Markup``-typed input passes through unchanged. entries = entries_html if isinstance(entries_html, Markup) else Markup(entries_html) rendered = ( @@ -159,43 +158,49 @@ 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") + parts: list[Markup] = [ + Markup('
'), + Markup("X"), + ] try: X = obj.X except Exception as e: # noqa: BLE001 - # Handle missing or broken X attribute gracefully error_msg = f"error: {type(e).__name__}" parts.append( - f'({escape_html(error_msg)})' + Markup( + f'({escape_html(error_msg)})' + ) ) - parts.append("
") - return "\n".join(parts) + parts.append(Markup("
")) + return Markup("\n").join(parts) if X is None: - parts.append("None") + parts.append(Markup("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)}' + Markup( + 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)})' + Markup( + f'({escape_html(error_msg)})' + ) ) - parts.append("
") - return "\n".join(parts) + parts.append(Markup("
")) + return Markup("\n").join(parts) def render_formatted_entry( diff --git a/src/anndata/_repr/formatters.py b/src/anndata/_repr/formatters.py index b3b036f75..9adeae540 100644 --- a/src/anndata/_repr/formatters.py +++ b/src/anndata/_repr/formatters.py @@ -895,14 +895,12 @@ def format(self, obj: object, context: FormatterContext) -> FormattedOutput: # Lazy import to avoid circular dependency from .html import generate_repr_html - nested_html = Markup( - generate_repr_html( - obj, # type: ignore[arg-type] - depth=context.depth + 1, - max_depth=context.max_depth, - show_header=True, - show_search=False, - ) + nested_html = generate_repr_html( + obj, # type: ignore[arg-type] + depth=context.depth + 1, + max_depth=context.max_depth, + show_header=True, + show_search=False, ) expanded_markup = Markup( f'
{nested_html}
' diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index dd0cc3bcc..aaf2fbc6a 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -17,11 +17,15 @@ 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 ..utils import iter_outer @@ -37,6 +41,9 @@ 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_search_box, @@ -81,15 +88,6 @@ 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 - def _collect_all_field_names(adata: AnnData) -> list[str]: """ @@ -219,7 +217,7 @@ def generate_repr_html( # noqa: PLR0913 show_header: bool = True, show_search: bool = True, _container_id: str | None = None, -) -> str: +) -> Markup: """ Generate HTML representation for an AnnData object. @@ -288,17 +286,12 @@ def generate_repr_html( # noqa: PLR0913 f"--anndata-type-col-width: {type_width}px;" ) - # Gather already-rendered HTML fragments and mark them as trusted Markup. - # Each of these is produced by existing formatter/renderer code; wrapping - # at this boundary is the trust assertion the POC illustrates. header_html: Markup | None = None if show_header: - header_html = Markup( - _render_header( - adata, - show_search=show_search and depth == 0, - container_id=container_id, - ) + header_html = _render_header( + adata, + show_search=show_search and depth == 0, + container_id=container_id, ) index_preview_markup: Markup | None = None @@ -307,8 +300,8 @@ def generate_repr_html( # noqa: PLR0913 css_html: Markup | None = None javascript_html: Markup | None = None if depth == 0: - index_preview_markup = Markup(_render_index_preview(adata)) - footer_html = Markup(_render_footer(adata)) + index_preview_markup = _render_index_preview(adata) + footer_html = _render_footer(adata) hints_html = Markup( '
' "Styled representation available in Jupyter and trusted notebooks " @@ -322,12 +315,7 @@ def generate_repr_html( # noqa: PLR0913 css_html = Markup(get_css()) javascript_html = Markup(get_javascript(container_id)) - sections_markup = [Markup(s) for s in _render_all_sections(adata, context)] - - # Render the outer template. `container_id`, `depth`, and `style` are - # plain strings and get autoescaped by the engine; the Markup-wrapped - # fragments pass through verbatim. - return ( + return Markup( get_env() .get_template("anndata.j2") .render( @@ -337,7 +325,7 @@ def generate_repr_html( # noqa: PLR0913 css=css_html, header=header_html, index_preview=index_preview_markup, - sections=sections_markup, + sections=_render_all_sections(adata, context), footer=footer_html, hints=hints_html, javascript=javascript_html, @@ -348,9 +336,9 @@ def generate_repr_html( # noqa: PLR0913 def _render_all_sections( adata: AnnData, context: FormatterContext, -) -> list[str]: +) -> list[Markup]: """Render all standard and custom sections.""" - parts: list[str] = [] + parts: list[Markup] = [] # Materialize iter_outer once. On backed AnnData each yield reopens/closes # the backing file, so we pay that cost once and reuse the names downstream. sections = list(iter_outer(adata)) @@ -390,7 +378,7 @@ def _render_section( section: str, elem: object, context: FormatterContext, -) -> str: +) -> Markup: """Render a single standard section. ``elem`` is the value yielded by ``iter_outer`` for this section. We pass it @@ -455,7 +443,7 @@ def _render_custom_section( adata: AnnData, formatter: SectionFormatter, context: FormatterContext, -) -> str: +) -> Markup: """Render a custom section using its registered formatter. If the formatter defines ``render_html(obj, context)``, it is tried @@ -463,10 +451,9 @@ def _render_custom_section( If ``render_html`` fails, falls back to the standard ``get_entries`` path so formatters can provide both an enhanced and a safe representation. """ - # Allow formatters to produce raw HTML (e.g., compact inline rows) if hasattr(formatter, "render_html"): try: - return formatter.render_html(adata, context) + return Markup(formatter.render_html(adata, context)) except Exception as e: # noqa: BLE001 from .._warnings import warn @@ -475,22 +462,20 @@ def _render_custom_section( f"falling back to get_entries: {e}", UserWarning, ) - # Fall through to get_entries below try: entries = formatter.get_entries(adata, context) except Exception as e: # noqa: BLE001 - # Intentional broad catch: custom formatters shouldn't crash the entire repr from .._warnings import warn warn( f"Custom section formatter '{formatter.section_name}' failed: {e}", UserWarning, ) - return "" + return Markup("") if not entries: - return "" + return Markup("") n_items = len(entries) section_name = formatter.section_name @@ -517,19 +502,18 @@ def _render_custom_section( def _render_header( adata: AnnData, *, show_search: bool = False, container_id: str = "" -) -> str: +) -> Markup: """Render the header with type, shape, badges, and optional search box.""" - parts = ['
'] + parts: list[Markup] = [Markup('
')] - # Type name - allow for extension types type_name = type(adata).__name__ - parts.append(f'{escape_html(type_name)}') + parts.append( + Markup(f'{escape_html(type_name)}') + ) - # Shape shape_str = f"{format_number(adata.n_obs)} obs × {format_number(adata.n_vars)} vars" - parts.append(f'{shape_str}') + parts.append(Markup(f'{shape_str}')) - # Badges - use render_badge() helper if is_view(adata): parts.append(render_badge("View", CSS_BADGE_VIEW)) @@ -539,10 +523,11 @@ def _render_header( format_str = backing.get("format", "") status = "Open" if backing.get("is_open") else "Closed" parts.append(render_badge(f"{format_str} ({status})", CSS_BADGE_BACKED)) - # Inline file path (full path, no truncation) if filename: parts.append( - f'{escape_html(filename)}' + Markup( + f'{escape_html(filename)}' + ) ) if is_lazy_adata(adata): @@ -552,7 +537,6 @@ def _render_header( parts.append(render_badge(f"Lazy ({lazy_format})", CSS_BADGE_LAZY)) else: parts.append(render_badge("Lazy", CSS_BADGE_LAZY)) - # Show file path for lazy AnnData (similar to backed) lazy_filename = lazy_info.get("filename", "") if lazy_filename: path_style = ( @@ -560,25 +544,23 @@ def _render_header( "color:var(--anndata-text-secondary, #6c757d);" ) parts.append( - f'' - f"{escape_html(lazy_filename)}" - f"" + Markup( + f'' + f"{escape_html(lazy_filename)}" + f"" + ) ) - # Check for extension type (not standard AnnData) if type_name != "AnnData": parts.append(render_badge(type_name, CSS_BADGE_EXTENSION)) - # README icon if uns["README"] exists with a string readme_content = adata.uns.get("README") if hasattr(adata, "uns") else None if isinstance(readme_content, str) and readme_content.strip(): - # Check max README size setting (0 means no limit) max_readme_size = get_setting( "repr_html_max_readme_size", default=DEFAULT_MAX_README_SIZE ) original_len = len(readme_content) if max_readme_size > 0 and original_len > max_readme_size: - # Truncate and add note readme_content = readme_content[:max_readme_size] truncation_note = ( f"\n\n---\n*README truncated: showing {max_readme_size:,} of " @@ -587,69 +569,65 @@ def _render_header( readme_content += truncation_note escaped_readme = escape_html(readme_content) - # Truncate for no-JS tooltip (first 500 chars) tooltip_text = readme_content[:TOOLTIP_TRUNCATE_LENGTH] if len(readme_content) > TOOLTIP_TRUNCATE_LENGTH: tooltip_text += "..." escaped_tooltip = escape_html(tooltip_text) parts.append( - f'' - f"ⓘ" - f"" + Markup( + f'' + f"ⓘ" + f"" + ) ) - # Search box on the right (spacer pushes it right) - use render_search_box() helper if show_search: - parts.append('') + parts.append(Markup('')) parts.append(render_search_box(container_id)) - parts.append("
") - return "\n".join(parts) + parts.append(Markup("
")) + return Markup("\n").join(parts) -def _render_footer(adata: AnnData) -> str: +def _render_footer(adata: AnnData) -> Markup: """Render the footer with version and memory info.""" - parts = ['")) + return Markup("\n").join(parts) -def _render_index_preview(adata: AnnData) -> str: +def _render_index_preview(adata: AnnData) -> Markup: """Render preview of obs_names and var_names.""" - parts = ['
'] - - # obs_names preview obs_preview = format_index_preview(adata.obs_names, DEFAULT_PREVIEW_ITEMS) - parts.append(f"
obs_names: {obs_preview}
") - - # var_names preview var_preview = format_index_preview(adata.var_names, DEFAULT_PREVIEW_ITEMS) - parts.append(f"
var_names: {var_preview}
") - - parts.append("
") - return "\n".join(parts) + return Markup( + '
\n' + f"
obs_names: {obs_preview}
\n" + f"
var_names: {var_preview}
\n" + "
" + ) -def _render_max_depth_indicator(adata: AnnData) -> str: +def _render_max_depth_indicator(adata: AnnData) -> Markup: """Render indicator when max depth is reached.""" n_obs = getattr(adata, "n_obs", "?") n_vars = getattr(adata, "n_vars", "?") - return f'
AnnData ({format_number(n_obs)} × {format_number(n_vars)}) - max depth reached
' + return Markup( + f'
AnnData ({format_number(n_obs)} × {format_number(n_vars)}) - max depth reached
' + ) diff --git a/src/anndata/_repr/templates/anndata.j2 b/src/anndata/_repr/templates/anndata.j2 index a3d202251..058334168 100644 --- a/src/anndata/_repr/templates/anndata.j2 +++ b/src/anndata/_repr/templates/anndata.j2 @@ -1,9 +1,7 @@ -{# Outer AnnData repr template (middle-ground POC). - All fragments (header, sections, footer, css, js) are rendered by the - existing Python code and arrive here as Markup — they pass through - autoescape verbatim. - The remaining interpolations ({{ container_id }}, {{ depth }}, - {{ style }}) are user-adjacent values; Jinja autoescapes them by default. +{# Outer AnnData repr frame. + Pre-rendered fragments (header, sections, footer, css, js) arrive as + Markup and pass through verbatim. Scalars (container_id, depth, style) + are plain str and autoescaped by the engine. #} {% if css %}{{ css }}{% endif %}
diff --git a/tests/repr/test_repr_formatters.py b/tests/repr/test_repr_formatters.py index 3ce2b9607..75e792818 100644 --- a/tests/repr/test_repr_formatters.py +++ b/tests/repr/test_repr_formatters.py @@ -823,7 +823,7 @@ def can_format(self, obj, context): ) def format(self, obj, context): - tree_html = """ + tree_html = Markup("""
  • Root @@ -834,7 +834,7 @@ def format(self, obj, context):
- """ + """) return FormattedOutput( type_name="TreeData (3 nodes)", css_class="anndata-dtype--tree", diff --git a/tests/repr/test_repr_registry.py b/tests/repr/test_repr_registry.py index 609759ee6..6c6a88e04 100644 --- a/tests/repr/test_repr_registry.py +++ b/tests/repr/test_repr_registry.py @@ -547,7 +547,9 @@ def format(self, obj, context): items = data.get("data", {}) return FormattedOutput( type_name="test config", - preview_markup=f'Items: {len(items)}', + preview_markup=Markup( + f'Items: {len(items)}' + ), ) formatter = TestConfigFormatter() From 00a3125f290f6fdd7bf47d9f26eeb10dea64c5dc Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 13:43:16 -0700 Subject: [PATCH 11/22] refactor(repr): tighten render_section entries to Markup (review batch B) Internal callers in sections.py (3 sites) and html.py (1 site) switch from ``"\n".join(rows)`` to ``Markup("\n").join(rows)``. With every caller now producing ``Markup``, ``render_section``'s signature tightens to ``entries: Markup`` (was ``str | Markup``) and the transitional implicit-wrap comment in ``core.py:108-111`` is gone. ``render_empty_section`` feeds ``Markup("")`` for the same reason. Docstring examples in ``core.py`` and ``__init__.py`` updated to show the new idiom. --- src/anndata/_repr/__init__.py | 2 +- src/anndata/_repr/core.py | 40 +++++++++++++++++------------------ src/anndata/_repr/html.py | 3 +-- src/anndata/_repr/sections.py | 6 +++--- 4 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py index c4df5db5a..bfd5f4029 100644 --- a/src/anndata/_repr/__init__.py +++ b/src/anndata/_repr/__init__.py @@ -284,7 +284,7 @@ def _repr_html_(self): parts.append( render_section( "items", - "\\n".join(entries), + Markup("\\n").join(entries), n_items=len(self.items), ) ) diff --git a/src/anndata/_repr/core.py b/src/anndata/_repr/core.py index 4726f4e40..f15c5cb09 100644 --- a/src/anndata/_repr/core.py +++ b/src/anndata/_repr/core.py @@ -31,7 +31,7 @@ def render_section( # noqa: PLR0913 name: str, - entries_html: str | Markup, + entries: Markup, *, n_items: int, doc_url: str | None = None, @@ -50,9 +50,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). ``Markup`` passes - through; ``str`` is escaped. + 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 @@ -74,6 +75,8 @@ def render_section( # noqa: PLR0913 -------- :: + from markupsafe import Markup + from anndata._repr import ( CSS_DTYPE_NDARRAY, FormattedEntry, @@ -82,19 +85,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", @@ -105,11 +110,7 @@ def render_section( # noqa: PLR0913 if count_str is None: count_str = f"({n_items} items)" - # Callers that produced HTML as plain ``str`` (legacy path) are implicitly - # trusted; ``Markup``-typed input passes through unchanged. - entries = entries_html if isinstance(entries_html, Markup) else Markup(entries_html) - - rendered = ( + return Markup( get_env() .get_template("section.j2") .render( @@ -123,7 +124,6 @@ def render_section( # noqa: PLR0913 entries=entries, ) ) - return Markup(rendered) def render_empty_section( @@ -132,7 +132,7 @@ def render_empty_section( tooltip: str = "", ) -> Markup: """Render an empty section indicator.""" - return render_section(name, "", n_items=0, doc_url=doc_url, tooltip=tooltip) + return render_section(name, Markup(""), n_items=0, doc_url=doc_url, tooltip=tooltip) def render_truncation_indicator(remaining: int) -> Markup: diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index aaf2fbc6a..814477d79 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -488,10 +488,9 @@ def _render_custom_section( break 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), + Markup("\n").join(rows), n_items=n_items, doc_url=getattr(formatter, "doc_url", None), tooltip=getattr(formatter, "tooltip", ""), diff --git a/src/anndata/_repr/sections.py b/src/anndata/_repr/sections.py index ae3f1f725..13c4b3716 100644 --- a/src/anndata/_repr/sections.py +++ b/src/anndata/_repr/sections.py @@ -143,7 +143,7 @@ def _render_dataframe_section( return render_section( section, - "\n".join(rows), + Markup("\n").join(rows), n_items=n_cols, doc_url=doc_url, tooltip=tooltip, @@ -192,7 +192,7 @@ def _render_mapping_section( return render_section( section, - "\n".join(rows), + Markup("\n").join(rows), n_items=n_items, doc_url=doc_url, tooltip=tooltip, @@ -231,7 +231,7 @@ def _render_uns_section( return render_section( "uns", - "\n".join(rows), + Markup("\n").join(rows), n_items=n_items, doc_url=doc_url, tooltip=tooltip, From 615fa9184f932532ff2136597d6b1cb131a2156f Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 13:49:52 -0700 Subject: [PATCH 12/22] refactor(repr): switch to Markup.format() idiom, drop escape_html at call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `Markup(f'...{escape_html(x)}...')` pattern with the idiomatic `Markup('...{}...').format(x)` — MarkupSafe's `.format()` autoescapes non-Markup args, so the manual escape_html wrapping is redundant. Treating the template string as trusted HTML and letting .format() escape user data is less error prone and removes a hand-rolled escape boundary at every call site. Migrated ~27 call sites across: - components.py: row_open, search_box, name_cell, category_list, type_cell - core.py: x_entry (error + type), formatted_entry error preview - formatters.py: DataFrame columns preview, color swatches - html.py: header type/filepath/lazy filepath, README icon, disabled fallback - registry.py: unknown-type error and warning previews - sections.py: unknown sections type cell, error entry `escape_html` definition retained in utils.py (remains exported from anndata._repr as a public helper). Usage in src/anndata/_repr/ drops from 27 call sites to 0 (outside utils.py's own internal use). Side improvements along the way: - registry.py: fixed a latent bug where the unknown-type warning preview built a plain str instead of a Markup (`preview_markup = f'...'`). Now a Markup, matching the field's type. - html.py: the `repr_html_enabled=False` fallback now returns a Markup instead of a plain str, matching the function's declared return type. All 614 repr tests pass; pre-commit clean. --- src/anndata/_repr/components.py | 94 ++++++++++++++++----------------- src/anndata/_repr/core.py | 18 +++---- src/anndata/_repr/formatters.py | 26 +++++---- src/anndata/_repr/html.py | 31 +++++------ src/anndata/_repr/registry.py | 16 +++--- src/anndata/_repr/sections.py | 17 +++--- 6 files changed, 98 insertions(+), 104 deletions(-) diff --git a/src/anndata/_repr/components.py b/src/anndata/_repr/components.py index cc35e03f0..13fdae759 100644 --- a/src/anndata/_repr/components.py +++ b/src/anndata/_repr/components.py @@ -26,7 +26,7 @@ STYLE_HIDDEN, ) from .environment import get_env -from .utils import escape_html, sanitize_css_color +from .utils import sanitize_css_color @cache @@ -77,16 +77,13 @@ def render_entry_row_open( classes.append("error") css_class = " ".join(classes) - escaped_key = escape_html(key) - escaped_dtype = escape_html(dtype) - if has_expandable_content: return Markup( - f'
' - f'' - ) - return Markup( - f'
' + '
' + '' + ).format(css_class, key, dtype) + return Markup('
').format( + css_class, key, dtype ) @@ -126,23 +123,21 @@ def render_search_box(container_id: str = "") -> Markup: ------- ``Markup`` HTML for the search box. """ - search_id = escape_html( - f"{container_id}-search" if container_id else "anndata-search" - ) + search_id = f"{container_id}-search" if container_id else "anndata-search" return Markup( - f'' - f'' - f'' - f'' - f'' - f"" - f"" - f'' - ) + '' + '' + '' + '' + '' + "" + "" + '' + ).format(style=STYLE_HIDDEN, sid=search_id) def render_copy_button(text: str, tooltip: str = "Copy") -> Markup: @@ -346,15 +341,14 @@ def render_name_cell(name: str) -> Markup: ------- ``Markup`` HTML for the cell span. """ - escaped_name = escape_html(name) return Markup( - f'' - f'' - f'{escaped_name}' - f"{render_copy_button(name, 'Copy name')}" - f"" - f"" - ) + '' + '' + '{name}' + "{copy_btn}" + "" + "" + ).format(name=name, copy_btn=render_copy_button(name, "Copy name")) def render_category_list( @@ -382,32 +376,37 @@ def render_category_list( ------- HTML string for the category list """ - parts = [''] + parts: list[Markup] = [Markup('')] for i, cat in enumerate(categories[:max_cats]): if i > 0: - parts.append(', ') - cat_name = escape_html(str(cat)) + parts.append(Markup(', ')) color = colors[i] if colors and i < len(colors) else None - parts.append('') + parts.append(Markup('')) if color: # Sanitize color to prevent CSS injection safe_color = sanitize_css_color(str(color)) if safe_color: parts.append( - f'' + Markup( + '' + ).format(safe_color) ) # Skip color dot if color is invalid/unsafe - parts.append(f"{cat_name}") - parts.append("") + parts.append(Markup("{}").format(str(cat))) + parts.append(Markup("")) # Calculate total hidden: from max_cats truncation + lazy truncation 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 Markup("".join(parts)) + parts.append( + Markup('...+{}').format( + CSS_TEXT_MUTED, total_hidden + ) + ) + parts.append(Markup("")) + return Markup("").join(parts) @dataclass @@ -518,15 +517,12 @@ def render_entry_type_cell(config: TypeCellConfig) -> Markup: parts.append(type_markup) elif tooltip: parts.append( - Markup( - f'' - f"{escape_html(type_name)}" + Markup('{}').format( + css_class, tooltip, type_name ) ) else: - parts.append( - Markup(f'{escape_html(type_name)}') - ) + parts.append(Markup('{}').format(css_class, type_name)) parts.append( render_warning_icon(warnings or [], is_not_serializable=is_not_serializable) diff --git a/src/anndata/_repr/core.py b/src/anndata/_repr/core.py index f15c5cb09..54e1b66b7 100644 --- a/src/anndata/_repr/core.py +++ b/src/anndata/_repr/core.py @@ -23,7 +23,7 @@ ) from .environment import get_env 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 @@ -174,8 +174,8 @@ def render_x_entry(obj: object, context: FormatterContext) -> Markup: except Exception as e: # noqa: BLE001 error_msg = f"error: {type(e).__name__}" parts.append( - Markup( - f'({escape_html(error_msg)})' + Markup('({})').format( + CSS_TEXT_MUTED, error_msg ) ) parts.append(Markup("
")) @@ -187,15 +187,15 @@ def render_x_entry(obj: object, context: FormatterContext) -> Markup: try: output = formatter_registry.format_value(X, context) parts.append( - Markup( - f'{escape_html(output.type_name)}' + Markup('{}').format( + output.css_class, output.type_name ) ) except Exception as e: # noqa: BLE001 error_msg = f"error formatting: {type(e).__name__}" parts.append( - Markup( - f'({escape_html(error_msg)})' + Markup('({})').format( + CSS_TEXT_MUTED, error_msg ) ) @@ -308,8 +308,8 @@ def render_formatted_entry( preview_markup = output.preview_markup preview_text = output.preview if output.error and not preview_markup: - preview_markup = Markup( - f'{escape_html(output.error)}' + preview_markup = Markup('{}').format( + CSS_TEXT_ERROR, output.error ) if preview_note and preview_text: diff --git a/src/anndata/_repr/formatters.py b/src/anndata/_repr/formatters.py index 9adeae540..4be9432e1 100644 --- a/src/anndata/_repr/formatters.py +++ b/src/anndata/_repr/formatters.py @@ -57,7 +57,6 @@ from .utils import ( check_color_category_mismatch, check_invalid_colors, - escape_html, format_invalid_colors_warning, format_number, get_categories_for_display, @@ -398,8 +397,9 @@ def format(self, obj: pd.DataFrame, context: FormatterContext) -> FormattedOutpu # Uses anndata-columns class for CSS truncation and JS wrap button 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_markup = Markup(f'[{col_str}]') + preview_markup = Markup('[{}]').format( + Markup(", ").join(str(c) for c in cols) + ) # Check if expandable _repr_html_ is enabled expand_dataframes = get_setting("repr_html_dataframe_expand", default=False) @@ -1037,30 +1037,34 @@ 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)) if safe_color: swatches.append( - f'' + Markup( + '' + ).format(CSS_COLORS_SWATCH, safe_color, str(color)) ) else: # Invalid/unsafe color - show as text only, no style invalid_count += 1 swatches.append( - f'?""" + Markup( + '?' + ).format(CSS_COLORS_SWATCH, CSS_COLORS_SWATCH_INVALID, str(color)) ) if n_colors > COLOR_PREVIEW_LIMIT: swatches.append( - f'+{n_colors - COLOR_PREVIEW_LIMIT}' + Markup('+{}').format( + CSS_TEXT_MUTED, n_colors - COLOR_PREVIEW_LIMIT + ) ) - preview_markup = Markup( - f'{"".join(swatches)}' + preview_markup = Markup('{}').format( + CSS_COLORS, Markup("").join(swatches) ) # Build warnings list (only for colors within preview limit) diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index 814477d79..063b7cbc2 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -72,7 +72,6 @@ _render_uns_section, ) from .utils import ( - escape_html, format_index_preview, format_memory_size, format_number, @@ -249,7 +248,7 @@ def generate_repr_html( # noqa: PLR0913 """ # Check if HTML repr is enabled if not get_setting("repr_html_enabled", default=True): - return f"
{escape_html(repr(adata))}
" + return Markup("
{}
").format(repr(adata)) # Create formatter context (resolves settings) context = _create_formatter_context( @@ -507,7 +506,7 @@ def _render_header( type_name = type(adata).__name__ parts.append( - Markup(f'{escape_html(type_name)}') + Markup('{}').format(type_name) ) shape_str = f"{format_number(adata.n_obs)} obs × {format_number(adata.n_vars)} vars" @@ -524,8 +523,8 @@ def _render_header( parts.append(render_badge(f"{format_str} ({status})", CSS_BADGE_BACKED)) if filename: parts.append( - Markup( - f'{escape_html(filename)}' + Markup('{}').format( + filename ) ) @@ -544,10 +543,8 @@ def _render_header( ) parts.append( Markup( - f'' - f"{escape_html(lazy_filename)}" - f"" - ) + '{}' + ).format(path_style, lazy_filename) ) if type_name != "AnnData": @@ -567,21 +564,19 @@ def _render_header( ) readme_content += truncation_note - escaped_readme = escape_html(readme_content) tooltip_text = readme_content[:TOOLTIP_TRUNCATE_LENGTH] if len(readme_content) > TOOLTIP_TRUNCATE_LENGTH: tooltip_text += "..." - escaped_tooltip = escape_html(tooltip_text) parts.append( Markup( - f'' - f"ⓘ" - f"" - ) + '' + "ⓘ" + "" + ).format(readme_content, tooltip_text) ) if show_search: diff --git a/src/anndata/_repr/registry.py b/src/anndata/_repr/registry.py index b81258583..cb76f8a31 100644 --- a/src/anndata/_repr/registry.py +++ b/src/anndata/_repr/registry.py @@ -73,7 +73,7 @@ def format(self, obj, context): DEFAULT_MAX_STRING_LENGTH, DEFAULT_UNIQUE_LIMIT, ) -from .utils import escape_html, validate_key +from .utils import validate_key @dataclass @@ -621,12 +621,13 @@ def format( # noqa: PLR0912, PLR0915 if all_errors: try: - error_text = escape_html(", ".join(all_errors)) - preview_markup = Markup( - f'{error_text}' + preview_markup = Markup('{}').format( + CSS_TEXT_ERROR, ", ".join(all_errors) ) except Exception: # noqa: BLE001 - preview_markup = Markup(f'Error') + preview_markup = Markup('Error').format( + CSS_TEXT_ERROR + ) else: # No errors - check if unknown type warning needed try: @@ -638,9 +639,8 @@ def format( # noqa: PLR0912, PLR0915 )) if not is_extension: warnings.append(f"Unknown type: {full_name}") - warning_text = escape_html(f"Unknown type: {full_name}") - preview_markup = ( - f'{warning_text}' + preview_markup = Markup('{}').format( + CSS_TEXT_WARNING, f"Unknown type: {full_name}" ) except Exception: # noqa: BLE001 pass diff --git a/src/anndata/_repr/sections.py b/src/anndata/_repr/sections.py index 13c4b3716..04df1f315 100644 --- a/src/anndata/_repr/sections.py +++ b/src/anndata/_repr/sections.py @@ -59,7 +59,6 @@ formatter_registry, ) from .utils import ( - escape_html, format_index_preview, format_number, ) @@ -351,8 +350,9 @@ def _render_unknown_sections(unknown_sections: list[tuple[str, str]]) -> Markup: parts.append(render_name_cell(attr_name)) parts.append('') parts.append( - f'' - f"{escape_html(type_desc)}" + Markup('{}').format( + CSS_DTYPE_UNKNOWN, type_desc + ) ) parts.append("") parts.append('') @@ -370,20 +370,19 @@ def _render_error_entry(section: str, error: str) -> Markup: error_str = str(error) if len(error_str) > ERROR_TRUNCATE_LENGTH: error_str = error_str[:ERROR_TRUNCATE_LENGTH] + "..." - error_escaped = escape_html(error_str) - return Markup(f""" -
+ return Markup(""" +
- {escape_html(section)} + {section} (error)
- Failed to render: {error_escaped} + Failed to render: {error_str}
-""") +""").format(section=section, error_str=error_str) # ----------------------------------------------------------------------------- From d04a287b53526b3dbe53e72df367027248ac78f5 Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 13:56:43 -0700 Subject: [PATCH 13/22] refactor(repr): unify cell rendering in _macros.j2 (C1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves all entry-row cell markup into `_macros.j2` so the Jinja templates are the single source of truth. Python helpers in `components.py` and `core.py` become thin wrappers that call the macros via `.module`, so the public signatures and return types (Markup) don't change. Macros added to `_macros.j2`: - `name_cell(entry_key)` - `type_cell(type_name, css_class, type_markup=None, tooltip='', all_warnings=None, is_not_serializable=false, has_columns_list=false, has_categories_list=false, append_type_markup=false)` — takes every input as an explicit parameter instead of reading outer template scope - `preview_cell(preview_markup=None, preview_text=None)` - `row_open(key, dtype, css_class, has_expandable_content=false)` — caller builds the space-joined class string - `nested_content(html_content)` - `truncation_indicator(remaining)` - `category_list(items, total_hidden=0)` — `items` is a sequence of `(label, safe_color_or_none)` pairs so Python keeps ownership of `sanitize_css_color`; total_hidden is pre-computed by the wrapper `entry.j2` now just imports the macros and dispatches the row open / three cells / close — no more local macro definitions. HTML-tag count in `components.py` drops from 29 to 10 (remaining tags are inside `render_search_box`, which is out of scope for C1, plus docstring examples). Tests: 614 passed, 1 skipped; visual inspection unchanged. --- src/anndata/_repr/components.py | 137 ++++++------------------- src/anndata/_repr/core.py | 10 +- src/anndata/_repr/templates/_macros.j2 | 88 +++++++++++++++- src/anndata/_repr/templates/entry.j2 | 42 ++------ 4 files changed, 130 insertions(+), 147 deletions(-) diff --git a/src/anndata/_repr/components.py b/src/anndata/_repr/components.py index 13fdae759..93614ec4c 100644 --- a/src/anndata/_repr/components.py +++ b/src/anndata/_repr/components.py @@ -20,11 +20,7 @@ from markupsafe import Markup -from .._repr_constants import ( - CSS_ENTRY, - CSS_TEXT_MUTED, - STYLE_HIDDEN, -) +from .._repr_constants import CSS_ENTRY, STYLE_HIDDEN from .environment import get_env from .utils import sanitize_css_color @@ -76,14 +72,8 @@ def render_entry_row_open( if is_error: classes.append("error") css_class = " ".join(classes) - - if has_expandable_content: - return Markup( - '
' - '' - ).format(css_class, key, dtype) - return Markup('
').format( - css_class, key, dtype + return Markup( + _macros().row_open(key, dtype, css_class, has_expandable_content) ) @@ -228,12 +218,7 @@ def render_nested_content(html_content: str | Markup) -> Markup: ``Markup`` HTML closing the summary and wrapping nested content. """ body = html_content if isinstance(html_content, Markup) else Markup(html_content) - return Markup( - f"
" - f'
' - f'
{body}
' - f"
" - ) + return Markup(_macros().nested_content(body)) def render_badge( @@ -341,14 +326,7 @@ def render_name_cell(name: str) -> Markup: ------- ``Markup`` HTML for the cell span. """ - return Markup( - '' - '' - '{name}' - "{copy_btn}" - "" - "" - ).format(name=name, copy_btn=render_copy_button(name, "Copy name")) + return Markup(_macros().name_cell(name)) def render_category_list( @@ -376,37 +354,16 @@ def render_category_list( ------- HTML string for the category list """ - parts: list[Markup] = [Markup('')] - for i, cat in enumerate(categories[:max_cats]): - if i > 0: - parts.append(Markup(', ')) - color = colors[i] if colors and i < len(colors) else None - parts.append(Markup('')) - if color: - # Sanitize color to prevent CSS injection - safe_color = sanitize_css_color(str(color)) - if safe_color: - parts.append( - Markup( - '' - ).format(safe_color) - ) - # Skip color dot if color is invalid/unsafe - parts.append(Markup("{}").format(str(cat))) - parts.append(Markup("")) - - # 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( - Markup('...+{}').format( - CSS_TEXT_MUTED, total_hidden - ) - ) - parts.append(Markup("")) - return Markup("").join(parts) + return Markup(_macros().category_list(items, total_hidden)) @dataclass @@ -497,48 +454,19 @@ def render_entry_type_cell(config: TypeCellConfig) -> Markup: ------- ``Markup`` HTML for the complete type cell. """ - type_name = config.type_name - css_class = config.css_class - type_markup = config.type_markup - 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_markup = config.append_type_markup - - parts: list[str | Markup] = [ - Markup( - '' - ) - ] - - if type_markup and not append_type_markup: - parts.append(type_markup) - elif tooltip: - parts.append( - Markup('{}').format( - css_class, tooltip, type_name - ) + return Markup( + _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(Markup('{}').format(css_class, type_name)) - - parts.append( - render_warning_icon(warnings or [], is_not_serializable=is_not_serializable) ) - if has_columns_list: - parts.append(render_columns_wrap_button()) - if has_categories_list: - parts.append(render_categories_wrap_button()) - - if type_markup and append_type_markup: - parts.append( - Markup(f'{type_markup}') - ) - - parts.append(Markup("")) - return Markup("").join(parts) def render_entry_preview_cell( @@ -561,16 +489,9 @@ def render_entry_preview_cell( ------- ``Markup`` HTML for the preview cell. """ - parts: list[str | Markup] = [ - Markup( - '' + return Markup( + _macros().preview_cell( + preview_markup=preview_markup, + preview_text=preview_text, ) - ] - - if preview_markup: - parts.append(preview_markup) - elif preview_text: - parts.append(render_muted_span(preview_text)) - - parts.append(Markup("")) - return Markup("").join(parts) + ) diff --git a/src/anndata/_repr/core.py b/src/anndata/_repr/core.py index 54e1b66b7..bdd76e493 100644 --- a/src/anndata/_repr/core.py +++ b/src/anndata/_repr/core.py @@ -11,6 +11,7 @@ from __future__ import annotations +from functools import cache from typing import TYPE_CHECKING from markupsafe import Markup @@ -29,6 +30,11 @@ from .registry import FormattedEntry, FormatterContext +@cache +def _macros(): + return get_env().get_template("_macros.j2").module + + def render_section( # noqa: PLR0913 name: str, entries: Markup, @@ -137,9 +143,7 @@ def render_empty_section( def render_truncation_indicator(remaining: int) -> Markup: """Render a truncation indicator.""" - return Markup( - f'
... and {format_number(remaining)} more
' - ) + return Markup(_macros().truncation_indicator(format_number(remaining))) def get_section_tooltip(section: str) -> str: diff --git a/src/anndata/_repr/templates/_macros.j2 b/src/anndata/_repr/templates/_macros.j2 index 8581d7c04..248adcd3c 100644 --- a/src/anndata/_repr/templates/_macros.j2 +++ b/src/anndata/_repr/templates/_macros.j2 @@ -1,4 +1,6 @@ -{# Reusable small HTML components used by entry.j2 and (later) other templates. +{# Reusable small HTML components used by templates and as the single source of + truth for cell-level HTML (mirrored by thin Python wrappers in + _repr/components.py and _repr/core.py). Trust contract (see environment.py): - plain str args are autoescaped by Jinja inside `{{ … }}` @@ -39,3 +41,87 @@ {%- macro wrap_button(css_class) -%} {%- endmacro -%} + +{# Name cell: first column of an entry row. Shows the key with a copy button. #} +{%- macro name_cell(entry_key) -%} +{{ entry_key }}{{ copy_button(entry_key, 'Copy name') }} +{%- endmacro -%} + +{# Type cell: second column of an entry row. Combines type label, warnings, + optional wrap buttons, and optional custom type_markup (either as replacement + or appended below the label when append_type_markup=True). #} +{%- macro type_cell(type_name, css_class, type_markup=None, tooltip='', all_warnings=None, is_not_serializable=false, has_columns_list=false, has_categories_list=false, append_type_markup=false) -%} + +{%- if type_markup and not append_type_markup -%} +{{ type_markup }} +{%- elif tooltip -%} +{{ type_name }} +{%- else -%} +{{ type_name }} +{%- endif -%} +{{ warning_icon(all_warnings or [], is_not_serializable) }} +{%- if has_columns_list %}{{ wrap_button('anndata-columns__wrap') }}{% endif -%} +{%- if has_categories_list %}{{ wrap_button('anndata-categories__wrap') }}{% endif -%} +{%- if type_markup and append_type_markup -%} +{{ type_markup }} +{%- endif -%} + +{%- endmacro -%} + +{# Preview cell: third column of an entry row. Either trusted Markup or muted + plain text. #} +{%- macro preview_cell(preview_markup=None, preview_text=None) -%} + +{%- if preview_markup -%} +{{ preview_markup }} +{%- elif preview_text -%} +{{ muted_span(preview_text) }} +{%- endif -%} + +{%- endmacro -%} + +{# Row open: renders the opening element(s) for an entry row. Caller builds + css_class (space-separated). When has_expandable_content is true, emits +
; otherwise a plain
. #} +{%- macro row_open(key, dtype, css_class, has_expandable_content=false) -%} +{%- if has_expandable_content -%} +
+{%- else -%} +
+{%- endif -%} +{%- endmacro -%} + +{# Nested content: closes the of an expandable entry and wraps the + nested HTML body. Caller is responsible for the closing
. #} +{%- macro nested_content(html_content) -%} +
{{ html_content }}
+{%- endmacro -%} + +{# Truncation indicator: "... and N more" line shown below a truncated section. #} +{%- macro truncation_indicator(remaining) -%} +
... and {{ remaining }} more
+{%- endmacro -%} + +{# Category list: comma-separated category swatches with optional color dots + and a "...+N" truncation tail. + + `items` is a sequence of (label, safe_color_or_none) pairs; the caller + (Python) is responsible for sanitizing colors via sanitize_css_color before + passing them in. `total_hidden` is the total number of hidden categories + (from both max_cats truncation and lazy n_hidden). #} +{%- macro category_list(items, total_hidden=0) -%} + +{%- for label, safe_color in items -%} +{%- if not loop.first %}, {% endif -%} + +{%- if safe_color -%} + +{%- endif -%} +{{ label }} + +{%- endfor -%} +{%- if total_hidden > 0 -%} +...+{{ total_hidden }} +{%- endif -%} + +{%- endmacro -%} diff --git a/src/anndata/_repr/templates/entry.j2 b/src/anndata/_repr/templates/entry.j2 index b760692ff..d6ffda5e6 100644 --- a/src/anndata/_repr/templates/entry.j2 +++ b/src/anndata/_repr/templates/entry.j2 @@ -6,46 +6,18 @@ - `type_markup`, `preview_markup`, `expanded_markup` arrive as Markup (produced by Python formatters) and pass through verbatim. #} -{% from '_macros.j2' import copy_button, muted_span, warning_icon, wrap_button %} +{% from '_macros.j2' import name_cell, nested_content, preview_cell, row_open, type_cell %} {%- set entry_classes = 'anndata-entry' -%} {%- if all_warnings %}{%- set entry_classes = entry_classes ~ ' warning' -%}{%- endif -%} {%- if has_error %}{%- set entry_classes = entry_classes ~ ' error' -%}{%- endif -%} -{%- macro name_cell(entry_key) -%} -{{ entry_key }}{{ copy_button(entry_key, 'Copy name') }} -{%- endmacro -%} - -{%- macro type_cell() -%} - -{%- if type_markup and not append_type_markup -%} -{{ type_markup }} -{%- elif tooltip -%} -{{ type_name }} -{%- else -%} -{{ type_name }} -{%- endif -%} -{{ warning_icon(all_warnings, is_not_serializable) }} -{%- if has_columns_list %}{{ wrap_button('anndata-columns__wrap') }}{% endif -%} -{%- if has_categories_list %}{{ wrap_button('anndata-categories__wrap') }}{% endif -%} -{%- if type_markup and append_type_markup -%} -{{ type_markup }} -{%- endif -%} - -{%- endmacro -%} - -{%- macro preview_cell() -%} - -{%- if preview_markup -%} -{{ preview_markup }} -{%- elif preview_text -%} -{{ muted_span(preview_text) }} -{%- endif -%} - -{%- endmacro -%} - +{{ row_open(entry_key, type_name, entry_classes, has_expandable_content) -}} +{{ name_cell(entry_key) -}} +{{ type_cell(type_name, css_class, type_markup=type_markup, tooltip=tooltip, all_warnings=all_warnings, is_not_serializable=is_not_serializable, has_columns_list=has_columns_list, has_categories_list=has_categories_list, append_type_markup=append_type_markup) -}} +{{ preview_cell(preview_markup=preview_markup, preview_text=preview_text) -}} {%- if has_expandable_content -%} -
{{ name_cell(entry_key) }}{{ type_cell() }}{{ preview_cell() }}
{{ expanded_markup }}
+{{ nested_content(expanded_markup) }}
{%- else -%} -
{{ name_cell(entry_key) }}{{ type_cell() }}{{ preview_cell() }}
+
{%- endif -%} From ce1abbfb799fd7498ca45b378ca7c1a39d1cfb9b Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 13:57:23 -0700 Subject: [PATCH 14/22] refactor(repr): templatize orchestrator (header / footer / index / hints) (C3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the remaining orchestrator-layer HTML in `_repr/html.py` into Jinja templates. Python keeps the structural logic (badge construction, README truncation, backing-info lookup, memory formatting); templates now own all frame HTML. New templates in `_repr/templates/`: - `header.j2` — `
` with type/shape, an ordered list of pre-rendered `extras` (badges + filepath spans + README icon), and an optional search box. - `footer.j2` — `
") - return Markup("\n".join(parts)) + n = len(unknown_sections) + return render_section( + "other", + Markup("\n").join(rows), + n_items=n, + section_id="unknown", + count_str=f"({n})", + extra_classes="anndata-sec-unknown", + ) def _render_error_entry(section: str, error: str) -> Markup: @@ -370,19 +367,11 @@ def _render_error_entry(section: str, error: str) -> Markup: error_str = str(error) if len(error_str) > ERROR_TRUNCATE_LENGTH: error_str = error_str[:ERROR_TRUNCATE_LENGTH] + "..." - return Markup(""" -
- - {section} - (error) - -
-
- Failed to render: {error_str} -
-
-
-""").format(section=section, error_str=error_str) + return Markup( + get_env() + .get_template("error_entry.j2") + .render(section=section, error=error_str) + ) # ----------------------------------------------------------------------------- @@ -470,35 +459,43 @@ def _render_raw_section( meta_parts = _get_raw_meta_parts(raw) meta_text = ", ".join(meta_parts) if meta_parts else "" - # Single row container (like a minimal section with just one entry) - parts = ['
'] - parts.append('
') - # Single row with raw info type_str = f"{format_number(n_obs)} obs × {format_number(n_vars)} var" - parts.append(render_entry_row_open("raw", "Raw", has_expandable_content=can_expand)) - parts.append(render_name_cell("raw")) - type_cell_config = TypeCellConfig( - type_name=type_str, - css_class=CSS_DTYPE_ANNDATA, - ) - parts.append(render_entry_type_cell(type_cell_config)) - parts.append(render_entry_preview_cell(preview_text=meta_text)) - - # Nested content (entry is
/ when can_expand) + row_parts: list[Markup] = [ + render_entry_row_open("raw", "Raw", has_expandable_content=can_expand), + render_name_cell("raw"), + render_entry_type_cell( + TypeCellConfig(type_name=type_str, css_class=CSS_DTYPE_ANNDATA) + ), + render_entry_preview_cell(preview_text=meta_text), + ] if can_expand: nested_html = _generate_raw_repr_html(raw, context.child("raw")) - # Wrap in anndata-entry__nested-anndata for specific styling - wrapped_html = f'
{nested_html}
' - parts.append(render_nested_content(wrapped_html)) - parts.append("
") # close expandable entry + wrapped_html = Markup( + '
{}
' + ).format(nested_html) + row_parts.append(render_nested_content(wrapped_html)) + row_parts.append(Markup("
")) else: - parts.append("
") # close plain entry + row_parts.append(Markup("")) - parts.append("") # close entries grid - parts.append("") # close section + entry_markup = Markup("\n").join(row_parts) + return Markup( + get_env() + .get_template("raw_section.j2") + .render(entry_markup=entry_markup) + ) - return Markup("\n".join(parts)) + +def _safe_index_preview(raw: object, attr: str) -> Markup | None: + """Read ``raw.`` and return its format_index_preview Markup, or None.""" + try: + names = getattr(raw, attr, None) + except Exception: # noqa: BLE001 + return None + if names is None: + return None + return format_index_preview(names) def _generate_raw_repr_html( @@ -517,75 +514,39 @@ def _generate_raw_repr_html( context FormatterContext with depth, max_depth, fold_threshold, max_items """ - # Safely get dimensions n_obs = _safe_get_attr(raw, "n_obs", "?") n_vars = _safe_get_attr(raw, "n_vars", "?") - - 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("
") - # Index preview (obs_names and var_names) - parts.append('
') - try: - obs_names = getattr(raw, "obs_names", None) - if obs_names is not None: - parts.append( - f"
obs_names: {format_index_preview(obs_names)}
" - ) - else: - parts.append( - "
obs_names: not available
" - ) - except Exception: # noqa: BLE001 - parts.append("
obs_names: not available
") - try: - var_names = getattr(raw, "var_names", None) - if var_names is not None: - parts.append( - f"
var_names: {format_index_preview(var_names)}
" - ) - else: - parts.append( - "
var_names: not available
" - ) - except Exception: # noqa: BLE001 - parts.append("
var_names: not available
") - parts.append("
") - - # X section - show matrix info (with error handling) + sections: list[Markup] = [] try: if hasattr(raw, "X") and raw.X is not None: - parts.append(render_x_entry(raw, context)) + sections.append(render_x_entry(raw, context)) except Exception as e: # noqa: BLE001 - parts.append(_render_error_entry("X", str(e))) + sections.append(_render_error_entry("X", str(e))) - # var section (like AnnData's var) try: if hasattr(raw, "var") and raw.var is not None and len(raw.var.columns) > 0: - # Raw doesn't have the same structure as AnnData, so clear adata_ref var_context = replace(context, adata_ref=None, section="var") - parts.append(_render_dataframe_section("var", raw.var, var_context)) + sections.append(_render_dataframe_section("var", raw.var, var_context)) except Exception as e: # noqa: BLE001 - parts.append(_render_error_entry("var", str(e))) + sections.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 = replace(context, adata_ref=None, section="varm") - parts.append(_render_mapping_section("varm", raw.varm, varm_context)) + sections.append(_render_mapping_section("varm", raw.varm, varm_context)) except Exception as e: # noqa: BLE001 - parts.append(_render_error_entry("varm", str(e))) - - parts.append("
") - - return Markup("\n".join(parts)) + sections.append(_render_error_entry("varm", str(e))) + + return Markup( + get_env() + .get_template("raw_repr.j2") + .render( + container_id=f"raw-repr-{id(raw)}", + shape_str=shape_str, + obs_preview=_safe_index_preview(raw, "obs_names"), + var_preview=_safe_index_preview(raw, "var_names"), + sections=sections, + ) + ) diff --git a/src/anndata/_repr/templates/error_entry.j2 b/src/anndata/_repr/templates/error_entry.j2 new file mode 100644 index 000000000..3eca9d8a6 --- /dev/null +++ b/src/anndata/_repr/templates/error_entry.j2 @@ -0,0 +1,22 @@ +{# Error-section block used when a whole section failed to render. + + Inputs: + - section (str): section name (e.g. "X", "var"). Autoescaped. + - error (str): truncated error message (caller handles truncation). + Autoescaped. + + Renders a
frame + with a single red error entry. Not a normal section: no entries grid, + no doc_url, no tooltip. +#} +
+ + {{ section }} + (error) + +
+
+ Failed to render: {{ error }} +
+
+
diff --git a/src/anndata/_repr/templates/raw_repr.j2 b/src/anndata/_repr/templates/raw_repr.j2 new file mode 100644 index 000000000..0b20760d1 --- /dev/null +++ b/src/anndata/_repr/templates/raw_repr.j2 @@ -0,0 +1,35 @@ +{# Inner HTML body for a Raw object (rendered when a Raw row is expanded). + + Inputs: + - container_id (str): unique id for the outer
. Autoescaped. + - shape_str (str): "N obs × M var". Autoescaped. + - obs_preview (Markup | None): Markup from format_index_preview, or None + when obs_names could not be read (renders an "not available" + placeholder). + - var_preview (Markup | None): same contract for var_names. + - sections (list[Markup]): pre-rendered X / var / varm sections (or error + placeholders), emitted in order. Trusted HTML. + + Role: produce the `
` body used as the + nested content of the Raw row. This intentionally mirrors anndata.j2's + shape but drops sections a Raw doesn't have (obs, obsm, layers, ...). +#} +
+
+ Raw + {{ shape_str }} +
+
+
obs_names: + {%- if obs_preview is not none %} {{ obs_preview }} + {%- else %} not available{% endif %} +
+
var_names: + {%- if var_preview is not none %} {{ var_preview }} + {%- else %} not available{% endif %} +
+
+ {%- for section in sections %} + {{ section }} + {%- endfor %} +
diff --git a/src/anndata/_repr/templates/raw_section.j2 b/src/anndata/_repr/templates/raw_section.j2 new file mode 100644 index 000000000..3e56adf2b --- /dev/null +++ b/src/anndata/_repr/templates/raw_section.j2 @@ -0,0 +1,16 @@ +{# Raw-section frame: a minimal single-row "section" for adata.raw. + + Inputs: + - entry_markup (Markup): the pre-rendered Raw row (from row_open + name/type/ + preview cells + optional nested_content + closing
|). + Trusted HTML, emitted verbatim. + + The outer container uses ``anndata-sec anndata-sec-raw`` (not the normal + ``anndata-section``
frame) because Raw shows a single row rather + than an expandable group of entries. +#} +
+
+ {{ entry_markup }} +
+
diff --git a/src/anndata/_repr/templates/section.j2 b/src/anndata/_repr/templates/section.j2 index 5fe805a2a..43f3dec4e 100644 --- a/src/anndata/_repr/templates/section.j2 +++ b/src/anndata/_repr/templates/section.j2 @@ -4,6 +4,8 @@ All user-facing strings (name, count_str, tooltip) are autoescaped; doc_url is autoescaped inside the href attribute. `entries` is expected to be Markup (pre-rendered HTML). + `extra_classes` is an optional str of additional CSS classes + (e.g. "anndata-sec-unknown") appended to the root
. #} {%- macro section_header(name, count_str, doc_url, tooltip) -%} @@ -14,15 +16,16 @@ {%- endif %} {%- endmacro -%} +{%- set root_class = 'anndata-section' ~ (' ' ~ extra_classes if extra_classes else '') -%} {%- if n_items == 0 -%} -
+
{{ section_header(name, '(empty)', doc_url, tooltip) }}
No entries
{%- else -%} -
+
{{ section_header(name, count_str, doc_url, tooltip) }}
From ff0f7d133a30d1caa5498e07818f23d855bcfe8d Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 14:08:40 -0700 Subject: [PATCH 16/22] test(repr): add Markup autoescape contract tests + update release note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New class ``TestMarkupAutoescapeContract`` in tests/repr/test_repr_robustness.py (4 tests): - Three negative tests: an extension-style TypeFormatter returns ``FormattedOutput(preview_markup=>)`` / same for type_markup and expanded_markup. All three verify the script tag comes out as ``<script>...</script>`` — Jinja autoescape catches the contract violation, so a future regression that loosens the type back to ``str`` (or a template change that disables autoescape) can't silently land. - One positive control: a correctly-wrapped ``Markup(html)`` value flows through verbatim. Release note for PR #2236 updated to mention the Jinja/MarkupSafe dependency and the ``*_markup`` field naming. --- docs/release-notes/2236.feat.md | 2 +- tests/repr/test_repr_robustness.py | 103 +++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) 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/tests/repr/test_repr_robustness.py b/tests/repr/test_repr_robustness.py index 209c93846..a72ae2ba6 100644 --- a/tests/repr/test_repr_robustness.py +++ b/tests/repr/test_repr_robustness.py @@ -1509,3 +1509,106 @@ def test_unicode_in_readme(self, validate_html) -> None: v.assert_html_well_formed() v.assert_element_exists(".anndata-readme__icon") + + +class TestMarkupAutoescapeContract: + """Verify the Markup trust boundary is enforced by Jinja autoescape. + + FormattedOutput.{type_markup, preview_markup, expanded_markup} are typed + ``Markup | None``. An ecosystem extension that violates the contract by + passing a bare ``str`` with HTML must get its input autoescaped — + the trust boundary is a type-level assertion, not a convention. + + These tests deliberately violate the contract to prove the safety net. + """ + + ATTACK = '' + 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) From a69b06734edbfea31fd2b4047a8f503e0c232ade Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 14:13:35 -0700 Subject: [PATCH 17/22] refactor(repr): final nits from the review pass (batch E) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ``section.j2`` no longer hardcodes ``'(empty)'`` when ``n_items == 0``; ``render_section`` sets the default ``count_str`` based on n_items so callers that pass an explicit ``count_str`` (e.g. ``"(5 columns)"``) are respected even for empty sections. - New ``filepath_span(path, style='')`` macro in ``_macros.j2`` with a Python wrapper ``render_filepath_span`` in components.py. ``html.py`` uses it for the backed/lazy filepath spans — drops the last two ```` f-strings in the orchestrator (html.py HTML-tag count goes from 5 to 2; the residual 2 are false-positives in a docstring and the ``
`` repr_html_enabled=False fallback).
- ``format_index_preview`` now returns ``Markup`` (was ``str`` that
  happened to contain escaped HTML — a latent double-escape hazard).
  Items are joined with ``Markup(", ").join(...)`` for autoescape. The
  ``Markup(format_index_preview(...))`` re-wraps in ``_render_index_preview``
  are dropped.
- Orchestrator templates (``header.j2``, ``footer.j2``,
  ``index_preview.j2``) reindented to the 2-space convention used by
  every other block-level template; in-line/macro files in ``_macros.j2``
  and ``entry.j2`` stay compact on purpose.
---
 src/anndata/_repr/components.py              | 21 +++++++++++++---
 src/anndata/_repr/core.py                    |  2 +-
 src/anndata/_repr/html.py                    | 23 ++++++------------
 src/anndata/_repr/templates/_macros.j2       | 10 ++++++++
 src/anndata/_repr/templates/footer.j2        |  8 +++----
 src/anndata/_repr/templates/header.j2        | 18 +++++++-------
 src/anndata/_repr/templates/index_preview.j2 |  4 ++--
 src/anndata/_repr/templates/section.j2       |  4 +++-
 src/anndata/_repr/utils.py                   | 25 +++++++++++---------
 9 files changed, 68 insertions(+), 47 deletions(-)

diff --git a/src/anndata/_repr/components.py b/src/anndata/_repr/components.py
index 93614ec4c..8a945c52c 100644
--- a/src/anndata/_repr/components.py
+++ b/src/anndata/_repr/components.py
@@ -72,9 +72,7 @@ def render_entry_row_open(
     if is_error:
         classes.append("error")
     css_class = " ".join(classes)
-    return Markup(
-        _macros().row_open(key, dtype, css_class, has_expandable_content)
-    )
+    return Markup(_macros().row_open(key, dtype, css_class, has_expandable_content))
 
 
 def render_warning_icon(
@@ -199,6 +197,23 @@ def render_muted_span(text: str) -> Markup:
     return Markup(_macros().muted_span(text))
 
 
+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(_macros().filepath_span(path, style))
+
+
 def render_nested_content(html_content: str | Markup) -> Markup:
     """Render nested/expanded content inside an expandable entry.
 
diff --git a/src/anndata/_repr/core.py b/src/anndata/_repr/core.py
index b077c4993..88500684b 100644
--- a/src/anndata/_repr/core.py
+++ b/src/anndata/_repr/core.py
@@ -115,7 +115,7 @@ def render_section(  # noqa: PLR0913
     if section_id is None:
         section_id = name
     if count_str is None:
-        count_str = f"({n_items} items)"
+        count_str = "(empty)" if n_items == 0 else f"({n_items} items)"
 
     return Markup(
         get_env()
diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py
index cfe129974..7edb63d44 100644
--- a/src/anndata/_repr/html.py
+++ b/src/anndata/_repr/html.py
@@ -46,6 +46,7 @@
 )
 from .components import (
     render_badge,
+    render_filepath_span,
     render_search_box,
 )
 from .core import (
@@ -546,11 +547,7 @@ def _render_header(
         status = "Open" if backing.get("is_open") else "Closed"
         extras.append(render_badge(f"{format_str} ({status})", CSS_BADGE_BACKED))
         if filename:
-            extras.append(
-                Markup('{}').format(
-                    filename
-                )
-            )
+            extras.append(render_filepath_span(filename))
 
     if is_lazy_adata(adata):
         lazy_info = get_lazy_backing_info(adata)
@@ -565,11 +562,7 @@ def _render_header(
                 "font-family:ui-monospace,monospace;font-size:11px;"
                 "color:var(--anndata-text-secondary, #6c757d);"
             )
-            extras.append(
-                Markup(
-                    '{}'
-                ).format(path_style, lazy_filename)
-            )
+            extras.append(render_filepath_span(lazy_filename, path_style))
 
     if type_name != "AnnData":
         extras.append(render_badge(type_name, CSS_BADGE_EXTENSION))
@@ -609,15 +602,13 @@ def _render_footer(adata: AnnData) -> Markup:
 
 def _render_index_preview(adata: AnnData) -> Markup:
     """Render preview of obs_names and var_names."""
-    # format_index_preview returns a plain str that already contains escaped HTML
-    # (e.g. "empty" and escape_html'd items), so wrap as Markup to avoid
-    # double-escaping when it passes through the template.
-    obs_preview = Markup(format_index_preview(adata.obs_names, DEFAULT_PREVIEW_ITEMS))
-    var_preview = Markup(format_index_preview(adata.var_names, DEFAULT_PREVIEW_ITEMS))
     return Markup(
         get_env()
         .get_template("index_preview.j2")
-        .render(obs_preview=obs_preview, var_preview=var_preview)
+        .render(
+            obs_preview=format_index_preview(adata.obs_names, DEFAULT_PREVIEW_ITEMS),
+            var_preview=format_index_preview(adata.var_names, DEFAULT_PREVIEW_ITEMS),
+        )
     )
 
 
diff --git a/src/anndata/_repr/templates/_macros.j2 b/src/anndata/_repr/templates/_macros.j2
index 248adcd3c..d57eb653a 100644
--- a/src/anndata/_repr/templates/_macros.j2
+++ b/src/anndata/_repr/templates/_macros.j2
@@ -42,6 +42,16 @@
 
 {%- endmacro -%}
 
+{# File-path span: {path} with
+   optional inline style. Used in the header next to backed/lazy badges. #}
+{%- macro filepath_span(path, style='') -%}
+{%- if style -%}
+{{ path }}
+{%- else -%}
+{{ path }}
+{%- endif -%}
+{%- endmacro -%}
+
 {# Name cell: first column of an entry row. Shows the key with a copy button. #}
 {%- macro name_cell(entry_key) -%}
 {{ entry_key }}{{ copy_button(entry_key, 'Copy name') }}
diff --git a/src/anndata/_repr/templates/footer.j2 b/src/anndata/_repr/templates/footer.j2
index 131aaaada..a9664cb20 100644
--- a/src/anndata/_repr/templates/footer.j2
+++ b/src/anndata/_repr/templates/footer.j2
@@ -8,8 +8,8 @@
    Role: produce `
")) else: @@ -481,9 +476,7 @@ def _render_raw_section( entry_markup = Markup("\n").join(row_parts) return Markup( - get_env() - .get_template("raw_section.j2") - .render(entry_markup=entry_markup) + get_env().get_template("raw_section.j2").render(entry_markup=entry_markup) ) diff --git a/src/anndata/_repr/templates/_macros.j2 b/src/anndata/_repr/templates/_macros.j2 index d57eb653a..e20cee86e 100644 --- a/src/anndata/_repr/templates/_macros.j2 +++ b/src/anndata/_repr/templates/_macros.j2 @@ -135,3 +135,79 @@ {%- endif -%} {%- endmacro -%} + +{# Error preview span: muted-red error message used in the preview column. #} +{%- macro error_preview(message) -%} +{{ message }} +{%- endmacro -%} + +{# Warning preview span: yellow warning message used in the preview column. #} +{%- macro warning_preview(message) -%} +{{ message }} +{%- endmacro -%} + +{# Muted error span: an italicised, parenthesised error shown in a muted + colour (used for attribute-access / format errors in the X entry and + similar compact rows). #} +{%- macro muted_error_span(error_msg) -%} +({{ error_msg }}) +{%- endmacro -%} + +{# DataFrame columns preview: "[col1, col2, ...]" list used in obsm/varm for + DataFrames. `columns` is a sequence of plain str values; Jinja autoescapes + each when rendered inside {{ … }}. #} +{%- macro columns_preview(columns) -%} +[{{ columns|join(", ") }}] +{%- endmacro -%} + +{# Single color swatch. Caller has already run sanitize_css_color on the + supplied `color` and passes `valid=True` when the result was non-None. + For invalid colors we render a "?" placeholder with the original label + in the tooltip. #} +{%- macro color_swatch(color, label, valid=true) -%} +{%- if valid -%} + +{%- else -%} +? +{%- endif -%} +{%- endmacro -%} + +{# Color preview wrapper: aggregates already-rendered swatches (Markup) and an + optional overflow counter. `swatches` is an iterable of Markup fragments + produced by `color_swatch`; they pass through verbatim. #} +{%- macro color_preview(swatches, overflow_count=0) -%} + +{%- for swatch in swatches -%} +{{ swatch }} +{%- endfor -%} +{%- if overflow_count and overflow_count > 0 -%} ++{{ overflow_count }} +{%- endif -%} + +{%- endmacro -%} + +{# Wrapper for a nested AnnData repr (the HTML fragment produced by + generate_repr_html for a nested object). `inner` is Markup and passes + through verbatim. #} +{%- macro nested_anndata_wrapper(inner) -%} +
{{ inner }}
+{%- endmacro -%} + +{# README info icon span. `content` and `tooltip` are plain str and must be + pre-scrubbed of NULs by the caller (Python replaces "\x00" -> "\ufffd" + before passing in). Jinja autoescapes the rest. #} +{%- macro readme_icon(content, tooltip) -%} + +{%- endmacro -%} + +{# Plain
 block for fallback rendering when the HTML repr is disabled. #}
+{%- macro pre_fallback(text) -%}
+
{{ text }}
+{%- endmacro -%} + +{# Search box: input + case/regex toggle buttons + filter indicator. The + whole box is hidden until JS enables it (STYLE_HIDDEN). `search_id` is a + plain str and is autoescaped. #} +{%- macro search_box(search_id) -%} + +{%- endmacro -%} diff --git a/src/anndata/_repr/templates/x_entry.j2 b/src/anndata/_repr/templates/x_entry.j2 new file mode 100644 index 000000000..be6f91261 --- /dev/null +++ b/src/anndata/_repr/templates/x_entry.j2 @@ -0,0 +1,22 @@ +{# Renders the
row. + + Inputs: + - x_label (str): the label to show in the name cell (always "X"). + - state (str): one of "ok", "none", "attribute_error", "format_error". + - type_name (str): type label (only used for state="ok"). Autoescaped. + - css_class (str): CSS dtype class (only used for state="ok"). + - error_msg (str): short error string (used for state="attribute_error" + and state="format_error"). Autoescaped. + + Trust contract: all inputs are plain str; Jinja autoescapes them. #} +{% from '_macros.j2' import muted_error_span %} +
+{{ x_label }} +{%- if state == "ok" -%} +{{ type_name }} +{%- elif state == "none" -%} +None +{%- else -%} +{{ muted_error_span(error_msg) }} +{%- endif -%} +
From 4c7e1eec57237968d867a7e943d0e284cc239eff Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 14:46:03 -0700 Subject: [PATCH 21/22] docs(repr): correct docstring examples to use Markup.format() idiom; expose get_macros() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue surfaced by review: the docstring examples in __init__.py and registry.py were teaching ecosystem authors the anti-pattern preview_markup=Markup(f'({obj.n_items} items)') The f-string interpolates ``obj.n_items`` before Markup sees it, bypassing autoescape. Replaced every example (six sites across __init__.py, registry.py, and core.py) with the correct idioms: - preview= (plain text, autoescaped) - preview_markup=Markup('{}').format(v) (standard MarkupSafe) - preview_markup=Markup(obj._repr_html_()) (reuse trusted HTML) - preview_markup=get_macros().my_macro(v) (Jinja macro path) Exposes ``get_macros()`` from ``anndata._repr`` so the fourth idiom is available to extension packages — the macro path is the only one that benefits from the engine's NUL-scrub finalize hook. Explicitly flags ``Markup(f'...{v}...')`` as the pattern to avoid in both the registry.py module docstring and the ``TypeFormatter`` class docstring. No behavior change. Nothing added to the public API beyond ``get_macros`` (already used internally by components.py / core.py). --- src/anndata/_repr/__init__.py | 34 +++++++++++++++++++++++--------- src/anndata/_repr/core.py | 6 ++---- src/anndata/_repr/environment.py | 8 +++++++- src/anndata/_repr/registry.py | 30 +++++++++++++++++++--------- 4 files changed, 55 insertions(+), 23 deletions(-) diff --git a/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py index ded97a1fc..7f7b5a93f 100644 --- a/src/anndata/_repr/__init__.py +++ b/src/anndata/_repr/__init__.py @@ -84,13 +84,27 @@ def format(self, obj, context): return FormattedOutput( type_name=f"MyArray {obj.shape}", css_class="anndata-dtype--myarray", - # preview_markup is typed Markup — wrap trusted HTML at - # the formatter boundary so autoescape lets it through. + # ``preview_markup`` takes trusted HTML. Build it with + # ``Markup('{}').format(value)`` — MarkupSafe + # autoescapes each non-``Markup`` arg at that boundary. preview_markup=Markup( - f'({obj.n_items} items)' - ), + '{} 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 @@ -125,7 +139,9 @@ def format(self, obj, context): hint, data = extract_uns_type_hint(obj) return FormattedOutput( type_name="config", - preview_markup=Markup("Custom config preview"), + preview_markup=Markup( + '{}' + ).format(data.get("name", "(unnamed)")), ) Data structure for type hints (works in any section):: @@ -295,16 +311,14 @@ def _repr_html_(self): **Embedding nested AnnData** with full interactivity:: - from markupsafe import Markup - 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_markup=Markup(nested_html), # Collapsible content below the row + expanded_markup=generate_repr_html(adata, depth=1, max_depth=3), ), ) @@ -365,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, @@ -425,6 +440,7 @@ def get_section_doc_url(section: str) -> str: # Building blocks for custom _repr_html_ implementations "get_css", "get_javascript", + "get_macros", "format_number", "format_memory_size", "render_section", diff --git a/src/anndata/_repr/core.py b/src/anndata/_repr/core.py index f26628e3e..1bb086571 100644 --- a/src/anndata/_repr/core.py +++ b/src/anndata/_repr/core.py @@ -254,15 +254,13 @@ def render_formatted_entry( With expandable nested content:: - from markupsafe import Markup - - 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_markup=Markup(nested_html), + expanded_markup=generate_repr_html(adata, depth=1), ), ) html = render_formatted_entry(entry) diff --git a/src/anndata/_repr/environment.py b/src/anndata/_repr/environment.py index 861629dc8..68d55da57 100644 --- a/src/anndata/_repr/environment.py +++ b/src/anndata/_repr/environment.py @@ -66,5 +66,11 @@ def get_env() -> Environment: @cache def get_macros(): - """Cached handle to the macros module from ``_macros.j2``.""" + """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/registry.py b/src/anndata/_repr/registry.py index 1f3876cc8..37f3f070a 100644 --- a/src/anndata/_repr/registry.py +++ b/src/anndata/_repr/registry.py @@ -13,7 +13,11 @@ from anndata._repr import register_formatter, TypeFormatter, FormattedOutput - # Format by Python type (e.g., custom array in obsm) + # Format by Python type (e.g., custom array in obsm). + # Three ways to populate the preview column: + # - preview= — plain text, autoescaped + # - preview_markup=Markup('{}').format(value) — custom HTML + # - preview_markup=Markup(obj._repr_html_()) — reuse trusted HTML @register_formatter class MyArrayFormatter(TypeFormatter): def can_format(self, obj, context): @@ -23,14 +27,12 @@ def format(self, obj, context): return FormattedOutput( type_name=f"MyArray {obj.shape}", css_class="anndata-dtype--myarray", - # preview_markup is typed Markup — wrap the trusted fragment - # at construction so the template renders it verbatim. preview_markup=Markup( - f'({obj.n_items} items)' - ), + '{} items' + ).format(obj.n_items), ) - # Format by embedded type hint (e.g., tagged data in uns) + # Format by embedded type hint (e.g., tagged data in uns). from anndata._repr import extract_uns_type_hint @register_formatter @@ -45,8 +47,16 @@ def format(self, obj, context): hint, data = extract_uns_type_hint(obj) return FormattedOutput( type_name="config", - preview_markup=Markup("Custom config preview"), + preview_markup=Markup( + '{}' + ).format(data.get("name", "(unnamed)")), ) + +Never build HTML via ``Markup(f'...{value}...')`` — the f-string interpolates +``value`` before ``Markup`` sees it, bypassing autoescape. Use +``Markup('{}').format(value)`` instead (standard MarkupSafe idiom), +or invoke a macro via ``get_macros()`` which also scrubs NUL bytes via the +template engine's finalize hook. """ from __future__ import annotations @@ -989,10 +999,12 @@ def can_format(self, obj, context): def format(self, obj, context): hint, data = extract_uns_type_hint(obj) - # Render your custom visualization return FormattedOutput( type_name="mytype", - preview_markup=Markup("Custom rendering"), + # ``Markup.format`` autoescapes each non-``Markup`` arg. + preview_markup=Markup( + '{}' + ).format(data.get("label", "untitled")), ) 2. When the user imports your package, the formatter is registered From ce8daa9f5f286140a74dba35f35a8f52e6b70b7c Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 21 Apr 2026 15:00:47 -0700 Subject: [PATCH 22/22] fix(visual-inspect): use Markup("\n").join in SpatialData demo str.join over a list of Markup returns plain str, which Jinja autoescapes when interpolated by render_section(entries=...). The result was every entry's HTML rendering as escaped text (<div>). Switch to Markup("\n").join(rows) in the six render_section call sites inside MockSpatialData so the demo teaches the right idiom. --- tests/visual_inspect_repr_html.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py index 4ccfd6a3f..ee9ab9c68 100644 --- a/tests/visual_inspect_repr_html.py +++ b/tests/visual_inspect_repr_html.py @@ -656,10 +656,13 @@ def _build_images_section(self) -> str: # 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)", ) @@ -683,7 +686,7 @@ def _build_labels_section(self) -> str: return render_section( "labels", - "\n".join(rows), + Markup("\n").join(rows), n_items=len(self.labels), tooltip="Segmentation masks (xarray.DataArray)", ) @@ -708,7 +711,7 @@ def _build_points_section(self) -> str: return render_section( "points", - "\n".join(rows), + Markup("\n").join(rows), n_items=len(self.points), tooltip="Point annotations (dask.DataFrame)", ) @@ -733,7 +736,7 @@ def _build_shapes_section(self) -> str: return render_section( "shapes", - "\n".join(rows), + Markup("\n").join(rows), n_items=len(self.shapes), tooltip="Vector shapes (geopandas.GeoDataFrame)", ) @@ -770,7 +773,7 @@ def _build_tables_section(self) -> str: return render_section( "tables", - "\n".join(rows), + Markup("\n").join(rows), n_items=len(self.tables), tooltip="Annotation tables (AnnData)", ) @@ -801,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", ""), )