From 2eef28fbc2f37ee52b48395f917d312653061eeb Mon Sep 17 00:00:00 2001 From: Dominik Date: Mon, 20 Apr 2026 14:19:02 -0700 Subject: [PATCH] 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 %}