"""Central Jinja2 template environment for GAIn.
Provides a singleton Environment that resolves templates in two stages:
1. Physical files under gain/templates/template_files/ via PackageLoader.
2. Strings supplied by callables registered under the
"gain.templates.providers" entry-point group. Each callable must
return a ``dict[str, str]`` mapping template name to template source.
All provider dictionaries are merged lazily on first miss.
Raises ``jinja2.TemplateNotFound`` if a name is not found in either stage.
The environment autoescapes, so a template interpolating a value that is
already markup -- a nested render, ``markdown2`` output, a pandas
``to_html`` table -- has to say so with ``|safe``. Autoescaping is HTML
escaping, which is wrong for the few templates in MARKDOWN_TEMPLATES:
they emit Markdown that GPF renders downstream, and escaping there
mangles the Markdown syntax and the ``&`` in histogram image URLs. The
decision is by template name rather than by extension because every
template here is named ``*.jinja``, HTML and Markdown alike.
Templates supplied by an out-of-tree provider load through this same
environment and are autoescaped along with the rest.
The environment carries two globals. The first is ``markdown``: the
Markdown wrapper from ``gain.templates.markdown_support``. It is
registered here rather than passed by each caller as a render kwarg so
that a template calling ``markdown(...)`` gets the rescuing wrapper
whether or not whoever renders it thought about the name -- gain#742 was
a render site that did not (gain#751). A render kwarg still shadows a
global, so a caller that passes its own ``markdown=`` wins; no caller in
this repo does.
That global serves templates. The several ``gain`` modules that render a
resource's ``meta`` description or an ``about.md`` in *Python* -- before
the result enters a dict some template dumps generically -- still import
``render_markdown`` and call it themselves; a template global cannot
reach them. Those imports are what the architecture fence governs.
``render_markdown`` is imported under its own name deliberately. The
wrapper module renders *through* markdown2 and so re-exports the raw
function under the bare name ``markdown``: binding *that* here would
leave every template in the stack without the bogus-tag rescue while
looking correct. ``core``'s architecture tests refuse that import; they
read imports, so reaching the same function as an attribute of an
imported module would pass them -- what catches that is the rescue being
asserted on rendered output.
The import sits inside ``get_jinja_env`` rather than at module scope so
that importing ``gain.templates`` does not drag in markdown2 for callers
that only ever fetch a template: it costs about 10ms, and the annotation
workers pay module import per spawned process.
The second global, ``natural_chromosome_key``, orders a contig name by
its digit runs so a per-chromosome table reads chr1, chr2, chr10 rather
than chr1, chr10, chr2. The info page's Coverage and Alleles tables
emit it as the Chromosome column's ``data-sort-value``, which is what
lets the client-side sorter reorder those rows without shipping any
ordering logic of its own (gain#983, gain#984). It is a global rather
than a field on the row objects because the two tables that use it do
not share a row type -- Coverage renders a ``CoverageRow`` NamedTuple
while Alleles renders a plain dict -- and because the reference genome
and gene models pages carry per-chromosome tables that will want the
same key.
Unlike ``render_markdown`` it is imported at module scope: the module it
comes from imports only ``re``, so there is no start-up cost to defer,
and ``gain.utils`` is where it deliberately lives so that the template
layer can reach it without importing ``genomic_resources``.
The third global, ``sqlite_wasm_path``, is the repository-relative
directory the index page imports its search engine from -- the same
directory ``build_index_info`` publishes the vendored sqlite-wasm files
to (``gain.templates.static_assets``, gain#1335). A global rather than
a render kwarg for the same reason as ``markdown``: the page has more
than one render site, the tests among them, and none of them should be
able to render an import that points somewhere the publisher did not.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from importlib.metadata import entry_points
from typing import TYPE_CHECKING
from jinja2 import (
BaseLoader,
ChoiceLoader,
Environment,
PackageLoader,
Template,
TemplateNotFound,
)
from gain.templates.static_assets import (
MATERIAL_SYMBOLS_FONT_PATH,
ROBOTO_FONT_PATH,
SQLITE_WASM_PATH,
)
from gain.utils.chromosome_order import natural_chromosome_key
if TYPE_CHECKING:
from collections.abc import Callable
@dataclass
class _TemplateCache:
env: Environment | None = field(default=None)
provider_cache: dict[str, str] | None = field(default=None)
_state = _TemplateCache()
[docs]
def reset_caches() -> None:
"""Forget the built environment and the merged provider templates.
Both are process-wide and built on first use, so a test that
registers a template provider, or patches what one returns, needs
the next call to build afresh -- this is the one way to ask for that
without reaching into the cache itself.
"""
_state.env = None
_state.provider_cache = None
MARKDOWN_TEMPLATES = frozenset({
"gene_score_help.jinja",
"genomic_score_help.jinja",
"score_histogram.jinja",
})
def _get_provider_templates() -> dict[str, str]:
if _state.provider_cache is None:
merged: dict[str, str] = {}
for ep in entry_points(group="gain.templates.providers"):
provider_fn = ep.load()
for name, source in provider_fn().items():
if name in merged and merged[name] != source:
raise ValueError(
f"Template name conflict: '{name}' registered by "
f"provider '{ep.name}' conflicts with an existing "
f"provider registration.",
)
merged[name] = source
_state.provider_cache = merged
return _state.provider_cache
class _ProviderLoader(BaseLoader):
"""Jinja2 loader that reads templates from entry-point provider dicts."""
def get_source(
self, environment: Environment, template: str, # ruff: ignore[unused-method-argument]
) -> tuple[str, None, Callable[[], bool]]:
source = _get_provider_templates().get(template)
if source is None:
raise TemplateNotFound(template)
return source, None, lambda: True
def _autoescape(template_name: str | None) -> bool:
"""Autoescape every template but the Markdown-emitting ones."""
return template_name not in MARKDOWN_TEMPLATES
[docs]
def get_jinja_env() -> Environment:
"""Return the singleton GAIn Jinja2 Environment."""
if _state.env is None:
# pylint: disable=import-outside-toplevel
from gain.templates.markdown_support import render_markdown
env = Environment(
loader=ChoiceLoader([
PackageLoader("gain.templates", "template_files"),
_ProviderLoader(),
]),
# Not `False`: a callable that escapes everything except the
# Markdown-emitting templates, which produce HTML on purpose
# (`_autoescape` above; pinned by tests/small/templates).
autoescape=_autoescape, # ruff: ignore[jinja2-autoescape-false]
)
env.globals["markdown"] = render_markdown
env.globals["natural_chromosome_key"] = natural_chromosome_key
env.globals["sqlite_wasm_path"] = SQLITE_WASM_PATH
env.globals["roboto_font_path"] = ROBOTO_FONT_PATH
env.globals["material_symbols_font_path"] = MATERIAL_SYMBOLS_FONT_PATH
# Published last, so no caller can reach a half-configured
# environment: assigning first and installing the globals after
# leaves a window where the singleton renders UndefinedError.
_state.env = env
return _state.env
[docs]
def get_template(name: str) -> Template:
"""Convenience wrapper — raises TemplateNotFound if name is absent."""
return get_jinja_env().get_template(name)