Skip to content

Commit 9d08d5e

Browse files
authored
♻️ Replace jinja2 with minijinja for template rendering (#1659)
1 parent aa1d284 commit 9d08d5e

File tree

18 files changed

+414
-163
lines changed

18 files changed

+414
-163
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ repos:
2929
- jsonschema-rs==0.37.1
3030
- types-docutils==0.20.0.20240201
3131
- types-requests
32+
- minijinja~=2.15
3233

3334
# TODO this does not work on pre-commit.ci
3435
# - repo: https://github.com/astral-sh/uv-pre-commit

performance/performance_test.py

Lines changed: 50 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313
from pathlib import Path
1414

1515
import click
16-
from jinja2 import Template
1716
from tabulate import tabulate
1817

18+
from sphinx_needs._jinja import render_template_string
19+
1920

2021
@click.group()
2122
def cli():
@@ -45,37 +46,45 @@ def start(
4546
# Render conf.py
4647
source_tmp_path_conf = os.path.join(source_tmp_path, "conf.template")
4748
source_tmp_path_conf_final = os.path.join(source_tmp_path, "conf.py")
48-
template = Template(Path(source_tmp_path_conf).read_text())
49-
rendered = template.render(
50-
pages=pages,
51-
needs=needs,
52-
needtables=needtables,
53-
dummies=dummies,
54-
parallel=parallel,
55-
keep=keep,
56-
browser=browser,
57-
debug=debug,
58-
basic=basic,
49+
template_content = Path(source_tmp_path_conf).read_text()
50+
rendered = render_template_string(
51+
template_content,
52+
{
53+
"pages": pages,
54+
"needs": needs,
55+
"needtables": needtables,
56+
"dummies": dummies,
57+
"parallel": parallel,
58+
"keep": keep,
59+
"browser": browser,
60+
"debug": debug,
61+
"basic": basic,
62+
},
63+
autoescape=False,
5964
)
6065
with open(source_tmp_path_conf_final, "w") as file:
6166
file.write(rendered)
6267

6368
# Render index files
6469
source_tmp_path_index = os.path.join(source_tmp_path, "index.template")
6570
source_tmp_path_index_final = os.path.join(source_tmp_path, "index.rst")
66-
template = Template(Path(source_tmp_path_index).read_text())
71+
template_content = Path(source_tmp_path_index).read_text()
6772
title = "Index"
68-
rendered = template.render(
69-
pages=pages,
70-
title=title,
71-
needs=needs,
72-
needtables=needtables,
73-
dummies=dummies,
74-
parallel=parallel,
75-
keep=keep,
76-
browser=browser,
77-
debug=debug,
78-
basic=basic,
73+
rendered = render_template_string(
74+
template_content,
75+
{
76+
"pages": pages,
77+
"title": title,
78+
"needs": needs,
79+
"needtables": needtables,
80+
"dummies": dummies,
81+
"parallel": parallel,
82+
"keep": keep,
83+
"browser": browser,
84+
"debug": debug,
85+
"basic": basic,
86+
},
87+
autoescape=False,
7988
)
8089
with open(source_tmp_path_index_final, "w") as file:
8190
file.write(rendered)
@@ -84,20 +93,24 @@ def start(
8493
for p in range(pages):
8594
source_tmp_path_page = os.path.join(source_tmp_path, "page.template")
8695
source_tmp_path_page_final = os.path.join(source_tmp_path, f"page_{p}.rst")
87-
template = Template(Path(source_tmp_path_page).read_text())
96+
template_content = Path(source_tmp_path_page).read_text()
8897
title = f"Page {p}"
89-
rendered = template.render(
90-
page=p,
91-
title=title,
92-
pages=pages,
93-
needs=needs,
94-
needtables=needtables,
95-
dummies=dummies,
96-
parallel=parallel,
97-
keep=keep,
98-
browser=browser,
99-
debug=debug,
100-
basic=basic,
98+
rendered = render_template_string(
99+
template_content,
100+
{
101+
"page": p,
102+
"title": title,
103+
"pages": pages,
104+
"needs": needs,
105+
"needtables": needtables,
106+
"dummies": dummies,
107+
"parallel": parallel,
108+
"keep": keep,
109+
"browser": browser,
110+
"debug": debug,
111+
"basic": basic,
112+
},
113+
autoescape=False,
101114
)
102115
with open(source_tmp_path_page_final, "w") as file:
103116
file.write(rendered)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ dependencies = [
3636
"sphinxcontrib-jquery~=4.0", # needed for datatables in sphinx>=6
3737
"tomli; python_version < '3.11'", # for needs_from_toml configuration
3838
"typing-extensions>=4.14.0", # for dict NotRequired type indication
39+
"minijinja~=2.15", # lightweight jinja2-compatible template engine
3940
]
4041

4142
[project.optional-dependencies]

sphinx_needs/_jinja.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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)

sphinx_needs/api/need.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@
1414
from docutils import nodes
1515
from docutils.parsers.rst.states import RSTState
1616
from docutils.statemachine import StringList
17-
from jinja2 import Template
1817
from sphinx.application import Sphinx
1918
from sphinx.environment import BuildEnvironment
2019

20+
from sphinx_needs._jinja import render_template_string
2121
from sphinx_needs.config import NeedsSphinxConfig
2222
from sphinx_needs.data import (
2323
NeedsInfoType,
@@ -967,13 +967,16 @@ def _prepare_template(
967967
with template_path.open() as template_file:
968968
template_content = "".join(template_file.readlines())
969969
try:
970-
template_obj = Template(template_content)
971-
new_content = template_obj.render(**needs_info, **needs_config.render_context)
970+
new_content = render_template_string(
971+
template_content,
972+
{**needs_info, **needs_config.render_context},
973+
autoescape=False,
974+
)
972975
except Exception as e:
973976
raise InvalidNeedException(
974977
"invalid_template",
975978
f"Error while rendering template {template_path}: {e}",
976-
)
979+
) from e
977980

978981
return new_content
979982

sphinx_needs/debug.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@
1515
from timeit import default_timer as timer # Used for timing measurements
1616
from typing import Any, TypeVar
1717

18-
from jinja2 import Environment, PackageLoader, select_autoescape
1918
from sphinx.application import Sphinx
2019

20+
from sphinx_needs._jinja import render_template_string
21+
2122
TIME_MEASUREMENTS: dict[str, Any] = {} # Stores the timing results
2223
EXECUTE_TIME_MEASUREMENTS = (
2324
False # Will be used to de/activate measurements. Set during a Sphinx Event
@@ -165,13 +166,17 @@ def _store_timing_results_json(app: Sphinx, build_data: dict[str, Any]) -> None:
165166

166167

167168
def _store_timing_results_html(app: Sphinx, build_data: dict[str, Any]) -> None:
168-
jinja_env = Environment(
169-
loader=PackageLoader("sphinx_needs"), autoescape=select_autoescape()
170-
)
171-
template = jinja_env.get_template("time_measurements.html")
169+
template_path = Path(__file__).parent / "templates" / "time_measurements.html"
170+
template_content = template_path.read_text(encoding="utf-8")
172171
out_file = Path(str(app.outdir)) / "debug_measurement.html"
173172
with open(out_file, "w", encoding="utf-8") as f:
174-
f.write(template.render(data=TIME_MEASUREMENTS, build_data=build_data))
173+
f.write(
174+
render_template_string(
175+
template_content,
176+
{"data": TIME_MEASUREMENTS, "build_data": build_data},
177+
autoescape=True,
178+
)
179+
)
175180
print(f"Timing measurement report (HTML) stored under {out_file}")
176181

177182

0 commit comments

Comments
 (0)