Skip to content

Commit 2eef28f

Browse files
committed
feat(repr): POC Jinja + Markup middle-ground for outer template
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.
1 parent 47066f2 commit 2eef28f

4 files changed

Lines changed: 100 additions & 43 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ dependencies = [
4747
"zarr >=3.1",
4848
"typing-extensions; python_version<'3.13'",
4949
"scverse-misc>=0.0.3",
50+
"jinja2>=3.1",
51+
"markupsafe>=3.0",
5052
]
5153
dynamic = [ "version" ]
5254

src/anndata/_repr/environment.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""
2+
Jinja2 Environment for the AnnData HTML repr (middle-ground POC).
3+
4+
This module wires Jinja2 into the existing repr pipeline in a minimal way:
5+
6+
- A single autoescape-enabled ``Environment`` loads templates from
7+
``anndata._repr.templates``.
8+
- The existing formatter machinery still produces HTML fragments as strings;
9+
the top-level renderer wraps those fragments in ``markupsafe.Markup`` at the
10+
boundary so they pass through autoescape verbatim.
11+
- Any additional values injected directly into the outer template (container
12+
id, depth, inline style, etc.) are autoescaped by default, which closes the
13+
"forgot to call ``html.escape()``" class of bug for those specific
14+
insertions.
15+
16+
This is deliberately narrow in scope. It illustrates the trust contract
17+
(``Markup`` = trusted, ``str`` = untrusted) without rewriting the per-type
18+
formatters.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
from functools import cache
24+
25+
from jinja2 import Environment, PackageLoader, select_autoescape
26+
27+
28+
@cache
29+
def get_env() -> Environment:
30+
return Environment(
31+
loader=PackageLoader("anndata._repr", "templates"),
32+
autoescape=select_autoescape(default=True, default_for_string=True),
33+
trim_blocks=True,
34+
lstrip_blocks=True,
35+
)

src/anndata/_repr/html.py

Lines changed: 45 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
import uuid
1515
from typing import TYPE_CHECKING
1616

