Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a525083
Initial plan
Copilot Feb 19, 2026
7806efc
Add minijinja dependency, create adapter module, configure mypy
Copilot Feb 19, 2026
2828e18
Phase 2: Migrate simple Template usage (utils, api/need, needreport, …
Copilot Feb 19, 2026
ea56786
Phase 2 complete: Migrate all simple Template usage files
Copilot Feb 19, 2026
343ceb0
Phase 3 complete: Migrate Environment().from_string() usage
Copilot Feb 19, 2026
0b66cdf
Phase 4 complete: Migrate PackageLoader usage
Copilot Feb 19, 2026
45ad72a
Phase 5 complete: Migrate complex needuml and test infrastructure
Copilot Feb 19, 2026
35979b8
Fix needuml jinja rendering: add wordwrap filter and fix context hand…
Copilot Feb 19, 2026
47f7507
Address performance review: cache Environment, remove no-op caches, f…
Copilot Feb 19, 2026
ba36f48
Fix remaining review issues: break_on_hyphens, thread safety comment,…
Copilot Feb 19, 2026
26f1723
Bump minijinja to ~=2.15, use add_global for functions (proper type s…
Copilot Feb 19, 2026
d893565
Clarify wordwrap filter documentation - verified built-in not availab…
Copilot Feb 19, 2026
d974fe4
Update wordwrap filter documentation to clarify Cargo feature flag issue
Copilot Feb 19, 2026
7774902
Merge branch 'master' into copilot/replace-jinja2-with-minijinja
chrisjsewell Feb 19, 2026
ca36eda
Update test snapshot for None->none rendering change in minijinja
Copilot Feb 19, 2026
c1f7722
Merge branch 'master' into copilot/replace-jinja2-with-minijinja
chrisjsewell Feb 19, 2026
dc8949d
Fix autoescape for non-HTML template rendering (RST, PlantUML, URLs)
Copilot Feb 19, 2026
e463303
Fix wordwrap filter to preserve newlines, move Path import to module …
Copilot Feb 19, 2026
0f188df
Cache base Environment for functions= path to avoid N env creations i…
Copilot Feb 19, 2026
26f8666
Update with needuml optiomsations
chrisjsewell Feb 22, 2026
c7b1e38
pre-compile template
chrisjsewell Feb 22, 2026
e103429
improve
chrisjsewell Feb 22, 2026
72821a5
minor improvements
chrisjsewell Feb 22, 2026
124ffa5
Update needuml.py
chrisjsewell Feb 22, 2026
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
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ repos:
- jsonschema-rs==0.37.1
- types-docutils==0.20.0.20240201
- types-requests
- minijinja~=2.15

# TODO this does not work on pre-commit.ci
# - repo: https://github.com/astral-sh/uv-pre-commit
Expand Down
87 changes: 50 additions & 37 deletions performance/performance_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
from pathlib import Path

import click
from jinja2 import Template
from tabulate import tabulate

from sphinx_needs._jinja import render_template_string


@click.group()
def cli():
Expand Down Expand Up @@ -45,37 +46,45 @@ def start(
# Render conf.py
source_tmp_path_conf = os.path.join(source_tmp_path, "conf.template")
source_tmp_path_conf_final = os.path.join(source_tmp_path, "conf.py")
template = Template(Path(source_tmp_path_conf).read_text())
rendered = template.render(
pages=pages,
needs=needs,
needtables=needtables,
dummies=dummies,
parallel=parallel,
keep=keep,
browser=browser,
debug=debug,
basic=basic,
template_content = Path(source_tmp_path_conf).read_text()
rendered = render_template_string(
template_content,
{
"pages": pages,
"needs": needs,
"needtables": needtables,
"dummies": dummies,
"parallel": parallel,
"keep": keep,
"browser": browser,
"debug": debug,
"basic": basic,
},
autoescape=False,
)
with open(source_tmp_path_conf_final, "w") as file:
file.write(rendered)

# Render index files
source_tmp_path_index = os.path.join(source_tmp_path, "index.template")
source_tmp_path_index_final = os.path.join(source_tmp_path, "index.rst")
template = Template(Path(source_tmp_path_index).read_text())
template_content = Path(source_tmp_path_index).read_text()
title = "Index"
rendered = template.render(
pages=pages,
title=title,
needs=needs,
needtables=needtables,
dummies=dummies,
parallel=parallel,
keep=keep,
browser=browser,
debug=debug,
basic=basic,
rendered = render_template_string(
template_content,
{
"pages": pages,
"title": title,
"needs": needs,
"needtables": needtables,
"dummies": dummies,
"parallel": parallel,
"keep": keep,
"browser": browser,
"debug": debug,
"basic": basic,
},
autoescape=False,
)
with open(source_tmp_path_index_final, "w") as file:
file.write(rendered)
Expand All @@ -84,20 +93,24 @@ def start(
for p in range(pages):
source_tmp_path_page = os.path.join(source_tmp_path, "page.template")
source_tmp_path_page_final = os.path.join(source_tmp_path, f"page_{p}.rst")
template = Template(Path(source_tmp_path_page).read_text())
template_content = Path(source_tmp_path_page).read_text()
title = f"Page {p}"
rendered = template.render(
page=p,
title=title,
pages=pages,
needs=needs,
needtables=needtables,
dummies=dummies,
parallel=parallel,
keep=keep,
browser=browser,
debug=debug,
basic=basic,
rendered = render_template_string(
template_content,
{
"page": p,
"title": title,
"pages": pages,
"needs": needs,
"needtables": needtables,
"dummies": dummies,
"parallel": parallel,
"keep": keep,
"browser": browser,
"debug": debug,
"basic": basic,
},
autoescape=False,
)
with open(source_tmp_path_page_final, "w") as file:
file.write(rendered)
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dependencies = [
"sphinxcontrib-jquery~=4.0", # needed for datatables in sphinx>=6
"tomli; python_version < '3.11'", # for needs_from_toml configuration
"typing-extensions>=4.14.0", # for dict NotRequired type indication
"minijinja~=2.15", # lightweight jinja2-compatible template engine
]

[project.optional-dependencies]
Expand Down
179 changes: 179 additions & 0 deletions sphinx_needs/_jinja.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""Jinja2-compatible template rendering adapter using MiniJinja.

This module provides a thin wrapper around MiniJinja for rendering Jinja2 templates,
centralizing all template rendering logic in one place.
"""

from __future__ import annotations

import textwrap
from functools import lru_cache
from typing import Any

from minijinja import Environment


def _wordwrap_filter(value: str, width: int = 79, wrapstring: str = "\n") -> str:
"""Jinja2-compatible wordwrap filter.

Wraps text to specified width, inserting wrapstring between wrapped lines.
This uses Python's textwrap module to match Jinja2's wordwrap behavior.

Like Jinja2, this preserves existing newlines and wraps each line independently.

Note: minijinja-contrib has a Rust-native wordwrap filter (since 2.12),
but it is gated behind the optional ``wordwrap`` Cargo feature flag.
The minijinja-py 2.15.1 wheel does not enable that feature
(see minijinja-py/Cargo.toml — only ``pycompat`` and ``html_entities``
are enabled from minijinja-contrib). Once a future minijinja-py release
enables the ``wordwrap`` feature, this custom filter can be removed.
Upstream tracking: https://github.com/mitsuhiko/minijinja — no issue
filed yet; consider opening one.
"""
if not value:
return value

# Preserve newlines by wrapping each line independently (matches Jinja2)
wrapped_lines = []
for line in value.splitlines():
# Use textwrap.wrap which matches jinja2's behavior
# break_on_hyphens=True is the Python/Jinja2 default
wrapped = textwrap.wrap(
line,
width=width,
break_long_words=True,
break_on_hyphens=True,
)
# textwrap.wrap returns empty list for empty strings, preserve empty lines
wrapped_lines.extend(wrapped or [""])

return wrapstring.join(wrapped_lines)


def _setup_builtin_filters(env: Environment) -> None:
"""Register filters missing from minijinja-py's compiled feature set.

The minijinja-py wheel currently ships without the ``wordwrap`` Cargo
feature of minijinja-contrib, so ``|wordwrap`` is unavailable by default.
This registers a Python-side replacement. This function can be removed
once minijinja-py enables the ``wordwrap`` feature upstream.
"""
env.add_filter("wordwrap", _wordwrap_filter)


def _new_env(autoescape: bool) -> Environment:
"""Create a new Environment with standard setup (filters, autoescape).

:param autoescape: Whether to enable autoescaping.
:return: A new Environment instance.
"""
env = Environment()
if autoescape:
env.auto_escape_callback = lambda _name: True
_setup_builtin_filters(env)
return env


@lru_cache(maxsize=2)
def _get_cached_env(autoescape: bool) -> Environment:
"""Get or create a cached Environment instance (no custom functions).

Cached per ``autoescape`` value. Safe because the returned
``Environment`` is not mutated after creation. ``lru_cache`` ensures
that concurrent calls for the same key return the same instance
(Python's GIL protects the cache dict). Note that the
``Environment`` object itself is **not** thread-safe — this is
acceptable because Sphinx builds are single-threaded.

The cache persists for the lifetime of the process (e.g. across
rebuilds in ``sphinx-autobuild``). This is fine because the
environments are stateless (no per-build data).

:param autoescape: Whether to enable autoescaping.
:return: A cached Environment instance.
"""
return _new_env(autoescape)


def render_template_string(
template_string: str,
context: dict[str, Any],
*,
autoescape: bool,
new_env: bool = False,
) -> str:
"""Render a Jinja template string with the given context.

:param template_string: The Jinja template string to render.
:param context: Dictionary containing template variables.
:param autoescape: Whether to enable autoescaping.
:param new_env: If True, create a fresh Environment instead of using
the shared cached one. This is required when rendering happens
*inside* a Python callback invoked by an ongoing ``render_str`` on
the cached Environment (e.g. needuml's ``{{ uml() }}`` callbacks),
because MiniJinja's ``Environment`` holds a non-reentrant lock
during ``render_str``.
:return: The rendered template as a string.
"""
env = _new_env(autoescape) if new_env else _get_cached_env(autoescape)
return env.render_str(template_string, **context)


class CompiledTemplate:
"""A pre-compiled template for efficient repeated rendering.

Use :func:`compile_template` to create instances. The template source
is parsed and compiled once; each :meth:`render` call only executes the
already-compiled template, avoiding the per-call parse overhead of
:func:`render_template_string` / ``Environment.render_str``.

This is useful when the same template is rendered many times in a loop
with different contexts (e.g. per-need in external needs loading,
per-cell in needtable string-link rendering, per-need in constraint
error messages, or per-node in PlantUML diagram generation).
"""

__slots__ = ("_env",)

_TEMPLATE_NAME = "__compiled__"

def __init__(self, env: Environment) -> None:
self._env = env

def render(self, context: dict[str, Any]) -> str:
"""Render the compiled template with the given context.

:param context: Dictionary containing template variables.
:return: The rendered template as a string.
"""
return self._env.render_template(self._TEMPLATE_NAME, **context)


@lru_cache(maxsize=32)
def compile_template(
template_string: str,
*,
autoescape: bool,
) -> CompiledTemplate:
"""Compile a template string for efficient repeated rendering.

The returned :class:`CompiledTemplate` parses the source once;
subsequent :meth:`~CompiledTemplate.render` calls skip parsing entirely.
Use this instead of :func:`render_template_string` when the same
template is rendered in a tight loop with varying contexts.

Results are cached by ``(template_string, autoescape)`` so that
multiple call sites sharing the same template (e.g.
``needs_config.diagram_template``) only compile once per build.

The cache persists for the lifetime of the process. This is safe
because compiled templates are keyed by their source text and are
stateless.

:param template_string: The Jinja template string to compile.
:param autoescape: Whether to enable autoescaping.
:return: A compiled template that can be rendered with different contexts.
"""
env = _new_env(autoescape)
env.add_template(CompiledTemplate._TEMPLATE_NAME, template_string)
return CompiledTemplate(env)
11 changes: 7 additions & 4 deletions sphinx_needs/api/need.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@
from docutils import nodes
from docutils.parsers.rst.states import RSTState
from docutils.statemachine import StringList
from jinja2 import Template
from sphinx.application import Sphinx
from sphinx.environment import BuildEnvironment

from sphinx_needs._jinja import render_template_string
from sphinx_needs.config import NeedsSphinxConfig
from sphinx_needs.data import (
NeedsInfoType,
Expand Down Expand Up @@ -967,13 +967,16 @@ def _prepare_template(
with template_path.open() as template_file:
template_content = "".join(template_file.readlines())
try:
template_obj = Template(template_content)
new_content = template_obj.render(**needs_info, **needs_config.render_context)
new_content = render_template_string(
template_content,
{**needs_info, **needs_config.render_context},
autoescape=False,
)
except Exception as e:
raise InvalidNeedException(
"invalid_template",
f"Error while rendering template {template_path}: {e}",
)
) from e

return new_content

Expand Down
17 changes: 11 additions & 6 deletions sphinx_needs/debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
from timeit import default_timer as timer # Used for timing measurements
from typing import Any, TypeVar

from jinja2 import Environment, PackageLoader, select_autoescape
from sphinx.application import Sphinx

from sphinx_needs._jinja import render_template_string

TIME_MEASUREMENTS: dict[str, Any] = {} # Stores the timing results
EXECUTE_TIME_MEASUREMENTS = (
False # Will be used to de/activate measurements. Set during a Sphinx Event
Expand Down Expand Up @@ -165,13 +166,17 @@ def _store_timing_results_json(app: Sphinx, build_data: dict[str, Any]) -> None:


def _store_timing_results_html(app: Sphinx, build_data: dict[str, Any]) -> None:
jinja_env = Environment(
loader=PackageLoader("sphinx_needs"), autoescape=select_autoescape()
)
template = jinja_env.get_template("time_measurements.html")
template_path = Path(__file__).parent / "templates" / "time_measurements.html"
template_content = template_path.read_text(encoding="utf-8")
out_file = Path(str(app.outdir)) / "debug_measurement.html"
with open(out_file, "w", encoding="utf-8") as f:
f.write(template.render(data=TIME_MEASUREMENTS, build_data=build_data))
f.write(
render_template_string(
template_content,
{"data": TIME_MEASUREMENTS, "build_data": build_data},
autoescape=True,
)
)
print(f"Timing measurement report (HTML) stored under {out_file}")


Expand Down
Loading