From 228f242c2e6eed0838498271c09958d9c2eb5294 Mon Sep 17 00:00:00 2001 From: PhilipPartsch Date: Fri, 1 May 2026 14:49:38 +0200 Subject: [PATCH 1/2] support jinja templates in needs-role-need-template --- docs/configuration.rst | 35 +++++++---- docs/roles.rst | 12 ++-- sphinx_needs/config.py | 13 ++-- sphinx_needs/roles/need_ref.py | 60 ++++++++++++++----- .../doc_role_need_max_title_length/conf.py | 2 +- .../conf.py | 2 +- tests/doc_test/doc_role_need_template/conf.py | 8 ++- .../doc_test/doc_role_need_template/index.rst | 16 +++++ tests/test_role_need_template.py | 15 ++++- 9 files changed, 122 insertions(+), 41 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 156d7dc59..644d6ebb9 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1223,7 +1223,7 @@ By default a referenced need is described by the following string: .. code-block:: jinja - {title} ({id}) + {{ title }} ({{ id }}) By using ``needs_role_need_template`` this representation can be easily adjusted to own requirements. @@ -1231,16 +1231,23 @@ Here are some ideas, how it could be used inside the **conf.py** file: .. code-block:: python - needs_role_need_template = "[{id}]: {title}" - needs_role_need_template = "-{id}-" - needs_role_need_template = "{type}: {title} ({status})" - needs_role_need_template = "{title} ({tags})" - needs_role_need_template = "{title:*^20s} - {content:.30}" - needs_role_need_template = "[{id}] {title} ({status}) {type_name}/{type} - {tags} - {links} - {links_back} - {content}" + needs_role_need_template = "[{{ id }}]: {{ title }}" + needs_role_need_template = "-{{ id }}-" + needs_role_need_template = "{{ type }}: {{ title }} ({{ status }})" + needs_role_need_template = "{{ title }} ({{ tags }})" + needs_role_need_template = "[{{ id }}] {{ title }} ({{ status }}) {{ type_name }}/{{ type }} - {{ tags }} - {{ links }} - {{ links_back }} - {{ content }}" + needs_role_need_template = "{% if type == 'spec' %}[SPEC] {{ title }}{% else %}[{{ type|upper }}] {{ title }}{% endif %}" -``needs_role_need_template`` must be a string, which supports the following placeholders: + # Multi-line template, important it the '-' in the jinja template '-}}': + needs_role_need_template = "" \ + "{% if type == 'spec' %}[SPEC] {{ title -}}" \ + "{% else %}[{{ type|upper }}] {{ title }}{% endif %}" -* id +``needs_role_need_template`` must be a Jinja string, which supports the following variables: + +* id or id_complete +* id_parent +* id_part * type (short version) * type_name (long, human readable version) * title @@ -1249,9 +1256,13 @@ Here are some ideas, how it could be used inside the **conf.py** file: * links, joined by ";" * links_back, joined by ";" * content +* is_need +* is_part + +To access the same values via an object, use ``need`` (for example ``{{ need.type }}``). -All options of Python's `.format() `_ function are supported. -Please see https://pyformat.info/ for more information. +Jinja filters and control structures are supported. +Please see https://jinja.palletsprojects.com/ for syntax and features. RST-attributes like ``**bold**`` are **not** supported. @@ -1753,7 +1764,7 @@ keys: :css_class: A class name as string, which gets set in link representations like :ref:`needtable`. The related CSS class definition must be done by the user, e.g. by :ref:`own_css`. (*optional*) (*default*: ``external_link``) -:allow_type_coercion: +:allow_type_coercion: Allows to enable or disable type coercion of fields for each need, and parsing of dynamic functions. For example if the ``tags`` need field is provided as a string like ``"tag1,tag2,[[func()]]"``, it will be parsed only if this option is set to ``True``, otherwise will fail. diff --git a/docs/roles.rst b/docs/roles.rst index 97424bf65..5eecb0c2b 100644 --- a/docs/roles.rst +++ b/docs/roles.rst @@ -15,7 +15,9 @@ The role ``:need:`` will add title, id and a link to the need. We use it to reference an existing need, without the need to keep title and link location manually in sync. -With ``[[`` and ``]]`` you can refer to defined and set :ref:`extra fields `. +With ``[[`` and ``]]`` you can refer to defined and set :ref:`extra fields `. + +The possible variables are listed in the configuration documentation for :ref:`needs_role_need_template`. .. need-example:: @@ -34,10 +36,10 @@ With ``[[`` and ``]]`` you can refer to defined and set :ref:`extra fields `_ - function. - Please see https://pyformat.info/ for more information. + ``needs_role_need_template`` is rendered using Jinja syntax. + The explicit role text variant ``:need:`[[...]] ``` is also + rendered as Jinja by internally converting ``[[``/``]]`` to + ``{{``/``}}``. RST-attributes like ``**bold**`` are **not** supported. .. warning:: diff --git a/sphinx_needs/config.py b/sphinx_needs/config.py index 7f53b5783..47f87a589 100644 --- a/sphinx_needs/config.py +++ b/sphinx_needs/config.py @@ -71,7 +71,7 @@ class NewFieldParams: """ default: None | Any = None """Default value for the field. - + Used if the field has not been specifically set, and no predicate matches. """ @@ -257,8 +257,8 @@ class ExternalSource(TypedDict, total=False): Values can be: - a tuple: ``(value, filter_string)``, where the default is only applied if the filter_string is fulfilled -- a tuple: ``(value, filter_string, alternative default)``, - where the default is applied if the filter_string is fulfilled, +- a tuple: ``(value, filter_string, alternative default)``, + where the default is applied if the filter_string is fulfilled, otherwise the alternative default is used - a list of the tuples above - otherwise, always set as the given value @@ -291,7 +291,7 @@ class NeedLinksConfig(TypedDict, total=False): schema: NotRequired[LinkSchemaType] """ A JSON schema for the link option. - + If given, the schema will apply to all needs that use this link option. The schema is applied locally on unresolved links, i.e. on the list of string ids. For more granular control and graph traversal, use the `needs_schema_definitions` configuration. @@ -338,7 +338,7 @@ class NeedFields(TypedDict): schema: NotRequired[FieldSchemaTypes] """ A JSON schema definition for the field. - + If given, the schema will apply to all needs that use this option. For more granular control, use the `needs_schema_definitions` configuration. """ @@ -636,7 +636,8 @@ def get_default(cls, name: str) -> Any: ) """Default style for the needtable.""" role_need_template: str = field( - default="{title} ({id})", metadata={"rebuild": "html", "types": (str,)} + default="{{ title }} ({{ id }})", + metadata={"rebuild": "html", "types": (str,)}, ) """Template for the need role output.""" role_need_max_title_length: int = field( diff --git a/sphinx_needs/roles/need_ref.py b/sphinx_needs/roles/need_ref.py index 4268f2d71..56ac3a6cd 100644 --- a/sphinx_needs/roles/need_ref.py +++ b/sphinx_needs/roles/need_ref.py @@ -8,6 +8,7 @@ from sphinx.application import Sphinx from sphinx.util.nodes import make_refnode +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.errors import NoUri @@ -67,6 +68,18 @@ def process_need_ref( env = app.env needs_config = NeedsSphinxConfig(env.config) all_needs = SphinxNeedsData(env).get_needs_view() + + role_template = None + try: + role_template = compile_template(needs_config.role_need_template, autoescape=False) + except Exception as exc: + log_warning( + log, + f"could not compile needs_role_need_template as Jinja template: {exc}", + "link_text", + location=None, + ) + # for node_need_ref in doctree.findall(NeedRef): for node_need_ref in found_nodes: # Let's create a dummy node, for the case we will not be able to create a real reference @@ -87,6 +100,7 @@ def process_need_ref( need_id_full = node_need_ref["reftarget"] need_id_main = need_link.id need_id_part = need_link.part + need_id_complete = need_link.to_link_string() if need_id_main not in all_needs: log_warning( @@ -112,6 +126,16 @@ def process_need_ref( target_need ) # Transform a dict in a dict of {str, str} + if need_id_part: + dict_need["id_complete"] = need_id_complete + dict_need["id_part"] = need_id_part + dict_need["is_need"] = False + dict_need["is_part"] = True + else: + dict_need["id_part"] = "" + dict_need["is_need"] = True + dict_need["is_part"] = False + # We set the id to the complete id maintained in node_need_ref["reftarget"] dict_need["id"] = need_id_full @@ -133,14 +157,18 @@ def process_need_ref( link_text = "" if ref_name and prefix in ref_name and postfix in ref_name: - # if ref_name is set and has prefix to process, we will do so. - ref_name = ref_name.replace(prefix, "{").replace(postfix, "}") + # Keep the user-facing [[...]] syntax, but render using Jinja. + ref_name = ref_name.replace(prefix, "{{").replace(postfix, "}}") try: - link_text = ref_name.format(**dict_need) - except KeyError as e: + link_text = render_template_string( + ref_name, + {"need": dict_need, **dict_need}, + autoescape=False, + ) + except Exception as exc: log_warning( log, - f"option placeholder {e} for need {node_need_ref['reftarget']} not found", + f"invalid inline need role template for need {node_need_ref['reftarget']}: {exc}", "link_text", location=node_need_ref, ) @@ -148,15 +176,19 @@ def process_need_ref( if ref_name: # If ref_name differs from the need id, we treat the "ref_name content" as title. dict_need["title"] = ref_name - try: - link_text = needs_config.role_need_template.format(**dict_need) - except KeyError as e: - log_warning( - log, - f"the config parameter needs_role_need_template uses unsupported placeholders: {e} ", - "link_text", - location=node_need_ref, - ) + if role_template is None: + link_text = f"{dict_need['title']} ({dict_need['id']})" + else: + try: + link_text = role_template.render({"need": dict_need, **dict_need}) + except Exception as exc: + log_warning( + log, + f"the config parameter needs_role_need_template uses invalid Jinja syntax or variables: {exc}", + "link_text", + location=node_need_ref, + ) + link_text = f"{dict_need['title']} ({dict_need['id']})" node_need_ref[0].children[0] = nodes.Text(link_text) # type: ignore[index] diff --git a/tests/doc_test/doc_role_need_max_title_length/conf.py b/tests/doc_test/doc_role_need_max_title_length/conf.py index 11f882b66..64a0debc4 100644 --- a/tests/doc_test/doc_role_need_max_title_length/conf.py +++ b/tests/doc_test/doc_role_need_max_title_length/conf.py @@ -31,6 +31,6 @@ }, ] -needs_role_need_template = "[{id}] {title} ({status}) {type_name}/{type} - {tags} - {links} - {links_back} - {content}" +needs_role_need_template = "[{{ id }}] {{ title }} ({{ status }}) {{ type_name }}/{{ type }} - {{ tags }} - {{ links }} - {{ links_back }} - {{ content }}" needs_role_need_max_title_length = 10 diff --git a/tests/doc_test/doc_role_need_max_title_length_unlimited/conf.py b/tests/doc_test/doc_role_need_max_title_length_unlimited/conf.py index 0a85db025..064dc96bd 100644 --- a/tests/doc_test/doc_role_need_max_title_length_unlimited/conf.py +++ b/tests/doc_test/doc_role_need_max_title_length_unlimited/conf.py @@ -31,6 +31,6 @@ }, ] -needs_role_need_template = "[{id}] {title} ({status}) {type_name}/{type} - {tags} - {links} - {links_back} - {content}" +needs_role_need_template = "[{{ id }}] {{ title }} ({{ status }}) {{ type_name }}/{{ type }} - {{ tags }} - {{ links }} - {{ links_back }} - {{ content }}" needs_role_need_max_title_length = -1 diff --git a/tests/doc_test/doc_role_need_template/conf.py b/tests/doc_test/doc_role_need_template/conf.py index fa86a8027..c594a3c1a 100644 --- a/tests/doc_test/doc_role_need_template/conf.py +++ b/tests/doc_test/doc_role_need_template/conf.py @@ -1,5 +1,7 @@ extensions = ["sphinx_needs"] +needs_build_json = True + needs_types = [ { "directive": "story", @@ -31,4 +33,8 @@ }, ] -needs_role_need_template = "[{id}] {title} ({status}) {type_name}/{type} - {tags} - {links} - {links_back} - {content}" +needs_role_need_template = "" \ + "{% if is_need %}[NEED] {% endif -%}" \ + "{% if is_part %}[NEEDPART]{% endif -%}" \ + "[{{id}}] [{{id_complete}}] [{{id_parent}}] [{{id_part}}] " \ + "[{{ type|upper }}] [{{ id }}] {{ title }} ({{ status }}) {{ type_name }}/{{ type }} - {{ tags }} - {{ links }} - {{ links_back }} - {{ content }}" diff --git a/tests/doc_test/doc_role_need_template/index.rst b/tests/doc_test/doc_role_need_template/index.rst index 06723ea45..d8e413991 100644 --- a/tests/doc_test/doc_role_need_template/index.rst +++ b/tests/doc_test/doc_role_need_template/index.rst @@ -9,11 +9,27 @@ ROLE NEED TEMPLATE The Tool awesome shall have a command line interface. + * :np:`(cli) Command parser support` + .. spec:: Test spec :id: SP_TOO_002 :status: open Test test +.. impl:: Command line implementation + :id: IM_TOO_001 + :status: implemented + + Implements command line interface. + Reference: :need:`SP_TOO_001` +Reference impl: :need:`IM_TOO_001` + +Reference custom inline: :need:`[[ type|upper ]] ` + +Reference need part: :need:`SP_TOO_001.cli` + +Reference need part custom inline: :need:`[[ title|upper ]] ` + diff --git a/tests/test_role_need_template.py b/tests/test_role_need_template.py index 8e2aa4e88..e743a7761 100644 --- a/tests/test_role_need_template.py +++ b/tests/test_role_need_template.py @@ -14,6 +14,19 @@ def test_doc_build_html(test_app): html = Path(app.outdir, "index.html").read_text() assert "ROLE NEED TEMPLATE" in html assert ( - "[SP_TOO_001] Command line interface (implemented) Specification/spec - test;test2 - SP_TOO_002 - - " + "[NEED] [SP_TOO_001] [SP_TOO_001] [SP_TOO_001] [] " + "[SPEC] [SP_TOO_001] Command line interface (implemented) Specification/spec - test;test2 - SP_TOO_002 - - " "The Tool awesome shall have a command line interface." in html ) + assert ( + "[NEED] [IM_TOO_001] [IM_TOO_001] [IM_TOO_001] [] " + "[IMPL] [IM_TOO_001] Command line implementation (implemented) Implementation/impl - - - - " + "Implements command line interface." in html + ) + assert 'IMPL' in html + assert ( + "[NEEDPART][SP_TOO_001.cli] [SP_TOO_001.cli] [SP_TOO_001] [cli] " + "[SPEC] [SP_TOO_001.cli] Command parser support (implemented) Specification/spec" + in html + ) + assert 'COMMAND PARSER SUPPORT' in html From 7aac9324f681d98f7341e185d6df359deff69c3b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 12:50:36 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- sphinx_needs/roles/need_ref.py | 8 ++++++-- tests/doc_test/doc_role_need_template/conf.py | 10 ++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/sphinx_needs/roles/need_ref.py b/sphinx_needs/roles/need_ref.py index 56ac3a6cd..f022e0f8b 100644 --- a/sphinx_needs/roles/need_ref.py +++ b/sphinx_needs/roles/need_ref.py @@ -71,7 +71,9 @@ def process_need_ref( role_template = None try: - role_template = compile_template(needs_config.role_need_template, autoescape=False) + role_template = compile_template( + needs_config.role_need_template, autoescape=False + ) except Exception as exc: log_warning( log, @@ -180,7 +182,9 @@ def process_need_ref( link_text = f"{dict_need['title']} ({dict_need['id']})" else: try: - link_text = role_template.render({"need": dict_need, **dict_need}) + link_text = role_template.render( + {"need": dict_need, **dict_need} + ) except Exception as exc: log_warning( log, diff --git a/tests/doc_test/doc_role_need_template/conf.py b/tests/doc_test/doc_role_need_template/conf.py index c594a3c1a..ee92ffe41 100644 --- a/tests/doc_test/doc_role_need_template/conf.py +++ b/tests/doc_test/doc_role_need_template/conf.py @@ -33,8 +33,10 @@ }, ] -needs_role_need_template = "" \ - "{% if is_need %}[NEED] {% endif -%}" \ - "{% if is_part %}[NEEDPART]{% endif -%}" \ - "[{{id}}] [{{id_complete}}] [{{id_parent}}] [{{id_part}}] " \ +needs_role_need_template = ( + "" + "{% if is_need %}[NEED] {% endif -%}" + "{% if is_part %}[NEEDPART]{% endif -%}" + "[{{id}}] [{{id_complete}}] [{{id_parent}}] [{{id_part}}] " "[{{ type|upper }}] [{{ id }}] {{ title }} ({{ status }}) {{ type_name }}/{{ type }} - {{ tags }} - {{ links }} - {{ links_back }} - {{ content }}" +)