17+
from markupsafe import Markup
18+
19+
from .environment import get_env
20+
1721
from .._repr_constants import (
1822
CSS_BADGE_BACKED,
1923
CSS_BADGE_EXTENSION,
@@ -267,13 +271,6 @@ def generate_repr_html( # noqa: PLR0913
267271
# Generate unique container ID
268272
container_id = _container_id or f"anndata-repr-{uuid.uuid4().hex[:8]}"
269273

270-
# Build HTML parts
271-
parts = []
272-
273-
# CSS and JS only at top level
274-
if depth == 0:
275-
parts.append(get_css())
276-
277274
# Calculate field name column width based on content
278275
max_field_width = get_setting(
279276
"repr_html_max_field_width", default=DEFAULT_MAX_FIELD_WIDTH
@@ -283,61 +280,66 @@ def generate_repr_html( # noqa: PLR0913
283280
# Get type column width from settings
284281
type_width = get_setting("repr_html_type_width", default=DEFAULT_TYPE_WIDTH)
285282

286-
# Container with computed column widths as CSS variables.
287-
# Inline font-family:monospace provides readable fallback when CSS is stripped
288-
# (GitHub, untrusted notebooks). CSS overrides with its own font stack.
289-
# Inline min-width on cells + CSS custom properties give column alignment
290-
# even without a stylesheet.
291-
style = f"font-family: monospace; --anndata-name-col-width: {field_width}px; --anndata-type-col-width: {type_width}px;"
292-
parts.append(
293-
f'<div class="anndata-repr" id="{container_id}" data-depth="{depth}" style="{style}">'
283+
# Computed column widths as CSS variables. Inline font-family:monospace
284+
# provides a readable fallback when CSS is stripped (GitHub, untrusted
285+
# notebooks).
286+
style = (
287+
f"font-family: monospace; "
288+
f"--anndata-name-col-width: {field_width}px; "
289+
f"--anndata-type-col-width: {type_width}px;"
294290
)
295291

296-
# Header (with search box integrated on the right)
292+
# Gather already-rendered HTML fragments and mark them as trusted Markup.
293+
# Each of these is produced by existing formatter/renderer code; wrapping
294+
# at this boundary is the trust assertion the POC illustrates.
295+
header_html: Markup | None = None
297296
if show_header:
298-
parts.append(
297+
header_html = Markup(
299298
_render_header(
300-
adata, show_search=show_search and depth == 0, container_id=container_id
299+
adata,
300+
show_search=show_search and depth == 0,
301+
container_id=container_id,
301302
)
302303
)
303304

304-
# Index preview (only at top level)
305-
if depth == 0:
306-
parts.append(_render_index_preview(adata))
307-
308-
# Sections container
309-
parts.append('<div class="anndata-repr__sections">')
310-
parts.extend(_render_all_sections(adata, context))
311-
parts.append("</div>") # anndata-repr__sections
312-
313-
# Footer with metadata (only at top level)
305+
index_preview_html: Markup | None = None
306+
footer_html: Markup | None = None
307+
hints_html: Markup | None = None
308+
css_html: Markup | None = None
309+
javascript_html: Markup | None = None
314310
if depth == 0:
315-
parts.append(_render_footer(adata))
316-
# Degradation hints: visible only when CSS or JS is missing.
317-
# No-CSS hint: visible by default, hidden by CSS.
318-
parts.append(
311+
index_preview_html = Markup(_render_index_preview(adata))
312+
footer_html = Markup(_render_footer(adata))
313+
hints_html = Markup(
319314
'<div class="anndata-repr__hint-nocss">'
320315
"<em>Styled representation available in Jupyter and trusted notebooks "
321316
"(colors, search, type highlighting).</em>"
322317
"</div>"
323-
)
324-
# No-JS hint: hidden by default (no-CSS case already has its own hint),
325-
# shown by CSS (for static HTML with styles but no JS),
326-
# hidden again by JS on init.
327-
parts.append(
328318
'<div class="anndata-repr__hint-nojs" style="display:none">'
329319
"<em>Interactive features (search, copy, category wrapping) "
330320
"require JavaScript. Trust this notebook to enable them.</em>"
331321
"</div>"
332322
)
323+
css_html = Markup(get_css())
324+
javascript_html = Markup(get_javascript(container_id))
333325

334-
parts.append("</div>") # anndata-repr
335-
336-
# JavaScript (only at top level)
337-
if depth == 0:
338-
parts.append(get_javascript(container_id))
326+
sections_markup = [Markup(s) for s in _render_all_sections(adata, context)]
339327

340-
return "\n".join(parts)
328+
# Render the outer template. `container_id`, `depth`, and `style` are
329+
# plain strings and get autoescaped by the engine; the Markup-wrapped
330+
# fragments pass through verbatim.
331+
return get_env().get_template("anndata.j2").render(
332+
container_id=container_id,
333+
depth=depth,
334+
style=style,
335+
css=css_html,
336+
header=header_html,
337+
index_preview=index_preview_html,
338+
sections=sections_markup,
339+
footer=footer_html,
340+
hints=hints_html,
341+
javascript=javascript_html,
342+
)
341343

342344

343345
def _render_all_sections(
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{# Outer AnnData repr template (middle-ground POC).
2+
All fragments (header, sections, footer, css, js) are rendered by the
3+
existing Python code and arrive here as Markup — they pass through
4+
autoescape verbatim.
5+
The remaining interpolations ({{ container_id }}, {{ depth }},
6+
{{ style }}) are user-adjacent values; Jinja autoescapes them by default.
7+
#}
8+
{% if css %}{{ css }}{% endif %}
9+
<div class="anndata-repr" id="{{ container_id }}" data-depth="{{ depth }}" style="{{ style }}">
10+
{% if header %}{{ header }}{% endif %}
11+
{% if index_preview %}{{ index_preview }}{% endif %}
12+
<div class="anndata-repr__sections">
13+
{% for section in sections %}{{ section }}{% endfor %}
14+
</div>
15+
{% if footer %}{{ footer }}{% endif %}
16+
{% if hints %}{{ hints }}{% endif %}
17+
</div>
18+
{% if javascript %}{{ javascript }}{% endif %}

0 commit comments

Comments
 (0)