diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 29e626920..91f928a75 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/performance/performance_test.py b/performance/performance_test.py index 7a7cd2c9f..ab36fff8a 100644 --- a/performance/performance_test.py +++ b/performance/performance_test.py @@ -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(): @@ -45,17 +46,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 +68,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 +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) diff --git a/pyproject.toml b/pyproject.toml index 5ac1dc866..62c92768e 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~=2.15", # lightweight jinja2-compatible template engine ] [project.optional-dependencies] diff --git a/sphinx_needs/_jinja.py b/sphinx_needs/_jinja.py new file mode 100644 index 000000000..400857ae1 --- /dev/null +++ b/sphinx_needs/_jinja.py @@ -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) 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/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/directives/list2need.py b/sphinx_needs/directives/list2need.py index 16a9dfb34..378c39f39 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=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/needflow/_plantuml.py b/sphinx_needs/directives/needflow/_plantuml.py index 2d18d5ed4..7d44e277d 100644 --- a/sphinx_needs/directives/needflow/_plantuml.py +++ b/sphinx_needs/directives/needflow/_plantuml.py @@ -2,12 +2,11 @@ import html import os -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 @@ -44,9 +43,12 @@ 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 = 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, + ) node_link = calculate_link(app, need_info, fromdocname) @@ -432,9 +434,3 @@ def render_connections( style_end=link_type.display.style_end, ) return puml_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) diff --git a/sphinx_needs/directives/needreport.py b/sphinx_needs/directives/needreport.py index 4048553a2..825a2af18 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=False + ) self.state_machine.insert_input( text.split("\n"), self.state_machine.document.attributes["source"] ) diff --git a/sphinx_needs/directives/needuml.py b/sphinx_needs/directives/needuml.py index 7a20bae67..0021853fc 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 @@ -219,46 +219,77 @@ 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. Prepare jinja template - mem_template = Environment(loader=BaseLoader()).from_string(uml_content) - - # 3. 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) - # 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 ) - # 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 Make the helpers available during rendering and overwrite maybe wrongly default and uml() kwargs settings - data.update( - { - "needs": jinja_utils.needs, - "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, - } - ) - - # 6. Render the uml content with the fetched data - uml = mem_template.render(**data) - - # 7. Get processed need ids + # 4.3 Add needs as data (dict-like mapping), not as a function + data["needs"] = jinja_utils.needs + # 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) @@ -287,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] @@ -355,16 +395,22 @@ 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 - (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, - ) + # 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 + 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) @@ -388,9 +434,18 @@ 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) - diagram_template = Template(needs_config.diagram_template) - node_text = diagram_template.render(**need_info, **needs_config.render_context) + node_text = render_template_string( + 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( id=make_entity_name(need_id), @@ -440,9 +495,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: 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, ) ) diff --git a/sphinx_needs/external_needs.py b/sphinx_needs/external_needs.py index 0e39b4ccb..7f86e0081 100644 --- a/sphinx_needs/external_needs.py +++ b/sphinx_needs/external_needs.py @@ -2,14 +2,13 @@ import json import os -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 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 @@ -20,16 +19,6 @@ log = get_logger(__name__) -@lru_cache(maxsize=20) -def get_target_template(target_url: str) -> Template: - """ - Provides template 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 - - def load_external_needs( app: Sphinx, env: BuildEnvironment, _docnames: list[str] ) -> None: @@ -142,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() @@ -169,10 +163,9 @@ def load_external_needs( need_params["external_css"] = source.get("css_class") - if target_url: + if target_tpl: # render jinja content - mem_template = get_target_template(target_url) - cal_target_url = mem_template.render(**{"need": need}) + 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']}" diff --git a/sphinx_needs/layout.py b/sphinx_needs/layout.py index 82b8f3c8c..535e873fd 100644 --- a/sphinx_needs/layout.py +++ b/sphinx_needs/layout.py @@ -22,10 +22,10 @@ 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 +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 @@ -296,13 +296,17 @@ 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": 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/need_constraints.py b/sphinx_needs/need_constraints.py index 552527fa9..e6834c62b 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,8 +23,6 @@ def process_constraints(needs: NeedsMutable, config: NeedsSphinxConfig) -> None: """ config_constraints = config.constraints - error_templates_cache: dict[str, jinja2.Template] = {} - for need in needs.values(): need_id = need["id"] @@ -51,10 +50,11 @@ 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) + error_msg = render_template_string( + msg, + 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..e50c8b05a 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=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 0072a2057..393c7cb07 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 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,11 +150,11 @@ 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"] + "url_template": compile_template( + link_conf["link_url"], autoescape=False ), - "name_template": Environment(autoescape=True).from_string( - link_conf["link_name"] + "name_template": compile_template( + link_conf["link_name"], autoescape=False ), "regex_compiled": re.compile(link_conf["regex"]), "options": link_conf["options"], @@ -393,14 +393,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=False) 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 @@ -495,10 +494,10 @@ def match_string_link( if match: render_content = match.groupdict() link_url = link_conf["url_template"].render( - **render_content, **render_context + {**render_content, **render_context} ) link_name = link_conf["name_template"].render( - **render_content, **render_context + {**render_content, **render_context} ) # if no string_link match was made, we handle it as normal string value 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 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"