Skip to content

Commit a158906

Browse files
authored
✨ Add if directive for variant-conditional content (#1716)
1 parent dfa857e commit a158906

10 files changed

Lines changed: 585 additions & 0 deletions

File tree

docs/directives/if.rst

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
.. _if:
2+
3+
if
4+
==
5+
6+
.. versionadded:: 8.2.0
7+
8+
The ``if`` directive conditionally includes or excludes content based on
9+
:ref:`variant data <filter_variant_data>` evaluated at parse time.
10+
11+
The directive argument is a Python expression evaluated against the ``var``
12+
namespace (populated from :ref:`needs_variant_data`).
13+
If the expression evaluates to ``True``, the directive body is parsed and
14+
included in the document. Otherwise the entire body is skipped.
15+
16+
.. code-block:: rst
17+
18+
.. if:: var.arch == "arm"
19+
20+
This paragraph only appears when ``needs_variant_data``
21+
has ``arch`` set to ``"arm"``.
22+
23+
.. req:: ARM-specific requirement
24+
:id: REQ_ARM_001
25+
26+
This need is only created for the ARM variant.
27+
28+
Usage
29+
-----
30+
31+
Basic conditions
32+
~~~~~~~~~~~~~~~~
33+
34+
.. code-block:: rst
35+
36+
.. if:: var.debug
37+
38+
Debug-only content here.
39+
40+
.. if:: var.build.optimization > 1
41+
42+
High-optimization content.
43+
44+
Membership tests
45+
~~~~~~~~~~~~~~~~
46+
47+
.. code-block:: rst
48+
49+
.. if:: "feature_x" in var.build.features
50+
51+
Feature X documentation.
52+
53+
Nested if directives
54+
~~~~~~~~~~~~~~~~~~~~
55+
56+
``if`` directives can be nested:
57+
58+
.. code-block:: rst
59+
60+
.. if:: var.arch == "arm"
61+
62+
.. if:: var.debug
63+
64+
ARM debug-specific content.
65+
66+
Content with sections
67+
~~~~~~~~~~~~~~~~~~~~~
68+
69+
The body may contain section headers and any valid reStructuredText:
70+
71+
.. code-block:: rst
72+
73+
.. if:: var.arch == "arm"
74+
75+
ARM-specific section
76+
~~~~~~~~~~~~~~~~~~~~
77+
78+
Content under a conditional heading.
79+
80+
Expression context
81+
------------------
82+
83+
Only the ``var`` namespace is available in the expression.
84+
Built-in Python functions (``open``, ``import``, etc.) are **not** accessible.
85+
86+
If the expression references a variant key that does not exist, a warning is
87+
emitted and the content is skipped.
88+
89+
Supported operators:
90+
91+
- Comparison: ``==``, ``!=``, ``<``, ``>``, ``<=``, ``>=``
92+
- Logical: ``and``, ``or``, ``not``
93+
- Membership: ``in``
94+
- Attribute access: ``var.a.b.c`` (nested dicts)
95+
96+
Behavior
97+
--------
98+
99+
- **Parse-time evaluation**: The condition is evaluated during RST parsing.
100+
Unlike Sphinx's ``only`` directive (which defers to doctree resolution),
101+
excluded content is never parsed at all.
102+
- **No need data access**: Because parsing happens before all needs are
103+
collected, need fields and IDs are not available in the expression.
104+
Use :ref:`filter` for need-aware filtering.
105+
- **Incremental builds**: If a document is re-read (e.g., because the source
106+
changed), all ``if`` directives in it are re-evaluated.
107+
108+
Warnings
109+
--------
110+
111+
The directive emits warnings (suppressible via ``suppress_warnings = ["needs.if"]``) when:
112+
113+
- ``needs_variant_data`` is not configured but the directive is used.
114+
- The expression raises an exception (syntax error, unknown key, etc.).

docs/directives/index.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ Directives for creating and modifying needs:
1313
needimport
1414
needservice
1515

16+
Directives for conditional content:
17+
18+
.. toctree::
19+
:maxdepth: 1
20+
21+
if
22+
1623
Directives for visualizing and analyzing needs:
1724

1825
.. toctree::

sphinx_needs/directives/needif.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Directive for conditionally including content based on variant data."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Sequence
6+
7+
from docutils import nodes
8+
from sphinx.util.docutils import SphinxDirective
9+
from sphinx.util.nodes import nested_parse_with_titles
10+
11+
from sphinx_needs.config import NeedsSphinxConfig
12+
from sphinx_needs.logging import get_logger, log_warning
13+
14+
LOGGER = get_logger(__name__)
15+
16+
17+
class IfDirective(SphinxDirective):
18+
"""Conditionally include content based on a variant data expression.
19+
20+
The directive argument is a Python expression evaluated against the
21+
``var`` namespace (from :confval:`needs_variant_data`).
22+
If the expression evaluates to a truthy value, the directive content
23+
is parsed and included in the document. Otherwise it is skipped entirely.
24+
25+
Example::
26+
27+
.. if:: var.arch == "arm"
28+
29+
This content only appears when arch is "arm".
30+
"""
31+
32+
required_arguments = 1
33+
optional_arguments = 0
34+
final_argument_whitespace = True
35+
has_content = True
36+
37+
def run(self) -> Sequence[nodes.Node]:
38+
expression = self.arguments[0]
39+
config = NeedsSphinxConfig(self.env.config)
40+
var_proxy = config.variant_data_proxy
41+
42+
if var_proxy is None:
43+
log_warning(
44+
LOGGER,
45+
f"'if' directive used but needs_variant_data is not configured: "
46+
f"{expression!r}",
47+
"if",
48+
location=self.get_location(),
49+
)
50+
return []
51+
52+
context: dict[str, object] = {"var": var_proxy, "__builtins__": {}}
53+
try:
54+
raw_result = eval(expression, context)
55+
except Exception as e:
56+
log_warning(
57+
LOGGER,
58+
f"'if' directive expression failed: {expression!r}{e}",
59+
"if",
60+
location=self.get_location(),
61+
)
62+
return []
63+
64+
if not isinstance(raw_result, bool):
65+
log_warning(
66+
LOGGER,
67+
f"'if' directive expression did not return a bool, "
68+
f"got {type(raw_result).__name__}: {raw_result!r} "
69+
f"(coercing to bool): {expression!r}",
70+
"if",
71+
location=self.get_location(),
72+
)
73+
74+
if not raw_result:
75+
return []
76+
77+
# Parse the content into a container node
78+
node = nodes.container()
79+
node.document = self.state.document
80+
nested_parse_with_titles(self.state, self.content, node, self.content_offset)
81+
return node.children

sphinx_needs/logging.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def get_logger(name: str) -> SphinxLoggerAdapter:
2929
"filter_func",
3030
"filter",
3131
"github",
32+
"if",
3233
"import_need",
3334
"json_load",
3435
"layout",
@@ -74,6 +75,7 @@ def get_logger(name: str) -> SphinxLoggerAdapter:
7475
"filter_func": "Error loading needs filter function",
7576
"filter": "Error processing needs filter",
7677
"github": "Error in processing GitHub service directive",
78+
"if": "Error in processing if directive",
7779
"import_need": "Failed to import a need",
7880
"layout": "Error occurred during layout rendering of a need",
7981
"link_condition_failed": "Link condition not satisfied by targeted need",

sphinx_needs/needs.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
NeedganttDirective,
7878
process_needgantt,
7979
)
80+
from sphinx_needs.directives.needif import IfDirective
8081
from sphinx_needs.directives.needimport import Needimport, NeedimportDirective
8182
from sphinx_needs.directives.needlist import (
8283
Needlist,
@@ -270,6 +271,7 @@ def setup(app: Sphinx) -> dict[str, Any]:
270271
app.add_directive("needextend", NeedextendDirective)
271272
app.add_directive("needreport", NeedReportDirective)
272273
app.add_directive("needuml", NeedumlDirective)
274+
app.add_directive("if", IfDirective)
273275
app.add_directive("needarch", NeedarchDirective)
274276
app.add_directive("list2need", List2NeedDirective)
275277

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
project = "needs_if_test"
2+
version = "0.1.0"
3+
extensions = ["sphinx_needs"]
4+
5+
suppress_warnings = ["epub.unknown_project_files"]
6+
7+
needs_types = [
8+
{
9+
"directive": "req",
10+
"title": "Requirement",
11+
"prefix": "REQ_",
12+
"color": "#BFD8D2",
13+
},
14+
]
15+
16+
needs_variant_data = {
17+
"arch": "abc",
18+
"debug": True,
19+
"build": {"opt": 2, "features": ["f1", "f2"]},
20+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
IF Directive Test
2+
=================
3+
4+
True condition
5+
--------------
6+
7+
.. if:: var.arch == "abc"
8+
9+
INCLUDED_ARCH_ABC
10+
11+
False condition
12+
---------------
13+
14+
.. if:: var.arch == "xyz"
15+
16+
EXCLUDED_ARCH_XYZ
17+
18+
Boolean truthiness
19+
------------------
20+
21+
.. if:: var.debug
22+
23+
INCLUDED_DEBUG_TRUE
24+
25+
.. if:: not var.debug
26+
27+
EXCLUDED_DEBUG_FALSE
28+
29+
Nested access
30+
-------------
31+
32+
.. if:: var.build.opt > 1
33+
34+
INCLUDED_BUILD_OPT
35+
36+
.. if:: var.build.opt > 99
37+
38+
EXCLUDED_BUILD_OPT_HIGH
39+
40+
Membership test
41+
---------------
42+
43+
.. if:: "f1" in var.build.features
44+
45+
INCLUDED_FEATURE_F1
46+
47+
.. if:: "missing" in var.build.features
48+
49+
EXCLUDED_FEATURE_MISSING
50+
51+
Nested if directives
52+
--------------------
53+
54+
.. if:: var.debug
55+
56+
OUTER_IF_CONTENT
57+
58+
.. if:: var.arch == "abc"
59+
60+
NESTED_IF_CONTENT
61+
62+
Header inside if
63+
-----------------
64+
65+
.. if:: var.arch == "abc"
66+
67+
Conditional section
68+
~~~~~~~~~~~~~~~~~~~
69+
70+
INCLUDED_SECTION_CONTENT
71+
72+
.. if:: var.arch == "xyz"
73+
74+
Skipped section
75+
~~~~~~~~~~~~~~~
76+
77+
EXCLUDED_SECTION_CONTENT
78+
79+
Needs inside if
80+
---------------
81+
82+
.. if:: var.arch == "abc"
83+
84+
.. req:: A conditional requirement
85+
:id: REQ_CONDITIONAL
86+
87+
This requirement only exists when arch is abc.
88+
89+
.. if:: var.arch == "xyz"
90+
91+
.. req:: A skipped requirement
92+
:id: REQ_SKIPPED
93+
94+
This requirement should not exist.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
project = "needs_if_no_variant"
2+
version = "0.1.0"
3+
extensions = ["sphinx_needs"]
4+
5+
suppress_warnings = ["epub.unknown_project_files"]
6+
7+
needs_types = [
8+
{
9+
"directive": "req",
10+
"title": "Requirement",
11+
"prefix": "REQ_",
12+
"color": "#BFD8D2",
13+
},
14+
]
15+
16+
# No needs_variant_data configured
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
IF without variant data
2+
=======================
3+
4+
.. if:: var.arch == "abc"
5+
6+
SHOULD_NOT_APPEAR

0 commit comments

Comments
 (0)