From a525083bbec4f612b602e41c546b7e60761ad5a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 07:52:32 +0000 Subject: [PATCH 01/22] Initial plan From 7806efce83c79ce0fab632c5f36ec391d2422c4b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 07:56:53 +0000 Subject: [PATCH 02/22] Add minijinja dependency, create adapter module, configure mypy Co-authored-by: chrisjsewell <2997570+chrisjsewell@users.noreply.github.com> --- pyproject.toml | 2 ++ sphinx_needs/_jinja.py | 55 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 sphinx_needs/_jinja.py diff --git a/pyproject.toml b/pyproject.toml index 5ac1dc866..0baa474cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", # lightweight jinja2-compatible template engine ] [project.optional-dependencies] @@ -124,6 +125,7 @@ disallow_subclassing_any = true [[tool.mypy.overrides]] module = [ "matplotlib.*", + "minijinja.*", "numpy.*", "requests_file", "sphinx_data_viewer.*", diff --git a/sphinx_needs/_jinja.py b/sphinx_needs/_jinja.py new file mode 100644 index 000000000..5942f710f --- /dev/null +++ b/sphinx_needs/_jinja.py @@ -0,0 +1,55 @@ +"""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 + +from typing import Any, cast + +from minijinja import Environment + + +def render_template_string( + template_string: str, + context: dict[str, Any], + *, + autoescape: bool = True, + functions: dict[str, Any] | None = None, +) -> 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 (default: True). + :param functions: Optional dictionary of custom functions to register. + :return: The rendered template as a string. + """ + env = Environment() + if autoescape: + # Set auto_escape_callback to always return True for all templates + env.auto_escape_callback = lambda _name: True + if functions: + for name, func in functions.items(): + env.add_function(name, func) + return cast(str, env.render_str(template_string, **context)) + + +def render_template_file( + template_path: str, + context: dict[str, Any], + *, + autoescape: bool = True, +) -> str: + """Render a Jinja template from a file path. + + :param template_path: Path to the template file. + :param context: Dictionary containing template variables. + :param autoescape: Whether to enable autoescaping (default: True). + :return: The rendered template as a string. + """ + from pathlib import Path + + content = Path(template_path).read_text(encoding="utf-8") + return render_template_string(content, context, autoescape=autoescape) From 2828e18939a96298efc64f3d6b6bbf0f4429114f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 07:58:43 +0000 Subject: [PATCH 03/22] Phase 2: Migrate simple Template usage (utils, api/need, needreport, list2need) Co-authored-by: chrisjsewell <2997570+chrisjsewell@users.noreply.github.com> --- sphinx_needs/api/need.py | 11 +++++++---- sphinx_needs/directives/list2need.py | 6 ++---- sphinx_needs/directives/needreport.py | 7 ++++--- sphinx_needs/utils.py | 27 +++++++++++++-------------- 4 files changed, 26 insertions(+), 25 deletions(-) diff --git a/sphinx_needs/api/need.py b/sphinx_needs/api/need.py index 00a0691bf..f5497dd7e 100644 --- a/sphinx_needs/api/need.py +++ b/sphinx_needs/api/need.py @@ -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, @@ -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 diff --git a/sphinx_needs/directives/list2need.py b/sphinx_needs/directives/list2need.py index 16a9dfb34..1d61e46d4 100644 --- a/sphinx_needs/directives/list2need.py +++ b/sphinx_needs/directives/list2need.py @@ -8,10 +8,10 @@ from docutils import nodes from docutils.parsers.rst import directives -from jinja2 import Template from sphinx.errors import SphinxError, SphinxWarning from sphinx.util.docutils import SphinxDirective +from sphinx_needs._jinja import render_template_string from sphinx_needs.config import NeedsSphinxConfig from sphinx_needs.data import SphinxNeedsData @@ -208,8 +208,6 @@ def run(self) -> Sequence[nodes.Node]: else: list_need["options"]["tags"] = tags - template = Template(NEED_TEMPLATE, autoescape=True) - data = list_need need_links_down = self.get_down_needs(list_needs, index) if ( @@ -223,7 +221,7 @@ def run(self) -> Sequence[nodes.Node]: else: data["set_links_down"] = False - text = template.render(**list_need) + text = render_template_string(NEED_TEMPLATE, list_need, autoescape=True) text_list = text.split("\n") if presentation == "nested": indented_text_list = [" " * list_need["level"] + x for x in text_list] diff --git a/sphinx_needs/directives/needreport.py b/sphinx_needs/directives/needreport.py index 4048553a2..efa65be3b 100644 --- a/sphinx_needs/directives/needreport.py +++ b/sphinx_needs/directives/needreport.py @@ -5,10 +5,10 @@ from docutils import nodes from docutils.parsers.rst import directives -from jinja2 import Template from sphinx.util import logging from sphinx.util.docutils import SphinxDirective +from sphinx_needs._jinja import render_template_string from sphinx_needs.config import NeedsSphinxConfig from sphinx_needs.data import SphinxNeedsData from sphinx_needs.logging import log_warning @@ -97,8 +97,9 @@ def run(self) -> Sequence[nodes.raw]: encoding="utf8" ) - template = Template(needs_report_template_file_content, autoescape=True) - text = template.render(**report_info) + text = render_template_string( + needs_report_template_file_content, report_info, autoescape=True + ) self.state_machine.insert_input( text.split("\n"), self.state_machine.document.attributes["source"] ) diff --git a/sphinx_needs/utils.py b/sphinx_needs/utils.py index 0072a2057..e3f1af9dd 100644 --- a/sphinx_needs/utils.py +++ b/sphinx_needs/utils.py @@ -12,10 +12,10 @@ from urllib.parse import urlparse from docutils import nodes -from jinja2 import Environment, 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 SphinxNeedsData from sphinx_needs.defaults import NEEDS_PROFILING @@ -150,12 +150,8 @@ def row_col_maker( link_string_list = {} for link_name, link_conf in needs_config.string_links.items(): link_string_list[link_name] = { - "url_template": Environment(autoescape=True).from_string( - link_conf["link_url"] - ), - "name_template": Environment(autoescape=True).from_string( - link_conf["link_name"] - ), + "url_template": link_conf["link_url"], + "name_template": link_conf["link_name"], "regex_compiled": re.compile(link_conf["regex"]), "options": link_conf["options"], "name": link_name, @@ -393,14 +389,13 @@ def jinja_parse(context: dict[str, Any], jinja_string: str) -> str: """ try: - content_template = Template(jinja_string, autoescape=True) + content = render_template_string(jinja_string, context, autoescape=True) except Exception as e: raise ReferenceError( f'There was an error in the jinja statement: "{jinja_string}". ' f"Error Msg: {e}" - ) + ) from e - content = content_template.render(**context) return content @@ -494,11 +489,15 @@ def match_string_link( match = link_conf["regex_compiled"].search(data) if match: render_content = match.groupdict() - link_url = link_conf["url_template"].render( - **render_content, **render_context + link_url = render_template_string( + link_conf["url_template"], + {**render_content, **render_context}, + autoescape=True, ) - link_name = link_conf["name_template"].render( - **render_content, **render_context + link_name = render_template_string( + link_conf["name_template"], + {**render_content, **render_context}, + autoescape=True, ) # if no string_link match was made, we handle it as normal string value From ea567861a68b086da13e65c5dc4204409be56a7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 08:01:00 +0000 Subject: [PATCH 04/22] Phase 2 complete: Migrate all simple Template usage files Co-authored-by: chrisjsewell <2997570+chrisjsewell@users.noreply.github.com> --- performance/performance_test.py | 99 ++++++++++++------- sphinx_needs/directives/needflow/_plantuml.py | 12 ++- sphinx_needs/need_constraints.py | 13 ++- sphinx_needs/services/open_needs.py | 5 +- 4 files changed, 79 insertions(+), 50 deletions(-) diff --git a/performance/performance_test.py b/performance/performance_test.py index 7a7cd2c9f..b1536b535 100644 --- a/performance/performance_test.py +++ b/performance/performance_test.py @@ -13,7 +13,20 @@ from pathlib import Path import click -from jinja2 import Template + +# Import from sphinx_needs if available, otherwise use minijinja directly +try: + from sphinx_needs._jinja import render_template_string +except ImportError: + from minijinja import Environment + + def render_template_string(template_string, context, *, autoescape=True): + env = Environment() + if autoescape: + env.auto_escape_callback = lambda _name: True + return env.render_str(template_string, **context) + + from tabulate import tabulate @@ -45,17 +58,21 @@ 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) @@ -63,19 +80,23 @@ def start( # 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) @@ -84,20 +105,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) diff --git a/sphinx_needs/directives/needflow/_plantuml.py b/sphinx_needs/directives/needflow/_plantuml.py index 2d18d5ed4..5a33700af 100644 --- a/sphinx_needs/directives/needflow/_plantuml.py +++ b/sphinx_needs/directives/needflow/_plantuml.py @@ -5,9 +5,9 @@ from functools import lru_cache from docutils import nodes -from jinja2 import Template from sphinx.application import Sphinx +from sphinx_needs._jinja import render_template_string from sphinx_needs.config import NeedsSphinxConfig from sphinx_needs.data import NeedsFlowType, SphinxNeedsData from sphinx_needs.debug import measure_time @@ -46,7 +46,9 @@ def get_need_node_rep_for_plantuml( needs_config = NeedsSphinxConfig(app.config) diagram_template = get_template(needs_config.diagram_template) - node_text = diagram_template.render(**need_info, **needs_config.render_context) + node_text = render_template_string( + diagram_template, {**need_info, **needs_config.render_context}, autoescape=False + ) node_link = calculate_link(app, need_info, fromdocname) @@ -435,6 +437,6 @@ def render_connections( @lru_cache -def get_template(template_name: str) -> Template: - """Checks if a template got already rendered, if it's the case, return it""" - return Template(template_name) +def get_template(template_name: str) -> str: + """Return the template string for caching purposes.""" + return template_name diff --git a/sphinx_needs/need_constraints.py b/sphinx_needs/need_constraints.py index 552527fa9..93fcf0b3f 100644 --- a/sphinx_needs/need_constraints.py +++ b/sphinx_needs/need_constraints.py @@ -1,7 +1,8 @@ from __future__ import annotations -import jinja2 +from typing import Any, cast +from sphinx_needs._jinja import render_template_string from sphinx_needs.config import NeedsSphinxConfig from sphinx_needs.data import NeedsMutable from sphinx_needs.exceptions import NeedsConstraintFailed @@ -22,7 +23,7 @@ def process_constraints(needs: NeedsMutable, config: NeedsSphinxConfig) -> None: """ config_constraints = config.constraints - error_templates_cache: dict[str, jinja2.Template] = {} + error_templates_cache: dict[str, str] = {} for need in needs.values(): need_id = need["id"] @@ -51,10 +52,12 @@ def process_constraints(needs: NeedsMutable, config: NeedsSphinxConfig) -> None: if not constraint_passed: if "error_message" in executable_constraints: msg = str(executable_constraints["error_message"]) - template = error_templates_cache.setdefault( - msg, jinja2.Template(msg) + template = error_templates_cache.setdefault(msg, msg) + error_msg = render_template_string( + template, + cast(dict[str, Any], need), + autoescape=False, ) - error_msg = template.render(**need) if "severity" not in executable_constraints: raise NeedsConstraintFailed( diff --git a/sphinx_needs/services/open_needs.py b/sphinx_needs/services/open_needs.py index 4ba1ae648..1e4a132e7 100644 --- a/sphinx_needs/services/open_needs.py +++ b/sphinx_needs/services/open_needs.py @@ -5,10 +5,10 @@ from typing import Any import requests -from jinja2 import Template from sphinx.application import Sphinx from sphinx.util.docutils import SphinxDirective +from sphinx_needs._jinja import render_template_string from sphinx_needs.config import NeedsSphinxConfig from sphinx_needs.utils import dict_get, jinja_parse @@ -157,9 +157,8 @@ def _extract_data( else: extra_data[name] = dict_get(item, selector) - content_template = Template(self.content, autoescape=True) context = {"data": item, "options": options, **needs_config.render_context} - content = content_template.render(context) + content = render_template_string(self.content, context, autoescape=True) content += "\n\n| \n" # Add enough space between content and extra_data # Add extra_data to content From 343ceb0222ed587e06952d6c6b34eb0dc62dc0e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 08:02:11 +0000 Subject: [PATCH 05/22] Phase 3 complete: Migrate Environment().from_string() usage Co-authored-by: chrisjsewell <2997570+chrisjsewell@users.noreply.github.com> --- sphinx_needs/external_needs.py | 15 ++++++++------- sphinx_needs/layout.py | 7 +++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/sphinx_needs/external_needs.py b/sphinx_needs/external_needs.py index 0e39b4ccb..b189446de 100644 --- a/sphinx_needs/external_needs.py +++ b/sphinx_needs/external_needs.py @@ -5,11 +5,11 @@ from functools import lru_cache import requests -from jinja2 import Environment, Template from requests_file import FileAdapter from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment +from sphinx_needs._jinja import render_template_string from sphinx_needs.api import InvalidNeedException, add_external_need, del_need from sphinx_needs.config import NeedsSphinxConfig from sphinx_needs.data import NeedsCoreFields, SphinxNeedsData @@ -21,13 +21,12 @@ @lru_cache(maxsize=20) -def get_target_template(target_url: str) -> Template: - """ - Provides template for target_link style +def get_target_template(target_url: str) -> str: + """Provides template string for target_link style. + Can be cached, as the template is always the same for a given target_url """ - mem_template = Environment().from_string(target_url) - return mem_template + return target_url def load_external_needs( @@ -172,7 +171,9 @@ def load_external_needs( if target_url: # render jinja content mem_template = get_target_template(target_url) - cal_target_url = mem_template.render(**{"need": need}) + cal_target_url = render_template_string( + mem_template, {"need": need}, autoescape=False + ) external_url = f"{source['base_url']}/{cal_target_url}" else: external_url = f"{source['base_url']}/{need.get('docname', '__error__')}.html#{need['id']}" diff --git a/sphinx_needs/layout.py b/sphinx_needs/layout.py index 82b8f3c8c..cb338b632 100644 --- a/sphinx_needs/layout.py +++ b/sphinx_needs/layout.py @@ -22,7 +22,6 @@ from docutils.parsers.rst import Parser, languages from docutils.parsers.rst.states import Inliner, Struct from docutils.utils import new_document -from jinja2 import Environment from sphinx.application import Sphinx from sphinx.util.logging import getLogger @@ -296,13 +295,13 @@ def __init__( # Prepare string_links dict, so that regex and templates get not recompiled too often. # # Do not set needs_string_links here and update it. - # This would lead to deepcopy()-errors, as needs_string_links gets some "pickled" and jinja Environment is + # This would lead to deepcopy()-errors, as needs_string_links gets some "pickled" and complex objects are # too complex for this. self.string_links = {} for link_name, link_conf in self.needs_config.string_links.items(): self.string_links[link_name] = { - "url_template": Environment().from_string(link_conf["link_url"]), - "name_template": Environment().from_string(link_conf["link_name"]), + "url_template": link_conf["link_url"], + "name_template": link_conf["link_name"], "regex_compiled": re.compile(link_conf["regex"]), "options": link_conf["options"], "name": link_name, From 0b66cdf70377292bebcc7c94d49186c0eb2f9d58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 08:03:06 +0000 Subject: [PATCH 06/22] Phase 4 complete: Migrate PackageLoader usage Co-authored-by: chrisjsewell <2997570+chrisjsewell@users.noreply.github.com> --- sphinx_needs/debug.py | 17 +++++++++++------ sphinx_needs/environment.py | 20 +++++++++++--------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/sphinx_needs/debug.py b/sphinx_needs/debug.py index 975d0e02d..52453f01b 100644 --- a/sphinx_needs/debug.py +++ b/sphinx_needs/debug.py @@ -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 @@ -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}") diff --git a/sphinx_needs/environment.py b/sphinx_needs/environment.py index 79a9a54b5..5f2ce9566 100644 --- a/sphinx_needs/environment.py +++ b/sphinx_needs/environment.py @@ -2,12 +2,12 @@ from pathlib import Path -from jinja2 import Environment, PackageLoader, select_autoescape from sphinx import version_info as sphinx_version from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment from sphinx.util.fileutil import copy_asset, copy_asset_file +from sphinx_needs._jinja import render_template_string from sphinx_needs.config import NeedsSphinxConfig from sphinx_needs.logging import log_warning from sphinx_needs.utils import logger @@ -120,19 +120,21 @@ def install_permalink_file(app: Sphinx, env: BuildEnvironment) -> None: return # load jinja template - jinja_env = Environment( - loader=PackageLoader("sphinx_needs"), autoescape=select_autoescape() - ) - template = jinja_env.get_template("permalink.html") + template_path = Path(__file__).parent / "templates" / "permalink.html" + template_content = template_path.read_text(encoding="utf-8") # save file to build dir sphinx_config = NeedsSphinxConfig(env.config) out_file = Path(builder.outdir) / Path(sphinx_config.permalink_file).name with open(out_file, "w", encoding="utf-8") as f: f.write( - template.render( - permalink_file=sphinx_config.permalink_file, - needs_file=sphinx_config.permalink_data, - **sphinx_config.render_context, + render_template_string( + template_content, + { + "permalink_file": sphinx_config.permalink_file, + "needs_file": sphinx_config.permalink_data, + **sphinx_config.render_context, + }, + autoescape=True, ) ) From 45ad72a262cab7b513bb81fd9465737b14b835cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 08:05:04 +0000 Subject: [PATCH 07/22] Phase 5 complete: Migrate complex needuml and test infrastructure Co-authored-by: chrisjsewell <2997570+chrisjsewell@users.noreply.github.com> --- sphinx_needs/directives/needuml.py | 31 +++++++++++++++--------------- tests/conftest.py | 8 +++++--- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/sphinx_needs/directives/needuml.py b/sphinx_needs/directives/needuml.py index 7a20bae67..634e3f7d6 100644 --- a/sphinx_needs/directives/needuml.py +++ b/sphinx_needs/directives/needuml.py @@ -9,10 +9,10 @@ from docutils import nodes from docutils.parsers.rst import directives -from jinja2 import BaseLoader, Environment, Template from sphinx.application import Sphinx from sphinx.util.docutils import SphinxDirective +from sphinx_needs._jinja import render_template_string from sphinx_needs.config import NeedsSphinxConfig from sphinx_needs.data import SphinxNeedsData from sphinx_needs.debug import measure_time @@ -224,13 +224,10 @@ def jinja2uml( # 1. Remove @startuml and @enduml uml_content = uml_content.replace("@startuml", "").replace("@enduml", "") - # 2. Prepare jinja template - mem_template = Environment(loader=BaseLoader()).from_string(uml_content) - - # 3. Get a new instance of Jinja Helper Functions + # 2. Get a new instance of Jinja Helper Functions jinja_utils = JinjaFunctions(app, fromdocname, parent_need_id, processed_need_ids) - # 4. Append need_id to processed_need_ids, so it will not been processed again + # 3. Append need_id to processed_need_ids, so it will not been processed again if parent_need_id: jinja_utils.append_need_to_processed_needs( need_id=parent_need_id, art="uml", key=key, kwargs=kwargs @@ -242,9 +239,13 @@ def jinja2uml( data.update(**NeedsSphinxConfig(app.config).render_context) # 5.2 Set uml() kwargs to data and maybe overwrite default settings data.update(kwargs) - # 5.3 Make the helpers available during rendering and overwrite maybe wrongly default and uml() kwargs settings - data.update( - { + + # 6. Render the uml content with the fetched data and custom functions + uml = render_template_string( + uml_content, + data, + autoescape=False, + functions={ "needs": jinja_utils.needs, "need": jinja_utils.need, "uml": jinja_utils.uml_from_need, @@ -252,12 +253,9 @@ def jinja2uml( "filter": jinja_utils.filter, "import": jinja_utils.imports, "ref": jinja_utils.ref, - } + }, ) - # 6. Render the uml content with the fetched data - uml = mem_template.render(**data) - # 7. Get processed need ids processed_need_ids_return = jinja_utils.get_processed_need_ids() @@ -389,8 +387,11 @@ def flow(self, need_id: str) -> str: link = calculate_link(self.app, need_info, self.fromdocname) needs_config = NeedsSphinxConfig(self.app.config) - diagram_template = Template(needs_config.diagram_template) - node_text = diagram_template.render(**need_info, **needs_config.render_context) + node_text = render_template_string( + needs_config.diagram_template, + {**need_info, **needs_config.render_context}, + autoescape=False, + ) need_uml = '{style} "{node_text}" as {id} [[{link}]] #{color}'.format( id=make_entity_name(need_id), diff --git a/tests/conftest.py b/tests/conftest.py index 68ed4e893..7b7bd2629 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,7 +18,6 @@ import yaml from _pytest.mark import ParameterSet from docutils.nodes import document -from jinja2 import Template from sphinx import version_info from sphinx.application import Sphinx from sphinx.testing.util import SphinxTestApp @@ -26,6 +25,8 @@ from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode from xprocess import ProcessStarter +from sphinx_needs._jinja import render_template_string + pytest_plugins = "sphinx.testing.fixtures" @@ -487,7 +488,6 @@ def schema_benchmark_app(tmpdir: Path, request: pytest.SubRequest, make_app): with page_template_path.open() as fp: template_content = fp.read() - template = Template(template_content) pages_dir = Path(tmpdir) / "pages" pages_dir.mkdir(exist_ok=True) toctree_content = """ @@ -498,7 +498,9 @@ def schema_benchmark_app(tmpdir: Path, request: pytest.SubRequest, make_app): width = len(str(page_cnt)) for i in range(1, page_cnt + 1): i_fmt = f"{i:0{width}d}" - page_rst_content = template.render(page_nr=i_fmt) + page_rst_content = render_template_string( + template_content, {"page_nr": i_fmt}, autoescape=False + ) page_name = f"page_{i_fmt}" page_file = f"{page_name}.rst" From 35979b8d2973978649a9f3585a2e57650530a4cc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 08:08:58 +0000 Subject: [PATCH 08/22] Fix needuml jinja rendering: add wordwrap filter and fix context handling Co-authored-by: chrisjsewell <2997570+chrisjsewell@users.noreply.github.com> --- sphinx_needs/_jinja.py | 45 ++++++++++++++++++++++++++++++ sphinx_needs/directives/needuml.py | 3 +- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/sphinx_needs/_jinja.py b/sphinx_needs/_jinja.py index 5942f710f..a58a3ef5f 100644 --- a/sphinx_needs/_jinja.py +++ b/sphinx_needs/_jinja.py @@ -11,6 +11,47 @@ 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 mimics the behavior of Jinja2's wordwrap filter. + """ + words = value.split() + if not words: + return value + + lines = [] + current_line: list[str] = [] + current_length = 0 + + for word in words: + word_length = len(word) + # +1 for the space before the word (except for the first word in a line) + space_needed = word_length + (1 if current_line else 0) + + if current_length + space_needed <= width or not current_line: + # Word fits on current line or it's the first word + current_line.append(word) + current_length += space_needed + else: + # Start a new line + lines.append(" ".join(current_line)) + current_line = [word] + current_length = word_length + + # Add the last line + if current_line: + lines.append(" ".join(current_line)) + + return wrapstring.join(lines) + + +def _setup_builtin_filters(env: Environment) -> None: + """Add Jinja2-compatible built-in filters to the environment.""" + env.add_filter("wordwrap", _wordwrap_filter) + + def render_template_string( template_string: str, context: dict[str, Any], @@ -30,6 +71,10 @@ def render_template_string( if autoescape: # Set auto_escape_callback to always return True for all templates env.auto_escape_callback = lambda _name: True + + # Add built-in filters + _setup_builtin_filters(env) + if functions: for name, func in functions.items(): env.add_function(name, func) diff --git a/sphinx_needs/directives/needuml.py b/sphinx_needs/directives/needuml.py index 634e3f7d6..5dd7ce28e 100644 --- a/sphinx_needs/directives/needuml.py +++ b/sphinx_needs/directives/needuml.py @@ -239,6 +239,8 @@ def jinja2uml( data.update(**NeedsSphinxConfig(app.config).render_context) # 5.2 Set uml() kwargs to data and maybe overwrite default settings data.update(kwargs) + # 5.3 Add needs as data (dict-like mapping), not as a function + data["needs"] = jinja_utils.needs # 6. Render the uml content with the fetched data and custom functions uml = render_template_string( @@ -246,7 +248,6 @@ def jinja2uml( data, autoescape=False, functions={ - "needs": jinja_utils.needs, "need": jinja_utils.need, "uml": jinja_utils.uml_from_need, "flow": jinja_utils.flow, From 47f750718fd4c15cb1fb0c9e75f73bf92a58ec3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:13:25 +0000 Subject: [PATCH 09/22] Address performance review: cache Environment, remove no-op caches, fix wordwrap, pin version Co-authored-by: chrisjsewell <2997570+chrisjsewell@users.noreply.github.com> --- performance/performance_test.py | 16 +--- pyproject.toml | 2 +- sphinx_needs/_jinja.py | 85 ++++++++++++------- sphinx_needs/directives/needflow/_plantuml.py | 12 +-- sphinx_needs/external_needs.py | 13 +-- sphinx_needs/need_constraints.py | 5 +- 6 files changed, 60 insertions(+), 73 deletions(-) diff --git a/performance/performance_test.py b/performance/performance_test.py index b1536b535..ab36fff8a 100644 --- a/performance/performance_test.py +++ b/performance/performance_test.py @@ -13,22 +13,10 @@ from pathlib import Path import click - -# Import from sphinx_needs if available, otherwise use minijinja directly -try: - from sphinx_needs._jinja import render_template_string -except ImportError: - from minijinja import Environment - - def render_template_string(template_string, context, *, autoescape=True): - env = Environment() - if autoescape: - env.auto_escape_callback = lambda _name: True - return env.render_str(template_string, **context) - - from tabulate import tabulate +from sphinx_needs._jinja import render_template_string + @click.group() def cli(): diff --git a/pyproject.toml b/pyproject.toml index 0baa474cd..d374dbce6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +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", # lightweight jinja2-compatible template engine + "minijinja~=2.0", # lightweight jinja2-compatible template engine ] [project.optional-dependencies] diff --git a/sphinx_needs/_jinja.py b/sphinx_needs/_jinja.py index a58a3ef5f..eaca2dd00 100644 --- a/sphinx_needs/_jinja.py +++ b/sphinx_needs/_jinja.py @@ -6,43 +6,33 @@ from __future__ import annotations +import textwrap from typing import Any, cast from minijinja import Environment +# Module-level cached environments for performance +_CACHED_ENV: Environment | None = None +_CACHED_ENV_AUTOESCAPE: Environment | None = None + 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 mimics the behavior of Jinja2's wordwrap filter. + This uses Python's textwrap module to match Jinja2's wordwrap behavior. """ - words = value.split() - if not words: + if not value: return value - lines = [] - current_line: list[str] = [] - current_length = 0 - - for word in words: - word_length = len(word) - # +1 for the space before the word (except for the first word in a line) - space_needed = word_length + (1 if current_line else 0) - - if current_length + space_needed <= width or not current_line: - # Word fits on current line or it's the first word - current_line.append(word) - current_length += space_needed - else: - # Start a new line - lines.append(" ".join(current_line)) - current_line = [word] - current_length = word_length - - # Add the last line - if current_line: - lines.append(" ".join(current_line)) + # Use textwrap.wrap which matches jinja2's behavior more closely + # It handles long words, preserves line breaks, etc. + lines = textwrap.wrap( + value, + width=width, + break_long_words=True, + break_on_hyphens=False, + ) return wrapstring.join(lines) @@ -52,6 +42,33 @@ def _setup_builtin_filters(env: Environment) -> None: env.add_filter("wordwrap", _wordwrap_filter) +def _get_cached_env(autoescape: bool) -> Environment: + """Get or create a cached Environment instance. + + For performance, we cache module-level Environment instances to avoid + recreating them on every render call. This is safe because the Environment + is stateless for rendering purposes. + + :param autoescape: Whether to enable autoescaping. + :return: A cached Environment instance. + """ + global _CACHED_ENV, _CACHED_ENV_AUTOESCAPE + + if autoescape: + if _CACHED_ENV_AUTOESCAPE is None: + env = Environment() + env.auto_escape_callback = lambda _name: True + _setup_builtin_filters(env) + _CACHED_ENV_AUTOESCAPE = env + return _CACHED_ENV_AUTOESCAPE + else: + if _CACHED_ENV is None: + env = Environment() + _setup_builtin_filters(env) + _CACHED_ENV = env + return _CACHED_ENV + + def render_template_string( template_string: str, context: dict[str, Any], @@ -67,17 +84,19 @@ def render_template_string( :param functions: Optional dictionary of custom functions to register. :return: The rendered template as a string. """ - env = Environment() - if autoescape: - # Set auto_escape_callback to always return True for all templates - env.auto_escape_callback = lambda _name: True - - # Add built-in filters - _setup_builtin_filters(env) - if functions: + # If custom functions are needed, create a new Environment + # This is only used in needuml.py and is relatively infrequent + env = Environment() + if autoescape: + env.auto_escape_callback = lambda _name: True + _setup_builtin_filters(env) for name, func in functions.items(): env.add_function(name, func) + else: + # For the common case (no custom functions), use cached Environment + env = _get_cached_env(autoescape) + return cast(str, env.render_str(template_string, **context)) diff --git a/sphinx_needs/directives/needflow/_plantuml.py b/sphinx_needs/directives/needflow/_plantuml.py index 5a33700af..7d44e277d 100644 --- a/sphinx_needs/directives/needflow/_plantuml.py +++ b/sphinx_needs/directives/needflow/_plantuml.py @@ -2,7 +2,6 @@ import html import os -from functools import lru_cache from docutils import nodes from sphinx.application import Sphinx @@ -44,10 +43,11 @@ def get_need_node_rep_for_plantuml( ) -> str: """Calculate need node representation for plantuml.""" needs_config = NeedsSphinxConfig(app.config) - diagram_template = get_template(needs_config.diagram_template) node_text = render_template_string( - diagram_template, {**need_info, **needs_config.render_context}, autoescape=False + needs_config.diagram_template, + {**need_info, **needs_config.render_context}, + autoescape=False, ) node_link = calculate_link(app, need_info, fromdocname) @@ -434,9 +434,3 @@ def render_connections( style_end=link_type.display.style_end, ) return puml_connections - - -@lru_cache -def get_template(template_name: str) -> str: - """Return the template string for caching purposes.""" - return template_name diff --git a/sphinx_needs/external_needs.py b/sphinx_needs/external_needs.py index b189446de..7f325fb20 100644 --- a/sphinx_needs/external_needs.py +++ b/sphinx_needs/external_needs.py @@ -2,7 +2,6 @@ import json import os -from functools import lru_cache import requests from requests_file import FileAdapter @@ -20,15 +19,6 @@ log = get_logger(__name__) -@lru_cache(maxsize=20) -def get_target_template(target_url: str) -> str: - """Provides template string for target_link style. - - Can be cached, as the template is always the same for a given target_url - """ - return target_url - - def load_external_needs( app: Sphinx, env: BuildEnvironment, _docnames: list[str] ) -> None: @@ -170,9 +160,8 @@ def load_external_needs( if target_url: # render jinja content - mem_template = get_target_template(target_url) cal_target_url = render_template_string( - mem_template, {"need": need}, autoescape=False + target_url, {"need": need}, autoescape=False ) external_url = f"{source['base_url']}/{cal_target_url}" else: diff --git a/sphinx_needs/need_constraints.py b/sphinx_needs/need_constraints.py index 93fcf0b3f..e6834c62b 100644 --- a/sphinx_needs/need_constraints.py +++ b/sphinx_needs/need_constraints.py @@ -23,8 +23,6 @@ def process_constraints(needs: NeedsMutable, config: NeedsSphinxConfig) -> None: """ config_constraints = config.constraints - error_templates_cache: dict[str, str] = {} - for need in needs.values(): need_id = need["id"] @@ -52,9 +50,8 @@ def process_constraints(needs: NeedsMutable, config: NeedsSphinxConfig) -> None: if not constraint_passed: if "error_message" in executable_constraints: msg = str(executable_constraints["error_message"]) - template = error_templates_cache.setdefault(msg, msg) error_msg = render_template_string( - template, + msg, cast(dict[str, Any], need), autoescape=False, ) From ba36f483d0c1294e9f275ac6aa1d19cb800baf80 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:52:29 +0000 Subject: [PATCH 10/22] Fix remaining review issues: break_on_hyphens, thread safety comment, verify jinja tests Co-authored-by: chrisjsewell <2997570+chrisjsewell@users.noreply.github.com> --- sphinx_needs/_jinja.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/sphinx_needs/_jinja.py b/sphinx_needs/_jinja.py index eaca2dd00..fa39f3487 100644 --- a/sphinx_needs/_jinja.py +++ b/sphinx_needs/_jinja.py @@ -25,13 +25,13 @@ def _wordwrap_filter(value: str, width: int = 79, wrapstring: str = "\n") -> str if not value: return value - # Use textwrap.wrap which matches jinja2's behavior more closely - # It handles long words, preserves line breaks, etc. + # Use textwrap.wrap which matches jinja2's behavior + # break_on_hyphens=True is the Python/Jinja2 default lines = textwrap.wrap( value, width=width, break_long_words=True, - break_on_hyphens=False, + break_on_hyphens=True, ) return wrapstring.join(lines) @@ -49,6 +49,10 @@ def _get_cached_env(autoescape: bool) -> Environment: recreating them on every render call. This is safe because the Environment is stateless for rendering purposes. + Thread safety: Benign race condition - if multiple threads check for None + simultaneously, worst case is creating extra Environment instances. Sphinx + parallel builds use processes, not threads, so this is not a concern. + :param autoescape: Whether to enable autoescaping. :return: A cached Environment instance. """ From 26f172343e03925fc811c4bf2b3a1136446522f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:26:22 +0000 Subject: [PATCH 11/22] Bump minijinja to ~=2.15, use add_global for functions (proper type stubs) Co-authored-by: chrisjsewell <2997570+chrisjsewell@users.noreply.github.com> --- .pre-commit-config.yaml | 1 + pyproject.toml | 3 +-- sphinx_needs/_jinja.py | 16 ++++++++++++---- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d6ffb0394..57e8def0a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index d374dbce6..62c92768e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +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.0", # lightweight jinja2-compatible template engine + "minijinja~=2.15", # lightweight jinja2-compatible template engine ] [project.optional-dependencies] @@ -125,7 +125,6 @@ disallow_subclassing_any = true [[tool.mypy.overrides]] module = [ "matplotlib.*", - "minijinja.*", "numpy.*", "requests_file", "sphinx_data_viewer.*", diff --git a/sphinx_needs/_jinja.py b/sphinx_needs/_jinja.py index fa39f3487..593973ea0 100644 --- a/sphinx_needs/_jinja.py +++ b/sphinx_needs/_jinja.py @@ -7,7 +7,7 @@ from __future__ import annotations import textwrap -from typing import Any, cast +from typing import Any from minijinja import Environment @@ -21,6 +21,9 @@ def _wordwrap_filter(value: str, width: int = 79, wrapstring: str = "\n") -> str Wraps text to specified width, inserting wrapstring between wrapped lines. This uses Python's textwrap module to match Jinja2's wordwrap behavior. + + Note: While minijinja 2.12+ includes a built-in wordwrap filter in Rust, + it is not currently exposed in the Python bindings (minijinja-py). """ if not value: return value @@ -38,7 +41,11 @@ def _wordwrap_filter(value: str, width: int = 79, wrapstring: str = "\n") -> str def _setup_builtin_filters(env: Environment) -> None: - """Add Jinja2-compatible built-in filters to the environment.""" + """Add Jinja2-compatible built-in filters to the environment. + + Note: This is needed because minijinja-py doesn't expose the Rust + built-in filters like wordwrap, even though they exist in minijinja 2.12+. + """ env.add_filter("wordwrap", _wordwrap_filter) @@ -96,12 +103,13 @@ def render_template_string( env.auto_escape_callback = lambda _name: True _setup_builtin_filters(env) for name, func in functions.items(): - env.add_function(name, func) + # Use add_global instead of add_function (same behavior, better type stubs) + env.add_global(name, func) else: # For the common case (no custom functions), use cached Environment env = _get_cached_env(autoescape) - return cast(str, env.render_str(template_string, **context)) + return env.render_str(template_string, **context) def render_template_file( From d89356526934b6c97e21282c8ede583d54f082b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:43:03 +0000 Subject: [PATCH 12/22] Clarify wordwrap filter documentation - verified built-in not available in Python bindings Co-authored-by: chrisjsewell <2997570+chrisjsewell@users.noreply.github.com> --- sphinx_needs/_jinja.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/sphinx_needs/_jinja.py b/sphinx_needs/_jinja.py index 593973ea0..71ac8967f 100644 --- a/sphinx_needs/_jinja.py +++ b/sphinx_needs/_jinja.py @@ -22,8 +22,10 @@ def _wordwrap_filter(value: str, width: int = 79, wrapstring: str = "\n") -> str Wraps text to specified width, inserting wrapstring between wrapped lines. This uses Python's textwrap module to match Jinja2's wordwrap behavior. - Note: While minijinja 2.12+ includes a built-in wordwrap filter in Rust, - it is not currently exposed in the Python bindings (minijinja-py). + Note: While minijinja 2.12+ includes a built-in wordwrap filter in Rust core, + it is NOT exposed in the Python bindings (minijinja-py 2.15.1). Testing confirms + Environment().render_str("{{ x | wordwrap(10) }}") raises "unknown filter" error. + Custom filters must be added via add_filter() in Python. """ if not value: return value @@ -43,8 +45,10 @@ def _wordwrap_filter(value: str, width: int = 79, wrapstring: str = "\n") -> str def _setup_builtin_filters(env: Environment) -> None: """Add Jinja2-compatible built-in filters to the environment. - Note: This is needed because minijinja-py doesn't expose the Rust - built-in filters like wordwrap, even though they exist in minijinja 2.12+. + Note: minijinja-py doesn't expose Rust built-in filters (like wordwrap) + to Python. Even though they exist in minijinja Rust core 2.12+, the Python + bindings only provide the add_filter() API for registering custom filters. + Tested on minijinja-py 2.15.1. """ env.add_filter("wordwrap", _wordwrap_filter) From d974fe465bd1f2d77971818bbb8c6e6db11b0073 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:07:25 +0000 Subject: [PATCH 13/22] Update wordwrap filter documentation to clarify Cargo feature flag issue Co-authored-by: chrisjsewell <2997570+chrisjsewell@users.noreply.github.com> --- sphinx_needs/_jinja.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/sphinx_needs/_jinja.py b/sphinx_needs/_jinja.py index 71ac8967f..47f0a73b5 100644 --- a/sphinx_needs/_jinja.py +++ b/sphinx_needs/_jinja.py @@ -22,10 +22,14 @@ def _wordwrap_filter(value: str, width: int = 79, wrapstring: str = "\n") -> str Wraps text to specified width, inserting wrapstring between wrapped lines. This uses Python's textwrap module to match Jinja2's wordwrap behavior. - Note: While minijinja 2.12+ includes a built-in wordwrap filter in Rust core, - it is NOT exposed in the Python bindings (minijinja-py 2.15.1). Testing confirms - Environment().render_str("{{ x | wordwrap(10) }}") raises "unknown filter" error. - Custom filters must be added via add_filter() in Python. + 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 @@ -43,12 +47,12 @@ def _wordwrap_filter(value: str, width: int = 79, wrapstring: str = "\n") -> str def _setup_builtin_filters(env: Environment) -> None: - """Add Jinja2-compatible built-in filters to the environment. + """Register filters missing from minijinja-py's compiled feature set. - Note: minijinja-py doesn't expose Rust built-in filters (like wordwrap) - to Python. Even though they exist in minijinja Rust core 2.12+, the Python - bindings only provide the add_filter() API for registering custom filters. - Tested on minijinja-py 2.15.1. + 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) From ca36eda9012a65c1c70b9f73a9acbcbda5ba81a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:44:31 +0000 Subject: [PATCH 14/22] Update test snapshot for None->none rendering change in minijinja Co-authored-by: chrisjsewell <2997570+chrisjsewell@users.noreply.github.com> --- tests/__snapshots__/test_needuml.ambr | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/__snapshots__/test_needuml.ambr b/tests/__snapshots__/test_needuml.ambr index 162b512da..5242bc98b 100644 --- a/tests/__snapshots__/test_needuml.ambr +++ b/tests/__snapshots__/test_needuml.ambr @@ -889,7 +889,7 @@ class "Test story" as test { implement - None + none } node "System\n**Test System**\nSYS_001" as SYS_001 [[../index.html#SYS_001]] #FF68D2 @@ -934,7 +934,7 @@ class "Test story" as test { implement - None + none } node "System\n**Test System**\nSYS_001" as SYS_001 [[../index.html#SYS_001]] #FF68D2 @@ -1011,7 +1011,7 @@ class "Test story" as test { implement - None + none } Alice -> Bob: Hi Bob @@ -1080,7 +1080,7 @@ class "Test story" as test { implement - None + none } node "System\n**Test System**\nSYS_001" as SYS_001 [[../index.html#SYS_001]] #FF68D2 From dc8949d66994f2f03af8dae1159b77b723d0b0ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:22:45 +0000 Subject: [PATCH 15/22] Fix autoescape for non-HTML template rendering (RST, PlantUML, URLs) Co-authored-by: chrisjsewell <2997570+chrisjsewell@users.noreply.github.com> --- sphinx_needs/directives/list2need.py | 2 +- sphinx_needs/directives/needreport.py | 2 +- sphinx_needs/services/open_needs.py | 2 +- sphinx_needs/utils.py | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sphinx_needs/directives/list2need.py b/sphinx_needs/directives/list2need.py index 1d61e46d4..378c39f39 100644 --- a/sphinx_needs/directives/list2need.py +++ b/sphinx_needs/directives/list2need.py @@ -221,7 +221,7 @@ def run(self) -> Sequence[nodes.Node]: else: data["set_links_down"] = False - text = render_template_string(NEED_TEMPLATE, list_need, autoescape=True) + text = render_template_string(NEED_TEMPLATE, list_need, autoescape=False) text_list = text.split("\n") if presentation == "nested": indented_text_list = [" " * list_need["level"] + x for x in text_list] diff --git a/sphinx_needs/directives/needreport.py b/sphinx_needs/directives/needreport.py index efa65be3b..825a2af18 100644 --- a/sphinx_needs/directives/needreport.py +++ b/sphinx_needs/directives/needreport.py @@ -98,7 +98,7 @@ def run(self) -> Sequence[nodes.raw]: ) text = render_template_string( - needs_report_template_file_content, report_info, autoescape=True + needs_report_template_file_content, report_info, autoescape=False ) self.state_machine.insert_input( text.split("\n"), self.state_machine.document.attributes["source"] diff --git a/sphinx_needs/services/open_needs.py b/sphinx_needs/services/open_needs.py index 1e4a132e7..e50c8b05a 100644 --- a/sphinx_needs/services/open_needs.py +++ b/sphinx_needs/services/open_needs.py @@ -158,7 +158,7 @@ def _extract_data( extra_data[name] = dict_get(item, selector) context = {"data": item, "options": options, **needs_config.render_context} - content = render_template_string(self.content, context, autoescape=True) + content = render_template_string(self.content, context, autoescape=False) content += "\n\n| \n" # Add enough space between content and extra_data # Add extra_data to content diff --git a/sphinx_needs/utils.py b/sphinx_needs/utils.py index e3f1af9dd..78c581738 100644 --- a/sphinx_needs/utils.py +++ b/sphinx_needs/utils.py @@ -389,7 +389,7 @@ def jinja_parse(context: dict[str, Any], jinja_string: str) -> str: """ try: - content = render_template_string(jinja_string, context, autoescape=True) + content = render_template_string(jinja_string, context, autoescape=False) except Exception as e: raise ReferenceError( f'There was an error in the jinja statement: "{jinja_string}". ' @@ -492,12 +492,12 @@ def match_string_link( link_url = render_template_string( link_conf["url_template"], {**render_content, **render_context}, - autoescape=True, + autoescape=False, ) link_name = render_template_string( link_conf["name_template"], {**render_content, **render_context}, - autoescape=True, + autoescape=False, ) # if no string_link match was made, we handle it as normal string value From e463303ebb7d48751f08e81c35826091b7629a30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:51:53 +0000 Subject: [PATCH 16/22] Fix wordwrap filter to preserve newlines, move Path import to module top, add functions param to render_template_file Co-authored-by: chrisjsewell <2997570+chrisjsewell@users.noreply.github.com> --- sphinx_needs/_jinja.py | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/sphinx_needs/_jinja.py b/sphinx_needs/_jinja.py index 47f0a73b5..3ed203628 100644 --- a/sphinx_needs/_jinja.py +++ b/sphinx_needs/_jinja.py @@ -7,6 +7,7 @@ from __future__ import annotations import textwrap +from pathlib import Path from typing import Any from minijinja import Environment @@ -22,6 +23,8 @@ def _wordwrap_filter(value: str, width: int = 79, wrapstring: str = "\n") -> str 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 @@ -34,16 +37,21 @@ def _wordwrap_filter(value: str, width: int = 79, wrapstring: str = "\n") -> str if not value: return value - # Use textwrap.wrap which matches jinja2's behavior - # break_on_hyphens=True is the Python/Jinja2 default - lines = textwrap.wrap( - value, - width=width, - break_long_words=True, - break_on_hyphens=True, - ) + # 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(lines) + return wrapstring.join(wrapped_lines) def _setup_builtin_filters(env: Environment) -> None: @@ -125,15 +133,17 @@ def render_template_file( context: dict[str, Any], *, autoescape: bool = True, + functions: dict[str, Any] | None = None, ) -> str: """Render a Jinja template from a file path. :param template_path: Path to the template file. :param context: Dictionary containing template variables. :param autoescape: Whether to enable autoescaping (default: True). + :param functions: Optional dictionary of custom functions to register. :return: The rendered template as a string. """ - from pathlib import Path - content = Path(template_path).read_text(encoding="utf-8") - return render_template_string(content, context, autoescape=autoescape) + return render_template_string( + content, context, autoescape=autoescape, functions=functions + ) From 0f188df8f2b245fcfec544551c2dbf4c4329fd14 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:37:48 +0000 Subject: [PATCH 17/22] Cache base Environment for functions= path to avoid N env creations in needuml diagrams Co-authored-by: chrisjsewell <2997570+chrisjsewell@users.noreply.github.com> --- sphinx_needs/_jinja.py | 74 ++++++++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/sphinx_needs/_jinja.py b/sphinx_needs/_jinja.py index 3ed203628..27ac12c03 100644 --- a/sphinx_needs/_jinja.py +++ b/sphinx_needs/_jinja.py @@ -7,15 +7,12 @@ from __future__ import annotations import textwrap +from functools import lru_cache from pathlib import Path from typing import Any from minijinja import Environment -# Module-level cached environments for performance -_CACHED_ENV: Environment | None = None -_CACHED_ENV_AUTOESCAPE: Environment | None = None - def _wordwrap_filter(value: str, width: int = 79, wrapstring: str = "\n") -> str: """Jinja2-compatible wordwrap filter. @@ -65,35 +62,49 @@ def _setup_builtin_filters(env: Environment) -> None: env.add_filter("wordwrap", _wordwrap_filter) -def _get_cached_env(autoescape: bool) -> Environment: - """Get or create a cached Environment instance. +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 - For performance, we cache module-level Environment instances to avoid - recreating them on every render call. This is safe because the Environment - is stateless for rendering purposes. - Thread safety: Benign race condition - if multiple threads check for None - simultaneously, worst case is creating extra Environment instances. Sphinx - parallel builds use processes, not threads, so this is not a concern. +@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 Environment is not + mutated after creation. Using lru_cache ensures thread safety. :param autoescape: Whether to enable autoescaping. :return: A cached Environment instance. """ - global _CACHED_ENV, _CACHED_ENV_AUTOESCAPE + return _new_env(autoescape) - if autoescape: - if _CACHED_ENV_AUTOESCAPE is None: - env = Environment() - env.auto_escape_callback = lambda _name: True - _setup_builtin_filters(env) - _CACHED_ENV_AUTOESCAPE = env - return _CACHED_ENV_AUTOESCAPE - else: - if _CACHED_ENV is None: - env = Environment() - _setup_builtin_filters(env) - _CACHED_ENV = env - return _CACHED_ENV + +@lru_cache(maxsize=2) +def _get_base_env_for_functions(autoescape: bool) -> Environment: + """Get a cached base Environment for the functions= code path. + + This env is mutated via add_global() on each call, but only with a + fixed set of keys (need, uml, flow, filter, import, ref) whose + values are overwritten each time, so no stale state leaks between calls. + + This optimization is important because needuml diagrams call + render_template_string(functions={...}) recursively - each {{ uml("ID") }} + in a template triggers another render, so a diagram with N needs would + create N throwaway environments without this cache. + + :param autoescape: Whether to enable autoescaping. + :return: A cached Environment instance (will be mutated). + """ + return _new_env(autoescape) def render_template_string( @@ -112,14 +123,13 @@ def render_template_string( :return: The rendered template as a string. """ if functions: - # If custom functions are needed, create a new Environment - # This is only used in needuml.py and is relatively infrequent - env = Environment() - if autoescape: - env.auto_escape_callback = lambda _name: True - _setup_builtin_filters(env) + # Use cached base environment and overwrite globals + # The functions dict keys are always the same (need, uml, flow, filter, import, ref) + # so we can safely reuse the environment and just update the global values + env = _get_base_env_for_functions(autoescape) for name, func in functions.items(): # Use add_global instead of add_function (same behavior, better type stubs) + # Overwrites any previous value for this key env.add_global(name, func) else: # For the common case (no custom functions), use cached Environment From 26f8666e4bb3fcb1da29de535e58835155223c39 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Sun, 22 Feb 2026 13:26:53 +0100 Subject: [PATCH 18/22] Update with needuml optiomsations --- sphinx_needs/_jinja.py | 48 +++---------- sphinx_needs/directives/needuml.py | 108 ++++++++++++++++++++--------- 2 files changed, 85 insertions(+), 71 deletions(-) diff --git a/sphinx_needs/_jinja.py b/sphinx_needs/_jinja.py index 27ac12c03..93b95268f 100644 --- a/sphinx_needs/_jinja.py +++ b/sphinx_needs/_jinja.py @@ -88,53 +88,27 @@ def _get_cached_env(autoescape: bool) -> Environment: return _new_env(autoescape) -@lru_cache(maxsize=2) -def _get_base_env_for_functions(autoescape: bool) -> Environment: - """Get a cached base Environment for the functions= code path. - - This env is mutated via add_global() on each call, but only with a - fixed set of keys (need, uml, flow, filter, import, ref) whose - values are overwritten each time, so no stale state leaks between calls. - - This optimization is important because needuml diagrams call - render_template_string(functions={...}) recursively - each {{ uml("ID") }} - in a template triggers another render, so a diagram with N needs would - create N throwaway environments without this cache. - - :param autoescape: Whether to enable autoescaping. - :return: A cached Environment instance (will be mutated). - """ - return _new_env(autoescape) - - def render_template_string( template_string: str, context: dict[str, Any], *, autoescape: bool = True, - functions: dict[str, Any] | None = None, + 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 (default: True). - :param functions: Optional dictionary of custom functions to register. + :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. """ - if functions: - # Use cached base environment and overwrite globals - # The functions dict keys are always the same (need, uml, flow, filter, import, ref) - # so we can safely reuse the environment and just update the global values - env = _get_base_env_for_functions(autoescape) - for name, func in functions.items(): - # Use add_global instead of add_function (same behavior, better type stubs) - # Overwrites any previous value for this key - env.add_global(name, func) - else: - # For the common case (no custom functions), use cached Environment - env = _get_cached_env(autoescape) - + env = _new_env(autoescape) if new_env else _get_cached_env(autoescape) return env.render_str(template_string, **context) @@ -143,17 +117,13 @@ def render_template_file( context: dict[str, Any], *, autoescape: bool = True, - functions: dict[str, Any] | None = None, ) -> str: """Render a Jinja template from a file path. :param template_path: Path to the template file. :param context: Dictionary containing template variables. :param autoescape: Whether to enable autoescaping (default: True). - :param functions: Optional dictionary of custom functions to register. :return: The rendered template as a string. """ content = Path(template_path).read_text(encoding="utf-8") - return render_template_string( - content, context, autoescape=autoescape, functions=functions - ) + return render_template_string(content, context, autoescape=autoescape) diff --git a/sphinx_needs/directives/needuml.py b/sphinx_needs/directives/needuml.py index 5dd7ce28e..1f60529e4 100644 --- a/sphinx_needs/directives/needuml.py +++ b/sphinx_needs/directives/needuml.py @@ -219,13 +219,46 @@ def jinja2uml( key: None | str, processed_need_ids: ProcessedNeedsType, kwargs: dict[str, Any], + jinja_utils: JinjaFunctions | None = None, ) -> tuple[str, ProcessedNeedsType]: - # Let's render jinja templates with uml content template to 'plantuml syntax' uml + """Render Jinja template content into PlantUML syntax. + + Processes ``uml_content`` by stripping ``@startuml``/``@enduml`` markers + and rendering the remainder as a Jinja template with access to all needs + data and helper functions (``uml``, ``flow``, ``filter``, ``import``, + ``ref``, ``need``). + + A single :class:`JinjaFunctions` instance is created on the first call + and reused across recursive invocations (triggered by ``{{ uml("ID") }}`` + calls in templates) to avoid redundant object creation. + + :param app: The Sphinx application instance. + :param fromdocname: The source document name, used for link calculation. + :param uml_content: The raw Jinja/PlantUML template string to render. + :param parent_need_id: The need ID that owns this diagram content, + or ``None`` for top-level ``needuml`` directives. + :param key: The arch key name, or ``None`` for the default ``diagram`` key. + :param processed_need_ids: Tracks already-rendered needs to prevent + infinite recursion in cyclic references. + :param kwargs: Extra keyword arguments passed from ``{{ uml(...) }}`` + calls, made available as template variables. + :param jinja_utils: An existing :class:`JinjaFunctions` instance to reuse + across recursion. ``None`` on the initial call (a new instance is + created); passed automatically on recursive calls. + :return: A tuple of the rendered PlantUML string and the updated + processed-needs mapping. + """ # 1. Remove @startuml and @enduml uml_content = uml_content.replace("@startuml", "").replace("@enduml", "") - # 2. Get a new instance of Jinja Helper Functions - jinja_utils = JinjaFunctions(app, fromdocname, parent_need_id, processed_need_ids) + # 2. Get or reuse a JinjaFunctions instance (reused across recursion) + if jinja_utils is None: + jinja_utils = JinjaFunctions( + app, fromdocname, parent_need_id, processed_need_ids + ) + else: + # Update parent_need_id for this recursion level + jinja_utils.set_parent_need_id(parent_need_id) # 3. Append need_id to processed_need_ids, so it will not been processed again if parent_need_id: @@ -233,31 +266,30 @@ def jinja2uml( need_id=parent_need_id, art="uml", key=key, kwargs=kwargs ) - # 5. Get data for the jinja processing + # 4. Get data for the jinja processing data: dict[str, Any] = {} - # 5.1 Set default config to data - data.update(**NeedsSphinxConfig(app.config).render_context) - # 5.2 Set uml() kwargs to data and maybe overwrite default settings + # 4.1 Set default config to data + data.update(**jinja_utils.needs_config.render_context) + # 4.2 Set uml() kwargs to data and maybe overwrite default settings data.update(kwargs) - # 5.3 Add needs as data (dict-like mapping), not as a function + # 4.3 Add needs as data (dict-like mapping), not as a function data["needs"] = jinja_utils.needs - - # 6. Render the uml content with the fetched data and custom functions - uml = render_template_string( - uml_content, - data, - autoescape=False, - functions={ - "need": jinja_utils.need, - "uml": jinja_utils.uml_from_need, - "flow": jinja_utils.flow, - "filter": jinja_utils.filter, - "import": jinja_utils.imports, - "ref": jinja_utils.ref, - }, - ) - - # 7. Get processed need ids + # 4.4 Add helper functions as callable context values + # (passed via context, not as registered functions, to avoid FFI overhead) + data["need"] = jinja_utils.need + data["uml"] = jinja_utils.uml_from_need + data["flow"] = jinja_utils.flow + data["filter"] = jinja_utils.filter + data["import"] = jinja_utils.imports + data["ref"] = jinja_utils.ref + + # 5. Render the uml content with the fetched data. + # new_env=True is required because template callbacks (uml, flow, etc.) + # may call render_template_string again, and MiniJinja's Environment + # holds a non-reentrant lock during render_str. + uml = render_template_string(uml_content, data, autoescape=False, new_env=True) + + # 6. Get processed need ids processed_need_ids_return = jinja_utils.get_processed_need_ids() return (uml, processed_need_ids_return) @@ -286,6 +318,15 @@ def __init__( f"JinjaFunctions initialized with undefined parent_need_id: '{parent_need_id}'" ) self.processed_need_ids = processed_need_ids + self.needs_config = NeedsSphinxConfig(app.config) + + def set_parent_need_id(self, parent_need_id: None | str) -> None: + """Update the parent need ID for a new recursion level.""" + if parent_need_id and parent_need_id not in self.needs: + raise NeedumlException( + f"JinjaFunctions set with undefined parent_need_id: '{parent_need_id}'" + ) + self.parent_need_id = parent_need_id def need_to_processed_data( self, art: str, key: None | str, kwargs: dict[str, Any] @@ -354,7 +395,9 @@ def uml_from_need(self, need_id: str, key: str = "diagram", **kwargs: Any) -> st return self.flow(need_id) # We need to re-render the fetched content, as it may contain also Jinja statements. - # use jinja2uml to render the current uml content + # Reuse this JinjaFunctions instance to avoid repeated object creation. + # Save and restore parent_need_id since jinja2uml will mutate it. + saved_parent_need_id = self.parent_need_id (uml, processed_need_ids_return) = jinja2uml( app=self.app, fromdocname=self.fromdocname, @@ -363,7 +406,9 @@ def uml_from_need(self, need_id: str, key: str = "diagram", **kwargs: Any) -> st key=key, processed_need_ids=self.processed_need_ids, kwargs=kwargs, + jinja_utils=self, ) + self.parent_need_id = saved_parent_need_id # Append processed needs to current proccessing self.append_needs_to_processed_needs(processed_need_ids_return) @@ -387,10 +432,9 @@ def flow(self, need_id: str) -> str: need_info = self.needs[need_id] link = calculate_link(self.app, need_info, self.fromdocname) - needs_config = NeedsSphinxConfig(self.app.config) node_text = render_template_string( - needs_config.diagram_template, - {**need_info, **needs_config.render_context}, + self.needs_config.diagram_template, + {**need_info, **self.needs_config.render_context}, autoescape=False, ) @@ -442,9 +486,9 @@ def filter(self, filter_string: str) -> list[NeedItem]: """ Return a list of found needs that pass the given filter string. """ - needs_config = NeedsSphinxConfig(self.app.config) - - return filter_needs_view(self.needs, needs_config, filter_string=filter_string) + return filter_needs_view( + self.needs, self.needs_config, filter_string=filter_string + ) def imports(self, *args: str) -> str: if not self.parent_need_id: From c7b1e3800775c72f4e4babf99a342da0cb8a450c Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Sun, 22 Feb 2026 14:05:55 +0100 Subject: [PATCH 19/22] pre-compile template --- sphinx_needs/_jinja.py | 56 ++++++++++++++++++++++++++++++++++ sphinx_needs/external_needs.py | 13 +++++--- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/sphinx_needs/_jinja.py b/sphinx_needs/_jinja.py index 93b95268f..68f829991 100644 --- a/sphinx_needs/_jinja.py +++ b/sphinx_needs/_jinja.py @@ -112,6 +112,62 @@ def render_template_string( 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 = True, +) -> 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. + + :param template_string: The Jinja template string to compile. + :param autoescape: Whether to enable autoescaping (default: True). + :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) + + def render_template_file( template_path: str, context: dict[str, Any], diff --git a/sphinx_needs/external_needs.py b/sphinx_needs/external_needs.py index 7f325fb20..7f86e0081 100644 --- a/sphinx_needs/external_needs.py +++ b/sphinx_needs/external_needs.py @@ -8,7 +8,7 @@ from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment -from sphinx_needs._jinja import render_template_string +from sphinx_needs._jinja import compile_template from sphinx_needs.api import InvalidNeedException, add_external_need, del_need from sphinx_needs.config import NeedsSphinxConfig from sphinx_needs.data import NeedsCoreFields, SphinxNeedsData @@ -131,6 +131,11 @@ def load_external_needs( *(f"{x}_back" for x in needs_schema.iter_link_field_names()), } + # Pre-compile target_url template once (avoids re-parsing per need) + target_tpl = ( + compile_template(target_url, autoescape=False) if target_url else None + ) + # collect keys for warning logs, so that we only log one warning per key unknown_keys: set[str] = set() @@ -158,11 +163,9 @@ def load_external_needs( need_params["external_css"] = source.get("css_class") - if target_url: + if target_tpl: # render jinja content - cal_target_url = render_template_string( - target_url, {"need": need}, autoescape=False - ) + cal_target_url = target_tpl.render({"need": need}) external_url = f"{source['base_url']}/{cal_target_url}" else: external_url = f"{source['base_url']}/{need.get('docname', '__error__')}.html#{need['id']}" From e10342924440d5852df4a25f3b80a6af89dc86a7 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Sun, 22 Feb 2026 14:15:35 +0100 Subject: [PATCH 20/22] improve --- sphinx_needs/layout.py | 9 +++++++-- sphinx_needs/utils.py | 22 +++++++++++----------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/sphinx_needs/layout.py b/sphinx_needs/layout.py index cb338b632..535e873fd 100644 --- a/sphinx_needs/layout.py +++ b/sphinx_needs/layout.py @@ -25,6 +25,7 @@ from sphinx.application import Sphinx from sphinx.util.logging import getLogger +from sphinx_needs._jinja import compile_template from sphinx_needs.config import NeedsSphinxConfig from sphinx_needs.data import NeedsCoreFields, SphinxNeedsData from sphinx_needs.debug import measure_time @@ -300,8 +301,12 @@ def __init__( self.string_links = {} for link_name, link_conf in self.needs_config.string_links.items(): self.string_links[link_name] = { - "url_template": link_conf["link_url"], - "name_template": link_conf["link_name"], + "url_template": compile_template( + link_conf["link_url"], autoescape=False + ), + "name_template": compile_template( + link_conf["link_name"], autoescape=False + ), "regex_compiled": re.compile(link_conf["regex"]), "options": link_conf["options"], "name": link_name, diff --git a/sphinx_needs/utils.py b/sphinx_needs/utils.py index 78c581738..393c7cb07 100644 --- a/sphinx_needs/utils.py +++ b/sphinx_needs/utils.py @@ -15,7 +15,7 @@ from sphinx.application import Sphinx from sphinx.environment import BuildEnvironment -from sphinx_needs._jinja import render_template_string +from sphinx_needs._jinja import compile_template, render_template_string from sphinx_needs.config import NeedsSphinxConfig from sphinx_needs.data import SphinxNeedsData from sphinx_needs.defaults import NEEDS_PROFILING @@ -150,8 +150,12 @@ def row_col_maker( link_string_list = {} for link_name, link_conf in needs_config.string_links.items(): link_string_list[link_name] = { - "url_template": link_conf["link_url"], - "name_template": link_conf["link_name"], + "url_template": compile_template( + link_conf["link_url"], autoescape=False + ), + "name_template": compile_template( + link_conf["link_name"], autoescape=False + ), "regex_compiled": re.compile(link_conf["regex"]), "options": link_conf["options"], "name": link_name, @@ -489,15 +493,11 @@ def match_string_link( match = link_conf["regex_compiled"].search(data) if match: render_content = match.groupdict() - link_url = render_template_string( - link_conf["url_template"], - {**render_content, **render_context}, - autoescape=False, + link_url = link_conf["url_template"].render( + {**render_content, **render_context} ) - link_name = render_template_string( - link_conf["name_template"], - {**render_content, **render_context}, - autoescape=False, + link_name = link_conf["name_template"].render( + {**render_content, **render_context} ) # if no string_link match was made, we handle it as normal string value From 72821a57a537950000fb46176f90c617b06a4f62 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Sun, 22 Feb 2026 14:48:26 +0100 Subject: [PATCH 21/22] minor improvements --- sphinx_needs/_jinja.py | 42 +++++++++++++----------------- sphinx_needs/directives/needuml.py | 24 +++++++++-------- 2 files changed, 31 insertions(+), 35 deletions(-) diff --git a/sphinx_needs/_jinja.py b/sphinx_needs/_jinja.py index 68f829991..400857ae1 100644 --- a/sphinx_needs/_jinja.py +++ b/sphinx_needs/_jinja.py @@ -8,7 +8,6 @@ import textwrap from functools import lru_cache -from pathlib import Path from typing import Any from minijinja import Environment @@ -79,8 +78,16 @@ def _new_env(autoescape: bool) -> Environment: def _get_cached_env(autoescape: bool) -> Environment: """Get or create a cached Environment instance (no custom functions). - Cached per autoescape value. Safe because the Environment is not - mutated after creation. Using lru_cache ensures thread safety. + 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. @@ -92,14 +99,14 @@ def render_template_string( template_string: str, context: dict[str, Any], *, - autoescape: bool = True, + 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 (default: True). + :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 @@ -146,7 +153,7 @@ def render(self, context: dict[str, Any]) -> str: def compile_template( template_string: str, *, - autoescape: bool = True, + autoescape: bool, ) -> CompiledTemplate: """Compile a template string for efficient repeated rendering. @@ -159,27 +166,14 @@ def compile_template( 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 (default: True). + :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) - - -def render_template_file( - template_path: str, - context: dict[str, Any], - *, - autoescape: bool = True, -) -> str: - """Render a Jinja template from a file path. - - :param template_path: Path to the template file. - :param context: Dictionary containing template variables. - :param autoescape: Whether to enable autoescaping (default: True). - :return: The rendered template as a string. - """ - content = Path(template_path).read_text(encoding="utf-8") - return render_template_string(content, context, autoescape=autoescape) diff --git a/sphinx_needs/directives/needuml.py b/sphinx_needs/directives/needuml.py index 1f60529e4..6a19493c7 100644 --- a/sphinx_needs/directives/needuml.py +++ b/sphinx_needs/directives/needuml.py @@ -398,17 +398,19 @@ def uml_from_need(self, need_id: str, key: str = "diagram", **kwargs: Any) -> st # Reuse this JinjaFunctions instance to avoid repeated object creation. # Save and restore parent_need_id since jinja2uml will mutate it. saved_parent_need_id = self.parent_need_id - (uml, processed_need_ids_return) = jinja2uml( - app=self.app, - fromdocname=self.fromdocname, - uml_content=uml_content, - parent_need_id=need_id, - key=key, - processed_need_ids=self.processed_need_ids, - kwargs=kwargs, - jinja_utils=self, - ) - self.parent_need_id = saved_parent_need_id + try: + (uml, processed_need_ids_return) = jinja2uml( + app=self.app, + fromdocname=self.fromdocname, + uml_content=uml_content, + parent_need_id=need_id, + key=key, + processed_need_ids=self.processed_need_ids, + kwargs=kwargs, + jinja_utils=self, + ) + finally: + self.parent_need_id = saved_parent_need_id # Append processed needs to current proccessing self.append_needs_to_processed_needs(processed_need_ids_return) From 124ffa5346083f512c9b163c2a9c942b71b376d5 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Sun, 22 Feb 2026 15:02:16 +0100 Subject: [PATCH 22/22] Update needuml.py --- sphinx_needs/directives/needuml.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sphinx_needs/directives/needuml.py b/sphinx_needs/directives/needuml.py index 6a19493c7..0021853fc 100644 --- a/sphinx_needs/directives/needuml.py +++ b/sphinx_needs/directives/needuml.py @@ -438,6 +438,13 @@ def flow(self, need_id: str) -> str: self.needs_config.diagram_template, {**need_info, **self.needs_config.render_context}, autoescape=False, + # new_env=True because flow() is called from within a template callback + # (e.g. {{ flow("ID") }}) while the outer jinja2uml render holds a lock + # on its Environment. Although jinja2uml uses new_env=True (so the + # *outer* lock is on a throwaway env, not the cached one), using new_env + # here as well is a defensive measure: if a user's diagram_template ever + # contained callback-invoking expressions, the cached env would deadlock. + new_env=True, ) need_uml = '{style} "{node_text}" as {id} [[{link}]] #{color}'.format(