|
| 1 | +"""Jinja2-compatible template rendering adapter using MiniJinja. |
| 2 | +
|
| 3 | +This module provides a thin wrapper around MiniJinja for rendering Jinja2 templates, |
| 4 | +centralizing all template rendering logic in one place. |
| 5 | +""" |
| 6 | + |
| 7 | +from __future__ import annotations |
| 8 | + |
| 9 | +import textwrap |
| 10 | +from functools import lru_cache |
| 11 | +from typing import Any |
| 12 | + |
| 13 | +from minijinja import Environment |
| 14 | + |
| 15 | + |
| 16 | +def _wordwrap_filter(value: str, width: int = 79, wrapstring: str = "\n") -> str: |
| 17 | + """Jinja2-compatible wordwrap filter. |
| 18 | +
|
| 19 | + Wraps text to specified width, inserting wrapstring between wrapped lines. |
| 20 | + This uses Python's textwrap module to match Jinja2's wordwrap behavior. |
| 21 | +
|
| 22 | + Like Jinja2, this preserves existing newlines and wraps each line independently. |
| 23 | +
|
| 24 | + Note: minijinja-contrib has a Rust-native wordwrap filter (since 2.12), |
| 25 | + but it is gated behind the optional ``wordwrap`` Cargo feature flag. |
| 26 | + The minijinja-py 2.15.1 wheel does not enable that feature |
| 27 | + (see minijinja-py/Cargo.toml — only ``pycompat`` and ``html_entities`` |
| 28 | + are enabled from minijinja-contrib). Once a future minijinja-py release |
| 29 | + enables the ``wordwrap`` feature, this custom filter can be removed. |
| 30 | + Upstream tracking: https://github.com/mitsuhiko/minijinja — no issue |
| 31 | + filed yet; consider opening one. |
| 32 | + """ |
| 33 | + if not value: |
| 34 | + return value |
| 35 | + |
| 36 | + # Preserve newlines by wrapping each line independently (matches Jinja2) |
| 37 | + wrapped_lines = [] |
| 38 | + for line in value.splitlines(): |
| 39 | + # Use textwrap.wrap which matches jinja2's behavior |
| 40 | + # break_on_hyphens=True is the Python/Jinja2 default |
| 41 | + wrapped = textwrap.wrap( |
| 42 | + line, |
| 43 | + width=width, |
| 44 | + break_long_words=True, |
| 45 | + break_on_hyphens=True, |
| 46 | + ) |
| 47 | + # textwrap.wrap returns empty list for empty strings, preserve empty lines |
| 48 | + wrapped_lines.extend(wrapped or [""]) |
| 49 | + |
| 50 | + return wrapstring.join(wrapped_lines) |
| 51 | + |
| 52 | + |
| 53 | +def _setup_builtin_filters(env: Environment) -> None: |
| 54 | + """Register filters missing from minijinja-py's compiled feature set. |
| 55 | +
|
| 56 | + The minijinja-py wheel currently ships without the ``wordwrap`` Cargo |
| 57 | + feature of minijinja-contrib, so ``|wordwrap`` is unavailable by default. |
| 58 | + This registers a Python-side replacement. This function can be removed |
| 59 | + once minijinja-py enables the ``wordwrap`` feature upstream. |
| 60 | + """ |
| 61 | + env.add_filter("wordwrap", _wordwrap_filter) |
| 62 | + |
| 63 | + |
| 64 | +def _new_env(autoescape: bool) -> Environment: |
| 65 | + """Create a new Environment with standard setup (filters, autoescape). |
| 66 | +
|
| 67 | + :param autoescape: Whether to enable autoescaping. |
| 68 | + :return: A new Environment instance. |
| 69 | + """ |
| 70 | + env = Environment() |
| 71 | + if autoescape: |
| 72 | + env.auto_escape_callback = lambda _name: True |
| 73 | + _setup_builtin_filters(env) |
| 74 | + return env |
| 75 | + |
| 76 | + |
| 77 | +@lru_cache(maxsize=2) |
| 78 | +def _get_cached_env(autoescape: bool) -> Environment: |
| 79 | + """Get or create a cached Environment instance (no custom functions). |
| 80 | +
|
| 81 | + Cached per ``autoescape`` value. Safe because the returned |
| 82 | + ``Environment`` is not mutated after creation. ``lru_cache`` ensures |
| 83 | + that concurrent calls for the same key return the same instance |
| 84 | + (Python's GIL protects the cache dict). Note that the |
| 85 | + ``Environment`` object itself is **not** thread-safe — this is |
| 86 | + acceptable because Sphinx builds are single-threaded. |
| 87 | +
|
| 88 | + The cache persists for the lifetime of the process (e.g. across |
| 89 | + rebuilds in ``sphinx-autobuild``). This is fine because the |
| 90 | + environments are stateless (no per-build data). |
| 91 | +
|
| 92 | + :param autoescape: Whether to enable autoescaping. |
| 93 | + :return: A cached Environment instance. |
| 94 | + """ |
| 95 | + return _new_env(autoescape) |
| 96 | + |
| 97 | + |
| 98 | +def render_template_string( |
| 99 | + template_string: str, |
| 100 | + context: dict[str, Any], |
| 101 | + *, |
| 102 | + autoescape: bool, |
| 103 | + new_env: bool = False, |
| 104 | +) -> str: |
| 105 | + """Render a Jinja template string with the given context. |
| 106 | +
|
| 107 | + :param template_string: The Jinja template string to render. |
| 108 | + :param context: Dictionary containing template variables. |
| 109 | + :param autoescape: Whether to enable autoescaping. |
| 110 | + :param new_env: If True, create a fresh Environment instead of using |
| 111 | + the shared cached one. This is required when rendering happens |
| 112 | + *inside* a Python callback invoked by an ongoing ``render_str`` on |
| 113 | + the cached Environment (e.g. needuml's ``{{ uml() }}`` callbacks), |
| 114 | + because MiniJinja's ``Environment`` holds a non-reentrant lock |
| 115 | + during ``render_str``. |
| 116 | + :return: The rendered template as a string. |
| 117 | + """ |
| 118 | + env = _new_env(autoescape) if new_env else _get_cached_env(autoescape) |
| 119 | + return env.render_str(template_string, **context) |
| 120 | + |
| 121 | + |
| 122 | +class CompiledTemplate: |
| 123 | + """A pre-compiled template for efficient repeated rendering. |
| 124 | +
|
| 125 | + Use :func:`compile_template` to create instances. The template source |
| 126 | + is parsed and compiled once; each :meth:`render` call only executes the |
| 127 | + already-compiled template, avoiding the per-call parse overhead of |
| 128 | + :func:`render_template_string` / ``Environment.render_str``. |
| 129 | +
|
| 130 | + This is useful when the same template is rendered many times in a loop |
| 131 | + with different contexts (e.g. per-need in external needs loading, |
| 132 | + per-cell in needtable string-link rendering, per-need in constraint |
| 133 | + error messages, or per-node in PlantUML diagram generation). |
| 134 | + """ |
| 135 | + |
| 136 | + __slots__ = ("_env",) |
| 137 | + |
| 138 | + _TEMPLATE_NAME = "__compiled__" |
| 139 | + |
| 140 | + def __init__(self, env: Environment) -> None: |
| 141 | + self._env = env |
| 142 | + |
| 143 | + def render(self, context: dict[str, Any]) -> str: |
| 144 | + """Render the compiled template with the given context. |
| 145 | +
|
| 146 | + :param context: Dictionary containing template variables. |
| 147 | + :return: The rendered template as a string. |
| 148 | + """ |
| 149 | + return self._env.render_template(self._TEMPLATE_NAME, **context) |
| 150 | + |
| 151 | + |
| 152 | +@lru_cache(maxsize=32) |
| 153 | +def compile_template( |
| 154 | + template_string: str, |
| 155 | + *, |
| 156 | + autoescape: bool, |
| 157 | +) -> CompiledTemplate: |
| 158 | + """Compile a template string for efficient repeated rendering. |
| 159 | +
|
| 160 | + The returned :class:`CompiledTemplate` parses the source once; |
| 161 | + subsequent :meth:`~CompiledTemplate.render` calls skip parsing entirely. |
| 162 | + Use this instead of :func:`render_template_string` when the same |
| 163 | + template is rendered in a tight loop with varying contexts. |
| 164 | +
|
| 165 | + Results are cached by ``(template_string, autoescape)`` so that |
| 166 | + multiple call sites sharing the same template (e.g. |
| 167 | + ``needs_config.diagram_template``) only compile once per build. |
| 168 | +
|
| 169 | + The cache persists for the lifetime of the process. This is safe |
| 170 | + because compiled templates are keyed by their source text and are |
| 171 | + stateless. |
| 172 | +
|
| 173 | + :param template_string: The Jinja template string to compile. |
| 174 | + :param autoescape: Whether to enable autoescaping. |
| 175 | + :return: A compiled template that can be rendered with different contexts. |
| 176 | + """ |
| 177 | + env = _new_env(autoescape) |
| 178 | + env.add_template(CompiledTemplate._TEMPLATE_NAME, template_string) |
| 179 | + return CompiledTemplate(env) |
0 commit comments