Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]

Expand Down
35 changes: 35 additions & 0 deletions src/anndata/_repr/environment.py
Original file line number Diff line number Diff line change
@@ -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,
)
88 changes: 45 additions & 43 deletions src/anndata/_repr/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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'<div class="anndata-repr" id="{container_id}" data-depth="{depth}" style="{style}">'
# 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('<div class="anndata-repr__sections">')
parts.extend(_render_all_sections(adata, context))
parts.append("</div>") # 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(
'<div class="anndata-repr__hint-nocss">'
"<em>Styled representation available in Jupyter and trusted notebooks "
"(colors, search, type highlighting).</em>"
"</div>"
)
# 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(
'<div class="anndata-repr__hint-nojs" style="display:none">'
"<em>Interactive features (search, copy, category wrapping) "
"require JavaScript. Trust this notebook to enable them.</em>"
"</div>"
)
css_html = Markup(get_css())
javascript_html = Markup(get_javascript(container_id))

parts.append("</div>") # 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(
Expand Down
18 changes: 18 additions & 0 deletions src/anndata/_repr/templates/anndata.j2
Original file line number Diff line number Diff line change
@@ -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 %}
<div class="anndata-repr" id="{{ container_id }}" data-depth="{{ depth }}" style="{{ style }}">
{% if header %}{{ header }}{% endif %}
{% if index_preview %}{{ index_preview }}{% endif %}
<div class="anndata-repr__sections">
{% for section in sections %}{{ section }}{% endfor %}
</div>
{% if footer %}{{ footer }}{% endif %}
{% if hints %}{{ hints }}{% endif %}
</div>
{% if javascript %}{{ javascript }}{% endif %}
Loading