From 797b44b1a07bdf0860435aea9c9b399d71c9a58b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 06:54:45 +0000 Subject: [PATCH 01/26] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20sphinx-needs:=20one?= =?UTF-8?q?=20evaluator=20for=20variant=20conditions,=20out=20of=20`if`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The evaluation of an `if` condition moves out of `IfDirective.run` into a module-level function, `evaluate_variant_condition`, which takes the directive name and the warning subtype as parameters. `if` calls it with `"if"` / `"if"`, so its three messages, its warning subtype and its behaviour are unchanged: unconfigured variant data and an expression that raises skip the body, and a result that is not a bool is warned about and then used as its truth value. This is preparation for the `case` directive of `match` (#2011), whose conditions must mean exactly what the same text means in `if`. One function for both makes that true by construction rather than by keeping two copies in step. The `if` warnings were compared byte for byte against the previous code over nine expressions, with and without variant data configured: identical. --- .../src/sphinx_needs/directives/needif.py | 114 ++++++++++++------ 1 file changed, 76 insertions(+), 38 deletions(-) diff --git a/packages/sphinx-needs/src/sphinx_needs/directives/needif.py b/packages/sphinx-needs/src/sphinx_needs/directives/needif.py index 522dc50f5..fc0797fa3 100644 --- a/packages/sphinx-needs/src/sphinx_needs/directives/needif.py +++ b/packages/sphinx-needs/src/sphinx_needs/directives/needif.py @@ -5,15 +5,83 @@ from collections.abc import Sequence from docutils import nodes +from sphinx.environment import BuildEnvironment from sphinx.util.docutils import SphinxDirective from sphinx.util.nodes import nested_parse_with_titles from sphinx_needs.config import NeedsSphinxConfig -from sphinx_needs.logging import get_logger, log_warning +from sphinx_needs.logging import WarningSubTypes, get_logger, log_warning LOGGER = get_logger(__name__) +def evaluate_variant_condition( + env: BuildEnvironment, + expression: str, + /, + *, + directive: str, + subtype: WarningSubTypes, + location: str | tuple[str | None, int | None] | nodes.Node | None, +) -> bool | None: + """Evaluate a variant condition, as the ``if`` directive and a ``case`` do. + + The expression is Python, evaluated with ``var`` (the proxy over + :confval:`needs_variant_data`) as its only name and no builtins. + This is the one evaluator of both directives, + so that a condition means the same thing whichever of them it is written on. + + Every problem is warned about here, once, naming ``directive``: + variant data that is not configured, and an expression that raises, + make the condition unevaluable; + a result that is not a ``bool`` is warned about and then used as its truth value. + + :param env: The build environment, whose config holds the variant data. + :param expression: The condition, as written. + :param directive: The directive name the warnings give, e.g. ``"if"``. + :param subtype: The warning subtype, ``needs.``. + :param location: Where the warnings are reported. + :return: The truth value of the condition, + or ``None`` when it could not be evaluated (a warning has been emitted). + """ + config = NeedsSphinxConfig(env.config) + var_proxy = config.variant_data_proxy + + if var_proxy is None: + log_warning( + LOGGER, + f"'{directive}' directive used but needs_variant_data is not configured: " + f"{expression!r}", + subtype, + location=location, + ) + return None + + context: dict[str, object] = {"var": var_proxy, "__builtins__": {}} + try: + raw_result = eval(expression, context) + except Exception as e: + log_warning( + LOGGER, + f"'{directive}' directive expression failed: {expression!r} — {e}", + subtype, + location=location, + ) + return None + + if not isinstance(raw_result, bool): + log_warning( + LOGGER, + f"'{directive}' directive expression did not return a bool, " + f"got {type(raw_result).__name__}: {raw_result!r} " + f"(coercing to bool): {expression!r}", + subtype, + location=location, + ) + + return bool(raw_result) + + class IfDirective(SphinxDirective): """Conditionally include content based on a variant data expression. @@ -35,43 +103,13 @@ class IfDirective(SphinxDirective): has_content = True def run(self) -> Sequence[nodes.Node]: - expression = self.arguments[0] - config = NeedsSphinxConfig(self.env.config) - var_proxy = config.variant_data_proxy - - if var_proxy is None: - log_warning( - LOGGER, - f"'if' directive used but needs_variant_data is not configured: " - f"{expression!r}", - "if", - location=self.get_location(), - ) - return [] - - context: dict[str, object] = {"var": var_proxy, "__builtins__": {}} - try: - raw_result = eval(expression, context) - except Exception as e: - log_warning( - LOGGER, - f"'if' directive expression failed: {expression!r} — {e}", - "if", - location=self.get_location(), - ) - return [] - - if not isinstance(raw_result, bool): - log_warning( - LOGGER, - f"'if' directive expression did not return a bool, " - f"got {type(raw_result).__name__}: {raw_result!r} " - f"(coercing to bool): {expression!r}", - "if", - location=self.get_location(), - ) - - if not raw_result: + if not evaluate_variant_condition( + self.env, + self.arguments[0], + directive="if", + subtype="if", + location=self.get_location(), + ): return [] # Parse the content into a container node From e70eebfba83a41ffaec635cc33db1d3b623605e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 06:58:44 +0000 Subject: [PATCH 02/26] =?UTF-8?q?=E2=9C=A8=20sphinx-needs:=20`match`=20and?= =?UTF-8?q?=20`case`=20directives=20(#2011)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `match` holds `case` directives and comments. The first `case` whose condition holds is included, and a `case` with no condition is the default, which must be the last. Only the content of that case is parsed, so the needs in every other case are never created, as for a false `if`. Why a container rather than `elif` / `else` siblings: a directive cannot see its source siblings, so sibling branches have to leave state behind for the next one, and every mechanism that needs (a marker node after every branch, a backward scan of the parent, a stripping transform, a second strip for the need-node cache) exists only for that. A `match` sees all its cases at once, decides once, and returns only the winner, so none of it is needed and `if` stays as it is. How it works: - `case` does not parse its content. Inside a `match` body it returns a private placeholder carrying its condition, its raw content and its location; outside one it warns and is skipped. - `match` parses its body into a detached node it never returns, accepts only placeholders and comments there, checks that there is at most one default and that it is last, and evaluates the conditions in order with `evaluate_variant_condition`, the evaluator `if` uses, so a condition means the same in both. Later conditions are not evaluated once a case is taken. - Whether a `case` is in a `match` body is a depth counter in `env.temp_data`, raised only around the body parse; the winner is parsed at depth 0, so a `case` loose in a case's content is caught too. - Under MyST a directive's `content_offset` is relative to its own line, so the winner's offset is re-based onto the `match`'s line there. - Content written directly in the body runs its directives when the body is parsed, so any need the body parse created is removed again: case content is deferred, so such a need can only come from that mistake. Every mistake warns once under the new `needs.match` subtype and skips the whole `match`: an argument on `match`, content other than cases and comments, no case, two defaults or a default before another case, variant data that is not configured (even for a default-only `match`), and a condition that cannot be evaluated, which also keeps the default from being taken, so a typo in the intended case never renders the fallback in its place. A result that is not a bool is warned about and used, as in `if`. A `system_message` in the body was already reported by docutils or MyST, so the `match` is skipped without a second warning. The placeholder and the body node are not registered with `add_node`, there is no transform, and `api/need.py` is not touched. --- .../src/sphinx_needs/directives/needmatch.py | 338 ++++++++++++++++++ .../sphinx-needs/src/sphinx_needs/logging.py | 2 + .../sphinx-needs/src/sphinx_needs/needs.py | 3 + 3 files changed, 343 insertions(+) create mode 100644 packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py diff --git a/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py b/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py new file mode 100644 index 000000000..e6a930b27 --- /dev/null +++ b/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py @@ -0,0 +1,338 @@ +"""Directives for including one of several branches of content based on variant data. + +A ``match`` holds ``case`` directives and comments, and nothing else. +The first ``case`` whose condition holds is included; +a ``case`` with no condition is the default, and must be the last. + +A ``case`` does not parse its content. +Inside a ``match`` body it returns a transient :class:`_CasePlaceholder` +carrying its condition and its raw content, +and the ``match`` parses its own body into a detached :class:`_MatchBody` +that it never returns. +Having seen every case at once, the ``match`` checks the structure, +evaluates the conditions in order with the evaluator of the ``if`` directive +(:func:`~sphinx_needs.directives.needif.evaluate_variant_condition`), +and parses only the content of the case it takes, returning those nodes. +So the content of every other case is never parsed: +the needs in it are never created and its mistakes are never reported, +exactly as for the body of a false ``if``. + +Neither node class reaches a doctree or the need-node cache, +so neither is registered with Sphinx: +one that ever escaped would make a writer fail loudly rather than render silently. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from itertools import islice + +from docutils import nodes +from docutils.parsers.rst.states import RSTState +from docutils.statemachine import StringList +from docutils.utils import get_source_line +from sphinx.util.docutils import SphinxDirective +from sphinx.util.nodes import nested_parse_with_titles + +from sphinx_needs.config import NeedsSphinxConfig +from sphinx_needs.data import SphinxNeedsData +from sphinx_needs.directives.needif import evaluate_variant_condition +from sphinx_needs.logging import get_logger, log_warning + +LOGGER = get_logger(__name__) + +_DEPTH_KEY = "sphinx_needs_match_depth" +"""The ``env.temp_data`` key counting the ``match`` bodies being parsed. + +A ``case`` is a child of a ``match`` body exactly when the count is above 0. +A ``match`` raises it only around the parse of its own body, +and parses the content of the case it takes at 0, +so a ``case`` written loose in a case's content is reported as well. +""" + + +class _CasePlaceholder(nodes.Element): + """What a ``case`` leaves in the body of its ``match``; it never reaches a doctree. + + The payload is held in plain Python attributes rather than docutils attributes: + the ``match`` reads it once and discards it with the body. + """ + + condition: str | None + """The condition, or ``None`` for the default case.""" + content: StringList + """The raw content of the case, parsed only if the case is taken.""" + content_offset: int + """The ``content_offset`` of the ``case`` directive.""" + lineno: int + """The ``lineno`` of the ``case`` directive.""" + location: str | None + """Where warnings about the case are reported.""" + + +class _MatchBody(nodes.Element): + """The detached node a ``match`` parses its own body into; it is never returned.""" + + +class CaseDirective(SphinxDirective): + """One branch of a ``match``, included if it is the first whose condition holds. + + The directive argument is a condition, exactly as for the ``if`` directive; + a ``case`` with no argument is the default of its ``match``. + Its content is not parsed here: the ``match`` parses it if it takes the case. + + Example:: + + .. match:: + + .. case:: var.arch == "arm" + + ARM content. + + .. case:: + + Content for every other architecture. + """ + + required_arguments = 0 + optional_arguments = 1 + final_argument_whitespace = True + has_content = True + + def run(self) -> Sequence[nodes.Node]: + if self.env.temp_data.get(_DEPTH_KEY, 0) <= 0: + log_warning( + LOGGER, + "'case' directive outside a 'match' (a 'case' must be a direct child " + "of a 'match'); its content is skipped", + "match", + location=self.get_location(), + ) + return [] + + placeholder = _CasePlaceholder() + # an argument of only whitespace is no condition: the default case + has_condition = bool(self.arguments and self.arguments[0].strip()) + placeholder.condition = self.arguments[0] if has_condition else None + placeholder.content = self.content + placeholder.content_offset = self.content_offset + placeholder.lineno = self.lineno + placeholder.location = self.get_location() + return [placeholder] + + +class MatchDirective(SphinxDirective): + """Include the content of the first ``case`` whose condition holds. + + The content may hold only ``case`` directives and comments. + Every mistake is warned about once, and skips the whole ``match``: + content that is neither a ``case`` nor a comment, a default ``case`` + that is not the last or is not the only one, variant data that is not configured, + and a condition that cannot be evaluated before a case is taken. + A typo in the condition of the case that should be taken + therefore never renders a later case, or the default, in its place. + + Example:: + + .. match:: + + .. case:: var.arch == "arm" + + ARM content. + + .. case:: var.arch == "x86" + + x86 content. + + .. case:: + + Content for every other architecture. + """ + + required_arguments = 0 + # reserved, and refused: with no argument declared, MyST would move the text into + # the content and docutils would reject the directive, so neither could say why + optional_arguments = 1 + final_argument_whitespace = True + has_content = True + + def run(self) -> Sequence[nodes.Node]: + if self.arguments and self.arguments[0].strip(): + self._warn( + f"'match' directive takes no argument, got {self.arguments[0]!r} " + "(write a condition on each 'case'); the whole match is skipped" + ) + return [] + + cases = self._collect_cases() + if cases is None: + return [] + + if NeedsSphinxConfig(self.env.config).variant_data_proxy is None: + self._warn( + "'match' directive used but needs_variant_data is not configured; " + "the whole match is skipped" + ) + return [] + + for case in cases: + if case.condition is None: + return self._parse_case(case) + taken = evaluate_variant_condition( + self.env, + case.condition, + directive="case", + subtype="match", + location=case.location, + ) + if taken is None: + # poisoned: no later case is evaluated or taken, the default included + return [] + if taken: + # the first case that holds wins; the later ones are not evaluated + return self._parse_case(case) + return [] + + def _warn(self, message: str, location: str | nodes.Node | None = None, /) -> None: + log_warning( + LOGGER, + message, + "match", + location=self.get_location() if location is None else location, + ) + + def _parse_body(self) -> list[nodes.Node]: + """Parse the content into a detached node, with every ``case`` deferred. + + Because the content of every case is deferred, + a need created while the body is parsed can only come from content + written outside a case, which is a mistake that skips the whole ``match``: + such needs are removed again, so that the mistake creates none. + + :return: The children of the parsed body. + """ + body = _MatchBody() + body.document = self.state.document + + data = SphinxNeedsData(self.env) + # outside the read phase no need can be added, so there is nothing to undo + needs = None if data.needs_is_post_processed else data.get_needs_mutable() + before = 0 if needs is None else len(needs) + + temp_data = self.env.temp_data + depth = temp_data.get(_DEPTH_KEY, 0) + temp_data[_DEPTH_KEY] = depth + 1 + try: + self.state.nested_parse(self.content, self.content_offset, body) + finally: + temp_data[_DEPTH_KEY] = depth + + if needs is not None and len(needs) > before: + # the newest entries are the ones the body added: O(new needs) + for need_id in list(islice(reversed(needs), len(needs) - before)): + data.remove_need(need_id) + + return list(body.children) + + def _collect_cases(self) -> list[_CasePlaceholder] | None: + """Parse the body and check its structure. + + :return: The cases, in order, + or ``None`` if the body is not a valid ``match`` (a warning has been emitted). + """ + children = self._parse_body() + cases: list[_CasePlaceholder] = [] + for index, child in enumerate(children): + if isinstance(child, _CasePlaceholder): + cases.append(child) + elif isinstance(child, nodes.comment): + continue + elif isinstance(child, nodes.system_message): + # reported by docutils or MyST when it was created: skip, silently + return None + else: + tagname = child.tagname if isinstance(child, nodes.Element) else "#text" + self._warn( + "'match' directive may contain only 'case' directives and " + f"comments, got <{tagname}>; the whole match is skipped", + self._location_of(children[index:]), + ) + return None + + if not cases: + self._warn("'match' directive has no 'case'") + return None + + defaults = [case for case in cases if case.condition is None] + if len(defaults) > 1: + self._warn( + "'match' directive has more than one default 'case' (a 'case' with " + "no condition); the whole match is skipped", + defaults[1].location, + ) + return None + if defaults and defaults[0] is not cases[-1]: + self._warn( + "'match' directive has a default 'case' (a 'case' with no condition) " + "that is not its last 'case'; the whole match is skipped", + defaults[0].location, + ) + return None + + return cases + + def _location_of(self, candidates: Sequence[nodes.Node]) -> nodes.Node | str | None: + """Where to report the first of ``candidates``. + + That is the first node, in or under them, that knows its source and line: + the body is detached, so no node can inherit them from an ancestor, + and some nodes carry none of their own + (such as the target a need directive emits before the need). + + :param candidates: The offending child and the children after it. + :return: That node, or the location of the ``match`` if none has both. + """ + for candidate in candidates: + for node in candidate.findall(nodes.Element): + source, line = get_source_line(node) + if source and line: + return node + return self.get_location() + + def _parse_case(self, case: _CasePlaceholder) -> list[nodes.Node]: + """Parse the content of the case that is taken, with section titles allowed. + + It is parsed outside every ``match`` body (at depth 0), + whatever encloses this ``match``, + so that a ``case`` written loose in it is reported rather than collected. + + :param case: The case that is taken. + :return: The parsed nodes. + """ + node = nodes.container() + node.document = self.state.document + temp_data = self.env.temp_data + depth = temp_data.get(_DEPTH_KEY, 0) + temp_data[_DEPTH_KEY] = 0 + try: + nested_parse_with_titles( + self.state, case.content, node, self._content_offset_of(case) + ) + finally: + temp_data[_DEPTH_KEY] = depth + return node.children + + def _content_offset_of(self, case: _CasePlaceholder) -> int: + """The offset at which this directive's state parses the content of ``case``. + + Under docutils a directive's ``content_offset`` is absolute in the input, + so the case's own offset is valid for any state. + Under MyST it is relative to the directive's own line + (the mock state adds the line it was created at), + so it is re-based from the case's line onto this directive's line. + + :param case: The case that is taken. + """ + if isinstance(self.state, RSTState): + return case.content_offset + return case.lineno - self.lineno + case.content_offset diff --git a/packages/sphinx-needs/src/sphinx_needs/logging.py b/packages/sphinx-needs/src/sphinx_needs/logging.py index 88fef8cbc..3a3d59eb3 100644 --- a/packages/sphinx-needs/src/sphinx_needs/logging.py +++ b/packages/sphinx-needs/src/sphinx_needs/logging.py @@ -41,6 +41,7 @@ def get_logger(name: str) -> SphinxLoggerAdapter: "link_text", "load_external_need", "load_service_need", + "match", "max_items", "mistyped_external_values", "mistyped_import_values", @@ -92,6 +93,7 @@ def get_logger(name: str) -> SphinxLoggerAdapter: "link_text": "Reference text could not be generated", "load_external_need": "Failed to load an external need", "load_service_need": "Failed to load a service need", + "match": "Error in processing match/case directive", "max_items": "View truncated by a max_items limit", "mistyped_external_values": "Unexpected value types found in external need data", "mistyped_import_values": "Unexpected value types found in imported need data", diff --git a/packages/sphinx-needs/src/sphinx_needs/needs.py b/packages/sphinx-needs/src/sphinx_needs/needs.py index 1b6be23fb..518aa77f6 100644 --- a/packages/sphinx-needs/src/sphinx_needs/needs.py +++ b/packages/sphinx-needs/src/sphinx_needs/needs.py @@ -86,6 +86,7 @@ NeedlistDirective, process_needlist, ) +from sphinx_needs.directives.needmatch import CaseDirective, MatchDirective from sphinx_needs.directives.needpie import Needpie, NeedpieDirective, process_needpie from sphinx_needs.directives.needreport import NeedReportDirective from sphinx_needs.directives.needsequence import ( @@ -309,6 +310,8 @@ def setup(app: Sphinx) -> dict[str, Any]: app.add_directive("needreport", NeedReportDirective) app.add_directive("needuml", NeedumlDirective) app.add_directive("if", IfDirective) + app.add_directive("match", MatchDirective) + app.add_directive("case", CaseDirective) app.add_directive("needarch", NeedarchDirective) app.add_directive("list2need", List2NeedDirective) From b929a5a09ee194136093868db7f309f53b025a34 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 07:05:03 +0000 Subject: [PATCH 03/26] =?UTF-8?q?=F0=9F=A7=AA=20sphinx-needs:=20tests=20fo?= =?UTF-8?q?r=20`match`=20and=20`case`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A doc project, `doc_match_directive`, holds the happy paths in reStructuredText: the first true case wins while a later condition that is not Python, and one naming an unknown key, are never evaluated (the build has no warning at all); the default is taken, and nothing at all when no case holds and there is none; the needs of the untaken cases are absent from the needs view and the taken one records its source line; a heading in the taken case is a section nested where it stands; a nested match; an include that supplies the cases and one inside the taken case; and a match in a need's content that `needextract` renders on another page. No placeholder or body node reaches any pickled doctree or the need-node cache. One parametrised table holds every mistake of the contract (#2011), each asserted to warn exactly once, with its text, `[needs.match]`, and the `/index.rst:N:` line of the offending directive or child, and to render nothing of the match; the cases of #1999's warning table map onto it one to one. A docutils error in the body is reported once, by docutils. The MyST twin covers colon and backtick fences, `%` comments and `+++`, needs in the taken and untaken case (with the true line, which the content-offset re-basing gives), nesting, a default with trailing spaces, needextract, and the MyST warnings; lines are asserted for the backtick spellings only, since MyST reports a directive nested in a colon fence one line late. One table of expressions runs through both `.. if::` and `.. case::` and asserts the same verdict and the same warning text for each, so the two directives cannot drift into two condition languages. `test_if_directive.py` and its projects are unchanged. --- .../doc_test/doc_match_directive/cases.txt | 7 + .../doc_test/doc_match_directive/conf.py | 22 + .../doc_test/doc_match_directive/index.rst | 173 ++++ .../doc_test/doc_match_directive/other.rst | 5 + .../doc_match_directive/taken_body.txt | 4 + .../tests/test_match_directive.py | 800 ++++++++++++++++++ 6 files changed, 1011 insertions(+) create mode 100644 packages/sphinx-needs/tests/doc_test/doc_match_directive/cases.txt create mode 100644 packages/sphinx-needs/tests/doc_test/doc_match_directive/conf.py create mode 100644 packages/sphinx-needs/tests/doc_test/doc_match_directive/index.rst create mode 100644 packages/sphinx-needs/tests/doc_test/doc_match_directive/other.rst create mode 100644 packages/sphinx-needs/tests/doc_test/doc_match_directive/taken_body.txt create mode 100644 packages/sphinx-needs/tests/test_match_directive.py diff --git a/packages/sphinx-needs/tests/doc_test/doc_match_directive/cases.txt b/packages/sphinx-needs/tests/doc_test/doc_match_directive/cases.txt new file mode 100644 index 000000000..0ddde93a9 --- /dev/null +++ b/packages/sphinx-needs/tests/doc_test/doc_match_directive/cases.txt @@ -0,0 +1,7 @@ +.. case:: var.arch == "xyz" + + SKIPPED_X1_FROM_INCLUDE + +.. case:: + + TAKEN_X1_DEFAULT_FROM_INCLUDE diff --git a/packages/sphinx-needs/tests/doc_test/doc_match_directive/conf.py b/packages/sphinx-needs/tests/doc_test/doc_match_directive/conf.py new file mode 100644 index 000000000..30f479e63 --- /dev/null +++ b/packages/sphinx-needs/tests/doc_test/doc_match_directive/conf.py @@ -0,0 +1,22 @@ +project = "needs_match_test" +version = "0.1.0" +extensions = ["sphinx_needs"] + +suppress_warnings = ["epub.unknown_project_files"] +# the files `.. include::` reads are not documents of their own +exclude_patterns = ["_build", "*.txt"] + +needs_types = [ + { + "directive": "req", + "title": "Requirement", + "prefix": "REQ_", + "color": "#BFD8D2", + }, +] + +needs_variant_data = { + "arch": "abc", + "debug": True, + "count": 5, +} diff --git a/packages/sphinx-needs/tests/doc_test/doc_match_directive/index.rst b/packages/sphinx-needs/tests/doc_test/doc_match_directive/index.rst new file mode 100644 index 000000000..5ac536864 --- /dev/null +++ b/packages/sphinx-needs/tests/doc_test/doc_match_directive/index.rst @@ -0,0 +1,173 @@ +MATCH Test +========== + +.. toctree:: + + other + +P1 first true case wins +----------------------- + +The conditions after the taken case are never evaluated, +so the invalid one and the unknown key cannot warn. + +.. match:: + + .. case:: var.arch == "abc" + + TAKEN_P1_FIRST + + .. case:: var.debug + + SKIPPED_P1_SECOND_TRUE + + .. case:: this is not python !!! + + SKIPPED_P1_INVALID_SYNTAX + + .. case:: var.no_such_key == 1 + + SKIPPED_P1_UNKNOWN_KEY + + .. case:: + + SKIPPED_P1_DEFAULT + +P2 default taken when no case holds +----------------------------------- + +.. match:: + + .. case:: var.arch == "xyz" + + SKIPPED_P2_FALSE + + .. a comment between two cases + + .. case:: + + TAKEN_P2_DEFAULT + +P2b no case holds and there is no default +----------------------------------------- + +.. match:: + + .. case:: var.arch == "xyz" + + SKIPPED_P2B_1 + + .. case:: not var.debug + + SKIPPED_P2B_2 + +TAKEN_P2B_AFTER_MATCH + +P3 needs in cases +----------------- + +.. match:: + + .. case:: var.arch == "xyz" + + .. req:: In a case that is not taken + :id: REQ_P3_SKIPPED + + .. case:: var.arch == "abc" + + .. req:: In the taken case + :id: REQ_P3_TAKEN + + .. case:: + + .. req:: In a default that is not taken + :id: REQ_P3_DEFAULT_SKIPPED + +P4 sections in the taken case +----------------------------- + +.. match:: + + .. case:: var.debug + + P4 conditional heading + ~~~~~~~~~~~~~~~~~~~~~~ + + TAKEN_P4_SECTION_BODY + + .. case:: + + P4 skipped heading + ~~~~~~~~~~~~~~~~~~ + + SKIPPED_P4_BODY + +P5 nested match +--------------- + +.. match:: + + .. case:: var.debug + + TAKEN_P5_OUTER + + .. match:: + + .. case:: var.arch == "xyz" + + SKIPPED_P5_INNER + + .. case:: + + TAKEN_P5_INNER_DEFAULT + + .. case:: + + SKIPPED_P5_OUTER + +P5b match in the content of a need +---------------------------------- + +The need is extracted on the other page, +which renders its content from the need-node cache. + +.. req:: Host with match content + :id: REQ_HOST + + .. match:: + + .. case:: var.arch == "xyz" + + SKIPPED_P5B_IN_NEED + + .. case:: var.arch == "abc" + + TAKEN_P5B_IN_NEED + + .. case:: + + SKIPPED_P5B_IN_NEED_DEFAULT + +X1 the cases come from an include +--------------------------------- + +.. match:: + + .. include:: cases.txt + +X2 an include inside the taken case +----------------------------------- + +.. match:: + + .. case:: var.debug + + .. include:: taken_body.txt + + TAKEN_X2_AFTER_INCLUDE + + .. case:: + + SKIPPED_X2_DEFAULT + +TAKEN_X2_AFTER_MATCH diff --git a/packages/sphinx-needs/tests/doc_test/doc_match_directive/other.rst b/packages/sphinx-needs/tests/doc_test/doc_match_directive/other.rst new file mode 100644 index 000000000..8376855a8 --- /dev/null +++ b/packages/sphinx-needs/tests/doc_test/doc_match_directive/other.rst @@ -0,0 +1,5 @@ +Other +===== + +.. needextract:: + :filter: id == 'REQ_HOST' diff --git a/packages/sphinx-needs/tests/doc_test/doc_match_directive/taken_body.txt b/packages/sphinx-needs/tests/doc_test/doc_match_directive/taken_body.txt new file mode 100644 index 000000000..7788e5f3a --- /dev/null +++ b/packages/sphinx-needs/tests/doc_test/doc_match_directive/taken_body.txt @@ -0,0 +1,4 @@ +TAKEN_X2_INCLUDED_TEXT + +.. req:: Included into the taken case + :id: REQ_X2_INCLUDED diff --git a/packages/sphinx-needs/tests/test_match_directive.py b/packages/sphinx-needs/tests/test_match_directive.py new file mode 100644 index 000000000..0c5d8c1f2 --- /dev/null +++ b/packages/sphinx-needs/tests/test_match_directive.py @@ -0,0 +1,800 @@ +"""Tests for the ``.. match::`` and ``.. case::`` directives.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from typing import NamedTuple + +import pytest +from docutils import nodes + +from sphinx_needs.data import SphinxNeedsData +from sphinx_needs.directives.needmatch import _CasePlaceholder, _MatchBody +from sphinx_needs_testkit import assert_no_warnings, build_warnings + +_NEEDS_TYPES = ( + "needs_types = [{'directive': 'req', 'title': 'Requirement'," + " 'prefix': 'REQ_', 'color': '#BFD8D2'}]\n" +) +_VARIANT_DATA = ( + "needs_variant_data = {'arch': 'abc', 'debug': True, 'count': 5," + " 'tags': ['a', 'b'], 'build': {'features': ['f1', 'f2']}}\n" +) +_CONF = "extensions = ['sphinx_needs']\n" + _VARIANT_DATA + _NEEDS_TYPES +_CONF_NO_VARIANT_DATA = "extensions = ['sphinx_needs']\n" + _NEEDS_TYPES +_CONF_MYST = ( + "extensions = ['sphinx_needs', 'myst_parser']\n" + "myst_enable_extensions = ['colon_fence']\n" + _VARIANT_DATA + _NEEDS_TYPES +) + +_HAS_MYST = importlib.util.find_spec("myst_parser") is not None + + +def _project( + body: str, + /, + *, + conf: str = _CONF, + myst: bool = False, + other: str | None = None, +) -> dict[str, object]: + """An inline project whose root document is a title followed by ``body``. + + :param body: The source after the title. + :param conf: The ``conf.py``. + :param myst: Write the documents as MyST Markdown (``.md``) rather than RST. + :param other: The source of a second document, ``other``, if there is one. + """ + suffix, title = (".md", "# Test\n\n") if myst else (".rst", "Test\n====\n\n") + files = [(Path("conf.py"), conf), (Path("index" + suffix), title + body)] + if other is not None: + files.append((Path("other" + suffix), other)) + return {"buildername": "html", "files": files} + + +def _line_of(source: str, text: str) -> int: + """The 1-based number of the one line of ``source`` that is exactly ``text``.""" + lines = [i for i, line in enumerate(source.splitlines(), 1) if line == text] + assert len(lines) == 1, f"{text!r} is on lines {lines}" + return lines[0] + + +def _assert_no_match_nodes(app) -> None: + """Neither private node class reaches a pickled doctree or the need-node cache.""" + private = (_CasePlaceholder, _MatchBody) + for docname in sorted(app.env.found_docs): + doctree = app.env.get_doctree(docname) + assert [ + n for n in doctree.findall(nodes.Element) if isinstance(n, private) + ] == [] + data = SphinxNeedsData(app.env) + for need_id in data.get_needs_view(): + need_node = data.get_need_node(need_id) + assert need_node is not None, need_id + assert [ + n for n in need_node.findall(nodes.Element) if isinstance(n, private) + ] == [], need_id + + +def _section_titles(app, docname: str) -> list[list[str]]: + """The title path of every section of a document, outermost first.""" + paths = [] + for section in app.env.get_doctree(docname).findall(nodes.section): + path = [] + node = section + while isinstance(node, nodes.section): + path.insert(0, node[0].astext()) + node = node.parent + paths.append(path) + return paths + + +# The happy paths, reStructuredText + + +@pytest.mark.parametrize( + "test_app", + [{"buildername": "html", "srcdir": "doc_test/doc_match_directive"}], + indirect=True, +) +def test_match_directive(test_app): + """First true case wins, the default, needs, sections, nesting and includes. + + The project builds without a single warning, + although a case after a taken one has a condition that is not Python + and another names an unknown key: neither is ever evaluated. + """ + app = test_app + app.build() + assert_no_warnings(app) + + html = Path(app.outdir, "index.html").read_text() + taken = [ + "TAKEN_P1_FIRST", + "TAKEN_P2_DEFAULT", + "TAKEN_P2B_AFTER_MATCH", + "TAKEN_P4_SECTION_BODY", + "TAKEN_P5_OUTER", + "TAKEN_P5_INNER_DEFAULT", + "TAKEN_P5B_IN_NEED", + "TAKEN_X1_DEFAULT_FROM_INCLUDE", + "TAKEN_X2_INCLUDED_TEXT", + "TAKEN_X2_AFTER_INCLUDE", + "TAKEN_X2_AFTER_MATCH", + ] + assert [word for word in taken if word not in html] == [] + # each taken case is rendered once + assert [word for word in taken if html.count(f"

{word}

") != 1] == [] + assert "SKIPPED_" not in html + + # the needs of the cases that are not taken are never created + needs = SphinxNeedsData(app.env).get_needs_view() + assert sorted(needs) == ["REQ_HOST", "REQ_P3_TAKEN", "REQ_X2_INCLUDED"] + # the need of the taken case knows the line it was written on + source = Path(app.srcdir, "index.rst").read_text() + assert needs["REQ_P3_TAKEN"]["lineno"] == _line_of( + source, " .. req:: In the taken case" + ) + + # a heading in the taken case is a section of the document, nested where it stands + sections = _section_titles(app, "index") + assert [ + "MATCH Test", + "P4 sections in the taken case", + "P4 conditional heading", + ] in sections + assert not [path for path in sections if "P4 skipped heading" in path] + + # the need with a match in its content is extracted on the other page + other = Path(app.outdir, "other.html").read_text() + assert "TAKEN_P5B_IN_NEED" in other + assert "SKIPPED_" not in other + + _assert_no_match_nodes(app) + + +# Each mistake warns once, at the line that has it, and skips the whole match + + +class _Expected(NamedTuple): + """What a build of ``body`` must report and render.""" + + body: str + #: per warning, in order: a substring, and the source line the warning must name + warnings: tuple[tuple[str, str], ...] + #: words that must be rendered (and no word starting ``SKIPPED_`` may be) + taken: tuple[str, ...] = () + #: the ids the needs view must hold + needs: tuple[str, ...] = () + conf: str = _CONF + + +_SKIP = "; the whole match is skipped" + +_WARNINGS = { + "default not last": _Expected( + ".. match::\n\n" + " .. case::\n\n SKIPPED_DEFAULT\n\n" + " .. case:: True\n\n SKIPPED_TRUE\n", + ( + ( + "'match' directive has a default 'case' (a 'case' with no condition) " + "that is not its last 'case'" + _SKIP, + " .. case::", + ), + ), + ), + "two defaults": _Expected( + ".. match::\n\n" + " .. case:: False\n\n SKIPPED_FALSE\n\n" + " .. case::\n\n SKIPPED_D1\n\n" + # only whitespace after `::` is no condition either: the second default + " .. case:: \n\n SKIPPED_D2\n", + ( + ( + "'match' directive has more than one default 'case' (a 'case' with " + "no condition)" + _SKIP, + " .. case:: ", + ), + ), + ), + "paragraph in the body": _Expected( + ".. match::\n\n" + " .. case:: True\n\n SKIPPED_CASE\n\n" + " A stray paragraph.\n", + ( + ( + "'match' directive may contain only 'case' directives and comments, " + "got " + _SKIP, + " A stray paragraph.", + ), + ), + ), + "note wrapping a case": _Expected( + ".. match::\n\n" + " .. note::\n\n .. case:: True\n\n SKIPPED_IN_NOTE\n", + (("got " + _SKIP, " .. note::"),), + ), + "case outside a match": _Expected( + "Para.\n\n.. case:: True\n\n SKIPPED_STRAY\n", + ( + ( + "'case' directive outside a 'match' (a 'case' must be a direct child " + "of a 'match'); its content is skipped", + ".. case:: True", + ), + ), + ), + "case loose in the taken case": _Expected( + ".. match::\n\n" + " .. case:: True\n\n TAKEN_OUTER\n\n" + " .. case:: True\n\n SKIPPED_LOOSE\n", + (("'case' directive outside a 'match'", " .. case:: True"),), + taken=("TAKEN_OUTER",), + ), + # two independent mistakes, two warnings: a match written directly in another + # match's body, whose taken case holds a loose case. The taken case is parsed + # outside every match body, so the loose case is reported and cannot become a case + # of the OUTER match, which then has none. + "case loose in the taken case of a misplaced match": _Expected( + ".. match::\n\n" + " .. match::\n\n" + " .. case:: True\n\n" + " .. case:: True\n\n SKIPPED_LOOSE\n", + ( + ("'case' directive outside a 'match'", " .. case:: True"), + ("'match' directive has no 'case'", ".. match::"), + ), + ), + "unevaluable first case poisons the default": _Expected( + ".. match::\n\n" + " .. case:: this is not python !!!\n\n SKIPPED_1\n\n" + " .. case:: True\n\n SKIPPED_2\n\n" + " .. case::\n\n SKIPPED_DEFAULT\n", + ( + ( + "'case' directive expression failed: 'this is not python !!!' — ", + " .. case:: this is not python !!!", + ), + ), + ), + "unknown key in a later case": _Expected( + ".. match::\n\n" + " .. case:: var.arch == 'xyz'\n\n SKIPPED_1\n\n" + " .. case:: var.no_such_key == 1\n\n SKIPPED_2\n\n" + " .. case::\n\n SKIPPED_DEFAULT\n", + ( + ( + "'case' directive expression failed: 'var.no_such_key == 1' — " + "Unknown variant key: var.no_such_key", + " .. case:: var.no_such_key == 1", + ), + ), + ), + "builtins blocked": _Expected( + ".. match::\n\n" + " .. case:: __import__('os').system('echo pwned')\n\n SKIPPED\n\n" + " .. case::\n\n SKIPPED_DEFAULT\n", + ( + ( + "'case' directive expression failed: " + "\"__import__('os').system('echo pwned')\" — " + "name '__import__' is not defined", + " .. case:: __import__('os').system('echo pwned')", + ), + ), + ), + "non-bool is coerced and taken": _Expected( + ".. match::\n\n" + " .. case:: var.count\n\n TAKEN_NONBOOL\n\n" + " .. case::\n\n SKIPPED_DEFAULT\n", + ( + ( + "'case' directive expression did not return a bool, got int: 5 " + "(coercing to bool): 'var.count'", + " .. case:: var.count", + ), + ), + taken=("TAKEN_NONBOOL",), + ), + "empty string condition": _Expected( + '.. match::\n\n .. case:: ""\n\n SKIPPED_EMPTY\n\n' + " .. case::\n\n TAKEN_DEFAULT\n", + ( + ( + "'case' directive expression did not return a bool, got str: '' " + "(coercing to bool): '\"\"'", + ' .. case:: ""', + ), + ), + taken=("TAKEN_DEFAULT",), + ), + "match with an argument": _Expected( + ".. match:: var.arch\n\n .. case:: True\n\n SKIPPED\n", + ( + ( + "'match' directive takes no argument, got 'var.arch' " + "(write a condition on each 'case')" + _SKIP, + ".. match:: var.arch", + ), + ), + ), + "empty match": _Expected( + ".. match::\n\nTAKEN_AFTER_EMPTY\n", + (("'match' directive has no 'case'", ".. match::"),), + taken=("TAKEN_AFTER_EMPTY",), + ), + "only comments": _Expected( + ".. match::\n\n .. just a comment\n\n .. and another\n", + (("'match' directive has no 'case'", ".. match::"),), + ), + "variant data not configured": _Expected( + ".. match::\n\n" + " .. case:: var.arch == 'abc'\n\n SKIPPED_1\n\n" + " .. case::\n\n SKIPPED_DEFAULT\n", + ( + ( + "'match' directive used but needs_variant_data is not configured" + + _SKIP, + ".. match::", + ), + ), + conf=_CONF_NO_VARIANT_DATA, + ), + # nothing is evaluated here, and still the match warns and renders nothing: + # the "used but not configured" rule holds for every match + "variant data not configured, only a default": _Expected( + ".. match::\n\n .. case::\n\n SKIPPED_DEFAULT_ONLY\n", + ( + ( + "'match' directive used but needs_variant_data is not configured" + + _SKIP, + ".. match::", + ), + ), + conf=_CONF_NO_VARIANT_DATA, + ), + # content outside a case is parsed with the body, so the need directive runs; + # the match removes the need again (the target is the first node it emits) + "need directly in the body": _Expected( + ".. match::\n\n" + " .. req:: Directly in the match body\n :id: REQ_DIRECT\n\n" + " .. case:: True\n\n SKIPPED\n", + (("got " + _SKIP, " .. req:: Directly in the match body"),), + ), + # a case that is not taken is never parsed, exactly as the body of a false `if` + "errors in an untaken case are never reported": _Expected( + ".. match::\n\n" + " .. case:: True\n\n TAKEN_E\n\n" + " .. req:: In the taken case\n :id: REQ_TAKEN\n\n" + " .. case:: False\n\n" + " .. match::\n\n" + " .. case:: invalid !!!\n\n SKIPPED\n\n" + " .. nosuchdirective::\n\n" + " .. req:: In the case that is not taken\n :id: REQ_SKIPPED\n", + (), + taken=("TAKEN_E",), + needs=("REQ_TAKEN",), + ), +} + + +@pytest.mark.parametrize( + ("test_app", "expected"), + [(_project(case.body, conf=case.conf), case) for case in _WARNINGS.values()], + ids=list(_WARNINGS), + indirect=["test_app"], +) +def test_match_warnings(test_app, expected: _Expected): + """Each mistake warns exactly once, at the offending line, and fails closed.""" + app = test_app + app.build() + warnings = build_warnings(app) + assert len(warnings) == len(expected.warnings), warnings + source = Path(app.srcdir, "index.rst").read_text() + for warning, (text, line) in zip(warnings, expected.warnings, strict=True): + assert warning.startswith( + f"/index.rst:{_line_of(source, line)}: WARNING: " + ), warning + assert text in warning, warning + assert warning.endswith(" [needs.match]"), warning + html = Path(app.outdir, "index.html").read_text() + assert [word for word in expected.taken if word not in html] == [] + assert "SKIPPED" not in html + assert sorted(SphinxNeedsData(app.env).get_needs_view()) == list(expected.needs) + _assert_no_match_nodes(app) + + +@pytest.mark.parametrize( + ("test_app", "error"), + [ + ( + _project( + ".. match::\n\n" + " .. cas:: True\n\n SKIPPED\n\n" + " .. case::\n\n SKIPPED_DEFAULT\n" + ), + 'Unknown directive type "cas"', + ), + ( + _project( + ".. match::\n\n" + " Title\n -----\n\n" + " .. case:: True\n\n SKIPPED\n" + ), + "Unexpected section title", + ), + ], + ids=["typo in a directive name", "section title in the body"], + indirect=["test_app"], +) +def test_match_body_error_reported_once(test_app, error: str): + """A mistake docutils reports in the body skips the match without a second warning.""" + app = test_app + app.build() + (warning,) = build_warnings(app) + assert error in warning + assert "needs.match" not in warning + html = Path(app.outdir, "index.html").read_text() + assert "SKIPPED" not in html + + +@pytest.mark.parametrize( + "test_app", + [ + _project( + "Para.\n\n.. case:: True\n\n SKIPPED_STRAY\n", + conf=_CONF + "suppress_warnings = ['needs.match']\n", + ) + ], + indirect=True, +) +def test_match_warnings_are_suppressible(test_app): + """Every ``match`` / ``case`` warning is of the ``needs.match`` type.""" + app = test_app + app.build() + assert_no_warnings(app) + assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() + + +# One condition language: `case` evaluates exactly what `if` does + +_EXPRESSIONS = { + # expression: whether it is true (None: it cannot be evaluated) + "var.arch == 'abc'": True, + "var.arch == 'xyz'": False, + "var.debug": True, + "not var.debug": False, + "var.count > 10": False, + "'f1' in var.build.features": True, + "var.count": True, + "var.tags": True, + '""': False, + "var.no_such_key == 1": None, + "var.build.no_such_key": None, + "invalid syntax !!!": None, + "__import__('os').system('echo pwned')": None, + "1 / 0": None, +} +#: the expressions whose value is not a bool, which warn and are then used +_NON_BOOL = {"var.count", "var.tags", '""'} + + +def _strip_location_and_type(warning: str) -> str: + """The message of a warning record, without its location and its type.""" + message = warning.split(": WARNING: ", 1)[1] + return message.rsplit(" [needs.", 1)[0] + + +@pytest.mark.parametrize( + ("test_app", "expression", "verdict"), + [ + ( + _project( + f".. if:: {expression}\n\n IF_TAKEN\n\n" + ".. match::\n\n" + f" .. case:: {expression}\n\n CASE_TAKEN\n" + ), + expression, + verdict, + ) + for expression, verdict in _EXPRESSIONS.items() + ], + ids=list(_EXPRESSIONS), + indirect=["test_app"], +) +def test_case_conditions_are_if_conditions(test_app, expression: str, verdict): + """``if`` and ``case`` give every condition the same verdict and the same warnings. + + Only the directive name and the warning type differ, + because both directives go through one evaluator. + """ + app = test_app + app.build() + html = Path(app.outdir, "index.html").read_text() + assert ("IF_TAKEN" in html) is bool(verdict) + assert ("CASE_TAKEN" in html) is bool(verdict) + + warnings = build_warnings(app) + if_warnings = [w for w in warnings if w.endswith(" [needs.if]")] + case_warnings = [w for w in warnings if w.endswith(" [needs.match]")] + assert len(if_warnings) + len(case_warnings) == len(warnings), warnings + assert len(if_warnings) == len(case_warnings), warnings + # an unevaluable condition warns, and so does a result that is not a bool + assert bool(if_warnings) is (verdict is None or expression in _NON_BOOL) + source = Path(app.srcdir, "index.rst").read_text() + for if_warning, case_warning in zip(if_warnings, case_warnings, strict=True): + assert if_warning.startswith( + f"/index.rst:{_line_of(source, f'.. if:: {expression}')}: " + ), if_warning + assert case_warning.startswith( + f"/index.rst:{_line_of(source, f' .. case:: {expression}')}: " + ), case_warning + assert _strip_location_and_type(if_warning).startswith("'if' directive ") + assert _strip_location_and_type(case_warning) == _strip_location_and_type( + if_warning + ).replace("'if' directive ", "'case' directive ", 1) + + +# MyST Markdown + +_MYST_HAPPY = """\ +```{toctree} +other +``` + +## Colon fences + +::::{match} +:::{case} var.arch == "abc" +TAKEN_M1_FIRST +::: +:::{case} var.debug +SKIPPED_M1_SECOND_TRUE +::: +:::{case} this is not python !!! +SKIPPED_M1_INVALID_SYNTAX +::: +:::{case} +SKIPPED_M1_DEFAULT +::: +:::: + +## Comments between cases + +::::{match} +:::{case} var.arch == "xyz" +SKIPPED_M2_FALSE +::: + +% a MyST comment between two cases + ++++ + +:::{case} +TAKEN_M2_DEFAULT +::: +:::: + +## Backtick fences, and needs in cases + +`````{match} +````{case} var.arch == "xyz" +```{req} In a case that is not taken +:id: REQ_M3_SKIPPED +``` +```` +````{case} var.arch == "abc" +TAKEN_M3 + +```{req} In the taken case +:id: REQ_M3_TAKEN +``` +```` +````` + +## A section in the taken case + +::::{match} +:::{case} var.debug +### M4 conditional heading + +TAKEN_M4_SECTION_BODY +::: +:::: + +## Nested match, one more fence character per level + +::::::{match} +:::::{case} var.debug +TAKEN_M5_OUTER + +::::{match} +:::{case} var.arch == "xyz" +SKIPPED_M5_INNER +::: +:::{case} +TAKEN_M5_INNER_DEFAULT +::: +:::: +::::: +:::::{case} +SKIPPED_M5_OUTER +::::: +:::::: + +## A default with trailing spaces + +::::{match} +:::{case} False +SKIPPED_M6 +::: +:::{case}\x20\x20\x20 +TAKEN_M6_DEFAULT +::: +:::: + +## Match in the content of a need + +:::::{req} Host with match content +:id: REQ_M_HOST + +::::{match} +:::{case} var.arch == "xyz" +SKIPPED_M7_IN_NEED +::: +:::{case} var.arch == "abc" +TAKEN_M7_IN_NEED +::: +:::: +::::: +""" + + +@pytest.mark.skipif(not _HAS_MYST, reason="needs myst-parser") +@pytest.mark.parametrize( + "test_app", + [ + _project( + _MYST_HAPPY, + conf=_CONF_MYST, + myst=True, + other="# Other\n\n```{needextract}\n:filter: id == 'REQ_M_HOST'\n```\n", + ) + ], + indirect=True, +) +def test_match_in_myst(test_app): + """Colon and backtick fences, comments, needs, sections, nesting, needextract.""" + app = test_app + app.build() + assert_no_warnings(app) + + html = Path(app.outdir, "index.html").read_text() + taken = [ + "TAKEN_M1_FIRST", + "TAKEN_M2_DEFAULT", + "TAKEN_M3", + "TAKEN_M4_SECTION_BODY", + "TAKEN_M5_OUTER", + "TAKEN_M5_INNER_DEFAULT", + "TAKEN_M6_DEFAULT", + "TAKEN_M7_IN_NEED", + ] + assert [word for word in taken if word not in html] == [] + assert "SKIPPED_" not in html + + needs = SphinxNeedsData(app.env).get_needs_view() + assert sorted(needs) == ["REQ_M3_TAKEN", "REQ_M_HOST"] + # MyST gives a directive a content offset relative to its own line, which the + # match re-bases for the case it takes: the need knows its true line + source = Path(app.srcdir, "index.md").read_text() + assert needs["REQ_M3_TAKEN"]["lineno"] == _line_of( + source, "```{req} In the taken case" + ) + + assert [ + "Test", + "A section in the taken case", + "M4 conditional heading", + ] in _section_titles(app, "index") + + other = Path(app.outdir, "other.html").read_text() + assert "TAKEN_M7_IN_NEED" in other + assert "SKIPPED_" not in other + + _assert_no_match_nodes(app) + + +# MyST reports a directive nested in a colon fence one line late (its own quirk, the +# same for a `{note}` in a `{note}`), so only the backtick spellings assert a line +_MYST_WARNINGS = { + "case outside a match, backticks": ( + "Para.\n\n```{case} True\nSKIPPED_STRAY\n```\n", + "'case' directive outside a 'match'", + "```{case} True", + ), + "case outside a match, colons": ( + "Para.\n\n:::{case} True\nSKIPPED_STRAY\n:::\n", + "'case' directive outside a 'match'", + None, + ), + "case loose in the taken case, backticks": ( + "`````{match}\n````{case} True\nTAKEN_OUTER\n\n" + "```{case} True\nSKIPPED_LOOSE\n```\n````\n`````\n", + "'case' directive outside a 'match'", + "```{case} True", + ), + "paragraph in the body, backticks": ( + "````{match}\n```{case} True\nSKIPPED\n```\n\nA stray paragraph.\n````\n", + "got " + _SKIP, + "A stray paragraph.", + ), + "paragraph in the body, colons": ( + "::::{match}\n:::{case} True\nSKIPPED\n:::\n\nA stray paragraph.\n::::\n", + "got " + _SKIP, + None, + ), + # an HTML comment is raw HTML, not a comment + "html comment between cases, backticks": ( + "````{match}\n```{case} False\nSKIPPED\n```\n\n\n\n" + "```{case}\nSKIPPED_DEFAULT\n```\n````\n", + "got " + _SKIP, + "", + ), + "html comment between cases, colons": ( + "::::{match}\n:::{case} False\nSKIPPED\n:::\n\n\n\n" + ":::{case}\nSKIPPED_DEFAULT\n:::\n::::\n", + "got " + _SKIP, + None, + ), + "match with an argument, backticks": ( + "````{match} var.arch\n```{case} True\nSKIPPED\n```\n````\n", + "'match' directive takes no argument, got 'var.arch'", + "````{match} var.arch", + ), + "match with an argument, colons": ( + "::::{match} var.arch\n:::{case} True\nSKIPPED\n:::\n::::\n", + "'match' directive takes no argument, got 'var.arch'", + None, + ), + "unevaluable condition, backticks": ( + "````{match}\n```{case} invalid !!!\nSKIPPED\n```\n" + "```{case}\nSKIPPED_DEFAULT\n```\n````\n", + "'case' directive expression failed: 'invalid !!!'", + "```{case} invalid !!!", + ), + "unevaluable condition, colons": ( + "::::{match}\n:::{case} invalid !!!\nSKIPPED\n:::\n" + ":::{case}\nSKIPPED_DEFAULT\n:::\n::::\n", + "'case' directive expression failed: 'invalid !!!'", + None, + ), +} + + +@pytest.mark.skipif(not _HAS_MYST, reason="needs myst-parser") +@pytest.mark.parametrize( + ("test_app", "text", "line"), + [ + (_project(body, conf=_CONF_MYST, myst=True), text, line) + for body, text, line in _MYST_WARNINGS.values() + ], + ids=list(_MYST_WARNINGS), + indirect=["test_app"], +) +def test_match_warnings_in_myst(test_app, text: str, line: str | None): + """The MyST spellings warn once each and fail closed, as in reStructuredText.""" + app = test_app + app.build() + (warning,) = build_warnings(app) + assert text in warning, warning + assert warning.endswith(" [needs.match]"), warning + if line is not None: + source = Path(app.srcdir, "index.md").read_text() + assert warning.startswith( + f"/index.md:{_line_of(source, line)}: WARNING: " + ), warning + assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() + _assert_no_match_nodes(app) From 57efbfe657ca5815b5843d667eb942db53f17d86 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 07:10:22 +0000 Subject: [PATCH 04/26] =?UTF-8?q?=F0=9F=93=9A=20sphinx-needs:=20document?= =?UTF-8?q?=20`match`=20and=20`case`,=20and=20the=20changelog=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/directives/match.rst` documents the directive pair after `if` in the directives index: the RST and MyST spellings (colon fences first, with the rule that an outer fence must be longer than the fences inside it, and `%` as the MyST comment), the rules (the first true case wins and later conditions are not evaluated, the default comes last, only cases and comments, the included case is ordinary content), that a condition is exactly an `if` condition, the one-line-condition pitfall, every warning under `needs.match`, and the one limitation: a directive that produces no node, written directly in a `match`, is not detected and still runs. `if.rst` gains a one-line pointer to `match`, a label on its expression context for that page to link to, and the warning `if` emits for a condition whose result is not a bool, which it has always emitted and never listed. The changelog gains an `Unreleased` section with the entry; its pull request number is a placeholder until the pull request exists. --- packages/sphinx-needs/docs/changelog.rst | 39 +++++ packages/sphinx-needs/docs/directives/if.rst | 4 + .../sphinx-needs/docs/directives/index.rst | 1 + .../sphinx-needs/docs/directives/match.rst | 161 ++++++++++++++++++ 4 files changed, 205 insertions(+) create mode 100644 packages/sphinx-needs/docs/directives/match.rst diff --git a/packages/sphinx-needs/docs/changelog.rst b/packages/sphinx-needs/docs/changelog.rst index 9ac5fa313..5e9125f8a 100644 --- a/packages/sphinx-needs/docs/changelog.rst +++ b/packages/sphinx-needs/docs/changelog.rst @@ -4,6 +4,45 @@ Changelog ========= +Unreleased +---------- + +Improvements +............ + +- ✨ New :ref:`match ` and ``case`` directives include one of several branches of + content, chosen by variant data (:pr:`NNNN`) + + A ``match`` holds ``case`` directives, and the first ``case`` whose condition is true + is included; a ``case`` with no condition is the default, and must come last. The + other cases are never parsed, so the needs inside them are never created: + + .. code-block:: rst + + .. match:: + + .. case:: var.arch == "arm" + + ARM content. + + .. case:: var.arch == "x86" + + x86 content. + + .. case:: + + Content for every other architecture. + + Conditions are exactly those of the :ref:`if ` directive, evaluated by the same + code, and the conditions after the case that is taken are not evaluated. A ``match`` + may contain only ``case`` directives and comments. Every mistake warns once under the + new ``needs.match`` type and skips the whole ``match``: content outside a case (any + need it creates is removed again), a misplaced or second default, an argument on + ``match``, variant data that is not configured, and a condition that cannot be + evaluated — so a typo in a condition never renders the default in its place. Works in + reStructuredText and in MyST Markdown. The undocumented warning ``if`` gives for a + condition whose result is not a bool is now listed in its documentation. + .. _`release:8.5.0`: 8.5.0 diff --git a/packages/sphinx-needs/docs/directives/if.rst b/packages/sphinx-needs/docs/directives/if.rst index 72f0bb6c3..c66647124 100644 --- a/packages/sphinx-needs/docs/directives/if.rst +++ b/packages/sphinx-needs/docs/directives/if.rst @@ -12,6 +12,7 @@ The directive argument is a Python expression evaluated against the ``var`` namespace (populated from :ref:`needs_variant_data`). If the expression evaluates to ``True``, the directive body is parsed and included in the document. Otherwise the entire body is skipped. +To include one of several branches instead, use :ref:`match `. .. code-block:: rst @@ -77,6 +78,8 @@ The body may contain section headers and any valid reStructuredText: Content under a conditional heading. +.. _if_expression_context: + Expression context ------------------ @@ -112,3 +115,4 @@ The directive emits warnings (suppressible via ``suppress_warnings = ["needs.if" - ``needs_variant_data`` is not configured but the directive is used. - The expression raises an exception (syntax error, unknown key, etc.). +- The expression does not return a bool (the result is still used, as its truth value). diff --git a/packages/sphinx-needs/docs/directives/index.rst b/packages/sphinx-needs/docs/directives/index.rst index aa9a692b7..df88af39b 100644 --- a/packages/sphinx-needs/docs/directives/index.rst +++ b/packages/sphinx-needs/docs/directives/index.rst @@ -19,6 +19,7 @@ Directives for conditional content: :maxdepth: 1 if + match Directives for visualizing and analyzing needs: diff --git a/packages/sphinx-needs/docs/directives/match.rst b/packages/sphinx-needs/docs/directives/match.rst new file mode 100644 index 000000000..60662e16c --- /dev/null +++ b/packages/sphinx-needs/docs/directives/match.rst @@ -0,0 +1,161 @@ +.. _match: + +match +===== + +.. versionadded:: 8.6.0 + +The ``match`` directive includes one of several branches of content, +chosen by :ref:`variant data ` at parse time. +Its content is a list of ``case`` directives: +the first ``case`` whose condition is true is included, +and a ``case`` with no condition is the default, +included when no condition before it is true. +The content of every other ``case`` is never parsed, +so the needs inside it are never created. + +.. code-block:: rst + + .. match:: + + .. case:: var.arch == "arm" + + ARM content. + + .. req:: ARM-specific requirement + :id: REQ_ARM_001 + + .. case:: var.arch == "x86" + + x86 content. + + .. a comment may stand between two cases + + .. case:: + + Content for every other architecture. + +A ``match`` is the many-branched form of :ref:`if `: +the example includes the ARM content, the x86 content or the default content, +and never more than one of them. + +MyST Markdown +------------- + +In MyST Markdown, ``match`` and ``case`` are fenced directives like any other. +With colon fences (the ``colon_fence`` extension): + +.. code-block:: md + + ::::{match} + :::{case} var.arch == "arm" + ARM content. + ::: + % a comment may stand between two cases + :::{case} + Content for every other architecture. + ::: + :::: + +and with backtick fences: + +.. code-block:: md + + ````{match} + ```{case} var.arch == "arm" + ARM content. + ``` + ```{case} + Content for every other architecture. + ``` + ```` + +An outer fence must be longer than the fences inside it, +so a ``match`` takes one more colon (or backtick) than its cases, +and a ``match`` nested in a case takes one fewer than that case: + +.. code-block:: md + + ::::::{match} + :::::{case} var.debug + ::::{match} + :::{case} var.arch == "arm" + ARM debug content. + ::: + :::: + ::::: + :::::: + +``%`` starts a MyST comment, and a ``+++`` block break counts as one too. + +Rules +----- + +- **The first true case wins.** + The conditions are evaluated in order, and the first ``case`` whose condition is true is included. + The conditions after it are not evaluated at all, so they cannot warn. + When no condition is true and there is no default, the ``match`` includes nothing, + without a warning, as a false ``if`` does. +- **The default comes last.** + A ``case`` with no condition is the default. + A ``match`` has at most one, and it must be its last ``case``. +- **Only cases and comments.** + A ``match`` may contain only ``case`` directives and comments: + reStructuredText comments (``..``), and in MyST ``%`` comments and ``+++`` block breaks. + In MyST, an HTML comment (````) is raw HTML rather than a comment, so it is a mistake here. + Any other content outside a case is a mistake, + and the needs it would create are removed again. + A ``case`` belongs directly in a ``match``: + one anywhere else, including one written loose in the content of another ``case``, is a mistake too. +- **The included case is ordinary content.** + It may hold headings, which become sections where the ``match`` stands, + needs, any other directive, and further ``match`` directives. + An ``.. include::`` may supply the cases of a ``match``, or part of the content of a case, + and a ``match`` may stand in the content of a need. +- **Parse-time evaluation**, as for ``if``: + the content of a ``case`` that is not included is never parsed, + so its needs are never created and its mistakes are never reported. + +Conditions +---------- + +A ``case`` condition is exactly a condition of the :ref:`if ` directive, +evaluated by the same code: +a Python expression over the ``var`` namespace, with no built-in functions +(see the ``if`` directive's :ref:`if_expression_context`). +A result that is not a ``bool`` is warned about and then used as its truth value, as for ``if``. + +A condition must fit on one line: +docutils joins a wrapped directive argument with a line break, +which makes the expression a syntax error. + +Warnings +-------- + +Every mistake warns once, under the ``needs.match`` type +(suppressible via ``suppress_warnings = ["needs.match"]``), +at the line of the directive or the content that has it, +and skips the **whole** ``match``: nothing of it is included, not even its default. +The mistakes are: + +- ``needs_variant_data`` is not configured, even when the ``match`` holds only a default. +- A condition cannot be evaluated (a syntax error, an unknown key, etc.) before a case is taken. + So a typo in the condition of the case that should be included + never includes a later case, or the default, in its place. +- The ``match`` contains something that is neither a ``case`` nor a comment. +- The ``match`` has more than one default ``case``, or a default that is not its last ``case``. +- The ``match`` has no ``case`` at all. +- The ``match`` is given an argument: the conditions go on the cases. + +A ``case`` outside a ``match`` warns as well, and its content is skipped. +A condition whose result is not a ``bool`` warns, and its truth value is used. +A mistake that docutils or MyST already reports in the content of a ``match``, +such as an unknown directive name, is not reported a second time; +the ``match`` is skipped all the same. + +.. note:: + + A directive that produces no node, such as ``default-role``, + written directly in a ``match`` is not detected: it still runs, and the ``match`` goes on. + Needs are the one effect of content outside a case that is undone; + any other (a label, a ``needextend``) stays, so keep every directive inside a ``case``. From 1255bcef7d54a42dfb9612cdbc8d00caf01c4691 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 07:33:11 +0000 Subject: [PATCH 05/26] =?UTF-8?q?=F0=9F=91=8C=20sphinx-needs:=20a=20`case`?= =?UTF-8?q?=20supplied=20through=20an=20include=20is=20refused?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every `case` of a `match` must now be written in the body of that `match`. A `case` that an `.. include::` (or a MyST `{include}`) supplies warns once under `needs.match`, at the `case` in the included file, and the whole `match` is skipped, before any condition is evaluated. An include inside the content of a case stays fine, and so does a whole `match` written in an included file. Why: one choice should be one directive in one place. Splitting a choice across files is exactly what the sibling `elif` / `else` design allowed and this one was chosen to avoid, and ubCode, which implements the same contract, sees an include in a `match` body as an invalid child (it splices includes as a separate tree, so it cannot see the cases one supplies). Refusing it here keeps both engines on one rule. How: a `case` records the source docutils or MyST attributes its line to (`get_source_info()`), and the `match` compares it with its own. Measured in both parsers, on Sphinx 9.1 and 7.4: docutils reports the included file and its exact line; MyST reports the included file too, with the line one late, as it does for every line of an included file. The doc project's include-supplied cases become two negative cases in the warnings table, with a MyST twin, and a whole `match` in an included file is added to the RST and MyST happy paths. `match.rst` states the rule and lists the warning. --- .../sphinx-needs/docs/directives/match.rst | 10 +- .../src/sphinx_needs/directives/needmatch.py | 29 ++++- .../doc_test/doc_match_directive/cases.txt | 7 -- .../doc_match_directive/included_match.txt | 9 ++ .../doc_test/doc_match_directive/index.rst | 9 +- .../tests/test_match_directive.py | 100 +++++++++++++++++- 6 files changed, 143 insertions(+), 21 deletions(-) delete mode 100644 packages/sphinx-needs/tests/doc_test/doc_match_directive/cases.txt create mode 100644 packages/sphinx-needs/tests/doc_test/doc_match_directive/included_match.txt diff --git a/packages/sphinx-needs/docs/directives/match.rst b/packages/sphinx-needs/docs/directives/match.rst index 60662e16c..75540572c 100644 --- a/packages/sphinx-needs/docs/directives/match.rst +++ b/packages/sphinx-needs/docs/directives/match.rst @@ -107,10 +107,16 @@ Rules and the needs it would create are removed again. A ``case`` belongs directly in a ``match``: one anywhere else, including one written loose in the content of another ``case``, is a mistake too. +- **The cases are written in place.** + Every ``case`` of a ``match`` is written in the body of that ``match``, in the same file, + so that one choice is one directive in one place. + An ``.. include::`` (in MyST, an ``{include}``) may not supply the cases; + it may be used inside the content of a case, + and a whole ``match`` may stand in an included file. - **The included case is ordinary content.** It may hold headings, which become sections where the ``match`` stands, needs, any other directive, and further ``match`` directives. - An ``.. include::`` may supply the cases of a ``match``, or part of the content of a case, + An ``.. include::`` may supply part of the content of a case, and a ``match`` may stand in the content of a need. - **Parse-time evaluation**, as for ``if``: the content of a ``case`` that is not included is never parsed, @@ -143,6 +149,8 @@ The mistakes are: So a typo in the condition of the case that should be included never includes a later case, or the default, in its place. - The ``match`` contains something that is neither a ``case`` nor a comment. +- A ``case`` is supplied through an include rather than written in the body of the ``match`` + (the warning points at the ``case`` in the included file). - The ``match`` has more than one default ``case``, or a default that is not its last ``case``. - The ``match`` has no ``case`` at all. - The ``match`` is given an argument: the conditions go on the cases. diff --git a/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py b/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py index e6a930b27..fe8f5ea0e 100644 --- a/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py +++ b/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py @@ -1,6 +1,7 @@ """Directives for including one of several branches of content based on variant data. -A ``match`` holds ``case`` directives and comments, and nothing else. +A ``match`` holds ``case`` directives and comments, written in its own body, +and nothing else. The first ``case`` whose condition holds is included; a ``case`` with no condition is the default, and must be the last. @@ -68,6 +69,8 @@ class _CasePlaceholder(nodes.Element): """The ``lineno`` of the ``case`` directive.""" location: str | None """Where warnings about the case are reported.""" + source: str | None + """The file the ``case`` directive is written in, as docutils or MyST reports it.""" class _MatchBody(nodes.Element): @@ -118,6 +121,7 @@ def run(self) -> Sequence[nodes.Node]: placeholder.content_offset = self.content_offset placeholder.lineno = self.lineno placeholder.location = self.get_location() + placeholder.source = self.get_source_info()[0] return [placeholder] @@ -126,8 +130,9 @@ class MatchDirective(SphinxDirective): The content may hold only ``case`` directives and comments. Every mistake is warned about once, and skips the whole ``match``: - content that is neither a ``case`` nor a comment, a default ``case`` - that is not the last or is not the only one, variant data that is not configured, + content that is neither a ``case`` nor a comment, a ``case`` supplied through an + include, a default ``case`` that is not the last or is not the only one, + variant data that is not configured, and a condition that cannot be evaluated before a case is taken. A typo in the condition of the case that should be taken therefore never renders a later case, or the default, in its place. @@ -164,7 +169,7 @@ def run(self) -> Sequence[nodes.Node]: ) return [] - cases = self._collect_cases() + cases = self._collect_cases(self.get_source_info()[0]) if cases is None: return [] @@ -234,9 +239,15 @@ def _parse_body(self) -> list[nodes.Node]: return list(body.children) - def _collect_cases(self) -> list[_CasePlaceholder] | None: + def _collect_cases(self, source: str | None, /) -> list[_CasePlaceholder] | None: """Parse the body and check its structure. + The cases must be written in the body itself: + a ``case`` an ``.. include::`` supplies is refused, + so that one ``match`` is one directive in one file. + + :param source: The file this ``match`` is written in, + as its cases report theirs. :return: The cases, in order, or ``None`` if the body is not a valid ``match`` (a warning has been emitted). """ @@ -244,6 +255,14 @@ def _collect_cases(self) -> list[_CasePlaceholder] | None: cases: list[_CasePlaceholder] = [] for index, child in enumerate(children): if isinstance(child, _CasePlaceholder): + if child.source != source: + self._warn( + "'case' supplied through an include is not supported (write " + "the cases in the body of the 'match'); the whole match is " + "skipped", + child.location, + ) + return None cases.append(child) elif isinstance(child, nodes.comment): continue diff --git a/packages/sphinx-needs/tests/doc_test/doc_match_directive/cases.txt b/packages/sphinx-needs/tests/doc_test/doc_match_directive/cases.txt deleted file mode 100644 index 0ddde93a9..000000000 --- a/packages/sphinx-needs/tests/doc_test/doc_match_directive/cases.txt +++ /dev/null @@ -1,7 +0,0 @@ -.. case:: var.arch == "xyz" - - SKIPPED_X1_FROM_INCLUDE - -.. case:: - - TAKEN_X1_DEFAULT_FROM_INCLUDE diff --git a/packages/sphinx-needs/tests/doc_test/doc_match_directive/included_match.txt b/packages/sphinx-needs/tests/doc_test/doc_match_directive/included_match.txt new file mode 100644 index 000000000..a7cdbb009 --- /dev/null +++ b/packages/sphinx-needs/tests/doc_test/doc_match_directive/included_match.txt @@ -0,0 +1,9 @@ +.. match:: + + .. case:: var.arch == "xyz" + + SKIPPED_X1_IN_INCLUDED_MATCH + + .. case:: + + TAKEN_X1_INCLUDED_MATCH_DEFAULT diff --git a/packages/sphinx-needs/tests/doc_test/doc_match_directive/index.rst b/packages/sphinx-needs/tests/doc_test/doc_match_directive/index.rst index 5ac536864..ed8d8f520 100644 --- a/packages/sphinx-needs/tests/doc_test/doc_match_directive/index.rst +++ b/packages/sphinx-needs/tests/doc_test/doc_match_directive/index.rst @@ -148,12 +148,13 @@ which renders its content from the need-node cache. SKIPPED_P5B_IN_NEED_DEFAULT -X1 the cases come from an include ---------------------------------- +X1 a whole match in an included file +------------------------------------ -.. match:: +The match and its cases are written in the same (included) file, +which is fine; cases an include supplies to a match written elsewhere are refused. - .. include:: cases.txt +.. include:: included_match.txt X2 an include inside the taken case ----------------------------------- diff --git a/packages/sphinx-needs/tests/test_match_directive.py b/packages/sphinx-needs/tests/test_match_directive.py index 0c5d8c1f2..a7e600be6 100644 --- a/packages/sphinx-needs/tests/test_match_directive.py +++ b/packages/sphinx-needs/tests/test_match_directive.py @@ -38,6 +38,7 @@ def _project( conf: str = _CONF, myst: bool = False, other: str | None = None, + extra: tuple[tuple[str, str], ...] = (), ) -> dict[str, object]: """An inline project whose root document is a title followed by ``body``. @@ -45,11 +46,13 @@ def _project( :param conf: The ``conf.py``. :param myst: Write the documents as MyST Markdown (``.md``) rather than RST. :param other: The source of a second document, ``other``, if there is one. + :param extra: Further files, as ``(name, text)``, such as files to include. """ suffix, title = (".md", "# Test\n\n") if myst else (".rst", "Test\n====\n\n") files = [(Path("conf.py"), conf), (Path("index" + suffix), title + body)] if other is not None: files.append((Path("other" + suffix), other)) + files.extend((Path(name), text) for name, text in extra) return {"buildername": "html", "files": files} @@ -118,7 +121,7 @@ def test_match_directive(test_app): "TAKEN_P5_OUTER", "TAKEN_P5_INNER_DEFAULT", "TAKEN_P5B_IN_NEED", - "TAKEN_X1_DEFAULT_FROM_INCLUDE", + "TAKEN_X1_INCLUDED_MATCH_DEFAULT", "TAKEN_X2_INCLUDED_TEXT", "TAKEN_X2_AFTER_INCLUDE", "TAKEN_X2_AFTER_MATCH", @@ -168,10 +171,23 @@ class _Expected(NamedTuple): #: the ids the needs view must hold needs: tuple[str, ...] = () conf: str = _CONF + #: further files of the project, as ``(name, text)`` + extra: tuple[tuple[str, str], ...] = () + #: the file the warnings are located in + located_in: str = "index.rst" _SKIP = "; the whole match is skipped" +_INCLUDED_CASE = ( + "'case' supplied through an include is not supported " + "(write the cases in the body of the 'match')" + _SKIP +) +_CASES_TXT = ( + '.. case:: var.arch == "xyz"\n\n SKIPPED_X1_FROM_INCLUDE\n\n' + ".. case::\n\n SKIPPED_X1_DEFAULT_FROM_INCLUDE\n" +) + _WARNINGS = { "default not last": _Expected( ".. match::\n\n" @@ -355,6 +371,24 @@ class _Expected(NamedTuple): ), conf=_CONF_NO_VARIANT_DATA, ), + # the cases of a match are written in its body: an include may not supply them, + # and the warning points at the case in the included file + "cases from an include": _Expected( + ".. match::\n\n .. include:: cases.txt\n", + ((_INCLUDED_CASE, '.. case:: var.arch == "xyz"'),), + extra=(("cases.txt", _CASES_TXT),), + located_in="cases.txt", + ), + # the structure is checked before any condition: a true case written in place + # before the included ones is not taken either + "a case from an include after a true case": _Expected( + ".. match::\n\n" + " .. case:: True\n\n SKIPPED_IN_PLACE\n\n" + " .. include:: cases.txt\n", + ((_INCLUDED_CASE, '.. case:: var.arch == "xyz"'),), + extra=(("cases.txt", _CASES_TXT),), + located_in="cases.txt", + ), # content outside a case is parsed with the body, so the need directive runs; # the match removes the need again (the target is the first node it emits) "need directly in the body": _Expected( @@ -382,7 +416,10 @@ class _Expected(NamedTuple): @pytest.mark.parametrize( ("test_app", "expected"), - [(_project(case.body, conf=case.conf), case) for case in _WARNINGS.values()], + [ + (_project(case.body, conf=case.conf, extra=case.extra), case) + for case in _WARNINGS.values() + ], ids=list(_WARNINGS), indirect=["test_app"], ) @@ -392,10 +429,10 @@ def test_match_warnings(test_app, expected: _Expected): app.build() warnings = build_warnings(app) assert len(warnings) == len(expected.warnings), warnings - source = Path(app.srcdir, "index.rst").read_text() + source = Path(app.srcdir, expected.located_in).read_text() for warning, (text, line) in zip(warnings, expected.warnings, strict=True): assert warning.startswith( - f"/index.rst:{_line_of(source, line)}: WARNING: " + f"/{expected.located_in}:{_line_of(source, line)}: WARNING: " ), warning assert text in warning, warning assert warning.endswith(" [needs.match]"), warning @@ -649,6 +686,22 @@ def test_case_conditions_are_if_conditions(test_app, expression: str, verdict): ::: :::: ::::: + +## A whole match in an included file + +```{include} included_match.txt +``` +""" + +_MYST_INCLUDED_MATCH = """\ +::::{match} +:::{case} var.arch == "xyz" +SKIPPED_M8_IN_INCLUDED_MATCH +::: +:::{case} +TAKEN_M8_INCLUDED_MATCH_DEFAULT +::: +:::: """ @@ -661,6 +714,7 @@ def test_case_conditions_are_if_conditions(test_app, expression: str, verdict): conf=_CONF_MYST, myst=True, other="# Other\n\n```{needextract}\n:filter: id == 'REQ_M_HOST'\n```\n", + extra=(("included_match.txt", _MYST_INCLUDED_MATCH),), ) ], indirect=True, @@ -681,6 +735,7 @@ def test_match_in_myst(test_app): "TAKEN_M5_INNER_DEFAULT", "TAKEN_M6_DEFAULT", "TAKEN_M7_IN_NEED", + "TAKEN_M8_INCLUDED_MATCH_DEFAULT", ] assert [word for word in taken if word not in html] == [] assert "SKIPPED_" not in html @@ -798,3 +853,40 @@ def test_match_warnings_in_myst(test_app, text: str, line: str | None): ), warning assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() _assert_no_match_nodes(app) + + +@pytest.mark.skipif(not _HAS_MYST, reason="needs myst-parser") +@pytest.mark.parametrize( + "test_app", + [ + _project( + "::::{match}\n:::{case} True\nSKIPPED_IN_PLACE\n:::\n\n" + "```{include} cases.txt\n```\n::::\n", + conf=_CONF_MYST, + myst=True, + extra=( + ( + "cases.txt", + ':::{case} var.arch == "xyz"\nSKIPPED_X1_FROM_INCLUDE\n:::\n' + ":::{case}\nSKIPPED_X1_DEFAULT_FROM_INCLUDE\n:::\n", + ), + ), + ) + ], + indirect=True, +) +def test_match_refuses_included_cases_in_myst(test_app): + """In MyST too, a ``case`` an ``{include}`` supplies is refused, in the included file. + + MyST reports the lines of an included file one late (the case on line 1 is + reported on line 2, with colon and backtick fences alike), so only the file is + asserted. + """ + app = test_app + app.build() + (warning,) = build_warnings(app) + assert warning.startswith("/cases.txt:"), warning + assert _INCLUDED_CASE in warning, warning + assert warning.endswith(" [needs.match]"), warning + assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() + _assert_no_match_nodes(app) From 4a2b18553e6d272153609f0f7a692cce1cc95191 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 08:18:01 +0000 Subject: [PATCH 06/26] =?UTF-8?q?=F0=9F=91=8C=20sphinx-needs:=20every=20mi?= =?UTF-8?q?stake=20in=20a=20`match`=20body=20warns,=20including=20the=20qu?= =?UTF-8?q?iet=20ones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways to get a `match` wrong were not reported, which broke its promise that every mistake warns once. A line of one to three punctuation characters in the body, such as `---` between two cases, makes docutils append an INFO message, which is below the report level and never shown, BEFORE the paragraph it then makes of the line. The match took every `system_message` child for one docutils had already reported, and skipped itself in silence: under `-W` the build was green with the content of the match gone. A message below the reporter's report level is now passed over, so the paragraph after it is judged as any other content and warns once, at its own line. A message at or above that level keeps the silent skip, since docutils or MyST did show it. A `case` inside a directive that returns the nodes of its own content (a true `if`, a `rst-class` with content, a MyST `{eval-rst}` block) was adopted as a case of the match, though it is not written in the match at all; under `{eval-rst}` its RST content was then parsed as Markdown. A `case` now records the node it is appended to (`state_machine.node`, which is docutils' `RSTState.parent` and MyST's current node alike: measured identical in both parsers on Sphinx 9.1 and 7.4), and the match refuses, with one warning at the case, any case whose owner is not its own body. This is also the rule ubCode applies to the same contract: anything but a case or a comment as a child of a `match` is an invalid child. The depth counter still decides whether a `case` is outside every `match`, so a case inside a `note` in a match still gives exactly one warning, the match's `got `. Tests: a `---` and a `...` line, cases in a true `if` and in `rst-class`, and a case in `{eval-rst}` in both MyST spellings each warn once and render nothing. `match.rst` says that a case belongs directly in its match, lists both mistakes, and states plainly that a FALSE `if` around cases returns nothing, so those cases vanish without a warning unless the match is left with none. The changelog entry's list of mistakes gains the case inside another directive and the include refusal. --- packages/sphinx-needs/docs/changelog.rst | 7 +-- .../sphinx-needs/docs/directives/match.rst | 14 ++++-- .../src/sphinx_needs/directives/needmatch.py | 38 ++++++++++++--- .../tests/test_match_directive.py | 47 +++++++++++++++++++ 4 files changed, 93 insertions(+), 13 deletions(-) diff --git a/packages/sphinx-needs/docs/changelog.rst b/packages/sphinx-needs/docs/changelog.rst index 5e9125f8a..628fe4140 100644 --- a/packages/sphinx-needs/docs/changelog.rst +++ b/packages/sphinx-needs/docs/changelog.rst @@ -37,9 +37,10 @@ Improvements code, and the conditions after the case that is taken are not evaluated. A ``match`` may contain only ``case`` directives and comments. Every mistake warns once under the new ``needs.match`` type and skips the whole ``match``: content outside a case (any - need it creates is removed again), a misplaced or second default, an argument on - ``match``, variant data that is not configured, and a condition that cannot be - evaluated — so a typo in a condition never renders the default in its place. Works in + need it creates is removed again), a ``case`` inside another directive or supplied + through an include, a misplaced or second default, an argument on ``match``, variant + data that is not configured, and a condition that cannot be evaluated — so a typo in + a condition never renders the default in its place. Works in reStructuredText and in MyST Markdown. The undocumented warning ``if`` gives for a condition whose result is not a bool is now listed in its documentation. diff --git a/packages/sphinx-needs/docs/directives/match.rst b/packages/sphinx-needs/docs/directives/match.rst index 75540572c..d4f41a5d3 100644 --- a/packages/sphinx-needs/docs/directives/match.rst +++ b/packages/sphinx-needs/docs/directives/match.rst @@ -106,7 +106,10 @@ Rules Any other content outside a case is a mistake, and the needs it would create are removed again. A ``case`` belongs directly in a ``match``: - one anywhere else, including one written loose in the content of another ``case``, is a mistake too. + one anywhere else is a mistake too, + whether it is written loose in the content of another ``case`` + or inside another directive in the ``match``, + even one that passes its content through, such as a true ``if`` or a ``rst-class``. - **The cases are written in place.** Every ``case`` of a ``match`` is written in the body of that ``match``, in the same file, so that one choice is one directive in one place. @@ -149,6 +152,8 @@ The mistakes are: So a typo in the condition of the case that should be included never includes a later case, or the default, in its place. - The ``match`` contains something that is neither a ``case`` nor a comment. + A line of only punctuation, such as ``---`` between two cases, is such content too. +- A ``case`` is written inside another directive in the ``match`` rather than directly in it. - A ``case`` is supplied through an include rather than written in the body of the ``match`` (the warning points at the ``case`` in the included file). - The ``match`` has more than one default ``case``, or a default that is not its last ``case``. @@ -163,7 +168,10 @@ the ``match`` is skipped all the same. .. note:: - A directive that produces no node, such as ``default-role``, - written directly in a ``match`` is not detected: it still runs, and the ``match`` goes on. + A directive that produces no node, written directly in a ``match``, is not detected: + it still runs, and the ``match`` goes on. + ``default-role`` is one such directive, and so is a **false** ``if``: + it returns nothing, so the cases written inside it vanish without a warning, + unless the ``match`` is left with no ``case`` at all. Needs are the one effect of content outside a case that is undone; any other (a label, a ``needextend``) stays, so keep every directive inside a ``case``. diff --git a/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py b/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py index fe8f5ea0e..e78e11017 100644 --- a/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py +++ b/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py @@ -71,6 +71,12 @@ class _CasePlaceholder(nodes.Element): """Where warnings about the case are reported.""" source: str | None """The file the ``case`` directive is written in, as docutils or MyST reports it.""" + owner: nodes.Element | None + """The node the ``case`` directive's result is appended to. + + That is the body of its ``match`` exactly when the ``case`` is written directly in it, + rather than inside another directive whose content was parsed into a node of its own. + """ class _MatchBody(nodes.Element): @@ -122,6 +128,9 @@ def run(self) -> Sequence[nodes.Node]: placeholder.lineno = self.lineno placeholder.location = self.get_location() placeholder.source = self.get_source_info()[0] + # docutils' `RSTState.parent` is this very node, and MyST's mock state machine + # holds the renderer's current node here, which is where MyST appends the result + placeholder.owner = self.state_machine.node return [placeholder] @@ -130,8 +139,9 @@ class MatchDirective(SphinxDirective): The content may hold only ``case`` directives and comments. Every mistake is warned about once, and skips the whole ``match``: - content that is neither a ``case`` nor a comment, a ``case`` supplied through an - include, a default ``case`` that is not the last or is not the only one, + content that is neither a ``case`` nor a comment, a ``case`` inside another + directive or supplied through an include, + a default ``case`` that is not the last or is not the only one, variant data that is not configured, and a condition that cannot be evaluated before a case is taken. A typo in the condition of the case that should be taken @@ -206,7 +216,7 @@ def _warn(self, message: str, location: str | nodes.Node | None = None, /) -> No location=self.get_location() if location is None else location, ) - def _parse_body(self) -> list[nodes.Node]: + def _parse_body(self) -> _MatchBody: """Parse the content into a detached node, with every ``case`` deferred. Because the content of every case is deferred, @@ -214,7 +224,7 @@ def _parse_body(self) -> list[nodes.Node]: written outside a case, which is a mistake that skips the whole ``match``: such needs are removed again, so that the mistake creates none. - :return: The children of the parsed body. + :return: The parsed body. """ body = _MatchBody() body.document = self.state.document @@ -237,13 +247,15 @@ def _parse_body(self) -> list[nodes.Node]: for need_id in list(islice(reversed(needs), len(needs) - before)): data.remove_need(need_id) - return list(body.children) + return body def _collect_cases(self, source: str | None, /) -> list[_CasePlaceholder] | None: """Parse the body and check its structure. The cases must be written in the body itself: - a ``case`` an ``.. include::`` supplies is refused, + a ``case`` inside another directive (one whose content is parsed into a node + of its own, even if it then returns that node's children, such as a true ``if``) + is refused, and so is a ``case`` an ``.. include::`` supplies, so that one ``match`` is one directive in one file. :param source: The file this ``match`` is written in, @@ -251,10 +263,18 @@ def _collect_cases(self, source: str | None, /) -> list[_CasePlaceholder] | None :return: The cases, in order, or ``None`` if the body is not a valid ``match`` (a warning has been emitted). """ - children = self._parse_body() + body = self._parse_body() + children = list(body.children) cases: list[_CasePlaceholder] = [] for index, child in enumerate(children): if isinstance(child, _CasePlaceholder): + if child.owner is not body: + self._warn( + "'case' directive is not a direct child of its 'match' (it is " + "inside another directive); the whole match is skipped", + child.location, + ) + return None if child.source != source: self._warn( "'case' supplied through an include is not supported (write " @@ -267,6 +287,10 @@ def _collect_cases(self, source: str | None, /) -> list[_CasePlaceholder] | None elif isinstance(child, nodes.comment): continue elif isinstance(child, nodes.system_message): + if child["level"] < self.state.document.reporter.report_level: + # below the report level, so it was never shown: judge what follows + # it (docutils puts an INFO before the paragraph of a `---` line) + continue # reported by docutils or MyST when it was created: skip, silently return None else: diff --git a/packages/sphinx-needs/tests/test_match_directive.py b/packages/sphinx-needs/tests/test_match_directive.py index a7e600be6..c6225e9a3 100644 --- a/packages/sphinx-needs/tests/test_match_directive.py +++ b/packages/sphinx-needs/tests/test_match_directive.py @@ -179,6 +179,10 @@ class _Expected(NamedTuple): _SKIP = "; the whole match is skipped" +_NOT_DIRECT = ( + "'case' directive is not a direct child of its 'match' (it is inside another " + "directive)" + _SKIP +) _INCLUDED_CASE = ( "'case' supplied through an include is not supported " "(write the cases in the body of the 'match')" + _SKIP @@ -227,11 +231,40 @@ class _Expected(NamedTuple): ), ), ), + # exactly one warning: the case inside the note is not a stray, it is in a match body "note wrapping a case": _Expected( ".. match::\n\n" " .. note::\n\n .. case:: True\n\n SKIPPED_IN_NOTE\n", (("got " + _SKIP, " .. note::"),), ), + # a directive that returns the nodes of its content (a true `if`, `rst-class`) + # would hand its cases to the match: a case must be written directly in it + "cases inside a true if": _Expected( + ".. match::\n\n" + " .. if:: var.debug\n\n" + " .. case:: var.arch == 'x86'\n\n SKIPPED_X86\n\n" + " .. case::\n\n SKIPPED_DEFAULT_FROM_IF\n", + ((_NOT_DIRECT, " .. case:: var.arch == 'x86'"),), + ), + "case inside rst-class": _Expected( + ".. match::\n\n" + " .. rst-class:: special\n\n" + " .. case:: var.arch == 'abc'\n\n SKIPPED_FROM_RST_CLASS\n", + ((_NOT_DIRECT, " .. case:: var.arch == 'abc'"),), + ), + # a line of one to three punctuation characters makes docutils emit an INFO + # message, which is never shown, before the paragraph: the paragraph is reported + "rule line between cases": _Expected( + ".. match::\n\n" + " .. case:: var.arch == 'x86'\n\n SKIPPED_X86\n\n" + " ---\n\n" + " .. case::\n\n SKIPPED_DEFAULT\n", + (("got " + _SKIP, " ---"),), + ), + "three dots in the body": _Expected( + ".. match::\n\n ...\n\n .. case::\n\n SKIPPED_DEFAULT\n", + (("got " + _SKIP, " ..."),), + ), "case outside a match": _Expected( "Para.\n\n.. case:: True\n\n SKIPPED_STRAY\n", ( @@ -826,6 +859,20 @@ def test_match_in_myst(test_app): "'case' directive expression failed: 'invalid !!!'", None, ), + # an `{eval-rst}` block is parsed by docutils into a document of its own, + # so the case in it is not a direct child of the match + "case inside eval-rst, backticks": ( + "````{match}\n```{eval-rst}\n.. case:: True\n\n SKIPPED_FROM_EVAL_RST\n```\n" + "````\n", + _NOT_DIRECT, + ".. case:: True", + ), + "case inside eval-rst, colons": ( + "::::{match}\n```{eval-rst}\n.. case:: True\n\n SKIPPED_FROM_EVAL_RST\n```\n" + "::::\n", + _NOT_DIRECT, + None, + ), } From 66fe6824c53accf167895e3d8a1d9a7b58325426 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 08:20:25 +0000 Subject: [PATCH 07/26] =?UTF-8?q?=F0=9F=A7=AA=20sphinx-needs:=20pin=20the?= =?UTF-8?q?=20rollback,=20the=20depth=20restore=20and=20the=20check=20orde?= =?UTF-8?q?r=20of=20`match`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three behaviours of `match` were correct but unguarded: a reviewer's mutations of each passed every test, and each mutant crashes or misleads a real build. - The rollback removes the NEWEST needs, the ones the body parse added. A new test writes a need before the stray-need `match` and one after it on the same page, and one in a document read earlier, and asserts all three survive while the stray one goes. Removing the oldest needs instead deletes a legitimate need and crashes `analyse_need_locations`. - The depth counter is restored in a `finally`. A new test has a project directive catch an exception raised from inside a `match` body, and asserts that the loose `case` after it is still reported; without the `finally` it becomes a placeholder that reaches the writer. - The structure is checked before the configuration. A new row has no variant data and a misplaced default, and expects the one structural warning at the case; checking the configuration first would report the other warning at the match and never parse the body. Also a row for an argument-less `case` outside every match (the counterpart of #1999's orphan `else`), in RST and MyST. The warning about content in the body now names the first node the author wrote: a need directive emits a target without a line before the need, so a stray need was reported as `got `; it now says `got `, at the same line. A label written in the body is still reported as a target. Prose: the one-line-condition sentence said a wrapped condition is always a syntax error, but a break inside brackets evaluates; and "a typo in a condition never renders the default" was not true for a typo that leaves a valid, false condition. Both now say what holds: an unevaluable condition, such as a misspelt key or a syntax error, never renders a later case or the default in its place. Two test comments no longer claim to guard the whitespace strips the parsers already perform. --- packages/sphinx-needs/docs/changelog.rst | 9 +- .../sphinx-needs/docs/directives/match.rst | 12 +- .../src/sphinx_needs/directives/needmatch.py | 27 +++- .../tests/test_match_directive.py | 137 +++++++++++++++++- 4 files changed, 169 insertions(+), 16 deletions(-) diff --git a/packages/sphinx-needs/docs/changelog.rst b/packages/sphinx-needs/docs/changelog.rst index 628fe4140..7d94923ae 100644 --- a/packages/sphinx-needs/docs/changelog.rst +++ b/packages/sphinx-needs/docs/changelog.rst @@ -39,10 +39,11 @@ Improvements new ``needs.match`` type and skips the whole ``match``: content outside a case (any need it creates is removed again), a ``case`` inside another directive or supplied through an include, a misplaced or second default, an argument on ``match``, variant - data that is not configured, and a condition that cannot be evaluated — so a typo in - a condition never renders the default in its place. Works in - reStructuredText and in MyST Markdown. The undocumented warning ``if`` gives for a - condition whose result is not a bool is now listed in its documentation. + data that is not configured, and a condition that cannot be evaluated — so a mistake + that makes a condition unevaluable, such as a misspelt key or a syntax error, never + renders a later case or the default in its place. Works in reStructuredText and in + MyST Markdown. The undocumented warning ``if`` gives for a condition whose result is + not a bool is now listed in its documentation. .. _`release:8.5.0`: diff --git a/packages/sphinx-needs/docs/directives/match.rst b/packages/sphinx-needs/docs/directives/match.rst index d4f41a5d3..c3d3a4244 100644 --- a/packages/sphinx-needs/docs/directives/match.rst +++ b/packages/sphinx-needs/docs/directives/match.rst @@ -134,9 +134,9 @@ a Python expression over the ``var`` namespace, with no built-in functions (see the ``if`` directive's :ref:`if_expression_context`). A result that is not a ``bool`` is warned about and then used as its truth value, as for ``if``. -A condition must fit on one line: -docutils joins a wrapped directive argument with a line break, -which makes the expression a syntax error. +A condition wrapped onto a second line is joined with a line break, +which is a syntax error unless the break falls inside brackets; +keep conditions on one line. Warnings -------- @@ -149,8 +149,10 @@ The mistakes are: - ``needs_variant_data`` is not configured, even when the ``match`` holds only a default. - A condition cannot be evaluated (a syntax error, an unknown key, etc.) before a case is taken. - So a typo in the condition of the case that should be included - never includes a later case, or the default, in its place. + So a mistake that makes a condition unevaluable, such as a misspelt key or a syntax error, + never renders a later case or the default in its place. + (A mistake that leaves a valid condition, such as a misspelt value, cannot be told apart + from a condition that is false.) - The ``match`` contains something that is neither a ``case`` nor a comment. A line of only punctuation, such as ``---`` between two cases, is such content too. - A ``case`` is written inside another directive in the ``match`` rather than directly in it. diff --git a/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py b/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py index e78e11017..ef20ba605 100644 --- a/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py +++ b/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py @@ -144,8 +144,8 @@ class MatchDirective(SphinxDirective): a default ``case`` that is not the last or is not the only one, variant data that is not configured, and a condition that cannot be evaluated before a case is taken. - A typo in the condition of the case that should be taken - therefore never renders a later case, or the default, in its place. + So a mistake that makes a condition unevaluable, such as a misspelt key + or a syntax error, never renders a later case or the default in its place. Example:: @@ -294,7 +294,10 @@ def _collect_cases(self, source: str | None, /) -> list[_CasePlaceholder] | None # reported by docutils or MyST when it was created: skip, silently return None else: - tagname = child.tagname if isinstance(child, nodes.Element) else "#text" + offender = self._offender(children[index:]) + tagname = ( + offender.tagname if isinstance(offender, nodes.Element) else "#text" + ) self._warn( "'match' directive may contain only 'case' directives and " f"comments, got <{tagname}>; the whole match is skipped", @@ -324,6 +327,24 @@ def _collect_cases(self, source: str | None, /) -> list[_CasePlaceholder] | None return cases + @staticmethod + def _offender(candidates: Sequence[nodes.Node]) -> nodes.Node: + """The node a warning about the first of ``candidates`` names. + + A need directive emits a target before the need, + which carries no line and is nothing the author wrote: + such leading targets are passed over, so that the warning names the need. + + :param candidates: The offending child and the children after it. + """ + for node in candidates: + if isinstance(node, nodes.target) and not get_source_line(node)[1]: + continue + if isinstance(node, _CasePlaceholder | nodes.comment): + break + return node + return candidates[0] + def _location_of(self, candidates: Sequence[nodes.Node]) -> nodes.Node | str | None: """Where to report the first of ``candidates``. diff --git a/packages/sphinx-needs/tests/test_match_directive.py b/packages/sphinx-needs/tests/test_match_directive.py index c6225e9a3..87664c372 100644 --- a/packages/sphinx-needs/tests/test_match_directive.py +++ b/packages/sphinx-needs/tests/test_match_directive.py @@ -209,7 +209,8 @@ class _Expected(NamedTuple): ".. match::\n\n" " .. case:: False\n\n SKIPPED_FALSE\n\n" " .. case::\n\n SKIPPED_D1\n\n" - # only whitespace after `::` is no condition either: the second default + # the second default is refused (its directive line ends in spaces, which the + # parser strips: it is a default like the first) " .. case:: \n\n SKIPPED_D2\n", ( ( @@ -275,6 +276,11 @@ class _Expected(NamedTuple): ), ), ), + # the counterpart of an orphan `else`: a default case outside every match + "default case outside a match": _Expected( + "Para.\n\n.. case::\n\n SKIPPED_STRAY_DEFAULT\n", + (("'case' directive outside a 'match'", ".. case::"),), + ), "case loose in the taken case": _Expected( ".. match::\n\n" " .. case:: True\n\n TAKEN_OUTER\n\n" @@ -404,6 +410,21 @@ class _Expected(NamedTuple): ), conf=_CONF_NO_VARIANT_DATA, ), + # the structure is checked before the configuration: a match that is wrong in both + # ways gets the one structural warning, at the case, and its body is still parsed + "variant data not configured, and a misplaced default": _Expected( + ".. match::\n\n" + " .. case::\n\n SKIPPED_DEFAULT\n\n" + " .. case:: var.arch == 'abc'\n\n SKIPPED_ABC\n", + ( + ( + "'match' directive has a default 'case' (a 'case' with no condition) " + "that is not its last 'case'" + _SKIP, + " .. case::", + ), + ), + conf=_CONF_NO_VARIANT_DATA, + ), # the cases of a match are written in its body: an include may not supply them, # and the warning points at the case in the included file "cases from an include": _Expected( @@ -423,12 +444,13 @@ class _Expected(NamedTuple): located_in="cases.txt", ), # content outside a case is parsed with the body, so the need directive runs; - # the match removes the need again (the target is the first node it emits) + # the match removes the need again "need directly in the body": _Expected( ".. match::\n\n" " .. req:: Directly in the match body\n :id: REQ_DIRECT\n\n" " .. case:: True\n\n SKIPPED\n", - (("got " + _SKIP, " .. req:: Directly in the match body"),), + # named after the need, not the target without a line that it emits first + (("got " + _SKIP, " .. req:: Directly in the match body"),), ), # a case that is not taken is never parsed, exactly as the body of a false `if` "errors in an untaken case are never reported": _Expected( @@ -528,6 +550,108 @@ def test_match_warnings_are_suppressible(test_app): assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() +@pytest.mark.parametrize( + "test_app", + [ + _project( + ".. req:: Written before the match\n :id: REQ_BEFORE\n\n" + ".. match::\n\n" + " .. req:: Directly in the match body\n :id: REQ_STRAY\n\n" + " .. case::\n\n SKIPPED_DEFAULT\n\n" + ".. req:: Written after the match\n :id: REQ_AFTER\n", + # read before `index`, so its need is older than every need of `index` + extra=( + ( + "aaa.rst", + ":orphan:\n\nEarlier\n=======\n\n" + ".. req:: In an earlier document\n :id: REQ_EARLIER\n", + ), + ), + ) + ], + indirect=True, +) +def test_match_rollback_removes_only_the_stray_needs(test_app): + """The rollback removes the needs the body created, the newest ones, and no other. + + The needs written before the ``match``, in its own document and in an earlier one, + are older entries of the same mapping, and must survive. + """ + app = test_app + app.build() + (warning,) = build_warnings(app) + source = Path(app.srcdir, "index.rst").read_text() + line = _line_of(source, " .. req:: Directly in the match body") + assert warning.startswith(f"/index.rst:{line}: WARNING: "), warning + assert "got " in warning + needs = SphinxNeedsData(app.env).get_needs_view() + assert sorted(needs) == ["REQ_AFTER", "REQ_BEFORE", "REQ_EARLIER"] + + +_SWALLOW_CONF = ( + _CONF + + """ +from docutils import nodes +from sphinx.util.docutils import SphinxDirective + + +class Boom(SphinxDirective): + def run(self): + raise RuntimeError("boom") + + +class Swallow(SphinxDirective): + has_content = True + + def run(self): + node = nodes.container() + try: + self.state.nested_parse(self.content, self.content_offset, node) + except RuntimeError: + return [nodes.paragraph(text="SWALLOWED")] + return [node] + + +def setup(app): + app.add_directive("boom", Boom) + app.add_directive("swallow", Swallow) +""" +) + + +@pytest.mark.parametrize( + "test_app", + [ + _project( + ".. swallow::\n\n" + " .. match::\n\n" + " .. case::\n\n SKIPPED_X\n\n" + " .. boom::\n\n" + ".. case:: True\n\n SKIPPED_LOOSE_AFTER\n", + conf=_SWALLOW_CONF, + ) + ], + indirect=True, +) +def test_match_restores_its_depth_when_its_body_raises(test_app): + """An exception out of a ``match`` body leaves no ``match`` open behind it. + + A directive of the project catches what a directive in the body raised; + the ``case`` after it is outside every ``match`` and must still be reported, + rather than collected as a placeholder that would reach the writer. + """ + app = test_app + app.build() + (warning,) = build_warnings(app) + source = Path(app.srcdir, "index.rst").read_text() + line = _line_of(source, ".. case:: True") + assert warning.startswith(f"/index.rst:{line}: WARNING: "), warning + assert "'case' directive outside a 'match'" in warning + html = Path(app.outdir, "index.html").read_text() + assert "SWALLOWED" in html + assert "SKIPPED" not in html + + # One condition language: `case` evaluates exactly what `if` does _EXPRESSIONS = { @@ -694,7 +818,7 @@ def test_case_conditions_are_if_conditions(test_app, expression: str, verdict): ::::: :::::: -## A default with trailing spaces +## A default whose fence line ends in spaces is a default ::::{match} :::{case} False @@ -803,6 +927,11 @@ def test_match_in_myst(test_app): "'case' directive outside a 'match'", "```{case} True", ), + "default case outside a match, backticks": ( + "Para.\n\n```{case}\nSKIPPED_STRAY_DEFAULT\n```\n", + "'case' directive outside a 'match'", + "```{case}", + ), "case outside a match, colons": ( "Para.\n\n:::{case} True\nSKIPPED_STRAY\n:::\n", "'case' directive outside a 'match'", From 43d2522a13f14967e2d329ecc9e44979075952f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 08:45:25 +0000 Subject: [PATCH 08/26] =?UTF-8?q?=F0=9F=91=8C=20sphinx-needs:=20below=20WA?= =?UTF-8?q?RNING,=20a=20docutils=20message=20never=20hides=20a=20`match`?= =?UTF-8?q?=20mistake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The match passed over a `system_message` in its body only when it was below the reporter's report level, taking any other for an error docutils had shown. A project whose `docutils.conf` sets `report_level: 1` shows INFO messages, but as information rather than warnings: the INFO docutils puts before the paragraph of a `---` line then made the match skip itself in silence again, and `-W` stayed green with its content gone. A message is now taken as reported only at WARNING or above (and at or above the report level), so the paragraph is reported whatever the report level is. A new test sets the level through the project's `docutils.conf`, as `sphinx-build` reads it. The include check now runs before the direct-child check, so a case that arrives through an include standing inside another directive (such as `.. include:: … :parser: …`) gets the include wording. `match.rst`: a wrapped condition also evaluates when the break is escaped with a backslash, and the limitation note names MyST substitutions too: a `{{ sub }}` in a `match` whose definition holds cases is expanded in place, and those cases are taken without a warning. --- .../sphinx-needs/docs/directives/match.rst | 4 +- .../src/sphinx_needs/directives/needmatch.py | 28 ++++++++------ .../tests/test_match_directive.py | 37 +++++++++++++++++++ 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/packages/sphinx-needs/docs/directives/match.rst b/packages/sphinx-needs/docs/directives/match.rst index c3d3a4244..e422c28f8 100644 --- a/packages/sphinx-needs/docs/directives/match.rst +++ b/packages/sphinx-needs/docs/directives/match.rst @@ -135,7 +135,7 @@ a Python expression over the ``var`` namespace, with no built-in functions A result that is not a ``bool`` is warned about and then used as its truth value, as for ``if``. A condition wrapped onto a second line is joined with a line break, -which is a syntax error unless the break falls inside brackets; +which is a syntax error unless the break falls inside brackets or is escaped with a backslash; keep conditions on one line. Warnings @@ -175,5 +175,7 @@ the ``match`` is skipped all the same. ``default-role`` is one such directive, and so is a **false** ``if``: it returns nothing, so the cases written inside it vanish without a warning, unless the ``match`` is left with no ``case`` at all. + Nor is a MyST substitution: a ``{{ sub }}`` in a ``match`` whose definition holds ``case`` directives + is expanded in place, and its cases are taken without a warning. Needs are the one effect of content outside a case that is undone; any other (a label, a ``needextend``) stays, so keep every directive inside a ``case``. diff --git a/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py b/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py index ef20ba605..841d91f48 100644 --- a/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py +++ b/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py @@ -31,7 +31,7 @@ from docutils import nodes from docutils.parsers.rst.states import RSTState from docutils.statemachine import StringList -from docutils.utils import get_source_line +from docutils.utils import Reporter, get_source_line from sphinx.util.docutils import SphinxDirective from sphinx.util.nodes import nested_parse_with_titles @@ -268,13 +268,8 @@ def _collect_cases(self, source: str | None, /) -> list[_CasePlaceholder] | None cases: list[_CasePlaceholder] = [] for index, child in enumerate(children): if isinstance(child, _CasePlaceholder): - if child.owner is not body: - self._warn( - "'case' directive is not a direct child of its 'match' (it is " - "inside another directive); the whole match is skipped", - child.location, - ) - return None + # the source first: a case an include supplies is reported as such, + # also when the include stands inside another directive if child.source != source: self._warn( "'case' supplied through an include is not supported (write " @@ -283,13 +278,24 @@ def _collect_cases(self, source: str | None, /) -> list[_CasePlaceholder] | None child.location, ) return None + if child.owner is not body: + self._warn( + "'case' directive is not a direct child of its 'match' (it is " + "inside another directive); the whole match is skipped", + child.location, + ) + return None cases.append(child) elif isinstance(child, nodes.comment): continue elif isinstance(child, nodes.system_message): - if child["level"] < self.state.document.reporter.report_level: - # below the report level, so it was never shown: judge what follows - # it (docutils puts an INFO before the paragraph of a `---` line) + reported = max( + self.state.document.reporter.report_level, Reporter.WARNING_LEVEL + ) + if child["level"] < reported: + # never shown as a problem (below the report level, or below WARNING + # however low that level is set): judge what follows it instead + # (docutils puts an INFO before the paragraph of a `---` line) continue # reported by docutils or MyST when it was created: skip, silently return None diff --git a/packages/sphinx-needs/tests/test_match_directive.py b/packages/sphinx-needs/tests/test_match_directive.py index 87664c372..4bf885d55 100644 --- a/packages/sphinx-needs/tests/test_match_directive.py +++ b/packages/sphinx-needs/tests/test_match_directive.py @@ -498,6 +498,43 @@ def test_match_warnings(test_app, expected: _Expected): _assert_no_match_nodes(app) +@pytest.mark.parametrize( + "test_app", + [ + _project( + ".. match::\n\n" + " .. case:: var.arch == 'x86'\n\n SKIPPED_X86\n\n" + " ---\n\n" + " .. case::\n\n SKIPPED_DEFAULT\n", + extra=(("docutils.conf", "[general]\nreport_level: 1\n"),), + ) + ], + indirect=True, +) +def test_match_info_message_is_never_a_reported_error(test_app, monkeypatch): + """An INFO message in the body is not taken for an error docutils reported. + + With ``report_level: 1`` in the project's ``docutils.conf`` the INFO before the + paragraph of a ``---`` line is shown, but as information, not as a warning, + so the paragraph must still be reported: otherwise the match would vanish + with ``-W`` green. ``sphinx-build`` points ``DOCUTILSCONFIG`` at the project's + ``docutils.conf``; this in-process build does it by hand. + """ + app = test_app + monkeypatch.setenv("DOCUTILSCONFIG", str(Path(app.srcdir, "docutils.conf"))) + app.build() + (warning,) = build_warnings(app) + source = Path(app.srcdir, "index.rst").read_text() + assert warning.startswith( + f"/index.rst:{_line_of(source, ' ---')}: WARNING: " + ), warning + assert "got " + _SKIP in warning, warning + assert warning.endswith(" [needs.match]"), warning + assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() + # the INFO itself was shown, so the setting took effect + assert "Unexpected possible title overline or transition" in app._status.getvalue() + + @pytest.mark.parametrize( ("test_app", "error"), [ From 69fa72ef0db2146670195396ceec598e404b7898 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 13:10:30 +0000 Subject: [PATCH 09/26] =?UTF-8?q?=F0=9F=93=9A=20sphinx-needs:=20the=20chan?= =?UTF-8?q?gelog=20entry=20names=20its=20pull=20request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `match` / `case` entry under `Unreleased` was stamped with a placeholder until the pull request existed; it is #2020. --- packages/sphinx-needs/docs/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sphinx-needs/docs/changelog.rst b/packages/sphinx-needs/docs/changelog.rst index 7d94923ae..70cf32974 100644 --- a/packages/sphinx-needs/docs/changelog.rst +++ b/packages/sphinx-needs/docs/changelog.rst @@ -11,7 +11,7 @@ Improvements ............ - ✨ New :ref:`match ` and ``case`` directives include one of several branches of - content, chosen by variant data (:pr:`NNNN`) + content, chosen by variant data (:pr:`2020`) A ``match`` holds ``case`` directives, and the first ``case`` whose condition is true is included; a ``case`` with no condition is the default, and must come last. The From 2edfb83e28b661545d1c479377424f3046856823 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 15:27:21 +0000 Subject: [PATCH 10/26] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20sphinx-needs:=20rena?= =?UTF-8?q?me=20`match`=20/=20`case`=20to=20`choose`=20/=20`when`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `match` and `case` name a construct with a subject, whose cases are patterns tested against it (Python, Rust); the container built here has no subject and holds conditions, which is XSLT's, JSTL's and MSBuild's `choose` / `when`. The rename leaves the names `match` and `case` free for a subject-based form later (#2011). This commit only renames; behaviour is unchanged, and the default is still a `when` with no condition until the next commit gives it a directive of its own: - `needmatch.py` becomes `needchoose.py`, with `ChooseDirective`, `WhenDirective`, `_BranchPlaceholder` and `_ChooseBody`, registered as `choose` and `when` next to `if`; the `env.temp_data` key becomes `sphinx_needs_choose_depth`. - The warning type `needs.match` becomes `needs.choose`, sorted into `WarningSubTypes`, and every message names `choose` and `when`. - The test module, its doc project and the docs page are renamed with `git mv` (`test_choose_directive.py`, `doc_choose_directive/`, `docs/directives/choose.rst`, label `choose`), as are the toctree entry, the pointer in `if.rst` and the changelog entry. `if` is untouched: only the evaluator's docstring names `when` now. --- packages/sphinx-needs/docs/changelog.rst | 28 +- .../sphinx-needs/docs/directives/choose.rst | 181 +++++ packages/sphinx-needs/docs/directives/if.rst | 2 +- .../sphinx-needs/docs/directives/index.rst | 2 +- .../sphinx-needs/docs/directives/match.rst | 181 ----- .../{needmatch.py => needchoose.py} | 240 +++---- .../src/sphinx_needs/directives/needif.py | 2 +- .../sphinx-needs/src/sphinx_needs/logging.py | 4 +- .../sphinx-needs/src/sphinx_needs/needs.py | 6 +- .../conf.py | 2 +- .../doc_choose_directive/included_choose.txt | 9 + .../doc_test/doc_choose_directive/index.rst | 174 +++++ .../other.rst | 0 .../taken_body.txt | 2 +- .../doc_match_directive/included_match.txt | 9 - .../doc_test/doc_match_directive/index.rst | 174 ----- ..._directive.py => test_choose_directive.py} | 670 +++++++++--------- 17 files changed, 844 insertions(+), 842 deletions(-) create mode 100644 packages/sphinx-needs/docs/directives/choose.rst delete mode 100644 packages/sphinx-needs/docs/directives/match.rst rename packages/sphinx-needs/src/sphinx_needs/directives/{needmatch.py => needchoose.py} (57%) rename packages/sphinx-needs/tests/doc_test/{doc_match_directive => doc_choose_directive}/conf.py (93%) create mode 100644 packages/sphinx-needs/tests/doc_test/doc_choose_directive/included_choose.txt create mode 100644 packages/sphinx-needs/tests/doc_test/doc_choose_directive/index.rst rename packages/sphinx-needs/tests/doc_test/{doc_match_directive => doc_choose_directive}/other.rst (100%) rename packages/sphinx-needs/tests/doc_test/{doc_match_directive => doc_choose_directive}/taken_body.txt (54%) delete mode 100644 packages/sphinx-needs/tests/doc_test/doc_match_directive/included_match.txt delete mode 100644 packages/sphinx-needs/tests/doc_test/doc_match_directive/index.rst rename packages/sphinx-needs/tests/{test_match_directive.py => test_choose_directive.py} (54%) diff --git a/packages/sphinx-needs/docs/changelog.rst b/packages/sphinx-needs/docs/changelog.rst index 70cf32974..e5be0e395 100644 --- a/packages/sphinx-needs/docs/changelog.rst +++ b/packages/sphinx-needs/docs/changelog.rst @@ -10,38 +10,38 @@ Unreleased Improvements ............ -- ✨ New :ref:`match ` and ``case`` directives include one of several branches of +- ✨ New :ref:`choose ` and ``when`` directives include one of several branches of content, chosen by variant data (:pr:`2020`) - A ``match`` holds ``case`` directives, and the first ``case`` whose condition is true - is included; a ``case`` with no condition is the default, and must come last. The - other cases are never parsed, so the needs inside them are never created: + A ``choose`` holds ``when`` directives, and the first ``when`` whose condition is true + is included; a ``when`` with no condition is the default, and must come last. The + other branches are never parsed, so the needs inside them are never created: .. code-block:: rst - .. match:: + .. choose:: - .. case:: var.arch == "arm" + .. when:: var.arch == "arm" ARM content. - .. case:: var.arch == "x86" + .. when:: var.arch == "x86" x86 content. - .. case:: + .. when:: Content for every other architecture. Conditions are exactly those of the :ref:`if ` directive, evaluated by the same - code, and the conditions after the case that is taken are not evaluated. A ``match`` - may contain only ``case`` directives and comments. Every mistake warns once under the - new ``needs.match`` type and skips the whole ``match``: content outside a case (any - need it creates is removed again), a ``case`` inside another directive or supplied - through an include, a misplaced or second default, an argument on ``match``, variant + code, and the conditions after the branch that is taken are not evaluated. A ``choose`` + may contain only ``when`` directives and comments. Every mistake warns once under the + new ``needs.choose`` type and skips the whole ``choose``: content outside a branch (any + need it creates is removed again), a ``when`` inside another directive or supplied + through an include, a misplaced or second default, an argument on ``choose``, variant data that is not configured, and a condition that cannot be evaluated — so a mistake that makes a condition unevaluable, such as a misspelt key or a syntax error, never - renders a later case or the default in its place. Works in reStructuredText and in + renders a later branch or the default in its place. Works in reStructuredText and in MyST Markdown. The undocumented warning ``if`` gives for a condition whose result is not a bool is now listed in its documentation. diff --git a/packages/sphinx-needs/docs/directives/choose.rst b/packages/sphinx-needs/docs/directives/choose.rst new file mode 100644 index 000000000..0f222bc95 --- /dev/null +++ b/packages/sphinx-needs/docs/directives/choose.rst @@ -0,0 +1,181 @@ +.. _choose: + +choose +====== + +.. versionadded:: 8.6.0 + +The ``choose`` directive includes one of several branches of content, +chosen by :ref:`variant data ` at parse time. +Its content is a list of ``when`` directives: +the first ``when`` whose condition is true is included, +and a ``when`` with no condition is the default, +included when no condition before it is true. +The content of every other ``when`` is never parsed, +so the needs inside it are never created. + +.. code-block:: rst + + .. choose:: + + .. when:: var.arch == "arm" + + ARM content. + + .. req:: ARM-specific requirement + :id: REQ_ARM_001 + + .. when:: var.arch == "x86" + + x86 content. + + .. a comment may stand between two branches + + .. when:: + + Content for every other architecture. + +A ``choose`` is the many-branched form of :ref:`if `: +the example includes the ARM content, the x86 content or the default content, +and never more than one of them. + +MyST Markdown +------------- + +In MyST Markdown, ``choose`` and ``when`` are fenced directives like any other. +With colon fences (the ``colon_fence`` extension): + +.. code-block:: md + + ::::{choose} + :::{when} var.arch == "arm" + ARM content. + ::: + % a comment may stand between two branches + :::{when} + Content for every other architecture. + ::: + :::: + +and with backtick fences: + +.. code-block:: md + + ````{choose} + ```{when} var.arch == "arm" + ARM content. + ``` + ```{when} + Content for every other architecture. + ``` + ```` + +An outer fence must be longer than the fences inside it, +so a ``choose`` takes one more colon (or backtick) than its branches, +and a ``choose`` nested in a branch takes one fewer than that branch: + +.. code-block:: md + + ::::::{choose} + :::::{when} var.debug + ::::{choose} + :::{when} var.arch == "arm" + ARM debug content. + ::: + :::: + ::::: + :::::: + +``%`` starts a MyST comment, and a ``+++`` block break counts as one too. + +Rules +----- + +- **The first true branch wins.** + The conditions are evaluated in order, and the first ``when`` whose condition is true is included. + The conditions after it are not evaluated at all, so they cannot warn. + When no condition is true and there is no default, the ``choose`` includes nothing, + without a warning, as a false ``if`` does. +- **The default comes last.** + A ``when`` with no condition is the default. + A ``choose`` has at most one, and it must be its last ``when``. +- **Only branches and comments.** + A ``choose`` may contain only ``when`` directives and comments: + reStructuredText comments (``..``), and in MyST ``%`` comments and ``+++`` block breaks. + In MyST, an HTML comment (````) is raw HTML rather than a comment, so it is a mistake here. + Any other content outside a branch is a mistake, + and the needs it would create are removed again. + A ``when`` belongs directly in a ``choose``: + one anywhere else is a mistake too, + whether it is written loose in the content of another ``when`` + or inside another directive in the ``choose``, + even one that passes its content through, such as a true ``if`` or a ``rst-class``. +- **The branches are written in place.** + Every ``when`` of a ``choose`` is written in the body of that ``choose``, in the same file, + so that one choice is one directive in one place. + An ``.. include::`` (in MyST, an ``{include}``) may not supply the branches; + it may be used inside the content of a branch, + and a whole ``choose`` may stand in an included file. +- **The included branch is ordinary content.** + It may hold headings, which become sections where the ``choose`` stands, + needs, any other directive, and further ``choose`` directives. + An ``.. include::`` may supply part of the content of a branch, + and a ``choose`` may stand in the content of a need. +- **Parse-time evaluation**, as for ``if``: + the content of a ``when`` that is not included is never parsed, + so its needs are never created and its mistakes are never reported. + +Conditions +---------- + +A ``when`` condition is exactly a condition of the :ref:`if ` directive, +evaluated by the same code: +a Python expression over the ``var`` namespace, with no built-in functions +(see the ``if`` directive's :ref:`if_expression_context`). +A result that is not a ``bool`` is warned about and then used as its truth value, as for ``if``. + +A condition wrapped onto a second line is joined with a line break, +which is a syntax error unless the break falls inside brackets or is escaped with a backslash; +keep conditions on one line. + +Warnings +-------- + +Every mistake warns once, under the ``needs.choose`` type +(suppressible via ``suppress_warnings = ["needs.choose"]``), +at the line of the directive or the content that has it, +and skips the **whole** ``choose``: nothing of it is included, not even its default. +The mistakes are: + +- ``needs_variant_data`` is not configured, even when the ``choose`` holds only a default. +- A condition cannot be evaluated (a syntax error, an unknown key, etc.) before a branch is taken. + So a mistake that makes a condition unevaluable, such as a misspelt key or a syntax error, + never renders a later branch or the default in its place. + (A mistake that leaves a valid condition, such as a misspelt value, cannot be told apart + from a condition that is false.) +- The ``choose`` contains something that is neither a ``when`` nor a comment. + A line of only punctuation, such as ``---`` between two branches, is such content too. +- A ``when`` is written inside another directive in the ``choose`` rather than directly in it. +- A ``when`` is supplied through an include rather than written in the body of the ``choose`` + (the warning points at the ``when`` in the included file). +- The ``choose`` has more than one default ``when``, or a default that is not its last ``when``. +- The ``choose`` has no ``when`` at all. +- The ``choose`` is given an argument: the conditions go on the branches. + +A ``when`` outside a ``choose`` warns as well, and its content is skipped. +A condition whose result is not a ``bool`` warns, and its truth value is used. +A mistake that docutils or MyST already reports in the content of a ``choose``, +such as an unknown directive name, is not reported a second time; +the ``choose`` is skipped all the same. + +.. note:: + + A directive that produces no node, written directly in a ``choose``, is not detected: + it still runs, and the ``choose`` goes on. + ``default-role`` is one such directive, and so is a **false** ``if``: + it returns nothing, so the branches written inside it vanish without a warning, + unless the ``choose`` is left with no ``when`` at all. + Nor is a MyST substitution: a ``{{ sub }}`` in a ``choose`` whose definition holds ``when`` directives + is expanded in place, and its branches are taken without a warning. + Needs are the one effect of content outside a branch that is undone; + any other (a label, a ``needextend``) stays, so keep every directive inside a ``when``. diff --git a/packages/sphinx-needs/docs/directives/if.rst b/packages/sphinx-needs/docs/directives/if.rst index c66647124..cd9b7ce97 100644 --- a/packages/sphinx-needs/docs/directives/if.rst +++ b/packages/sphinx-needs/docs/directives/if.rst @@ -12,7 +12,7 @@ The directive argument is a Python expression evaluated against the ``var`` namespace (populated from :ref:`needs_variant_data`). If the expression evaluates to ``True``, the directive body is parsed and included in the document. Otherwise the entire body is skipped. -To include one of several branches instead, use :ref:`match `. +To include one of several branches instead, use :ref:`choose `. .. code-block:: rst diff --git a/packages/sphinx-needs/docs/directives/index.rst b/packages/sphinx-needs/docs/directives/index.rst index df88af39b..31fc2faa0 100644 --- a/packages/sphinx-needs/docs/directives/index.rst +++ b/packages/sphinx-needs/docs/directives/index.rst @@ -19,7 +19,7 @@ Directives for conditional content: :maxdepth: 1 if - match + choose Directives for visualizing and analyzing needs: diff --git a/packages/sphinx-needs/docs/directives/match.rst b/packages/sphinx-needs/docs/directives/match.rst deleted file mode 100644 index e422c28f8..000000000 --- a/packages/sphinx-needs/docs/directives/match.rst +++ /dev/null @@ -1,181 +0,0 @@ -.. _match: - -match -===== - -.. versionadded:: 8.6.0 - -The ``match`` directive includes one of several branches of content, -chosen by :ref:`variant data ` at parse time. -Its content is a list of ``case`` directives: -the first ``case`` whose condition is true is included, -and a ``case`` with no condition is the default, -included when no condition before it is true. -The content of every other ``case`` is never parsed, -so the needs inside it are never created. - -.. code-block:: rst - - .. match:: - - .. case:: var.arch == "arm" - - ARM content. - - .. req:: ARM-specific requirement - :id: REQ_ARM_001 - - .. case:: var.arch == "x86" - - x86 content. - - .. a comment may stand between two cases - - .. case:: - - Content for every other architecture. - -A ``match`` is the many-branched form of :ref:`if `: -the example includes the ARM content, the x86 content or the default content, -and never more than one of them. - -MyST Markdown -------------- - -In MyST Markdown, ``match`` and ``case`` are fenced directives like any other. -With colon fences (the ``colon_fence`` extension): - -.. code-block:: md - - ::::{match} - :::{case} var.arch == "arm" - ARM content. - ::: - % a comment may stand between two cases - :::{case} - Content for every other architecture. - ::: - :::: - -and with backtick fences: - -.. code-block:: md - - ````{match} - ```{case} var.arch == "arm" - ARM content. - ``` - ```{case} - Content for every other architecture. - ``` - ```` - -An outer fence must be longer than the fences inside it, -so a ``match`` takes one more colon (or backtick) than its cases, -and a ``match`` nested in a case takes one fewer than that case: - -.. code-block:: md - - ::::::{match} - :::::{case} var.debug - ::::{match} - :::{case} var.arch == "arm" - ARM debug content. - ::: - :::: - ::::: - :::::: - -``%`` starts a MyST comment, and a ``+++`` block break counts as one too. - -Rules ------ - -- **The first true case wins.** - The conditions are evaluated in order, and the first ``case`` whose condition is true is included. - The conditions after it are not evaluated at all, so they cannot warn. - When no condition is true and there is no default, the ``match`` includes nothing, - without a warning, as a false ``if`` does. -- **The default comes last.** - A ``case`` with no condition is the default. - A ``match`` has at most one, and it must be its last ``case``. -- **Only cases and comments.** - A ``match`` may contain only ``case`` directives and comments: - reStructuredText comments (``..``), and in MyST ``%`` comments and ``+++`` block breaks. - In MyST, an HTML comment (````) is raw HTML rather than a comment, so it is a mistake here. - Any other content outside a case is a mistake, - and the needs it would create are removed again. - A ``case`` belongs directly in a ``match``: - one anywhere else is a mistake too, - whether it is written loose in the content of another ``case`` - or inside another directive in the ``match``, - even one that passes its content through, such as a true ``if`` or a ``rst-class``. -- **The cases are written in place.** - Every ``case`` of a ``match`` is written in the body of that ``match``, in the same file, - so that one choice is one directive in one place. - An ``.. include::`` (in MyST, an ``{include}``) may not supply the cases; - it may be used inside the content of a case, - and a whole ``match`` may stand in an included file. -- **The included case is ordinary content.** - It may hold headings, which become sections where the ``match`` stands, - needs, any other directive, and further ``match`` directives. - An ``.. include::`` may supply part of the content of a case, - and a ``match`` may stand in the content of a need. -- **Parse-time evaluation**, as for ``if``: - the content of a ``case`` that is not included is never parsed, - so its needs are never created and its mistakes are never reported. - -Conditions ----------- - -A ``case`` condition is exactly a condition of the :ref:`if ` directive, -evaluated by the same code: -a Python expression over the ``var`` namespace, with no built-in functions -(see the ``if`` directive's :ref:`if_expression_context`). -A result that is not a ``bool`` is warned about and then used as its truth value, as for ``if``. - -A condition wrapped onto a second line is joined with a line break, -which is a syntax error unless the break falls inside brackets or is escaped with a backslash; -keep conditions on one line. - -Warnings --------- - -Every mistake warns once, under the ``needs.match`` type -(suppressible via ``suppress_warnings = ["needs.match"]``), -at the line of the directive or the content that has it, -and skips the **whole** ``match``: nothing of it is included, not even its default. -The mistakes are: - -- ``needs_variant_data`` is not configured, even when the ``match`` holds only a default. -- A condition cannot be evaluated (a syntax error, an unknown key, etc.) before a case is taken. - So a mistake that makes a condition unevaluable, such as a misspelt key or a syntax error, - never renders a later case or the default in its place. - (A mistake that leaves a valid condition, such as a misspelt value, cannot be told apart - from a condition that is false.) -- The ``match`` contains something that is neither a ``case`` nor a comment. - A line of only punctuation, such as ``---`` between two cases, is such content too. -- A ``case`` is written inside another directive in the ``match`` rather than directly in it. -- A ``case`` is supplied through an include rather than written in the body of the ``match`` - (the warning points at the ``case`` in the included file). -- The ``match`` has more than one default ``case``, or a default that is not its last ``case``. -- The ``match`` has no ``case`` at all. -- The ``match`` is given an argument: the conditions go on the cases. - -A ``case`` outside a ``match`` warns as well, and its content is skipped. -A condition whose result is not a ``bool`` warns, and its truth value is used. -A mistake that docutils or MyST already reports in the content of a ``match``, -such as an unknown directive name, is not reported a second time; -the ``match`` is skipped all the same. - -.. note:: - - A directive that produces no node, written directly in a ``match``, is not detected: - it still runs, and the ``match`` goes on. - ``default-role`` is one such directive, and so is a **false** ``if``: - it returns nothing, so the cases written inside it vanish without a warning, - unless the ``match`` is left with no ``case`` at all. - Nor is a MyST substitution: a ``{{ sub }}`` in a ``match`` whose definition holds ``case`` directives - is expanded in place, and its cases are taken without a warning. - Needs are the one effect of content outside a case that is undone; - any other (a label, a ``needextend``) stays, so keep every directive inside a ``case``. diff --git a/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py similarity index 57% rename from packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py rename to packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py index 841d91f48..eb52b80f5 100644 --- a/packages/sphinx-needs/src/sphinx_needs/directives/needmatch.py +++ b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py @@ -1,20 +1,20 @@ """Directives for including one of several branches of content based on variant data. -A ``match`` holds ``case`` directives and comments, written in its own body, +A ``choose`` holds ``when`` directives and comments, written in its own body, and nothing else. -The first ``case`` whose condition holds is included; -a ``case`` with no condition is the default, and must be the last. +The first ``when`` whose condition holds is included; +a ``when`` with no condition is the default, and must be the last. -A ``case`` does not parse its content. -Inside a ``match`` body it returns a transient :class:`_CasePlaceholder` +A ``when`` does not parse its content. +Inside a ``choose`` body it returns a transient :class:`_BranchPlaceholder` carrying its condition and its raw content, -and the ``match`` parses its own body into a detached :class:`_MatchBody` +and the ``choose`` parses its own body into a detached :class:`_ChooseBody` that it never returns. -Having seen every case at once, the ``match`` checks the structure, +Having seen every branch at once, the ``choose`` checks the structure, evaluates the conditions in order with the evaluator of the ``if`` directive (:func:`~sphinx_needs.directives.needif.evaluate_variant_condition`), -and parses only the content of the case it takes, returning those nodes. -So the content of every other case is never parsed: +and parses only the content of the branch it takes, returning those nodes. +So the content of every other branch is never parsed: the needs in it are never created and its mistakes are never reported, exactly as for the body of a false ``if``. @@ -42,63 +42,63 @@ LOGGER = get_logger(__name__) -_DEPTH_KEY = "sphinx_needs_match_depth" -"""The ``env.temp_data`` key counting the ``match`` bodies being parsed. +_DEPTH_KEY = "sphinx_needs_choose_depth" +"""The ``env.temp_data`` key counting the ``choose`` bodies being parsed. -A ``case`` is a child of a ``match`` body exactly when the count is above 0. -A ``match`` raises it only around the parse of its own body, -and parses the content of the case it takes at 0, -so a ``case`` written loose in a case's content is reported as well. +A ``when`` is a child of a ``choose`` body exactly when the count is above 0. +A ``choose`` raises it only around the parse of its own body, +and parses the content of the branch it takes at 0, +so a ``when`` written loose in a branch's content is reported as well. """ -class _CasePlaceholder(nodes.Element): - """What a ``case`` leaves in the body of its ``match``; it never reaches a doctree. +class _BranchPlaceholder(nodes.Element): + """What a ``when`` leaves in the body of its ``choose``; it never reaches a doctree. The payload is held in plain Python attributes rather than docutils attributes: - the ``match`` reads it once and discards it with the body. + the ``choose`` reads it once and discards it with the body. """ condition: str | None - """The condition, or ``None`` for the default case.""" + """The condition, or ``None`` for the default branch.""" content: StringList - """The raw content of the case, parsed only if the case is taken.""" + """The raw content of the branch, parsed only if the branch is taken.""" content_offset: int - """The ``content_offset`` of the ``case`` directive.""" + """The ``content_offset`` of the ``when`` directive.""" lineno: int - """The ``lineno`` of the ``case`` directive.""" + """The ``lineno`` of the ``when`` directive.""" location: str | None - """Where warnings about the case are reported.""" + """Where warnings about the branch are reported.""" source: str | None - """The file the ``case`` directive is written in, as docutils or MyST reports it.""" + """The file the ``when`` directive is written in, as docutils or MyST reports it.""" owner: nodes.Element | None - """The node the ``case`` directive's result is appended to. + """The node the ``when`` directive's result is appended to. - That is the body of its ``match`` exactly when the ``case`` is written directly in it, + That is the body of its ``choose`` exactly when the ``when`` is written directly in it, rather than inside another directive whose content was parsed into a node of its own. """ -class _MatchBody(nodes.Element): - """The detached node a ``match`` parses its own body into; it is never returned.""" +class _ChooseBody(nodes.Element): + """The detached node a ``choose`` parses its own body into; it is never returned.""" -class CaseDirective(SphinxDirective): - """One branch of a ``match``, included if it is the first whose condition holds. +class WhenDirective(SphinxDirective): + """One branch of a ``choose``, included if it is the first whose condition holds. The directive argument is a condition, exactly as for the ``if`` directive; - a ``case`` with no argument is the default of its ``match``. - Its content is not parsed here: the ``match`` parses it if it takes the case. + a ``when`` with no argument is the default of its ``choose``. + Its content is not parsed here: the ``choose`` parses it if it takes the branch. Example:: - .. match:: + .. choose:: - .. case:: var.arch == "arm" + .. when:: var.arch == "arm" ARM content. - .. case:: + .. when:: Content for every other architecture. """ @@ -112,15 +112,15 @@ def run(self) -> Sequence[nodes.Node]: if self.env.temp_data.get(_DEPTH_KEY, 0) <= 0: log_warning( LOGGER, - "'case' directive outside a 'match' (a 'case' must be a direct child " - "of a 'match'); its content is skipped", - "match", + "'when' directive outside a 'choose' (a 'when' must be a direct child " + "of a 'choose'); its content is skipped", + "choose", location=self.get_location(), ) return [] - placeholder = _CasePlaceholder() - # an argument of only whitespace is no condition: the default case + placeholder = _BranchPlaceholder() + # an argument of only whitespace is no condition: the default branch has_condition = bool(self.arguments and self.arguments[0].strip()) placeholder.condition = self.arguments[0] if has_condition else None placeholder.content = self.content @@ -134,32 +134,32 @@ def run(self) -> Sequence[nodes.Node]: return [placeholder] -class MatchDirective(SphinxDirective): - """Include the content of the first ``case`` whose condition holds. +class ChooseDirective(SphinxDirective): + """Include the content of the first ``when`` whose condition holds. - The content may hold only ``case`` directives and comments. - Every mistake is warned about once, and skips the whole ``match``: - content that is neither a ``case`` nor a comment, a ``case`` inside another + The content may hold only ``when`` directives and comments. + Every mistake is warned about once, and skips the whole ``choose``: + content that is neither a ``when`` nor a comment, a ``when`` inside another directive or supplied through an include, - a default ``case`` that is not the last or is not the only one, + a default ``when`` that is not the last or is not the only one, variant data that is not configured, - and a condition that cannot be evaluated before a case is taken. + and a condition that cannot be evaluated before a branch is taken. So a mistake that makes a condition unevaluable, such as a misspelt key - or a syntax error, never renders a later case or the default in its place. + or a syntax error, never renders a later branch or the default in its place. Example:: - .. match:: + .. choose:: - .. case:: var.arch == "arm" + .. when:: var.arch == "arm" ARM content. - .. case:: var.arch == "x86" + .. when:: var.arch == "x86" x86 content. - .. case:: + .. when:: Content for every other architecture. """ @@ -174,59 +174,59 @@ class MatchDirective(SphinxDirective): def run(self) -> Sequence[nodes.Node]: if self.arguments and self.arguments[0].strip(): self._warn( - f"'match' directive takes no argument, got {self.arguments[0]!r} " - "(write a condition on each 'case'); the whole match is skipped" + f"'choose' directive takes no argument, got {self.arguments[0]!r} " + "(write a condition on each 'when'); the whole choose is skipped" ) return [] - cases = self._collect_cases(self.get_source_info()[0]) - if cases is None: + branches = self._collect_branches(self.get_source_info()[0]) + if branches is None: return [] if NeedsSphinxConfig(self.env.config).variant_data_proxy is None: self._warn( - "'match' directive used but needs_variant_data is not configured; " - "the whole match is skipped" + "'choose' directive used but needs_variant_data is not configured; " + "the whole choose is skipped" ) return [] - for case in cases: - if case.condition is None: - return self._parse_case(case) + for branch in branches: + if branch.condition is None: + return self._parse_branch(branch) taken = evaluate_variant_condition( self.env, - case.condition, - directive="case", - subtype="match", - location=case.location, + branch.condition, + directive="when", + subtype="choose", + location=branch.location, ) if taken is None: - # poisoned: no later case is evaluated or taken, the default included + # poisoned: no later branch is evaluated or taken, the default included return [] if taken: - # the first case that holds wins; the later ones are not evaluated - return self._parse_case(case) + # the first branch that holds wins; the later ones are not evaluated + return self._parse_branch(branch) return [] def _warn(self, message: str, location: str | nodes.Node | None = None, /) -> None: log_warning( LOGGER, message, - "match", + "choose", location=self.get_location() if location is None else location, ) - def _parse_body(self) -> _MatchBody: - """Parse the content into a detached node, with every ``case`` deferred. + def _parse_body(self) -> _ChooseBody: + """Parse the content into a detached node, with every branch deferred. - Because the content of every case is deferred, + Because the content of every branch is deferred, a need created while the body is parsed can only come from content - written outside a case, which is a mistake that skips the whole ``match``: + written outside a branch, which is a mistake that skips the whole ``choose``: such needs are removed again, so that the mistake creates none. :return: The parsed body. """ - body = _MatchBody() + body = _ChooseBody() body.document = self.state.document data = SphinxNeedsData(self.env) @@ -249,43 +249,45 @@ def _parse_body(self) -> _MatchBody: return body - def _collect_cases(self, source: str | None, /) -> list[_CasePlaceholder] | None: + def _collect_branches( + self, source: str | None, / + ) -> list[_BranchPlaceholder] | None: """Parse the body and check its structure. - The cases must be written in the body itself: - a ``case`` inside another directive (one whose content is parsed into a node + The branches must be written in the body itself: + a ``when`` inside another directive (one whose content is parsed into a node of its own, even if it then returns that node's children, such as a true ``if``) - is refused, and so is a ``case`` an ``.. include::`` supplies, - so that one ``match`` is one directive in one file. + is refused, and so is a ``when`` an ``.. include::`` supplies, + so that one ``choose`` is one directive in one file. - :param source: The file this ``match`` is written in, - as its cases report theirs. - :return: The cases, in order, - or ``None`` if the body is not a valid ``match`` (a warning has been emitted). + :param source: The file this ``choose`` is written in, + as its branches report theirs. + :return: The branches, in order, + or ``None`` if the body is not a valid ``choose`` (a warning has been emitted). """ body = self._parse_body() children = list(body.children) - cases: list[_CasePlaceholder] = [] + branches: list[_BranchPlaceholder] = [] for index, child in enumerate(children): - if isinstance(child, _CasePlaceholder): - # the source first: a case an include supplies is reported as such, + if isinstance(child, _BranchPlaceholder): + # the source first: a branch an include supplies is reported as such, # also when the include stands inside another directive if child.source != source: self._warn( - "'case' supplied through an include is not supported (write " - "the cases in the body of the 'match'); the whole match is " - "skipped", + "'when' supplied through an include is not supported (write " + "the branches in the body of the 'choose'); the whole choose " + "is skipped", child.location, ) return None if child.owner is not body: self._warn( - "'case' directive is not a direct child of its 'match' (it is " - "inside another directive); the whole match is skipped", + "'when' directive is not a direct child of its 'choose' (it is " + "inside another directive); the whole choose is skipped", child.location, ) return None - cases.append(child) + branches.append(child) elif isinstance(child, nodes.comment): continue elif isinstance(child, nodes.system_message): @@ -305,33 +307,33 @@ def _collect_cases(self, source: str | None, /) -> list[_CasePlaceholder] | None offender.tagname if isinstance(offender, nodes.Element) else "#text" ) self._warn( - "'match' directive may contain only 'case' directives and " - f"comments, got <{tagname}>; the whole match is skipped", + "'choose' directive may contain only 'when' directives and " + f"comments, got <{tagname}>; the whole choose is skipped", self._location_of(children[index:]), ) return None - if not cases: - self._warn("'match' directive has no 'case'") + if not branches: + self._warn("'choose' directive has no 'when'") return None - defaults = [case for case in cases if case.condition is None] + defaults = [branch for branch in branches if branch.condition is None] if len(defaults) > 1: self._warn( - "'match' directive has more than one default 'case' (a 'case' with " - "no condition); the whole match is skipped", + "'choose' directive has more than one default 'when' (a 'when' with " + "no condition); the whole choose is skipped", defaults[1].location, ) return None - if defaults and defaults[0] is not cases[-1]: + if defaults and defaults[0] is not branches[-1]: self._warn( - "'match' directive has a default 'case' (a 'case' with no condition) " - "that is not its last 'case'; the whole match is skipped", + "'choose' directive has a default 'when' (a 'when' with no condition) " + "that is not its last 'when'; the whole choose is skipped", defaults[0].location, ) return None - return cases + return branches @staticmethod def _offender(candidates: Sequence[nodes.Node]) -> nodes.Node: @@ -346,7 +348,7 @@ def _offender(candidates: Sequence[nodes.Node]) -> nodes.Node: for node in candidates: if isinstance(node, nodes.target) and not get_source_line(node)[1]: continue - if isinstance(node, _CasePlaceholder | nodes.comment): + if isinstance(node, _BranchPlaceholder | nodes.comment): break return node return candidates[0] @@ -360,7 +362,7 @@ def _location_of(self, candidates: Sequence[nodes.Node]) -> nodes.Node | str | N (such as the target a need directive emits before the need). :param candidates: The offending child and the children after it. - :return: That node, or the location of the ``match`` if none has both. + :return: That node, or the location of the ``choose`` if none has both. """ for candidate in candidates: for node in candidate.findall(nodes.Element): @@ -369,14 +371,14 @@ def _location_of(self, candidates: Sequence[nodes.Node]) -> nodes.Node | str | N return node return self.get_location() - def _parse_case(self, case: _CasePlaceholder) -> list[nodes.Node]: - """Parse the content of the case that is taken, with section titles allowed. + def _parse_branch(self, branch: _BranchPlaceholder) -> list[nodes.Node]: + """Parse the content of the branch that is taken, with section titles allowed. - It is parsed outside every ``match`` body (at depth 0), - whatever encloses this ``match``, - so that a ``case`` written loose in it is reported rather than collected. + It is parsed outside every ``choose`` body (at depth 0), + whatever encloses this ``choose``, + so that a ``when`` written loose in it is reported rather than collected. - :param case: The case that is taken. + :param branch: The branch that is taken. :return: The parsed nodes. """ node = nodes.container() @@ -386,23 +388,23 @@ def _parse_case(self, case: _CasePlaceholder) -> list[nodes.Node]: temp_data[_DEPTH_KEY] = 0 try: nested_parse_with_titles( - self.state, case.content, node, self._content_offset_of(case) + self.state, branch.content, node, self._content_offset_of(branch) ) finally: temp_data[_DEPTH_KEY] = depth return node.children - def _content_offset_of(self, case: _CasePlaceholder) -> int: - """The offset at which this directive's state parses the content of ``case``. + def _content_offset_of(self, branch: _BranchPlaceholder) -> int: + """The offset at which this directive's state parses the content of ``branch``. Under docutils a directive's ``content_offset`` is absolute in the input, - so the case's own offset is valid for any state. + so the branch's own offset is valid for any state. Under MyST it is relative to the directive's own line (the mock state adds the line it was created at), - so it is re-based from the case's line onto this directive's line. + so it is re-based from the branch's line onto this directive's line. - :param case: The case that is taken. + :param branch: The branch that is taken. """ if isinstance(self.state, RSTState): - return case.content_offset - return case.lineno - self.lineno + case.content_offset + return branch.content_offset + return branch.lineno - self.lineno + branch.content_offset diff --git a/packages/sphinx-needs/src/sphinx_needs/directives/needif.py b/packages/sphinx-needs/src/sphinx_needs/directives/needif.py index fc0797fa3..157bf9904 100644 --- a/packages/sphinx-needs/src/sphinx_needs/directives/needif.py +++ b/packages/sphinx-needs/src/sphinx_needs/directives/needif.py @@ -24,7 +24,7 @@ def evaluate_variant_condition( subtype: WarningSubTypes, location: str | tuple[str | None, int | None] | nodes.Node | None, ) -> bool | None: - """Evaluate a variant condition, as the ``if`` directive and a ``case`` do. + """Evaluate a variant condition, as the ``if`` directive and a ``when`` do. The expression is Python, evaluated with ``var`` (the proxy over :confval:`needs_variant_data`) as its only name and no builtins. diff --git a/packages/sphinx-needs/src/sphinx_needs/logging.py b/packages/sphinx-needs/src/sphinx_needs/logging.py index 3a3d59eb3..6d233f0ec 100644 --- a/packages/sphinx-needs/src/sphinx_needs/logging.py +++ b/packages/sphinx-needs/src/sphinx_needs/logging.py @@ -16,6 +16,7 @@ def get_logger(name: str) -> SphinxLoggerAdapter: WarningSubTypes = Literal[ "beta", "card_layout", + "choose", "config", "constraint", "create_need", @@ -41,7 +42,6 @@ def get_logger(name: str) -> SphinxLoggerAdapter: "link_text", "load_external_need", "load_service_need", - "match", "max_items", "mistyped_external_values", "mistyped_import_values", @@ -69,6 +69,7 @@ def get_logger(name: str) -> SphinxLoggerAdapter: WarningSubTypeDescription: dict[WarningSubTypes, str] = { "beta": "Beta feature, subject to change", "card_layout": "Invalid ``needs_card_layouts`` specification", + "choose": "Error in processing choose/when directive", "config": "Invalid configuration", "constraint": "Constraint violation", "create_need": "Creation of a need from directive failed", @@ -93,7 +94,6 @@ def get_logger(name: str) -> SphinxLoggerAdapter: "link_text": "Reference text could not be generated", "load_external_need": "Failed to load an external need", "load_service_need": "Failed to load a service need", - "match": "Error in processing match/case directive", "max_items": "View truncated by a max_items limit", "mistyped_external_values": "Unexpected value types found in external need data", "mistyped_import_values": "Unexpected value types found in imported need data", diff --git a/packages/sphinx-needs/src/sphinx_needs/needs.py b/packages/sphinx-needs/src/sphinx_needs/needs.py index 518aa77f6..8ceb7531c 100644 --- a/packages/sphinx-needs/src/sphinx_needs/needs.py +++ b/packages/sphinx-needs/src/sphinx_needs/needs.py @@ -59,6 +59,7 @@ purge_needs, ) from sphinx_needs.directives.needbar import Needbar, NeedbarDirective, process_needbar +from sphinx_needs.directives.needchoose import ChooseDirective, WhenDirective from sphinx_needs.directives.needextend import Needextend, NeedextendDirective from sphinx_needs.directives.needextract import ( Needextract, @@ -86,7 +87,6 @@ NeedlistDirective, process_needlist, ) -from sphinx_needs.directives.needmatch import CaseDirective, MatchDirective from sphinx_needs.directives.needpie import Needpie, NeedpieDirective, process_needpie from sphinx_needs.directives.needreport import NeedReportDirective from sphinx_needs.directives.needsequence import ( @@ -310,8 +310,8 @@ def setup(app: Sphinx) -> dict[str, Any]: app.add_directive("needreport", NeedReportDirective) app.add_directive("needuml", NeedumlDirective) app.add_directive("if", IfDirective) - app.add_directive("match", MatchDirective) - app.add_directive("case", CaseDirective) + app.add_directive("choose", ChooseDirective) + app.add_directive("when", WhenDirective) app.add_directive("needarch", NeedarchDirective) app.add_directive("list2need", List2NeedDirective) diff --git a/packages/sphinx-needs/tests/doc_test/doc_match_directive/conf.py b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/conf.py similarity index 93% rename from packages/sphinx-needs/tests/doc_test/doc_match_directive/conf.py rename to packages/sphinx-needs/tests/doc_test/doc_choose_directive/conf.py index 30f479e63..eb5a545ba 100644 --- a/packages/sphinx-needs/tests/doc_test/doc_match_directive/conf.py +++ b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/conf.py @@ -1,4 +1,4 @@ -project = "needs_match_test" +project = "needs_choose_test" version = "0.1.0" extensions = ["sphinx_needs"] diff --git a/packages/sphinx-needs/tests/doc_test/doc_choose_directive/included_choose.txt b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/included_choose.txt new file mode 100644 index 000000000..c95c290d7 --- /dev/null +++ b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/included_choose.txt @@ -0,0 +1,9 @@ +.. choose:: + + .. when:: var.arch == "xyz" + + SKIPPED_X1_IN_INCLUDED_CHOOSE + + .. when:: + + TAKEN_X1_INCLUDED_CHOOSE_DEFAULT diff --git a/packages/sphinx-needs/tests/doc_test/doc_choose_directive/index.rst b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/index.rst new file mode 100644 index 000000000..0fbeedf67 --- /dev/null +++ b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/index.rst @@ -0,0 +1,174 @@ +CHOOSE Test +=========== + +.. toctree:: + + other + +P1 first true branch wins +------------------------- + +The conditions after the taken branch are never evaluated, +so the invalid one and the unknown key cannot warn. + +.. choose:: + + .. when:: var.arch == "abc" + + TAKEN_P1_FIRST + + .. when:: var.debug + + SKIPPED_P1_SECOND_TRUE + + .. when:: this is not python !!! + + SKIPPED_P1_INVALID_SYNTAX + + .. when:: var.no_such_key == 1 + + SKIPPED_P1_UNKNOWN_KEY + + .. when:: + + SKIPPED_P1_DEFAULT + +P2 default taken when no branch holds +------------------------------------- + +.. choose:: + + .. when:: var.arch == "xyz" + + SKIPPED_P2_FALSE + + .. a comment between two branches + + .. when:: + + TAKEN_P2_DEFAULT + +P2b no branch holds and there is no default +------------------------------------------- + +.. choose:: + + .. when:: var.arch == "xyz" + + SKIPPED_P2B_1 + + .. when:: not var.debug + + SKIPPED_P2B_2 + +TAKEN_P2B_AFTER_CHOOSE + +P3 needs in branches +-------------------- + +.. choose:: + + .. when:: var.arch == "xyz" + + .. req:: In a branch that is not taken + :id: REQ_P3_SKIPPED + + .. when:: var.arch == "abc" + + .. req:: In the taken branch + :id: REQ_P3_TAKEN + + .. when:: + + .. req:: In a default that is not taken + :id: REQ_P3_DEFAULT_SKIPPED + +P4 sections in the taken branch +------------------------------- + +.. choose:: + + .. when:: var.debug + + P4 conditional heading + ~~~~~~~~~~~~~~~~~~~~~~ + + TAKEN_P4_SECTION_BODY + + .. when:: + + P4 skipped heading + ~~~~~~~~~~~~~~~~~~ + + SKIPPED_P4_BODY + +P5 nested choose +---------------- + +.. choose:: + + .. when:: var.debug + + TAKEN_P5_OUTER + + .. choose:: + + .. when:: var.arch == "xyz" + + SKIPPED_P5_INNER + + .. when:: + + TAKEN_P5_INNER_DEFAULT + + .. when:: + + SKIPPED_P5_OUTER + +P5b choose in the content of a need +----------------------------------- + +The need is extracted on the other page, +which renders its content from the need-node cache. + +.. req:: Host with choose content + :id: REQ_HOST + + .. choose:: + + .. when:: var.arch == "xyz" + + SKIPPED_P5B_IN_NEED + + .. when:: var.arch == "abc" + + TAKEN_P5B_IN_NEED + + .. when:: + + SKIPPED_P5B_IN_NEED_DEFAULT + +X1 a whole choose in an included file +------------------------------------- + +The choose and its branches are written in the same (included) file, +which is fine; branches an include supplies to a choose written elsewhere are refused. + +.. include:: included_choose.txt + +X2 an include inside the taken branch +------------------------------------- + +.. choose:: + + .. when:: var.debug + + .. include:: taken_body.txt + + TAKEN_X2_AFTER_INCLUDE + + .. when:: + + SKIPPED_X2_DEFAULT + +TAKEN_X2_AFTER_CHOOSE diff --git a/packages/sphinx-needs/tests/doc_test/doc_match_directive/other.rst b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/other.rst similarity index 100% rename from packages/sphinx-needs/tests/doc_test/doc_match_directive/other.rst rename to packages/sphinx-needs/tests/doc_test/doc_choose_directive/other.rst diff --git a/packages/sphinx-needs/tests/doc_test/doc_match_directive/taken_body.txt b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/taken_body.txt similarity index 54% rename from packages/sphinx-needs/tests/doc_test/doc_match_directive/taken_body.txt rename to packages/sphinx-needs/tests/doc_test/doc_choose_directive/taken_body.txt index 7788e5f3a..218c89d71 100644 --- a/packages/sphinx-needs/tests/doc_test/doc_match_directive/taken_body.txt +++ b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/taken_body.txt @@ -1,4 +1,4 @@ TAKEN_X2_INCLUDED_TEXT -.. req:: Included into the taken case +.. req:: Included into the taken branch :id: REQ_X2_INCLUDED diff --git a/packages/sphinx-needs/tests/doc_test/doc_match_directive/included_match.txt b/packages/sphinx-needs/tests/doc_test/doc_match_directive/included_match.txt deleted file mode 100644 index a7cdbb009..000000000 --- a/packages/sphinx-needs/tests/doc_test/doc_match_directive/included_match.txt +++ /dev/null @@ -1,9 +0,0 @@ -.. match:: - - .. case:: var.arch == "xyz" - - SKIPPED_X1_IN_INCLUDED_MATCH - - .. case:: - - TAKEN_X1_INCLUDED_MATCH_DEFAULT diff --git a/packages/sphinx-needs/tests/doc_test/doc_match_directive/index.rst b/packages/sphinx-needs/tests/doc_test/doc_match_directive/index.rst deleted file mode 100644 index ed8d8f520..000000000 --- a/packages/sphinx-needs/tests/doc_test/doc_match_directive/index.rst +++ /dev/null @@ -1,174 +0,0 @@ -MATCH Test -========== - -.. toctree:: - - other - -P1 first true case wins ------------------------ - -The conditions after the taken case are never evaluated, -so the invalid one and the unknown key cannot warn. - -.. match:: - - .. case:: var.arch == "abc" - - TAKEN_P1_FIRST - - .. case:: var.debug - - SKIPPED_P1_SECOND_TRUE - - .. case:: this is not python !!! - - SKIPPED_P1_INVALID_SYNTAX - - .. case:: var.no_such_key == 1 - - SKIPPED_P1_UNKNOWN_KEY - - .. case:: - - SKIPPED_P1_DEFAULT - -P2 default taken when no case holds ------------------------------------ - -.. match:: - - .. case:: var.arch == "xyz" - - SKIPPED_P2_FALSE - - .. a comment between two cases - - .. case:: - - TAKEN_P2_DEFAULT - -P2b no case holds and there is no default ------------------------------------------ - -.. match:: - - .. case:: var.arch == "xyz" - - SKIPPED_P2B_1 - - .. case:: not var.debug - - SKIPPED_P2B_2 - -TAKEN_P2B_AFTER_MATCH - -P3 needs in cases ------------------ - -.. match:: - - .. case:: var.arch == "xyz" - - .. req:: In a case that is not taken - :id: REQ_P3_SKIPPED - - .. case:: var.arch == "abc" - - .. req:: In the taken case - :id: REQ_P3_TAKEN - - .. case:: - - .. req:: In a default that is not taken - :id: REQ_P3_DEFAULT_SKIPPED - -P4 sections in the taken case ------------------------------ - -.. match:: - - .. case:: var.debug - - P4 conditional heading - ~~~~~~~~~~~~~~~~~~~~~~ - - TAKEN_P4_SECTION_BODY - - .. case:: - - P4 skipped heading - ~~~~~~~~~~~~~~~~~~ - - SKIPPED_P4_BODY - -P5 nested match ---------------- - -.. match:: - - .. case:: var.debug - - TAKEN_P5_OUTER - - .. match:: - - .. case:: var.arch == "xyz" - - SKIPPED_P5_INNER - - .. case:: - - TAKEN_P5_INNER_DEFAULT - - .. case:: - - SKIPPED_P5_OUTER - -P5b match in the content of a need ----------------------------------- - -The need is extracted on the other page, -which renders its content from the need-node cache. - -.. req:: Host with match content - :id: REQ_HOST - - .. match:: - - .. case:: var.arch == "xyz" - - SKIPPED_P5B_IN_NEED - - .. case:: var.arch == "abc" - - TAKEN_P5B_IN_NEED - - .. case:: - - SKIPPED_P5B_IN_NEED_DEFAULT - -X1 a whole match in an included file ------------------------------------- - -The match and its cases are written in the same (included) file, -which is fine; cases an include supplies to a match written elsewhere are refused. - -.. include:: included_match.txt - -X2 an include inside the taken case ------------------------------------ - -.. match:: - - .. case:: var.debug - - .. include:: taken_body.txt - - TAKEN_X2_AFTER_INCLUDE - - .. case:: - - SKIPPED_X2_DEFAULT - -TAKEN_X2_AFTER_MATCH diff --git a/packages/sphinx-needs/tests/test_match_directive.py b/packages/sphinx-needs/tests/test_choose_directive.py similarity index 54% rename from packages/sphinx-needs/tests/test_match_directive.py rename to packages/sphinx-needs/tests/test_choose_directive.py index 4bf885d55..234771c03 100644 --- a/packages/sphinx-needs/tests/test_match_directive.py +++ b/packages/sphinx-needs/tests/test_choose_directive.py @@ -1,4 +1,4 @@ -"""Tests for the ``.. match::`` and ``.. case::`` directives.""" +"""Tests for the ``.. choose::`` and ``.. when::`` directives.""" from __future__ import annotations @@ -10,7 +10,7 @@ from docutils import nodes from sphinx_needs.data import SphinxNeedsData -from sphinx_needs.directives.needmatch import _CasePlaceholder, _MatchBody +from sphinx_needs.directives.needchoose import _BranchPlaceholder, _ChooseBody from sphinx_needs_testkit import assert_no_warnings, build_warnings _NEEDS_TYPES = ( @@ -63,9 +63,9 @@ def _line_of(source: str, text: str) -> int: return lines[0] -def _assert_no_match_nodes(app) -> None: +def _assert_no_choose_nodes(app) -> None: """Neither private node class reaches a pickled doctree or the need-node cache.""" - private = (_CasePlaceholder, _MatchBody) + private = (_BranchPlaceholder, _ChooseBody) for docname in sorted(app.env.found_docs): doctree = app.env.get_doctree(docname) assert [ @@ -98,14 +98,14 @@ def _section_titles(app, docname: str) -> list[list[str]]: @pytest.mark.parametrize( "test_app", - [{"buildername": "html", "srcdir": "doc_test/doc_match_directive"}], + [{"buildername": "html", "srcdir": "doc_test/doc_choose_directive"}], indirect=True, ) -def test_match_directive(test_app): - """First true case wins, the default, needs, sections, nesting and includes. +def test_choose_directive(test_app): + """First true branch wins, the default, needs, sections, nesting and includes. The project builds without a single warning, - although a case after a taken one has a condition that is not Python + although a branch after a taken one has a condition that is not Python and another names an unknown key: neither is ever evaluated. """ app = test_app @@ -116,48 +116,48 @@ def test_match_directive(test_app): taken = [ "TAKEN_P1_FIRST", "TAKEN_P2_DEFAULT", - "TAKEN_P2B_AFTER_MATCH", + "TAKEN_P2B_AFTER_CHOOSE", "TAKEN_P4_SECTION_BODY", "TAKEN_P5_OUTER", "TAKEN_P5_INNER_DEFAULT", "TAKEN_P5B_IN_NEED", - "TAKEN_X1_INCLUDED_MATCH_DEFAULT", + "TAKEN_X1_INCLUDED_CHOOSE_DEFAULT", "TAKEN_X2_INCLUDED_TEXT", "TAKEN_X2_AFTER_INCLUDE", - "TAKEN_X2_AFTER_MATCH", + "TAKEN_X2_AFTER_CHOOSE", ] assert [word for word in taken if word not in html] == [] - # each taken case is rendered once + # each taken branch is rendered once assert [word for word in taken if html.count(f"

{word}

") != 1] == [] assert "SKIPPED_" not in html - # the needs of the cases that are not taken are never created + # the needs of the branches that are not taken are never created needs = SphinxNeedsData(app.env).get_needs_view() assert sorted(needs) == ["REQ_HOST", "REQ_P3_TAKEN", "REQ_X2_INCLUDED"] - # the need of the taken case knows the line it was written on + # the need of the taken branch knows the line it was written on source = Path(app.srcdir, "index.rst").read_text() assert needs["REQ_P3_TAKEN"]["lineno"] == _line_of( - source, " .. req:: In the taken case" + source, " .. req:: In the taken branch" ) - # a heading in the taken case is a section of the document, nested where it stands + # a heading in the taken branch is a section of the document, nested where it stands sections = _section_titles(app, "index") assert [ - "MATCH Test", - "P4 sections in the taken case", + "CHOOSE Test", + "P4 sections in the taken branch", "P4 conditional heading", ] in sections assert not [path for path in sections if "P4 skipped heading" in path] - # the need with a match in its content is extracted on the other page + # the need with a choose in its content is extracted on the other page other = Path(app.outdir, "other.html").read_text() assert "TAKEN_P5B_IN_NEED" in other assert "SKIPPED_" not in other - _assert_no_match_nodes(app) + _assert_no_choose_nodes(app) -# Each mistake warns once, at the line that has it, and skips the whole match +# Each mistake warns once, at the line that has it, and skips the whole choose class _Expected(NamedTuple): @@ -177,291 +177,291 @@ class _Expected(NamedTuple): located_in: str = "index.rst" -_SKIP = "; the whole match is skipped" +_SKIP = "; the whole choose is skipped" _NOT_DIRECT = ( - "'case' directive is not a direct child of its 'match' (it is inside another " + "'when' directive is not a direct child of its 'choose' (it is inside another " "directive)" + _SKIP ) -_INCLUDED_CASE = ( - "'case' supplied through an include is not supported " - "(write the cases in the body of the 'match')" + _SKIP +_INCLUDED_BRANCH = ( + "'when' supplied through an include is not supported " + "(write the branches in the body of the 'choose')" + _SKIP ) -_CASES_TXT = ( - '.. case:: var.arch == "xyz"\n\n SKIPPED_X1_FROM_INCLUDE\n\n' - ".. case::\n\n SKIPPED_X1_DEFAULT_FROM_INCLUDE\n" +_BRANCHES_TXT = ( + '.. when:: var.arch == "xyz"\n\n SKIPPED_X1_FROM_INCLUDE\n\n' + ".. when::\n\n SKIPPED_X1_DEFAULT_FROM_INCLUDE\n" ) _WARNINGS = { "default not last": _Expected( - ".. match::\n\n" - " .. case::\n\n SKIPPED_DEFAULT\n\n" - " .. case:: True\n\n SKIPPED_TRUE\n", + ".. choose::\n\n" + " .. when::\n\n SKIPPED_DEFAULT\n\n" + " .. when:: True\n\n SKIPPED_TRUE\n", ( ( - "'match' directive has a default 'case' (a 'case' with no condition) " - "that is not its last 'case'" + _SKIP, - " .. case::", + "'choose' directive has a default 'when' (a 'when' with no condition) " + "that is not its last 'when'" + _SKIP, + " .. when::", ), ), ), "two defaults": _Expected( - ".. match::\n\n" - " .. case:: False\n\n SKIPPED_FALSE\n\n" - " .. case::\n\n SKIPPED_D1\n\n" + ".. choose::\n\n" + " .. when:: False\n\n SKIPPED_FALSE\n\n" + " .. when::\n\n SKIPPED_D1\n\n" # the second default is refused (its directive line ends in spaces, which the # parser strips: it is a default like the first) - " .. case:: \n\n SKIPPED_D2\n", + " .. when:: \n\n SKIPPED_D2\n", ( ( - "'match' directive has more than one default 'case' (a 'case' with " + "'choose' directive has more than one default 'when' (a 'when' with " "no condition)" + _SKIP, - " .. case:: ", + " .. when:: ", ), ), ), "paragraph in the body": _Expected( - ".. match::\n\n" - " .. case:: True\n\n SKIPPED_CASE\n\n" + ".. choose::\n\n" + " .. when:: True\n\n SKIPPED_BRANCH\n\n" " A stray paragraph.\n", ( ( - "'match' directive may contain only 'case' directives and comments, " + "'choose' directive may contain only 'when' directives and comments, " "got " + _SKIP, " A stray paragraph.", ), ), ), - # exactly one warning: the case inside the note is not a stray, it is in a match body - "note wrapping a case": _Expected( - ".. match::\n\n" - " .. note::\n\n .. case:: True\n\n SKIPPED_IN_NOTE\n", + # exactly one warning: the branch inside the note is not a stray, it is in a choose body + "note wrapping a branch": _Expected( + ".. choose::\n\n" + " .. note::\n\n .. when:: True\n\n SKIPPED_IN_NOTE\n", (("got " + _SKIP, " .. note::"),), ), # a directive that returns the nodes of its content (a true `if`, `rst-class`) - # would hand its cases to the match: a case must be written directly in it - "cases inside a true if": _Expected( - ".. match::\n\n" + # would hand its branches to the choose: a branch must be written directly in it + "branches inside a true if": _Expected( + ".. choose::\n\n" " .. if:: var.debug\n\n" - " .. case:: var.arch == 'x86'\n\n SKIPPED_X86\n\n" - " .. case::\n\n SKIPPED_DEFAULT_FROM_IF\n", - ((_NOT_DIRECT, " .. case:: var.arch == 'x86'"),), + " .. when:: var.arch == 'x86'\n\n SKIPPED_X86\n\n" + " .. when::\n\n SKIPPED_DEFAULT_FROM_IF\n", + ((_NOT_DIRECT, " .. when:: var.arch == 'x86'"),), ), - "case inside rst-class": _Expected( - ".. match::\n\n" + "branch inside rst-class": _Expected( + ".. choose::\n\n" " .. rst-class:: special\n\n" - " .. case:: var.arch == 'abc'\n\n SKIPPED_FROM_RST_CLASS\n", - ((_NOT_DIRECT, " .. case:: var.arch == 'abc'"),), + " .. when:: var.arch == 'abc'\n\n SKIPPED_FROM_RST_CLASS\n", + ((_NOT_DIRECT, " .. when:: var.arch == 'abc'"),), ), # a line of one to three punctuation characters makes docutils emit an INFO # message, which is never shown, before the paragraph: the paragraph is reported - "rule line between cases": _Expected( - ".. match::\n\n" - " .. case:: var.arch == 'x86'\n\n SKIPPED_X86\n\n" + "rule line between branches": _Expected( + ".. choose::\n\n" + " .. when:: var.arch == 'x86'\n\n SKIPPED_X86\n\n" " ---\n\n" - " .. case::\n\n SKIPPED_DEFAULT\n", + " .. when::\n\n SKIPPED_DEFAULT\n", (("got " + _SKIP, " ---"),), ), "three dots in the body": _Expected( - ".. match::\n\n ...\n\n .. case::\n\n SKIPPED_DEFAULT\n", + ".. choose::\n\n ...\n\n .. when::\n\n SKIPPED_DEFAULT\n", (("got " + _SKIP, " ..."),), ), - "case outside a match": _Expected( - "Para.\n\n.. case:: True\n\n SKIPPED_STRAY\n", + "branch outside a choose": _Expected( + "Para.\n\n.. when:: True\n\n SKIPPED_STRAY\n", ( ( - "'case' directive outside a 'match' (a 'case' must be a direct child " - "of a 'match'); its content is skipped", - ".. case:: True", + "'when' directive outside a 'choose' (a 'when' must be a direct child " + "of a 'choose'); its content is skipped", + ".. when:: True", ), ), ), - # the counterpart of an orphan `else`: a default case outside every match - "default case outside a match": _Expected( - "Para.\n\n.. case::\n\n SKIPPED_STRAY_DEFAULT\n", - (("'case' directive outside a 'match'", ".. case::"),), + # the counterpart of an orphan `else`: a default branch outside every choose + "default branch outside a choose": _Expected( + "Para.\n\n.. when::\n\n SKIPPED_STRAY_DEFAULT\n", + (("'when' directive outside a 'choose'", ".. when::"),), ), - "case loose in the taken case": _Expected( - ".. match::\n\n" - " .. case:: True\n\n TAKEN_OUTER\n\n" - " .. case:: True\n\n SKIPPED_LOOSE\n", - (("'case' directive outside a 'match'", " .. case:: True"),), + "branch loose in the taken branch": _Expected( + ".. choose::\n\n" + " .. when:: True\n\n TAKEN_OUTER\n\n" + " .. when:: True\n\n SKIPPED_LOOSE\n", + (("'when' directive outside a 'choose'", " .. when:: True"),), taken=("TAKEN_OUTER",), ), - # two independent mistakes, two warnings: a match written directly in another - # match's body, whose taken case holds a loose case. The taken case is parsed - # outside every match body, so the loose case is reported and cannot become a case - # of the OUTER match, which then has none. - "case loose in the taken case of a misplaced match": _Expected( - ".. match::\n\n" - " .. match::\n\n" - " .. case:: True\n\n" - " .. case:: True\n\n SKIPPED_LOOSE\n", + # two independent mistakes, two warnings: a choose written directly in another + # choose's body, whose taken branch holds a loose branch. The taken branch is parsed + # outside every choose body, so the loose branch is reported and cannot become a branch + # of the OUTER choose, which then has none. + "branch loose in the taken branch of a misplaced choose": _Expected( + ".. choose::\n\n" + " .. choose::\n\n" + " .. when:: True\n\n" + " .. when:: True\n\n SKIPPED_LOOSE\n", ( - ("'case' directive outside a 'match'", " .. case:: True"), - ("'match' directive has no 'case'", ".. match::"), + ("'when' directive outside a 'choose'", " .. when:: True"), + ("'choose' directive has no 'when'", ".. choose::"), ), ), - "unevaluable first case poisons the default": _Expected( - ".. match::\n\n" - " .. case:: this is not python !!!\n\n SKIPPED_1\n\n" - " .. case:: True\n\n SKIPPED_2\n\n" - " .. case::\n\n SKIPPED_DEFAULT\n", + "unevaluable first branch poisons the default": _Expected( + ".. choose::\n\n" + " .. when:: this is not python !!!\n\n SKIPPED_1\n\n" + " .. when:: True\n\n SKIPPED_2\n\n" + " .. when::\n\n SKIPPED_DEFAULT\n", ( ( - "'case' directive expression failed: 'this is not python !!!' — ", - " .. case:: this is not python !!!", + "'when' directive expression failed: 'this is not python !!!' — ", + " .. when:: this is not python !!!", ), ), ), - "unknown key in a later case": _Expected( - ".. match::\n\n" - " .. case:: var.arch == 'xyz'\n\n SKIPPED_1\n\n" - " .. case:: var.no_such_key == 1\n\n SKIPPED_2\n\n" - " .. case::\n\n SKIPPED_DEFAULT\n", + "unknown key in a later branch": _Expected( + ".. choose::\n\n" + " .. when:: var.arch == 'xyz'\n\n SKIPPED_1\n\n" + " .. when:: var.no_such_key == 1\n\n SKIPPED_2\n\n" + " .. when::\n\n SKIPPED_DEFAULT\n", ( ( - "'case' directive expression failed: 'var.no_such_key == 1' — " + "'when' directive expression failed: 'var.no_such_key == 1' — " "Unknown variant key: var.no_such_key", - " .. case:: var.no_such_key == 1", + " .. when:: var.no_such_key == 1", ), ), ), "builtins blocked": _Expected( - ".. match::\n\n" - " .. case:: __import__('os').system('echo pwned')\n\n SKIPPED\n\n" - " .. case::\n\n SKIPPED_DEFAULT\n", + ".. choose::\n\n" + " .. when:: __import__('os').system('echo pwned')\n\n SKIPPED\n\n" + " .. when::\n\n SKIPPED_DEFAULT\n", ( ( - "'case' directive expression failed: " + "'when' directive expression failed: " "\"__import__('os').system('echo pwned')\" — " "name '__import__' is not defined", - " .. case:: __import__('os').system('echo pwned')", + " .. when:: __import__('os').system('echo pwned')", ), ), ), "non-bool is coerced and taken": _Expected( - ".. match::\n\n" - " .. case:: var.count\n\n TAKEN_NONBOOL\n\n" - " .. case::\n\n SKIPPED_DEFAULT\n", + ".. choose::\n\n" + " .. when:: var.count\n\n TAKEN_NONBOOL\n\n" + " .. when::\n\n SKIPPED_DEFAULT\n", ( ( - "'case' directive expression did not return a bool, got int: 5 " + "'when' directive expression did not return a bool, got int: 5 " "(coercing to bool): 'var.count'", - " .. case:: var.count", + " .. when:: var.count", ), ), taken=("TAKEN_NONBOOL",), ), "empty string condition": _Expected( - '.. match::\n\n .. case:: ""\n\n SKIPPED_EMPTY\n\n' - " .. case::\n\n TAKEN_DEFAULT\n", + '.. choose::\n\n .. when:: ""\n\n SKIPPED_EMPTY\n\n' + " .. when::\n\n TAKEN_DEFAULT\n", ( ( - "'case' directive expression did not return a bool, got str: '' " + "'when' directive expression did not return a bool, got str: '' " "(coercing to bool): '\"\"'", - ' .. case:: ""', + ' .. when:: ""', ), ), taken=("TAKEN_DEFAULT",), ), - "match with an argument": _Expected( - ".. match:: var.arch\n\n .. case:: True\n\n SKIPPED\n", + "choose with an argument": _Expected( + ".. choose:: var.arch\n\n .. when:: True\n\n SKIPPED\n", ( ( - "'match' directive takes no argument, got 'var.arch' " - "(write a condition on each 'case')" + _SKIP, - ".. match:: var.arch", + "'choose' directive takes no argument, got 'var.arch' " + "(write a condition on each 'when')" + _SKIP, + ".. choose:: var.arch", ), ), ), - "empty match": _Expected( - ".. match::\n\nTAKEN_AFTER_EMPTY\n", - (("'match' directive has no 'case'", ".. match::"),), + "empty choose": _Expected( + ".. choose::\n\nTAKEN_AFTER_EMPTY\n", + (("'choose' directive has no 'when'", ".. choose::"),), taken=("TAKEN_AFTER_EMPTY",), ), "only comments": _Expected( - ".. match::\n\n .. just a comment\n\n .. and another\n", - (("'match' directive has no 'case'", ".. match::"),), + ".. choose::\n\n .. just a comment\n\n .. and another\n", + (("'choose' directive has no 'when'", ".. choose::"),), ), "variant data not configured": _Expected( - ".. match::\n\n" - " .. case:: var.arch == 'abc'\n\n SKIPPED_1\n\n" - " .. case::\n\n SKIPPED_DEFAULT\n", + ".. choose::\n\n" + " .. when:: var.arch == 'abc'\n\n SKIPPED_1\n\n" + " .. when::\n\n SKIPPED_DEFAULT\n", ( ( - "'match' directive used but needs_variant_data is not configured" + "'choose' directive used but needs_variant_data is not configured" + _SKIP, - ".. match::", + ".. choose::", ), ), conf=_CONF_NO_VARIANT_DATA, ), - # nothing is evaluated here, and still the match warns and renders nothing: - # the "used but not configured" rule holds for every match + # nothing is evaluated here, and still the choose warns and renders nothing: + # the "used but not configured" rule holds for every choose "variant data not configured, only a default": _Expected( - ".. match::\n\n .. case::\n\n SKIPPED_DEFAULT_ONLY\n", + ".. choose::\n\n .. when::\n\n SKIPPED_DEFAULT_ONLY\n", ( ( - "'match' directive used but needs_variant_data is not configured" + "'choose' directive used but needs_variant_data is not configured" + _SKIP, - ".. match::", + ".. choose::", ), ), conf=_CONF_NO_VARIANT_DATA, ), - # the structure is checked before the configuration: a match that is wrong in both - # ways gets the one structural warning, at the case, and its body is still parsed + # the structure is checked before the configuration: a choose that is wrong in both + # ways gets the one structural warning, at the branch, and its body is still parsed "variant data not configured, and a misplaced default": _Expected( - ".. match::\n\n" - " .. case::\n\n SKIPPED_DEFAULT\n\n" - " .. case:: var.arch == 'abc'\n\n SKIPPED_ABC\n", + ".. choose::\n\n" + " .. when::\n\n SKIPPED_DEFAULT\n\n" + " .. when:: var.arch == 'abc'\n\n SKIPPED_ABC\n", ( ( - "'match' directive has a default 'case' (a 'case' with no condition) " - "that is not its last 'case'" + _SKIP, - " .. case::", + "'choose' directive has a default 'when' (a 'when' with no condition) " + "that is not its last 'when'" + _SKIP, + " .. when::", ), ), conf=_CONF_NO_VARIANT_DATA, ), - # the cases of a match are written in its body: an include may not supply them, - # and the warning points at the case in the included file - "cases from an include": _Expected( - ".. match::\n\n .. include:: cases.txt\n", - ((_INCLUDED_CASE, '.. case:: var.arch == "xyz"'),), - extra=(("cases.txt", _CASES_TXT),), - located_in="cases.txt", + # the branches of a choose are written in its body: an include may not supply them, + # and the warning points at the branch in the included file + "branches from an include": _Expected( + ".. choose::\n\n .. include:: branches.txt\n", + ((_INCLUDED_BRANCH, '.. when:: var.arch == "xyz"'),), + extra=(("branches.txt", _BRANCHES_TXT),), + located_in="branches.txt", ), - # the structure is checked before any condition: a true case written in place + # the structure is checked before any condition: a true branch written in place # before the included ones is not taken either - "a case from an include after a true case": _Expected( - ".. match::\n\n" - " .. case:: True\n\n SKIPPED_IN_PLACE\n\n" - " .. include:: cases.txt\n", - ((_INCLUDED_CASE, '.. case:: var.arch == "xyz"'),), - extra=(("cases.txt", _CASES_TXT),), - located_in="cases.txt", + "a branch from an include after a true branch": _Expected( + ".. choose::\n\n" + " .. when:: True\n\n SKIPPED_IN_PLACE\n\n" + " .. include:: branches.txt\n", + ((_INCLUDED_BRANCH, '.. when:: var.arch == "xyz"'),), + extra=(("branches.txt", _BRANCHES_TXT),), + located_in="branches.txt", ), - # content outside a case is parsed with the body, so the need directive runs; - # the match removes the need again + # content outside a branch is parsed with the body, so the need directive runs; + # the choose removes the need again "need directly in the body": _Expected( - ".. match::\n\n" - " .. req:: Directly in the match body\n :id: REQ_DIRECT\n\n" - " .. case:: True\n\n SKIPPED\n", + ".. choose::\n\n" + " .. req:: Directly in the choose body\n :id: REQ_DIRECT\n\n" + " .. when:: True\n\n SKIPPED\n", # named after the need, not the target without a line that it emits first - (("got " + _SKIP, " .. req:: Directly in the match body"),), + (("got " + _SKIP, " .. req:: Directly in the choose body"),), ), - # a case that is not taken is never parsed, exactly as the body of a false `if` - "errors in an untaken case are never reported": _Expected( - ".. match::\n\n" - " .. case:: True\n\n TAKEN_E\n\n" - " .. req:: In the taken case\n :id: REQ_TAKEN\n\n" - " .. case:: False\n\n" - " .. match::\n\n" - " .. case:: invalid !!!\n\n SKIPPED\n\n" + # a branch that is not taken is never parsed, exactly as the body of a false `if` + "errors in an untaken branch are never reported": _Expected( + ".. choose::\n\n" + " .. when:: True\n\n TAKEN_E\n\n" + " .. req:: In the taken branch\n :id: REQ_TAKEN\n\n" + " .. when:: False\n\n" + " .. choose::\n\n" + " .. when:: invalid !!!\n\n SKIPPED\n\n" " .. nosuchdirective::\n\n" - " .. req:: In the case that is not taken\n :id: REQ_SKIPPED\n", + " .. req:: In the branch that is not taken\n :id: REQ_SKIPPED\n", (), taken=("TAKEN_E",), needs=("REQ_TAKEN",), @@ -472,13 +472,13 @@ class _Expected(NamedTuple): @pytest.mark.parametrize( ("test_app", "expected"), [ - (_project(case.body, conf=case.conf, extra=case.extra), case) - for case in _WARNINGS.values() + (_project(row.body, conf=row.conf, extra=row.extra), row) + for row in _WARNINGS.values() ], ids=list(_WARNINGS), indirect=["test_app"], ) -def test_match_warnings(test_app, expected: _Expected): +def test_choose_warnings(test_app, expected: _Expected): """Each mistake warns exactly once, at the offending line, and fails closed.""" app = test_app app.build() @@ -490,33 +490,33 @@ def test_match_warnings(test_app, expected: _Expected): f"/{expected.located_in}:{_line_of(source, line)}: WARNING: " ), warning assert text in warning, warning - assert warning.endswith(" [needs.match]"), warning + assert warning.endswith(" [needs.choose]"), warning html = Path(app.outdir, "index.html").read_text() assert [word for word in expected.taken if word not in html] == [] assert "SKIPPED" not in html assert sorted(SphinxNeedsData(app.env).get_needs_view()) == list(expected.needs) - _assert_no_match_nodes(app) + _assert_no_choose_nodes(app) @pytest.mark.parametrize( "test_app", [ _project( - ".. match::\n\n" - " .. case:: var.arch == 'x86'\n\n SKIPPED_X86\n\n" + ".. choose::\n\n" + " .. when:: var.arch == 'x86'\n\n SKIPPED_X86\n\n" " ---\n\n" - " .. case::\n\n SKIPPED_DEFAULT\n", + " .. when::\n\n SKIPPED_DEFAULT\n", extra=(("docutils.conf", "[general]\nreport_level: 1\n"),), ) ], indirect=True, ) -def test_match_info_message_is_never_a_reported_error(test_app, monkeypatch): +def test_choose_info_message_is_never_a_reported_error(test_app, monkeypatch): """An INFO message in the body is not taken for an error docutils reported. With ``report_level: 1`` in the project's ``docutils.conf`` the INFO before the paragraph of a ``---`` line is shown, but as information, not as a warning, - so the paragraph must still be reported: otherwise the match would vanish + so the paragraph must still be reported: otherwise the choose would vanish with ``-W`` green. ``sphinx-build`` points ``DOCUTILSCONFIG`` at the project's ``docutils.conf``; this in-process build does it by hand. """ @@ -529,7 +529,7 @@ def test_match_info_message_is_never_a_reported_error(test_app, monkeypatch): f"/index.rst:{_line_of(source, ' ---')}: WARNING: " ), warning assert "got " + _SKIP in warning, warning - assert warning.endswith(" [needs.match]"), warning + assert warning.endswith(" [needs.choose]"), warning assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() # the INFO itself was shown, so the setting took effect assert "Unexpected possible title overline or transition" in app._status.getvalue() @@ -540,17 +540,17 @@ def test_match_info_message_is_never_a_reported_error(test_app, monkeypatch): [ ( _project( - ".. match::\n\n" + ".. choose::\n\n" " .. cas:: True\n\n SKIPPED\n\n" - " .. case::\n\n SKIPPED_DEFAULT\n" + " .. when::\n\n SKIPPED_DEFAULT\n" ), 'Unknown directive type "cas"', ), ( _project( - ".. match::\n\n" + ".. choose::\n\n" " Title\n -----\n\n" - " .. case:: True\n\n SKIPPED\n" + " .. when:: True\n\n SKIPPED\n" ), "Unexpected section title", ), @@ -558,13 +558,13 @@ def test_match_info_message_is_never_a_reported_error(test_app, monkeypatch): ids=["typo in a directive name", "section title in the body"], indirect=["test_app"], ) -def test_match_body_error_reported_once(test_app, error: str): - """A mistake docutils reports in the body skips the match without a second warning.""" +def test_choose_body_error_reported_once(test_app, error: str): + """A mistake docutils reports in the body skips the choose without a second warning.""" app = test_app app.build() (warning,) = build_warnings(app) assert error in warning - assert "needs.match" not in warning + assert "needs.choose" not in warning html = Path(app.outdir, "index.html").read_text() assert "SKIPPED" not in html @@ -573,14 +573,14 @@ def test_match_body_error_reported_once(test_app, error: str): "test_app", [ _project( - "Para.\n\n.. case:: True\n\n SKIPPED_STRAY\n", - conf=_CONF + "suppress_warnings = ['needs.match']\n", + "Para.\n\n.. when:: True\n\n SKIPPED_STRAY\n", + conf=_CONF + "suppress_warnings = ['needs.choose']\n", ) ], indirect=True, ) -def test_match_warnings_are_suppressible(test_app): - """Every ``match`` / ``case`` warning is of the ``needs.match`` type.""" +def test_choose_warnings_are_suppressible(test_app): + """Every ``choose`` / ``when`` warning is of the ``needs.choose`` type.""" app = test_app app.build() assert_no_warnings(app) @@ -591,11 +591,11 @@ def test_match_warnings_are_suppressible(test_app): "test_app", [ _project( - ".. req:: Written before the match\n :id: REQ_BEFORE\n\n" - ".. match::\n\n" - " .. req:: Directly in the match body\n :id: REQ_STRAY\n\n" - " .. case::\n\n SKIPPED_DEFAULT\n\n" - ".. req:: Written after the match\n :id: REQ_AFTER\n", + ".. req:: Written before the choose\n :id: REQ_BEFORE\n\n" + ".. choose::\n\n" + " .. req:: Directly in the choose body\n :id: REQ_STRAY\n\n" + " .. when::\n\n SKIPPED_DEFAULT\n\n" + ".. req:: Written after the choose\n :id: REQ_AFTER\n", # read before `index`, so its need is older than every need of `index` extra=( ( @@ -608,17 +608,17 @@ def test_match_warnings_are_suppressible(test_app): ], indirect=True, ) -def test_match_rollback_removes_only_the_stray_needs(test_app): +def test_choose_rollback_removes_only_the_stray_needs(test_app): """The rollback removes the needs the body created, the newest ones, and no other. - The needs written before the ``match``, in its own document and in an earlier one, + The needs written before the ``choose``, in its own document and in an earlier one, are older entries of the same mapping, and must survive. """ app = test_app app.build() (warning,) = build_warnings(app) source = Path(app.srcdir, "index.rst").read_text() - line = _line_of(source, " .. req:: Directly in the match body") + line = _line_of(source, " .. req:: Directly in the choose body") assert warning.startswith(f"/index.rst:{line}: WARNING: "), warning assert "got " in warning needs = SphinxNeedsData(app.env).get_needs_view() @@ -661,35 +661,35 @@ def setup(app): [ _project( ".. swallow::\n\n" - " .. match::\n\n" - " .. case::\n\n SKIPPED_X\n\n" + " .. choose::\n\n" + " .. when::\n\n SKIPPED_X\n\n" " .. boom::\n\n" - ".. case:: True\n\n SKIPPED_LOOSE_AFTER\n", + ".. when:: True\n\n SKIPPED_LOOSE_AFTER\n", conf=_SWALLOW_CONF, ) ], indirect=True, ) -def test_match_restores_its_depth_when_its_body_raises(test_app): - """An exception out of a ``match`` body leaves no ``match`` open behind it. +def test_choose_restores_its_depth_when_its_body_raises(test_app): + """An exception out of a ``choose`` body leaves no ``choose`` open behind it. A directive of the project catches what a directive in the body raised; - the ``case`` after it is outside every ``match`` and must still be reported, + the ``when`` after it is outside every ``choose`` and must still be reported, rather than collected as a placeholder that would reach the writer. """ app = test_app app.build() (warning,) = build_warnings(app) source = Path(app.srcdir, "index.rst").read_text() - line = _line_of(source, ".. case:: True") + line = _line_of(source, ".. when:: True") assert warning.startswith(f"/index.rst:{line}: WARNING: "), warning - assert "'case' directive outside a 'match'" in warning + assert "'when' directive outside a 'choose'" in warning html = Path(app.outdir, "index.html").read_text() assert "SWALLOWED" in html assert "SKIPPED" not in html -# One condition language: `case` evaluates exactly what `if` does +# One condition language: `when` evaluates exactly what `if` does _EXPRESSIONS = { # expression: whether it is true (None: it cannot be evaluated) @@ -724,8 +724,8 @@ def _strip_location_and_type(warning: str) -> str: ( _project( f".. if:: {expression}\n\n IF_TAKEN\n\n" - ".. match::\n\n" - f" .. case:: {expression}\n\n CASE_TAKEN\n" + ".. choose::\n\n" + f" .. when:: {expression}\n\n WHEN_TAKEN\n" ), expression, verdict, @@ -735,8 +735,8 @@ def _strip_location_and_type(warning: str) -> str: ids=list(_EXPRESSIONS), indirect=["test_app"], ) -def test_case_conditions_are_if_conditions(test_app, expression: str, verdict): - """``if`` and ``case`` give every condition the same verdict and the same warnings. +def test_when_conditions_are_if_conditions(test_app, expression: str, verdict): + """``if`` and ``when`` give every condition the same verdict and the same warnings. Only the directive name and the warning type differ, because both directives go through one evaluator. @@ -745,27 +745,27 @@ def test_case_conditions_are_if_conditions(test_app, expression: str, verdict): app.build() html = Path(app.outdir, "index.html").read_text() assert ("IF_TAKEN" in html) is bool(verdict) - assert ("CASE_TAKEN" in html) is bool(verdict) + assert ("WHEN_TAKEN" in html) is bool(verdict) warnings = build_warnings(app) if_warnings = [w for w in warnings if w.endswith(" [needs.if]")] - case_warnings = [w for w in warnings if w.endswith(" [needs.match]")] - assert len(if_warnings) + len(case_warnings) == len(warnings), warnings - assert len(if_warnings) == len(case_warnings), warnings + when_warnings = [w for w in warnings if w.endswith(" [needs.choose]")] + assert len(if_warnings) + len(when_warnings) == len(warnings), warnings + assert len(if_warnings) == len(when_warnings), warnings # an unevaluable condition warns, and so does a result that is not a bool assert bool(if_warnings) is (verdict is None or expression in _NON_BOOL) source = Path(app.srcdir, "index.rst").read_text() - for if_warning, case_warning in zip(if_warnings, case_warnings, strict=True): + for if_warning, when_warning in zip(if_warnings, when_warnings, strict=True): assert if_warning.startswith( f"/index.rst:{_line_of(source, f'.. if:: {expression}')}: " ), if_warning - assert case_warning.startswith( - f"/index.rst:{_line_of(source, f' .. case:: {expression}')}: " - ), case_warning + assert when_warning.startswith( + f"/index.rst:{_line_of(source, f' .. when:: {expression}')}: " + ), when_warning assert _strip_location_and_type(if_warning).startswith("'if' directive ") - assert _strip_location_and_type(case_warning) == _strip_location_and_type( + assert _strip_location_and_type(when_warning) == _strip_location_and_type( if_warning - ).replace("'if' directive ", "'case' directive ", 1) + ).replace("'if' directive ", "'when' directive ", 1) # MyST Markdown @@ -777,123 +777,123 @@ def test_case_conditions_are_if_conditions(test_app, expression: str, verdict): ## Colon fences -::::{match} -:::{case} var.arch == "abc" +::::{choose} +:::{when} var.arch == "abc" TAKEN_M1_FIRST ::: -:::{case} var.debug +:::{when} var.debug SKIPPED_M1_SECOND_TRUE ::: -:::{case} this is not python !!! +:::{when} this is not python !!! SKIPPED_M1_INVALID_SYNTAX ::: -:::{case} +:::{when} SKIPPED_M1_DEFAULT ::: :::: -## Comments between cases +## Comments between branches -::::{match} -:::{case} var.arch == "xyz" +::::{choose} +:::{when} var.arch == "xyz" SKIPPED_M2_FALSE ::: -% a MyST comment between two cases +% a MyST comment between two branches +++ -:::{case} +:::{when} TAKEN_M2_DEFAULT ::: :::: -## Backtick fences, and needs in cases +## Backtick fences, and needs in branches -`````{match} -````{case} var.arch == "xyz" -```{req} In a case that is not taken +`````{choose} +````{when} var.arch == "xyz" +```{req} In a branch that is not taken :id: REQ_M3_SKIPPED ``` ```` -````{case} var.arch == "abc" +````{when} var.arch == "abc" TAKEN_M3 -```{req} In the taken case +```{req} In the taken branch :id: REQ_M3_TAKEN ``` ```` ````` -## A section in the taken case +## A section in the taken branch -::::{match} -:::{case} var.debug +::::{choose} +:::{when} var.debug ### M4 conditional heading TAKEN_M4_SECTION_BODY ::: :::: -## Nested match, one more fence character per level +## Nested choose, one more fence character per level -::::::{match} -:::::{case} var.debug +::::::{choose} +:::::{when} var.debug TAKEN_M5_OUTER -::::{match} -:::{case} var.arch == "xyz" +::::{choose} +:::{when} var.arch == "xyz" SKIPPED_M5_INNER ::: -:::{case} +:::{when} TAKEN_M5_INNER_DEFAULT ::: :::: ::::: -:::::{case} +:::::{when} SKIPPED_M5_OUTER ::::: :::::: ## A default whose fence line ends in spaces is a default -::::{match} -:::{case} False +::::{choose} +:::{when} False SKIPPED_M6 ::: -:::{case}\x20\x20\x20 +:::{when}\x20\x20\x20 TAKEN_M6_DEFAULT ::: :::: -## Match in the content of a need +## Choose in the content of a need -:::::{req} Host with match content +:::::{req} Host with choose content :id: REQ_M_HOST -::::{match} -:::{case} var.arch == "xyz" +::::{choose} +:::{when} var.arch == "xyz" SKIPPED_M7_IN_NEED ::: -:::{case} var.arch == "abc" +:::{when} var.arch == "abc" TAKEN_M7_IN_NEED ::: :::: ::::: -## A whole match in an included file +## A whole choose in an included file -```{include} included_match.txt +```{include} included_choose.txt ``` """ -_MYST_INCLUDED_MATCH = """\ -::::{match} -:::{case} var.arch == "xyz" -SKIPPED_M8_IN_INCLUDED_MATCH +_MYST_INCLUDED_CHOOSE = """\ +::::{choose} +:::{when} var.arch == "xyz" +SKIPPED_M8_IN_INCLUDED_CHOOSE ::: -:::{case} -TAKEN_M8_INCLUDED_MATCH_DEFAULT +:::{when} +TAKEN_M8_INCLUDED_CHOOSE_DEFAULT ::: :::: """ @@ -908,12 +908,12 @@ def test_case_conditions_are_if_conditions(test_app, expression: str, verdict): conf=_CONF_MYST, myst=True, other="# Other\n\n```{needextract}\n:filter: id == 'REQ_M_HOST'\n```\n", - extra=(("included_match.txt", _MYST_INCLUDED_MATCH),), + extra=(("included_choose.txt", _MYST_INCLUDED_CHOOSE),), ) ], indirect=True, ) -def test_match_in_myst(test_app): +def test_choose_in_myst(test_app): """Colon and backtick fences, comments, needs, sections, nesting, needextract.""" app = test_app app.build() @@ -929,7 +929,7 @@ def test_match_in_myst(test_app): "TAKEN_M5_INNER_DEFAULT", "TAKEN_M6_DEFAULT", "TAKEN_M7_IN_NEED", - "TAKEN_M8_INCLUDED_MATCH_DEFAULT", + "TAKEN_M8_INCLUDED_CHOOSE_DEFAULT", ] assert [word for word in taken if word not in html] == [] assert "SKIPPED_" not in html @@ -937,15 +937,15 @@ def test_match_in_myst(test_app): needs = SphinxNeedsData(app.env).get_needs_view() assert sorted(needs) == ["REQ_M3_TAKEN", "REQ_M_HOST"] # MyST gives a directive a content offset relative to its own line, which the - # match re-bases for the case it takes: the need knows its true line + # choose re-bases for the branch it takes: the need knows its true line source = Path(app.srcdir, "index.md").read_text() assert needs["REQ_M3_TAKEN"]["lineno"] == _line_of( - source, "```{req} In the taken case" + source, "```{req} In the taken branch" ) assert [ "Test", - "A section in the taken case", + "A section in the taken branch", "M4 conditional heading", ] in _section_titles(app, "index") @@ -953,88 +953,88 @@ def test_match_in_myst(test_app): assert "TAKEN_M7_IN_NEED" in other assert "SKIPPED_" not in other - _assert_no_match_nodes(app) + _assert_no_choose_nodes(app) # MyST reports a directive nested in a colon fence one line late (its own quirk, the # same for a `{note}` in a `{note}`), so only the backtick spellings assert a line _MYST_WARNINGS = { - "case outside a match, backticks": ( - "Para.\n\n```{case} True\nSKIPPED_STRAY\n```\n", - "'case' directive outside a 'match'", - "```{case} True", + "branch outside a choose, backticks": ( + "Para.\n\n```{when} True\nSKIPPED_STRAY\n```\n", + "'when' directive outside a 'choose'", + "```{when} True", ), - "default case outside a match, backticks": ( - "Para.\n\n```{case}\nSKIPPED_STRAY_DEFAULT\n```\n", - "'case' directive outside a 'match'", - "```{case}", + "default branch outside a choose, backticks": ( + "Para.\n\n```{when}\nSKIPPED_STRAY_DEFAULT\n```\n", + "'when' directive outside a 'choose'", + "```{when}", ), - "case outside a match, colons": ( - "Para.\n\n:::{case} True\nSKIPPED_STRAY\n:::\n", - "'case' directive outside a 'match'", + "branch outside a choose, colons": ( + "Para.\n\n:::{when} True\nSKIPPED_STRAY\n:::\n", + "'when' directive outside a 'choose'", None, ), - "case loose in the taken case, backticks": ( - "`````{match}\n````{case} True\nTAKEN_OUTER\n\n" - "```{case} True\nSKIPPED_LOOSE\n```\n````\n`````\n", - "'case' directive outside a 'match'", - "```{case} True", + "branch loose in the taken branch, backticks": ( + "`````{choose}\n````{when} True\nTAKEN_OUTER\n\n" + "```{when} True\nSKIPPED_LOOSE\n```\n````\n`````\n", + "'when' directive outside a 'choose'", + "```{when} True", ), "paragraph in the body, backticks": ( - "````{match}\n```{case} True\nSKIPPED\n```\n\nA stray paragraph.\n````\n", + "````{choose}\n```{when} True\nSKIPPED\n```\n\nA stray paragraph.\n````\n", "got " + _SKIP, "A stray paragraph.", ), "paragraph in the body, colons": ( - "::::{match}\n:::{case} True\nSKIPPED\n:::\n\nA stray paragraph.\n::::\n", + "::::{choose}\n:::{when} True\nSKIPPED\n:::\n\nA stray paragraph.\n::::\n", "got " + _SKIP, None, ), # an HTML comment is raw HTML, not a comment - "html comment between cases, backticks": ( - "````{match}\n```{case} False\nSKIPPED\n```\n\n\n\n" - "```{case}\nSKIPPED_DEFAULT\n```\n````\n", + "html comment between branches, backticks": ( + "````{choose}\n```{when} False\nSKIPPED\n```\n\n\n\n" + "```{when}\nSKIPPED_DEFAULT\n```\n````\n", "got " + _SKIP, "", ), - "html comment between cases, colons": ( - "::::{match}\n:::{case} False\nSKIPPED\n:::\n\n\n\n" - ":::{case}\nSKIPPED_DEFAULT\n:::\n::::\n", + "html comment between branches, colons": ( + "::::{choose}\n:::{when} False\nSKIPPED\n:::\n\n\n\n" + ":::{when}\nSKIPPED_DEFAULT\n:::\n::::\n", "got " + _SKIP, None, ), - "match with an argument, backticks": ( - "````{match} var.arch\n```{case} True\nSKIPPED\n```\n````\n", - "'match' directive takes no argument, got 'var.arch'", - "````{match} var.arch", + "choose with an argument, backticks": ( + "````{choose} var.arch\n```{when} True\nSKIPPED\n```\n````\n", + "'choose' directive takes no argument, got 'var.arch'", + "````{choose} var.arch", ), - "match with an argument, colons": ( - "::::{match} var.arch\n:::{case} True\nSKIPPED\n:::\n::::\n", - "'match' directive takes no argument, got 'var.arch'", + "choose with an argument, colons": ( + "::::{choose} var.arch\n:::{when} True\nSKIPPED\n:::\n::::\n", + "'choose' directive takes no argument, got 'var.arch'", None, ), "unevaluable condition, backticks": ( - "````{match}\n```{case} invalid !!!\nSKIPPED\n```\n" - "```{case}\nSKIPPED_DEFAULT\n```\n````\n", - "'case' directive expression failed: 'invalid !!!'", - "```{case} invalid !!!", + "````{choose}\n```{when} invalid !!!\nSKIPPED\n```\n" + "```{when}\nSKIPPED_DEFAULT\n```\n````\n", + "'when' directive expression failed: 'invalid !!!'", + "```{when} invalid !!!", ), "unevaluable condition, colons": ( - "::::{match}\n:::{case} invalid !!!\nSKIPPED\n:::\n" - ":::{case}\nSKIPPED_DEFAULT\n:::\n::::\n", - "'case' directive expression failed: 'invalid !!!'", + "::::{choose}\n:::{when} invalid !!!\nSKIPPED\n:::\n" + ":::{when}\nSKIPPED_DEFAULT\n:::\n::::\n", + "'when' directive expression failed: 'invalid !!!'", None, ), # an `{eval-rst}` block is parsed by docutils into a document of its own, - # so the case in it is not a direct child of the match - "case inside eval-rst, backticks": ( - "````{match}\n```{eval-rst}\n.. case:: True\n\n SKIPPED_FROM_EVAL_RST\n```\n" + # so the branch in it is not a direct child of the choose + "branch inside eval-rst, backticks": ( + "````{choose}\n```{eval-rst}\n.. when:: True\n\n SKIPPED_FROM_EVAL_RST\n```\n" "````\n", _NOT_DIRECT, - ".. case:: True", + ".. when:: True", ), - "case inside eval-rst, colons": ( - "::::{match}\n```{eval-rst}\n.. case:: True\n\n SKIPPED_FROM_EVAL_RST\n```\n" + "branch inside eval-rst, colons": ( + "::::{choose}\n```{eval-rst}\n.. when:: True\n\n SKIPPED_FROM_EVAL_RST\n```\n" "::::\n", _NOT_DIRECT, None, @@ -1052,20 +1052,20 @@ def test_match_in_myst(test_app): ids=list(_MYST_WARNINGS), indirect=["test_app"], ) -def test_match_warnings_in_myst(test_app, text: str, line: str | None): +def test_choose_warnings_in_myst(test_app, text: str, line: str | None): """The MyST spellings warn once each and fail closed, as in reStructuredText.""" app = test_app app.build() (warning,) = build_warnings(app) assert text in warning, warning - assert warning.endswith(" [needs.match]"), warning + assert warning.endswith(" [needs.choose]"), warning if line is not None: source = Path(app.srcdir, "index.md").read_text() assert warning.startswith( f"/index.md:{_line_of(source, line)}: WARNING: " ), warning assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() - _assert_no_match_nodes(app) + _assert_no_choose_nodes(app) @pytest.mark.skipif(not _HAS_MYST, reason="needs myst-parser") @@ -1073,33 +1073,33 @@ def test_match_warnings_in_myst(test_app, text: str, line: str | None): "test_app", [ _project( - "::::{match}\n:::{case} True\nSKIPPED_IN_PLACE\n:::\n\n" - "```{include} cases.txt\n```\n::::\n", + "::::{choose}\n:::{when} True\nSKIPPED_IN_PLACE\n:::\n\n" + "```{include} branches.txt\n```\n::::\n", conf=_CONF_MYST, myst=True, extra=( ( - "cases.txt", - ':::{case} var.arch == "xyz"\nSKIPPED_X1_FROM_INCLUDE\n:::\n' - ":::{case}\nSKIPPED_X1_DEFAULT_FROM_INCLUDE\n:::\n", + "branches.txt", + ':::{when} var.arch == "xyz"\nSKIPPED_X1_FROM_INCLUDE\n:::\n' + ":::{when}\nSKIPPED_X1_DEFAULT_FROM_INCLUDE\n:::\n", ), ), ) ], indirect=True, ) -def test_match_refuses_included_cases_in_myst(test_app): - """In MyST too, a ``case`` an ``{include}`` supplies is refused, in the included file. +def test_choose_refuses_included_branches_in_myst(test_app): + """In MyST too, a ``when`` an ``{include}`` supplies is refused, in the included file. - MyST reports the lines of an included file one late (the case on line 1 is + MyST reports the lines of an included file one late (the branch on line 1 is reported on line 2, with colon and backtick fences alike), so only the file is asserted. """ app = test_app app.build() (warning,) = build_warnings(app) - assert warning.startswith("/cases.txt:"), warning - assert _INCLUDED_CASE in warning, warning - assert warning.endswith(" [needs.match]"), warning + assert warning.startswith("/branches.txt:"), warning + assert _INCLUDED_BRANCH in warning, warning + assert warning.endswith(" [needs.choose]"), warning assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() - _assert_no_match_nodes(app) + _assert_no_choose_nodes(app) From 745929fc9402eb179c0db9d7238c62231cd7249b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 15:36:15 +0000 Subject: [PATCH 11/26] =?UTF-8?q?=E2=9C=A8=20sphinx-needs:=20an=20explicit?= =?UTF-8?q?=20`otherwise`,=20and=20a=20`when`=20must=20have=20a=20conditio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `when` with no condition was the default of its `choose`, so a `when` whose condition had been forgotten silently became the catch-all for every other variant. The default now has a directive of its own, `otherwise`, and every `when` must have a condition. These are the only behaviour changes; every other rule of `choose` is as before. - `when` declares its argument optional, not required, so that the `choose` refuses a missing or blank condition itself, once, at the `when`: "'when' directive has no condition (use 'otherwise' for the default); the whole choose is skipped". A required argument would have made docutils reject the directive with an error of its own. - `otherwise` declares an argument only to refuse one: "'otherwise' directive takes no condition; the whole choose is skipped". It must be the last branch, at most one per `choose` (the former default-position warnings, reworded for `otherwise`). - A branch outside a `choose`, a branch inside another directive and a branch supplied through an include are warned about under the name of the directive it is written with; a `choose` with no branch says it has no 'when' or 'otherwise'. - The `choose` argument is refused as before, but no longer described as reserved: `match` and `case` stay free for a form with a subject. Both new refusals are structural, so they are checked after the body's contents and before the configuration. `when` and `otherwise` share one private base directive, and the placeholder carries the kind of its branch, so that the `choose` can tell a `when` without a condition from an `otherwise`. Tests: every default in the module and its doc project is an `otherwise` now; new rows for a `when` without a condition (RST, MyST backticks, and a blank condition in MyST colons), an `otherwise` with a condition (RST and MyST), an `otherwise` outside a `choose` (full text), inside a true `if` and supplied through an include, and the check order of a `when` without a condition when variant data is not configured. --- .../src/sphinx_needs/directives/needchoose.py | 195 +++++++++----- .../sphinx-needs/src/sphinx_needs/logging.py | 2 +- .../sphinx-needs/src/sphinx_needs/needs.py | 7 +- .../doc_choose_directive/included_choose.txt | 2 +- .../doc_test/doc_choose_directive/index.rst | 26 +- .../tests/test_choose_directive.py | 250 ++++++++++++------ 6 files changed, 323 insertions(+), 159 deletions(-) diff --git a/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py index eb52b80f5..444500f3d 100644 --- a/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py +++ b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py @@ -1,13 +1,14 @@ """Directives for including one of several branches of content based on variant data. -A ``choose`` holds ``when`` directives and comments, written in its own body, -and nothing else. +A ``choose`` holds ``when`` and ``otherwise`` directives (its branches) and comments, +written in its own body, and nothing else. The first ``when`` whose condition holds is included; -a ``when`` with no condition is the default, and must be the last. +an ``otherwise``, which takes no condition, is the default, +and must be the last branch. -A ``when`` does not parse its content. +A branch does not parse its content. Inside a ``choose`` body it returns a transient :class:`_BranchPlaceholder` -carrying its condition and its raw content, +carrying its kind, its condition and its raw content, and the ``choose`` parses its own body into a detached :class:`_ChooseBody` that it never returns. Having seen every branch at once, the ``choose`` checks the structure, @@ -27,6 +28,7 @@ from collections.abc import Sequence from itertools import islice +from typing import ClassVar, Literal from docutils import nodes from docutils.parsers.rst.states import RSTState @@ -45,37 +47,47 @@ _DEPTH_KEY = "sphinx_needs_choose_depth" """The ``env.temp_data`` key counting the ``choose`` bodies being parsed. -A ``when`` is a child of a ``choose`` body exactly when the count is above 0. +A branch is a child of a ``choose`` body exactly when the count is above 0. A ``choose`` raises it only around the parse of its own body, and parses the content of the branch it takes at 0, -so a ``when`` written loose in a branch's content is reported as well. +so a branch written loose in a branch's content is reported as well. """ +_BranchKind = Literal["when", "otherwise"] +"""The directive a branch is written with.""" + class _BranchPlaceholder(nodes.Element): - """What a ``when`` leaves in the body of its ``choose``; it never reaches a doctree. + """What a branch leaves in the body of its ``choose``; it never reaches a doctree. The payload is held in plain Python attributes rather than docutils attributes: the ``choose`` reads it once and discards it with the body. """ + kind: _BranchKind + """The directive the branch is written with.""" condition: str | None - """The condition, or ``None`` for the default branch.""" + """The condition, or ``None`` if the directive has none (or only whitespace). + + The ``choose`` refuses a ``when`` without one and an ``otherwise`` with one, + so after its checks ``None`` marks the ``otherwise``. + """ content: StringList """The raw content of the branch, parsed only if the branch is taken.""" content_offset: int - """The ``content_offset`` of the ``when`` directive.""" + """The ``content_offset`` of the branch directive.""" lineno: int - """The ``lineno`` of the ``when`` directive.""" + """The ``lineno`` of the branch directive.""" location: str | None """Where warnings about the branch are reported.""" source: str | None - """The file the ``when`` directive is written in, as docutils or MyST reports it.""" + """The file the branch directive is written in, as docutils or MyST reports it.""" owner: nodes.Element | None - """The node the ``when`` directive's result is appended to. + """The node the branch directive's result is appended to. - That is the body of its ``choose`` exactly when the ``when`` is written directly in it, - rather than inside another directive whose content was parsed into a node of its own. + That is the body of its ``choose`` exactly when the branch is written directly + in it, rather than inside another directive whose content was parsed into a node + of its own. """ @@ -83,44 +95,40 @@ class _ChooseBody(nodes.Element): """The detached node a ``choose`` parses its own body into; it is never returned.""" -class WhenDirective(SphinxDirective): - """One branch of a ``choose``, included if it is the first whose condition holds. - - The directive argument is a condition, exactly as for the ``if`` directive; - a ``when`` with no argument is the default of its ``choose``. - Its content is not parsed here: the ``choose`` parses it if it takes the branch. - - Example:: - - .. choose:: - - .. when:: var.arch == "arm" - - ARM content. +class _BranchDirective(SphinxDirective): + """What ``when`` and ``otherwise`` share: a deferred branch of a ``choose``. - .. when:: - - Content for every other architecture. + The content is not parsed here: the ``choose`` parses it if it takes the branch. + Both directives declare one optional argument, and the ``choose`` checks it, + so that a missing condition on a ``when``, or one on an ``otherwise``, + is warned about once, in the words of this extension, at the branch, + rather than by docutils (or MyST) with an error of its own. """ + branch_kind: ClassVar[_BranchKind] + """The directive name, which the placeholder and the warnings carry.""" + required_arguments = 0 optional_arguments = 1 final_argument_whitespace = True has_content = True def run(self) -> Sequence[nodes.Node]: + kind = self.branch_kind if self.env.temp_data.get(_DEPTH_KEY, 0) <= 0: + article = "an" if kind == "otherwise" else "a" log_warning( LOGGER, - "'when' directive outside a 'choose' (a 'when' must be a direct child " - "of a 'choose'); its content is skipped", + f"'{kind}' directive outside a 'choose' ({article} '{kind}' must be a " + "direct child of a 'choose'); its content is skipped", "choose", location=self.get_location(), ) return [] placeholder = _BranchPlaceholder() - # an argument of only whitespace is no condition: the default branch + placeholder.kind = kind + # an argument of only whitespace is no condition has_condition = bool(self.arguments and self.arguments[0].strip()) placeholder.condition = self.arguments[0] if has_condition else None placeholder.content = self.content @@ -134,18 +142,52 @@ def run(self) -> Sequence[nodes.Node]: return [placeholder] +class WhenDirective(_BranchDirective): + """A branch of a ``choose``, included if it is the first whose condition holds. + + The directive argument is a condition, exactly as for the ``if`` directive, + and a ``when`` must have one: its ``choose`` refuses a ``when`` without one, + since ``otherwise`` is the default. + + Example:: + + .. choose:: + + .. when:: var.arch == "arm" + + ARM content. + + .. otherwise:: + + Content for every other architecture. + """ + + branch_kind = "when" + + +class OtherwiseDirective(_BranchDirective): + """The default branch of a ``choose``, included when no ``when`` before it holds. + + It takes no condition (its ``choose`` refuses one), must be the last branch, + and a ``choose`` has at most one. + """ + + branch_kind = "otherwise" + + class ChooseDirective(SphinxDirective): - """Include the content of the first ``when`` whose condition holds. + """Include the first ``when`` whose condition holds, or else the ``otherwise``. - The content may hold only ``when`` directives and comments. + The content may hold only ``when`` and ``otherwise`` directives and comments. Every mistake is warned about once, and skips the whole ``choose``: - content that is neither a ``when`` nor a comment, a ``when`` inside another + content that is neither a branch nor a comment, a branch inside another directive or supplied through an include, - a default ``when`` that is not the last or is not the only one, + a ``when`` without a condition, an ``otherwise`` with one, + an ``otherwise`` that is not the last branch or is not the only one, variant data that is not configured, and a condition that cannot be evaluated before a branch is taken. So a mistake that makes a condition unevaluable, such as a misspelt key - or a syntax error, never renders a later branch or the default in its place. + or a syntax error, never renders a later branch or the ``otherwise`` in its place. Example:: @@ -159,14 +201,14 @@ class ChooseDirective(SphinxDirective): x86 content. - .. when:: + .. otherwise:: Content for every other architecture. """ required_arguments = 0 - # reserved, and refused: with no argument declared, MyST would move the text into - # the content and docutils would reject the directive, so neither could say why + # declared only to be refused: with no argument declared, MyST would move the text + # into the content and docutils would reject the directive, so neither could say why optional_arguments = 1 final_argument_whitespace = True has_content = True @@ -192,6 +234,7 @@ def run(self) -> Sequence[nodes.Node]: for branch in branches: if branch.condition is None: + # the otherwise: the checks leave no other branch without a condition return self._parse_branch(branch) taken = evaluate_variant_condition( self.env, @@ -201,7 +244,7 @@ def run(self) -> Sequence[nodes.Node]: location=branch.location, ) if taken is None: - # poisoned: no later branch is evaluated or taken, the default included + # poisoned: no later branch is evaluated or taken, nor the otherwise return [] if taken: # the first branch that holds wins; the later ones are not evaluated @@ -255,15 +298,18 @@ def _collect_branches( """Parse the body and check its structure. The branches must be written in the body itself: - a ``when`` inside another directive (one whose content is parsed into a node + a branch inside another directive (one whose content is parsed into a node of its own, even if it then returns that node's children, such as a true ``if``) - is refused, and so is a ``when`` an ``.. include::`` supplies, + is refused, and so is a branch an ``.. include::`` supplies, so that one ``choose`` is one directive in one file. + Then every ``when`` must have a condition and the ``otherwise`` none, + and there may be one ``otherwise`` at most, as the last branch. :param source: The file this ``choose`` is written in, as its branches report theirs. :return: The branches, in order, - or ``None`` if the body is not a valid ``choose`` (a warning has been emitted). + or ``None`` if the body is not a valid ``choose`` + (a warning has been emitted). """ body = self._parse_body() children = list(body.children) @@ -274,16 +320,17 @@ def _collect_branches( # also when the include stands inside another directive if child.source != source: self._warn( - "'when' supplied through an include is not supported (write " - "the branches in the body of the 'choose'); the whole choose " - "is skipped", + f"'{child.kind}' supplied through an include is not supported " + "(write the branches in the body of the 'choose'); the whole " + "choose is skipped", child.location, ) return None if child.owner is not body: self._warn( - "'when' directive is not a direct child of its 'choose' (it is " - "inside another directive); the whole choose is skipped", + f"'{child.kind}' directive is not a direct child of its " + "'choose' (it is inside another directive); the whole choose " + "is skipped", child.location, ) return None @@ -307,29 +354,47 @@ def _collect_branches( offender.tagname if isinstance(offender, nodes.Element) else "#text" ) self._warn( - "'choose' directive may contain only 'when' directives and " - f"comments, got <{tagname}>; the whole choose is skipped", + "'choose' directive may contain only 'when' and 'otherwise' " + f"directives and comments, got <{tagname}>; the whole choose is " + "skipped", self._location_of(children[index:]), ) return None if not branches: - self._warn("'choose' directive has no 'when'") + self._warn("'choose' directive has no 'when' or 'otherwise'") return None - defaults = [branch for branch in branches if branch.condition is None] - if len(defaults) > 1: + for branch in branches: + if branch.kind == "when" and branch.condition is None: + # a forgotten condition must not make a catch-all of this branch + self._warn( + "'when' directive has no condition (use 'otherwise' for the " + "default); the whole choose is skipped", + branch.location, + ) + return None + if branch.kind == "otherwise" and branch.condition is not None: + self._warn( + "'otherwise' directive takes no condition; the whole choose is " + "skipped", + branch.location, + ) + return None + + otherwises = [branch for branch in branches if branch.kind == "otherwise"] + if len(otherwises) > 1: self._warn( - "'choose' directive has more than one default 'when' (a 'when' with " - "no condition); the whole choose is skipped", - defaults[1].location, + "'choose' directive has more than one 'otherwise'; the whole choose " + "is skipped", + otherwises[1].location, ) return None - if defaults and defaults[0] is not branches[-1]: + if otherwises and otherwises[0] is not branches[-1]: self._warn( - "'choose' directive has a default 'when' (a 'when' with no condition) " - "that is not its last 'when'; the whole choose is skipped", - defaults[0].location, + "'choose' directive has an 'otherwise' that is not its last branch; " + "the whole choose is skipped", + otherwises[0].location, ) return None @@ -376,7 +441,7 @@ def _parse_branch(self, branch: _BranchPlaceholder) -> list[nodes.Node]: It is parsed outside every ``choose`` body (at depth 0), whatever encloses this ``choose``, - so that a ``when`` written loose in it is reported rather than collected. + so that a branch written loose in it is reported rather than collected. :param branch: The branch that is taken. :return: The parsed nodes. diff --git a/packages/sphinx-needs/src/sphinx_needs/logging.py b/packages/sphinx-needs/src/sphinx_needs/logging.py index 6d233f0ec..e06c844a9 100644 --- a/packages/sphinx-needs/src/sphinx_needs/logging.py +++ b/packages/sphinx-needs/src/sphinx_needs/logging.py @@ -69,7 +69,7 @@ def get_logger(name: str) -> SphinxLoggerAdapter: WarningSubTypeDescription: dict[WarningSubTypes, str] = { "beta": "Beta feature, subject to change", "card_layout": "Invalid ``needs_card_layouts`` specification", - "choose": "Error in processing choose/when directive", + "choose": "Error in processing choose/when/otherwise directive", "config": "Invalid configuration", "constraint": "Constraint violation", "create_need": "Creation of a need from directive failed", diff --git a/packages/sphinx-needs/src/sphinx_needs/needs.py b/packages/sphinx-needs/src/sphinx_needs/needs.py index 8ceb7531c..b39af5f5e 100644 --- a/packages/sphinx-needs/src/sphinx_needs/needs.py +++ b/packages/sphinx-needs/src/sphinx_needs/needs.py @@ -59,7 +59,11 @@ purge_needs, ) from sphinx_needs.directives.needbar import Needbar, NeedbarDirective, process_needbar -from sphinx_needs.directives.needchoose import ChooseDirective, WhenDirective +from sphinx_needs.directives.needchoose import ( + ChooseDirective, + OtherwiseDirective, + WhenDirective, +) from sphinx_needs.directives.needextend import Needextend, NeedextendDirective from sphinx_needs.directives.needextract import ( Needextract, @@ -312,6 +316,7 @@ def setup(app: Sphinx) -> dict[str, Any]: app.add_directive("if", IfDirective) app.add_directive("choose", ChooseDirective) app.add_directive("when", WhenDirective) + app.add_directive("otherwise", OtherwiseDirective) app.add_directive("needarch", NeedarchDirective) app.add_directive("list2need", List2NeedDirective) diff --git a/packages/sphinx-needs/tests/doc_test/doc_choose_directive/included_choose.txt b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/included_choose.txt index c95c290d7..38d919ae0 100644 --- a/packages/sphinx-needs/tests/doc_test/doc_choose_directive/included_choose.txt +++ b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/included_choose.txt @@ -4,6 +4,6 @@ SKIPPED_X1_IN_INCLUDED_CHOOSE - .. when:: + .. otherwise:: TAKEN_X1_INCLUDED_CHOOSE_DEFAULT diff --git a/packages/sphinx-needs/tests/doc_test/doc_choose_directive/index.rst b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/index.rst index 0fbeedf67..7484df246 100644 --- a/packages/sphinx-needs/tests/doc_test/doc_choose_directive/index.rst +++ b/packages/sphinx-needs/tests/doc_test/doc_choose_directive/index.rst @@ -29,12 +29,12 @@ so the invalid one and the unknown key cannot warn. SKIPPED_P1_UNKNOWN_KEY - .. when:: + .. otherwise:: SKIPPED_P1_DEFAULT -P2 default taken when no branch holds -------------------------------------- +P2 the otherwise is taken when no condition holds +------------------------------------------------- .. choose:: @@ -44,12 +44,12 @@ P2 default taken when no branch holds .. a comment between two branches - .. when:: + .. otherwise:: TAKEN_P2_DEFAULT -P2b no branch holds and there is no default -------------------------------------------- +P2b no condition holds and there is no otherwise +------------------------------------------------ .. choose:: @@ -78,9 +78,9 @@ P3 needs in branches .. req:: In the taken branch :id: REQ_P3_TAKEN - .. when:: + .. otherwise:: - .. req:: In a default that is not taken + .. req:: In an otherwise that is not taken :id: REQ_P3_DEFAULT_SKIPPED P4 sections in the taken branch @@ -95,7 +95,7 @@ P4 sections in the taken branch TAKEN_P4_SECTION_BODY - .. when:: + .. otherwise:: P4 skipped heading ~~~~~~~~~~~~~~~~~~ @@ -117,11 +117,11 @@ P5 nested choose SKIPPED_P5_INNER - .. when:: + .. otherwise:: TAKEN_P5_INNER_DEFAULT - .. when:: + .. otherwise:: SKIPPED_P5_OUTER @@ -144,7 +144,7 @@ which renders its content from the need-node cache. TAKEN_P5B_IN_NEED - .. when:: + .. otherwise:: SKIPPED_P5B_IN_NEED_DEFAULT @@ -167,7 +167,7 @@ X2 an include inside the taken branch TAKEN_X2_AFTER_INCLUDE - .. when:: + .. otherwise:: SKIPPED_X2_DEFAULT diff --git a/packages/sphinx-needs/tests/test_choose_directive.py b/packages/sphinx-needs/tests/test_choose_directive.py index 234771c03..17eaa30e7 100644 --- a/packages/sphinx-needs/tests/test_choose_directive.py +++ b/packages/sphinx-needs/tests/test_choose_directive.py @@ -1,4 +1,4 @@ -"""Tests for the ``.. choose::`` and ``.. when::`` directives.""" +"""Tests for the ``.. choose::``, ``.. when::`` and ``.. otherwise::`` directives.""" from __future__ import annotations @@ -102,7 +102,7 @@ def _section_titles(app, docname: str) -> list[list[str]]: indirect=True, ) def test_choose_directive(test_app): - """First true branch wins, the default, needs, sections, nesting and includes. + """First true branch wins, the otherwise, needs, sections, nesting and includes. The project builds without a single warning, although a branch after a taken one has a condition that is not Python @@ -179,44 +179,84 @@ class _Expected(NamedTuple): _SKIP = "; the whole choose is skipped" -_NOT_DIRECT = ( - "'when' directive is not a direct child of its 'choose' (it is inside another " - "directive)" + _SKIP -) -_INCLUDED_BRANCH = ( - "'when' supplied through an include is not supported " - "(write the branches in the body of the 'choose')" + _SKIP + +def _not_direct(kind: str) -> str: + """The warning about a branch of the kind ``kind`` inside another directive.""" + return ( + f"'{kind}' directive is not a direct child of its 'choose' " + "(it is inside another directive)" + _SKIP + ) + + +def _included(kind: str) -> str: + """The warning about a branch of the kind ``kind`` that an include supplies.""" + return ( + f"'{kind}' supplied through an include is not supported " + "(write the branches in the body of the 'choose')" + _SKIP + ) + + +_NOT_DIRECT = _not_direct("when") +_INCLUDED_BRANCH = _included("when") +_ONLY_BRANCHES = ( + "'choose' directive may contain only 'when' and 'otherwise' directives and comments" ) +_NO_BRANCH = "'choose' directive has no 'when' or 'otherwise'" _BRANCHES_TXT = ( '.. when:: var.arch == "xyz"\n\n SKIPPED_X1_FROM_INCLUDE\n\n' - ".. when::\n\n SKIPPED_X1_DEFAULT_FROM_INCLUDE\n" + ".. otherwise::\n\n SKIPPED_X1_DEFAULT_FROM_INCLUDE\n" ) _WARNINGS = { - "default not last": _Expected( + "otherwise not last": _Expected( ".. choose::\n\n" - " .. when::\n\n SKIPPED_DEFAULT\n\n" + " .. otherwise::\n\n SKIPPED_DEFAULT\n\n" " .. when:: True\n\n SKIPPED_TRUE\n", ( ( - "'choose' directive has a default 'when' (a 'when' with no condition) " - "that is not its last 'when'" + _SKIP, - " .. when::", + "'choose' directive has an 'otherwise' that is not its last branch" + + _SKIP, + " .. otherwise::", ), ), ), - "two defaults": _Expected( + "two otherwise": _Expected( ".. choose::\n\n" " .. when:: False\n\n SKIPPED_FALSE\n\n" - " .. when::\n\n SKIPPED_D1\n\n" - # the second default is refused (its directive line ends in spaces, which the - # parser strips: it is a default like the first) - " .. when:: \n\n SKIPPED_D2\n", + " .. otherwise::\n\n SKIPPED_D1\n\n" + # the second otherwise is refused as a second one: its directive line ends in + # spaces, which is no condition, so it is not refused as having one + " .. otherwise:: \n\n SKIPPED_D2\n", ( ( - "'choose' directive has more than one default 'when' (a 'when' with " - "no condition)" + _SKIP, - " .. when:: ", + "'choose' directive has more than one 'otherwise'" + _SKIP, + " .. otherwise:: ", + ), + ), + ), + # a forgotten condition on the last `when` would make a catch-all of it: refused, + # since the default is written as an `otherwise` + "when without a condition": _Expected( + ".. choose::\n\n" + " .. when:: var.arch == 'xyz'\n\n SKIPPED_XYZ\n\n" + " .. when::\n\n SKIPPED_FORGOTTEN_CONDITION\n", + ( + ( + "'when' directive has no condition (use 'otherwise' for the default)" + + _SKIP, + " .. when::", + ), + ), + ), + # a true condition: an `otherwise` that took it as a `when` would render it + "otherwise with a condition": _Expected( + ".. choose::\n\n" + " .. when:: var.arch == 'xyz'\n\n SKIPPED_XYZ\n\n" + " .. otherwise:: var.debug\n\n SKIPPED_OTHERWISE\n", + ( + ( + "'otherwise' directive takes no condition" + _SKIP, + " .. otherwise:: var.debug", ), ), ), @@ -226,13 +266,12 @@ class _Expected(NamedTuple): " A stray paragraph.\n", ( ( - "'choose' directive may contain only 'when' directives and comments, " - "got " + _SKIP, + _ONLY_BRANCHES + ", got " + _SKIP, " A stray paragraph.", ), ), ), - # exactly one warning: the branch inside the note is not a stray, it is in a choose body + # exactly one warning: the branch in the note is not a stray, it is in a choose body "note wrapping a branch": _Expected( ".. choose::\n\n" " .. note::\n\n .. when:: True\n\n SKIPPED_IN_NOTE\n", @@ -244,7 +283,7 @@ class _Expected(NamedTuple): ".. choose::\n\n" " .. if:: var.debug\n\n" " .. when:: var.arch == 'x86'\n\n SKIPPED_X86\n\n" - " .. when::\n\n SKIPPED_DEFAULT_FROM_IF\n", + " .. otherwise::\n\n SKIPPED_DEFAULT_FROM_IF\n", ((_NOT_DIRECT, " .. when:: var.arch == 'x86'"),), ), "branch inside rst-class": _Expected( @@ -253,20 +292,28 @@ class _Expected(NamedTuple): " .. when:: var.arch == 'abc'\n\n SKIPPED_FROM_RST_CLASS\n", ((_NOT_DIRECT, " .. when:: var.arch == 'abc'"),), ), + # the warnings name the kind of the branch + "otherwise inside a true if": _Expected( + ".. choose::\n\n" + " .. when:: False\n\n SKIPPED_FALSE\n\n" + " .. if:: var.debug\n\n" + " .. otherwise::\n\n SKIPPED_DEFAULT_FROM_IF\n", + ((_not_direct("otherwise"), " .. otherwise::"),), + ), # a line of one to three punctuation characters makes docutils emit an INFO # message, which is never shown, before the paragraph: the paragraph is reported "rule line between branches": _Expected( ".. choose::\n\n" " .. when:: var.arch == 'x86'\n\n SKIPPED_X86\n\n" " ---\n\n" - " .. when::\n\n SKIPPED_DEFAULT\n", + " .. otherwise::\n\n SKIPPED_DEFAULT\n", (("got " + _SKIP, " ---"),), ), "three dots in the body": _Expected( - ".. choose::\n\n ...\n\n .. when::\n\n SKIPPED_DEFAULT\n", + ".. choose::\n\n ...\n\n .. otherwise::\n\n SKIPPED_DEFAULT\n", (("got " + _SKIP, " ..."),), ), - "branch outside a choose": _Expected( + "when outside a choose": _Expected( "Para.\n\n.. when:: True\n\n SKIPPED_STRAY\n", ( ( @@ -276,10 +323,16 @@ class _Expected(NamedTuple): ), ), ), - # the counterpart of an orphan `else`: a default branch outside every choose - "default branch outside a choose": _Expected( - "Para.\n\n.. when::\n\n SKIPPED_STRAY_DEFAULT\n", - (("'when' directive outside a 'choose'", ".. when::"),), + # the counterpart of an orphan `else`: an otherwise outside every choose + "otherwise outside a choose": _Expected( + "Para.\n\n.. otherwise::\n\n SKIPPED_STRAY_DEFAULT\n", + ( + ( + "'otherwise' directive outside a 'choose' (an 'otherwise' must be a " + "direct child of a 'choose'); its content is skipped", + ".. otherwise::", + ), + ), ), "branch loose in the taken branch": _Expected( ".. choose::\n\n" @@ -289,9 +342,9 @@ class _Expected(NamedTuple): taken=("TAKEN_OUTER",), ), # two independent mistakes, two warnings: a choose written directly in another - # choose's body, whose taken branch holds a loose branch. The taken branch is parsed - # outside every choose body, so the loose branch is reported and cannot become a branch - # of the OUTER choose, which then has none. + # choose's body, whose taken branch holds a loose branch. The taken branch is + # parsed outside every choose body, so the loose branch is reported and cannot + # become a branch of the OUTER choose, which then has none. "branch loose in the taken branch of a misplaced choose": _Expected( ".. choose::\n\n" " .. choose::\n\n" @@ -299,14 +352,14 @@ class _Expected(NamedTuple): " .. when:: True\n\n SKIPPED_LOOSE\n", ( ("'when' directive outside a 'choose'", " .. when:: True"), - ("'choose' directive has no 'when'", ".. choose::"), + (_NO_BRANCH, ".. choose::"), ), ), - "unevaluable first branch poisons the default": _Expected( + "unevaluable first branch poisons the otherwise": _Expected( ".. choose::\n\n" " .. when:: this is not python !!!\n\n SKIPPED_1\n\n" " .. when:: True\n\n SKIPPED_2\n\n" - " .. when::\n\n SKIPPED_DEFAULT\n", + " .. otherwise::\n\n SKIPPED_DEFAULT\n", ( ( "'when' directive expression failed: 'this is not python !!!' — ", @@ -318,7 +371,7 @@ class _Expected(NamedTuple): ".. choose::\n\n" " .. when:: var.arch == 'xyz'\n\n SKIPPED_1\n\n" " .. when:: var.no_such_key == 1\n\n SKIPPED_2\n\n" - " .. when::\n\n SKIPPED_DEFAULT\n", + " .. otherwise::\n\n SKIPPED_DEFAULT\n", ( ( "'when' directive expression failed: 'var.no_such_key == 1' — " @@ -330,7 +383,7 @@ class _Expected(NamedTuple): "builtins blocked": _Expected( ".. choose::\n\n" " .. when:: __import__('os').system('echo pwned')\n\n SKIPPED\n\n" - " .. when::\n\n SKIPPED_DEFAULT\n", + " .. otherwise::\n\n SKIPPED_DEFAULT\n", ( ( "'when' directive expression failed: " @@ -343,7 +396,7 @@ class _Expected(NamedTuple): "non-bool is coerced and taken": _Expected( ".. choose::\n\n" " .. when:: var.count\n\n TAKEN_NONBOOL\n\n" - " .. when::\n\n SKIPPED_DEFAULT\n", + " .. otherwise::\n\n SKIPPED_DEFAULT\n", ( ( "'when' directive expression did not return a bool, got int: 5 " @@ -355,7 +408,7 @@ class _Expected(NamedTuple): ), "empty string condition": _Expected( '.. choose::\n\n .. when:: ""\n\n SKIPPED_EMPTY\n\n' - " .. when::\n\n TAKEN_DEFAULT\n", + " .. otherwise::\n\n TAKEN_DEFAULT\n", ( ( "'when' directive expression did not return a bool, got str: '' " @@ -377,17 +430,17 @@ class _Expected(NamedTuple): ), "empty choose": _Expected( ".. choose::\n\nTAKEN_AFTER_EMPTY\n", - (("'choose' directive has no 'when'", ".. choose::"),), + ((_NO_BRANCH, ".. choose::"),), taken=("TAKEN_AFTER_EMPTY",), ), "only comments": _Expected( ".. choose::\n\n .. just a comment\n\n .. and another\n", - (("'choose' directive has no 'when'", ".. choose::"),), + ((_NO_BRANCH, ".. choose::"),), ), "variant data not configured": _Expected( ".. choose::\n\n" " .. when:: var.arch == 'abc'\n\n SKIPPED_1\n\n" - " .. when::\n\n SKIPPED_DEFAULT\n", + " .. otherwise::\n\n SKIPPED_DEFAULT\n", ( ( "'choose' directive used but needs_variant_data is not configured" @@ -399,8 +452,8 @@ class _Expected(NamedTuple): ), # nothing is evaluated here, and still the choose warns and renders nothing: # the "used but not configured" rule holds for every choose - "variant data not configured, only a default": _Expected( - ".. choose::\n\n .. when::\n\n SKIPPED_DEFAULT_ONLY\n", + "variant data not configured, only an otherwise": _Expected( + ".. choose::\n\n .. otherwise::\n\n SKIPPED_DEFAULT_ONLY\n", ( ( "'choose' directive used but needs_variant_data is not configured" @@ -412,14 +465,26 @@ class _Expected(NamedTuple): ), # the structure is checked before the configuration: a choose that is wrong in both # ways gets the one structural warning, at the branch, and its body is still parsed - "variant data not configured, and a misplaced default": _Expected( + "variant data not configured, and a misplaced otherwise": _Expected( ".. choose::\n\n" - " .. when::\n\n SKIPPED_DEFAULT\n\n" + " .. otherwise::\n\n SKIPPED_DEFAULT\n\n" " .. when:: var.arch == 'abc'\n\n SKIPPED_ABC\n", ( ( - "'choose' directive has a default 'when' (a 'when' with no condition) " - "that is not its last 'when'" + _SKIP, + "'choose' directive has an 'otherwise' that is not its last branch" + + _SKIP, + " .. otherwise::", + ), + ), + conf=_CONF_NO_VARIANT_DATA, + ), + # and a when without a condition is a structural mistake too, warned at the when + "variant data not configured, and a when without a condition": _Expected( + ".. choose::\n\n .. when::\n\n SKIPPED_FORGOTTEN_CONDITION\n", + ( + ( + "'when' directive has no condition (use 'otherwise' for the default)" + + _SKIP, " .. when::", ), ), @@ -443,6 +508,14 @@ class _Expected(NamedTuple): extra=(("branches.txt", _BRANCHES_TXT),), located_in="branches.txt", ), + "an otherwise from an include": _Expected( + ".. choose::\n\n" + " .. when:: False\n\n SKIPPED_FALSE\n\n" + " .. include:: otherwise.txt\n", + ((_included("otherwise"), ".. otherwise::"),), + extra=(("otherwise.txt", ".. otherwise::\n\n SKIPPED_FROM_INCLUDE\n"),), + located_in="otherwise.txt", + ), # content outside a branch is parsed with the body, so the need directive runs; # the choose removes the need again "need directly in the body": _Expected( @@ -505,7 +578,7 @@ def test_choose_warnings(test_app, expected: _Expected): ".. choose::\n\n" " .. when:: var.arch == 'x86'\n\n SKIPPED_X86\n\n" " ---\n\n" - " .. when::\n\n SKIPPED_DEFAULT\n", + " .. otherwise::\n\n SKIPPED_DEFAULT\n", extra=(("docutils.conf", "[general]\nreport_level: 1\n"),), ) ], @@ -542,7 +615,7 @@ def test_choose_info_message_is_never_a_reported_error(test_app, monkeypatch): _project( ".. choose::\n\n" " .. cas:: True\n\n SKIPPED\n\n" - " .. when::\n\n SKIPPED_DEFAULT\n" + " .. otherwise::\n\n SKIPPED_DEFAULT\n" ), 'Unknown directive type "cas"', ), @@ -559,7 +632,7 @@ def test_choose_info_message_is_never_a_reported_error(test_app, monkeypatch): indirect=["test_app"], ) def test_choose_body_error_reported_once(test_app, error: str): - """A mistake docutils reports in the body skips the choose without a second warning.""" + """A mistake docutils reports in the body skips the choose and warns only once.""" app = test_app app.build() (warning,) = build_warnings(app) @@ -580,7 +653,7 @@ def test_choose_body_error_reported_once(test_app, error: str): indirect=True, ) def test_choose_warnings_are_suppressible(test_app): - """Every ``choose`` / ``when`` warning is of the ``needs.choose`` type.""" + """Every warning of the three directives is of the ``needs.choose`` type.""" app = test_app app.build() assert_no_warnings(app) @@ -594,7 +667,7 @@ def test_choose_warnings_are_suppressible(test_app): ".. req:: Written before the choose\n :id: REQ_BEFORE\n\n" ".. choose::\n\n" " .. req:: Directly in the choose body\n :id: REQ_STRAY\n\n" - " .. when::\n\n SKIPPED_DEFAULT\n\n" + " .. otherwise::\n\n SKIPPED_DEFAULT\n\n" ".. req:: Written after the choose\n :id: REQ_AFTER\n", # read before `index`, so its need is older than every need of `index` extra=( @@ -662,7 +735,7 @@ def setup(app): _project( ".. swallow::\n\n" " .. choose::\n\n" - " .. when::\n\n SKIPPED_X\n\n" + " .. otherwise::\n\n SKIPPED_X\n\n" " .. boom::\n\n" ".. when:: True\n\n SKIPPED_LOOSE_AFTER\n", conf=_SWALLOW_CONF, @@ -787,7 +860,7 @@ def test_when_conditions_are_if_conditions(test_app, expression: str, verdict): :::{when} this is not python !!! SKIPPED_M1_INVALID_SYNTAX ::: -:::{when} +:::{otherwise} SKIPPED_M1_DEFAULT ::: :::: @@ -803,7 +876,7 @@ def test_when_conditions_are_if_conditions(test_app, expression: str, verdict): +++ -:::{when} +:::{otherwise} TAKEN_M2_DEFAULT ::: :::: @@ -845,23 +918,23 @@ def test_when_conditions_are_if_conditions(test_app, expression: str, verdict): :::{when} var.arch == "xyz" SKIPPED_M5_INNER ::: -:::{when} +:::{otherwise} TAKEN_M5_INNER_DEFAULT ::: :::: ::::: -:::::{when} +:::::{otherwise} SKIPPED_M5_OUTER ::::: :::::: -## A default whose fence line ends in spaces is a default +## An otherwise whose fence line ends in spaces has no condition ::::{choose} :::{when} False SKIPPED_M6 ::: -:::{when}\x20\x20\x20 +:::{otherwise}\x20\x20\x20 TAKEN_M6_DEFAULT ::: :::: @@ -892,7 +965,7 @@ def test_when_conditions_are_if_conditions(test_app, expression: str, verdict): :::{when} var.arch == "xyz" SKIPPED_M8_IN_INCLUDED_CHOOSE ::: -:::{when} +:::{otherwise} TAKEN_M8_INCLUDED_CHOOSE_DEFAULT ::: :::: @@ -959,17 +1032,18 @@ def test_choose_in_myst(test_app): # MyST reports a directive nested in a colon fence one line late (its own quirk, the # same for a `{note}` in a `{note}`), so only the backtick spellings assert a line _MYST_WARNINGS = { - "branch outside a choose, backticks": ( + "when outside a choose, backticks": ( "Para.\n\n```{when} True\nSKIPPED_STRAY\n```\n", "'when' directive outside a 'choose'", "```{when} True", ), - "default branch outside a choose, backticks": ( - "Para.\n\n```{when}\nSKIPPED_STRAY_DEFAULT\n```\n", - "'when' directive outside a 'choose'", - "```{when}", + "otherwise outside a choose, backticks": ( + "Para.\n\n```{otherwise}\nSKIPPED_STRAY_DEFAULT\n```\n", + "'otherwise' directive outside a 'choose' (an 'otherwise' must be a direct " + "child of a 'choose'); its content is skipped", + "```{otherwise}", ), - "branch outside a choose, colons": ( + "when outside a choose, colons": ( "Para.\n\n:::{when} True\nSKIPPED_STRAY\n:::\n", "'when' directive outside a 'choose'", None, @@ -993,13 +1067,13 @@ def test_choose_in_myst(test_app): # an HTML comment is raw HTML, not a comment "html comment between branches, backticks": ( "````{choose}\n```{when} False\nSKIPPED\n```\n\n\n\n" - "```{when}\nSKIPPED_DEFAULT\n```\n````\n", + "```{otherwise}\nSKIPPED_DEFAULT\n```\n````\n", "got " + _SKIP, "", ), "html comment between branches, colons": ( "::::{choose}\n:::{when} False\nSKIPPED\n:::\n\n\n\n" - ":::{when}\nSKIPPED_DEFAULT\n:::\n::::\n", + ":::{otherwise}\nSKIPPED_DEFAULT\n:::\n::::\n", "got " + _SKIP, None, ), @@ -1013,15 +1087,35 @@ def test_choose_in_myst(test_app): "'choose' directive takes no argument, got 'var.arch'", None, ), + "when without a condition, backticks": ( + "````{choose}\n```{when} var.arch == 'xyz'\nSKIPPED_XYZ\n```\n" + "```{when}\nSKIPPED_FORGOTTEN_CONDITION\n```\n````\n", + "'when' directive has no condition (use 'otherwise' for the default)" + _SKIP, + "```{when}", + ), + # a fence line that ends in spaces gives a blank condition, which is none + "when with a blank condition, colons": ( + "::::{choose}\n:::{when} var.arch == 'xyz'\nSKIPPED_XYZ\n:::\n" + ":::{when}\x20\x20\x20\nSKIPPED_BLANK_CONDITION\n:::\n::::\n", + "'when' directive has no condition (use 'otherwise' for the default)" + _SKIP, + None, + ), + # MyST would fold the text into the content of a directive without an argument + "otherwise with a condition, backticks": ( + "````{choose}\n```{when} var.arch == 'xyz'\nSKIPPED_XYZ\n```\n" + "```{otherwise} var.debug\nSKIPPED_OTHERWISE\n```\n````\n", + "'otherwise' directive takes no condition" + _SKIP, + "```{otherwise} var.debug", + ), "unevaluable condition, backticks": ( "````{choose}\n```{when} invalid !!!\nSKIPPED\n```\n" - "```{when}\nSKIPPED_DEFAULT\n```\n````\n", + "```{otherwise}\nSKIPPED_DEFAULT\n```\n````\n", "'when' directive expression failed: 'invalid !!!'", "```{when} invalid !!!", ), "unevaluable condition, colons": ( "::::{choose}\n:::{when} invalid !!!\nSKIPPED\n:::\n" - ":::{when}\nSKIPPED_DEFAULT\n:::\n::::\n", + ":::{otherwise}\nSKIPPED_DEFAULT\n:::\n::::\n", "'when' directive expression failed: 'invalid !!!'", None, ), @@ -1081,7 +1175,7 @@ def test_choose_warnings_in_myst(test_app, text: str, line: str | None): ( "branches.txt", ':::{when} var.arch == "xyz"\nSKIPPED_X1_FROM_INCLUDE\n:::\n' - ":::{when}\nSKIPPED_X1_DEFAULT_FROM_INCLUDE\n:::\n", + ":::{otherwise}\nSKIPPED_X1_DEFAULT_FROM_INCLUDE\n:::\n", ), ), ) @@ -1089,7 +1183,7 @@ def test_choose_warnings_in_myst(test_app, text: str, line: str | None): indirect=True, ) def test_choose_refuses_included_branches_in_myst(test_app): - """In MyST too, a ``when`` an ``{include}`` supplies is refused, in the included file. + """In MyST too, a branch an ``{include}`` supplies is refused, in the included file. MyST reports the lines of an included file one late (the branch on line 1 is reported on line 2, with colon and backtick fences alike), so only the file is From 1a4bc835d3e0339891360030945cf614cc24daa2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 15:42:38 +0000 Subject: [PATCH 12/26] =?UTF-8?q?=F0=9F=93=9A=20sphinx-needs:=20document?= =?UTF-8?q?=20`choose`,=20`when`=20and=20`otherwise`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `choose.rst` now describes the directives as built: `choose` runs its `when` tests in order and the first true one is included; `otherwise` is the optional default and comes last; when no test holds, nothing is rendered. It names the precedent (XSLT, JSTL, MSBuild), says that a `choose` has no subject, and lists the two new mistakes: a `when` without a condition, and an `otherwise` with one. The MyST examples use `{otherwise}`. The content of the taken branch is parsed into a detached container, as the body of a true `if` is, so a sphinx-design `tab-item` in it warns that its parent should be a `tab-set`, even when the directive stands in one. `choose.rst` says so in a note, with the remedy (put the `choose` inside the `tab-item`), and `if.rst` gains a one-line bullet. A new test pins the warning for both directives, at each `tab-item`, with the same text, and that a `choose` inside a `tab-item` does not warn. The changelog entry names the three directives and lists the two new mistakes. --- packages/sphinx-needs/docs/changelog.rst | 35 ++++---- .../sphinx-needs/docs/directives/choose.rst | 86 +++++++++++-------- packages/sphinx-needs/docs/directives/if.rst | 2 + .../tests/test_choose_directive.py | 62 +++++++++++++ 4 files changed, 134 insertions(+), 51 deletions(-) diff --git a/packages/sphinx-needs/docs/changelog.rst b/packages/sphinx-needs/docs/changelog.rst index e5be0e395..562f34d91 100644 --- a/packages/sphinx-needs/docs/changelog.rst +++ b/packages/sphinx-needs/docs/changelog.rst @@ -10,12 +10,13 @@ Unreleased Improvements ............ -- ✨ New :ref:`choose ` and ``when`` directives include one of several branches of - content, chosen by variant data (:pr:`2020`) +- ✨ New :ref:`choose `, ``when`` and ``otherwise`` directives include one of + several branches of content, chosen by variant data (:pr:`2020`) - A ``choose`` holds ``when`` directives, and the first ``when`` whose condition is true - is included; a ``when`` with no condition is the default, and must come last. The - other branches are never parsed, so the needs inside them are never created: + A ``choose`` runs its ``when`` tests in order, and the first ``when`` whose condition + is true is included; an ``otherwise``, the optional default, comes last; when no test + holds and there is no ``otherwise``, nothing is rendered. The other branches are never + parsed, so the needs inside them are never created: .. code-block:: rst @@ -29,21 +30,23 @@ Improvements x86 content. - .. when:: + .. otherwise:: Content for every other architecture. Conditions are exactly those of the :ref:`if ` directive, evaluated by the same - code, and the conditions after the branch that is taken are not evaluated. A ``choose`` - may contain only ``when`` directives and comments. Every mistake warns once under the - new ``needs.choose`` type and skips the whole ``choose``: content outside a branch (any - need it creates is removed again), a ``when`` inside another directive or supplied - through an include, a misplaced or second default, an argument on ``choose``, variant - data that is not configured, and a condition that cannot be evaluated — so a mistake - that makes a condition unevaluable, such as a misspelt key or a syntax error, never - renders a later branch or the default in its place. Works in reStructuredText and in - MyST Markdown. The undocumented warning ``if`` gives for a condition whose result is - not a bool is now listed in its documentation. + code, and the conditions after the branch that is taken are not evaluated. A + ``choose`` may contain only ``when`` and ``otherwise`` directives and comments. Every + mistake warns once under the new ``needs.choose`` type and skips the whole + ``choose``: content outside a branch (any need it creates is removed again), a branch + inside another directive or supplied through an include, a ``when`` without a + condition, an ``otherwise`` with one, a misplaced or second ``otherwise``, an + argument on ``choose``, variant data that is not configured, and a condition that + cannot be evaluated — so a mistake that makes a condition unevaluable, such as a + misspelt key or a syntax error, never renders a later branch or the ``otherwise`` in + its place. Works in reStructuredText and in MyST Markdown. The undocumented warning + ``if`` gives for a condition whose result is not a bool is now listed in its + documentation. .. _`release:8.5.0`: diff --git a/packages/sphinx-needs/docs/directives/choose.rst b/packages/sphinx-needs/docs/directives/choose.rst index 0f222bc95..7b2dab6e4 100644 --- a/packages/sphinx-needs/docs/directives/choose.rst +++ b/packages/sphinx-needs/docs/directives/choose.rst @@ -7,12 +7,13 @@ choose The ``choose`` directive includes one of several branches of content, chosen by :ref:`variant data ` at parse time. -Its content is a list of ``when`` directives: -the first ``when`` whose condition is true is included, -and a ``when`` with no condition is the default, -included when no condition before it is true. -The content of every other ``when`` is never parsed, +Its branches are ``when`` and ``otherwise`` directives: +``choose`` runs its ``when`` tests in order, and the first true one is included; +``otherwise`` is the optional default, and comes last; +when no test holds and there is no ``otherwise``, nothing is rendered. +The content of every other branch is never parsed, so the needs inside it are never created. +The names and their meaning are those of ``choose`` / ``when`` / ``otherwise`` in XSLT, JSTL and MSBuild. .. code-block:: rst @@ -31,18 +32,20 @@ so the needs inside it are never created. .. a comment may stand between two branches - .. when:: + .. otherwise:: Content for every other architecture. A ``choose`` is the many-branched form of :ref:`if `: -the example includes the ARM content, the x86 content or the default content, +the example includes the ARM content, the x86 content or the content of the ``otherwise``, and never more than one of them. +Unlike a ``switch`` or a ``match`` statement, a ``choose`` has no subject: +every ``when`` holds a whole condition. MyST Markdown ------------- -In MyST Markdown, ``choose`` and ``when`` are fenced directives like any other. +In MyST Markdown, ``choose``, ``when`` and ``otherwise`` are fenced directives like any other. With colon fences (the ``colon_fence`` extension): .. code-block:: md @@ -52,7 +55,7 @@ With colon fences (the ``colon_fence`` extension): ARM content. ::: % a comment may stand between two branches - :::{when} + :::{otherwise} Content for every other architecture. ::: :::: @@ -65,7 +68,7 @@ and with backtick fences: ```{when} var.arch == "arm" ARM content. ``` - ```{when} + ```{otherwise} Content for every other architecture. ``` ```` @@ -94,24 +97,26 @@ Rules - **The first true branch wins.** The conditions are evaluated in order, and the first ``when`` whose condition is true is included. The conditions after it are not evaluated at all, so they cannot warn. - When no condition is true and there is no default, the ``choose`` includes nothing, - without a warning, as a false ``if`` does. -- **The default comes last.** - A ``when`` with no condition is the default. - A ``choose`` has at most one, and it must be its last ``when``. + When no condition is true, the ``otherwise`` is included; + without an ``otherwise``, the ``choose`` then includes nothing, without a warning, as a false ``if`` does. +- **Every test has a condition, and the default has none.** + A ``when`` without a condition is a mistake rather than a default, + so a condition forgotten on the last ``when`` cannot make it the branch for every other variant. + An ``otherwise`` takes no condition. + A ``choose`` has at most one ``otherwise``, and it must be its last branch. - **Only branches and comments.** - A ``choose`` may contain only ``when`` directives and comments: + A ``choose`` may contain only ``when`` and ``otherwise`` directives and comments: reStructuredText comments (``..``), and in MyST ``%`` comments and ``+++`` block breaks. In MyST, an HTML comment (````) is raw HTML rather than a comment, so it is a mistake here. Any other content outside a branch is a mistake, and the needs it would create are removed again. - A ``when`` belongs directly in a ``choose``: + A branch belongs directly in a ``choose``: one anywhere else is a mistake too, - whether it is written loose in the content of another ``when`` + whether it is written loose in the content of another branch or inside another directive in the ``choose``, even one that passes its content through, such as a true ``if`` or a ``rst-class``. - **The branches are written in place.** - Every ``when`` of a ``choose`` is written in the body of that ``choose``, in the same file, + Every branch of a ``choose`` is written in the body of that ``choose``, in the same file, so that one choice is one directive in one place. An ``.. include::`` (in MyST, an ``{include}``) may not supply the branches; it may be used inside the content of a branch, @@ -122,7 +127,7 @@ Rules An ``.. include::`` may supply part of the content of a branch, and a ``choose`` may stand in the content of a need. - **Parse-time evaluation**, as for ``if``: - the content of a ``when`` that is not included is never parsed, + the content of a branch that is not included is never parsed, so its needs are never created and its mistakes are never reported. Conditions @@ -144,25 +149,27 @@ Warnings Every mistake warns once, under the ``needs.choose`` type (suppressible via ``suppress_warnings = ["needs.choose"]``), at the line of the directive or the content that has it, -and skips the **whole** ``choose``: nothing of it is included, not even its default. +and skips the **whole** ``choose``: nothing of it is included, not even its ``otherwise``. The mistakes are: -- ``needs_variant_data`` is not configured, even when the ``choose`` holds only a default. +- ``needs_variant_data`` is not configured, even when the ``choose`` holds only an ``otherwise``. - A condition cannot be evaluated (a syntax error, an unknown key, etc.) before a branch is taken. So a mistake that makes a condition unevaluable, such as a misspelt key or a syntax error, - never renders a later branch or the default in its place. + never renders a later branch or the ``otherwise`` in its place. (A mistake that leaves a valid condition, such as a misspelt value, cannot be told apart from a condition that is false.) -- The ``choose`` contains something that is neither a ``when`` nor a comment. +- The ``choose`` contains something that is neither a ``when``, an ``otherwise`` nor a comment. A line of only punctuation, such as ``---`` between two branches, is such content too. -- A ``when`` is written inside another directive in the ``choose`` rather than directly in it. -- A ``when`` is supplied through an include rather than written in the body of the ``choose`` - (the warning points at the ``when`` in the included file). -- The ``choose`` has more than one default ``when``, or a default that is not its last ``when``. -- The ``choose`` has no ``when`` at all. -- The ``choose`` is given an argument: the conditions go on the branches. - -A ``when`` outside a ``choose`` warns as well, and its content is skipped. +- A branch is written inside another directive in the ``choose`` rather than directly in it. +- A branch is supplied through an include rather than written in the body of the ``choose`` + (the warning points at the branch in the included file). +- A ``when`` has no condition: write the default as an ``otherwise``. +- An ``otherwise`` is given a condition. +- The ``choose`` has more than one ``otherwise``, or an ``otherwise`` that is not its last branch. +- The ``choose`` has no ``when`` or ``otherwise`` at all. +- The ``choose`` is given an argument: the conditions go on the ``when`` directives. + +A ``when`` or an ``otherwise`` outside a ``choose`` warns as well, and its content is skipped. A condition whose result is not a ``bool`` warns, and its truth value is used. A mistake that docutils or MyST already reports in the content of a ``choose``, such as an unknown directive name, is not reported a second time; @@ -174,8 +181,17 @@ the ``choose`` is skipped all the same. it still runs, and the ``choose`` goes on. ``default-role`` is one such directive, and so is a **false** ``if``: it returns nothing, so the branches written inside it vanish without a warning, - unless the ``choose`` is left with no ``when`` at all. - Nor is a MyST substitution: a ``{{ sub }}`` in a ``choose`` whose definition holds ``when`` directives + unless the ``choose`` is left with no branch at all. + Nor is a MyST substitution: a ``{{ sub }}`` in a ``choose`` whose definition holds branches is expanded in place, and its branches are taken without a warning. Needs are the one effect of content outside a branch that is undone; - any other (a label, a ``needextend``) stays, so keep every directive inside a ``when``. + any other (a label, a ``needextend``) stays, so keep every directive inside a branch. + +.. note:: + + The content of the branch that is taken is parsed on its own, as the body of a true ``if`` is, + so a directive in it that checks its parent does not find the parent of the ``choose``. + A sphinx-design ``tab-item`` in the taken branch warns + ``The parent of a 'tab-item' should be a 'tab-set'``, exactly as in a true ``if``, + even when the ``choose`` stands in a ``tab-set``. + To vary the content of a tab, put the ``choose`` inside the ``tab-item``. diff --git a/packages/sphinx-needs/docs/directives/if.rst b/packages/sphinx-needs/docs/directives/if.rst index cd9b7ce97..c61de2acf 100644 --- a/packages/sphinx-needs/docs/directives/if.rst +++ b/packages/sphinx-needs/docs/directives/if.rst @@ -107,6 +107,8 @@ Behavior Use :ref:`filter` for need-aware filtering. - **Incremental builds**: If a document is re-read (e.g., because the source changed), all ``if`` directives in it are re-evaluated. +- **Parsed on its own**: the body does not see where the ``if`` stands, so a sphinx-design + ``tab-item`` in a true ``if`` warns that its parent should be a ``tab-set``, even inside one. Warnings -------- diff --git a/packages/sphinx-needs/tests/test_choose_directive.py b/packages/sphinx-needs/tests/test_choose_directive.py index 17eaa30e7..35fbeacb1 100644 --- a/packages/sphinx-needs/tests/test_choose_directive.py +++ b/packages/sphinx-needs/tests/test_choose_directive.py @@ -29,6 +29,7 @@ ) _HAS_MYST = importlib.util.find_spec("myst_parser") is not None +_HAS_SPHINX_DESIGN = importlib.util.find_spec("sphinx_design") is not None def _project( @@ -762,6 +763,67 @@ def test_choose_restores_its_depth_when_its_body_raises(test_app): assert "SKIPPED" not in html +_TAB_PARENT = "The parent of a 'tab-item' should be a 'tab-set'" + + +@pytest.mark.skipif(not _HAS_SPHINX_DESIGN, reason="needs sphinx-design") +@pytest.mark.parametrize( + "test_app", + [ + _project( + ".. tab-set::\n\n" + " .. if:: var.debug\n\n" + " .. tab-item:: IF_TAB\n\n TAKEN_IF_TAB_BODY\n\n" + ".. tab-set::\n\n" + " .. choose::\n\n" + " .. when:: var.debug\n\n" + " .. tab-item:: WHEN_TAB\n\n TAKEN_WHEN_TAB_BODY\n\n" + " .. otherwise::\n\n SKIPPED_TAB\n\n" + ".. tab-set::\n\n" + " .. tab-item:: CHOOSE_INSIDE_TAB\n\n" + " .. choose::\n\n" + " .. when:: var.debug\n\n TAKEN_INSIDE_TAB\n", + conf=_CONF.replace( + "extensions = ['sphinx_needs']", + "extensions = ['sphinx_needs', 'sphinx_design']", + ), + ) + ], + indirect=True, +) +def test_tab_item_in_the_taken_when_warns_as_in_a_true_if(test_app): + """A ``tab-item`` in the taken branch warns exactly as one in a true ``if`` does. + + The content of the taken branch, like the body of a true ``if``, is parsed into + a detached container, so sphinx-design's ``tab-item`` does not see the + ``tab-set`` around the directive and warns about its parent. This pins the + limitation the docs of both directives describe, with the same warning for both, + and the remedy they give: a ``choose`` inside the ``tab-item`` does not warn. + """ + app = test_app + app.build() + warnings = build_warnings(app) + assert len(warnings) == 2, warnings + source = Path(app.srcdir, "index.rst").read_text() + if_warning, when_warning = warnings + assert if_warning.startswith( + f"/index.rst:{_line_of(source, ' .. tab-item:: IF_TAB')}: WARNING: " + ), if_warning + assert when_warning.startswith( + f"/index.rst:{_line_of(source, ' .. tab-item:: WHEN_TAB')}: " + "WARNING: " + ), when_warning + assert _TAB_PARENT in if_warning, if_warning + assert ( + if_warning.split(": WARNING: ", 1)[1] == when_warning.split(": WARNING: ", 1)[1] + ) + html = Path(app.outdir, "index.html").read_text() + for word in ("TAKEN_IF_TAB_BODY", "TAKEN_WHEN_TAB_BODY", "TAKEN_INSIDE_TAB"): + assert word in html, word + assert "SKIPPED" not in html + _assert_no_choose_nodes(app) + + # One condition language: `when` evaluates exactly what `if` does _EXPRESSIONS = { From 72157c92c3465640d69f102e48795228aa0a205f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:23:14 +0000 Subject: [PATCH 13/26] =?UTF-8?q?=F0=9F=A7=AA=20sphinx-needs:=20pin=20the?= =?UTF-8?q?=20choose=20check=20order,=20name=20the=20otherwise=20text,=20d?= =?UTF-8?q?ocs=20nits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The order between the condition faults and the `otherwise` count and position was what the code did but nothing pinned: moving the count and position checks before the condition faults passed every test. New rows, in reStructuredText and as MyST twins for the first two: - two `otherwise` and then a `when` without a condition warn about the `when` (the condition faults, in document order, come first); - an `otherwise` with a condition, a `when` and a bare `otherwise` warn about the condition, at the first `otherwise`; - two bare `otherwise` and nothing else warn "more than one 'otherwise'" at the second. An `otherwise` given a condition now names it ("'otherwise' directive takes no condition, got '…'"), since the text may be content docutils took for the argument because no blank line followed the directive; `choose.rst` says so in its Warnings list. The `tab-item` test no longer skips when sphinx-design is missing: it imports it, so that the test fails if sphinx-design ever leaves the shared `test` group, rather than silently no longer pinning the documented limitation. The two whitespace-only-argument guards are kept as the contract's, with a comment saying that docutils and MyST already drop such an argument. The `ChooseDirective` docstring and the changelog entry list "no branch at all" among the mistakes, and `choose.rst` no longer claims that the names mean exactly what they mean in XSLT, JSTL and MSBuild, which require a `when`: it says that those run their tests the same way. --- packages/sphinx-needs/docs/changelog.rst | 16 ++--- .../sphinx-needs/docs/directives/choose.rst | 5 +- .../src/sphinx_needs/directives/needchoose.py | 13 ++-- .../tests/test_choose_directive.py | 63 +++++++++++++++++-- 4 files changed, 79 insertions(+), 18 deletions(-) diff --git a/packages/sphinx-needs/docs/changelog.rst b/packages/sphinx-needs/docs/changelog.rst index 562f34d91..8f869cf09 100644 --- a/packages/sphinx-needs/docs/changelog.rst +++ b/packages/sphinx-needs/docs/changelog.rst @@ -39,14 +39,14 @@ Improvements ``choose`` may contain only ``when`` and ``otherwise`` directives and comments. Every mistake warns once under the new ``needs.choose`` type and skips the whole ``choose``: content outside a branch (any need it creates is removed again), a branch - inside another directive or supplied through an include, a ``when`` without a - condition, an ``otherwise`` with one, a misplaced or second ``otherwise``, an - argument on ``choose``, variant data that is not configured, and a condition that - cannot be evaluated — so a mistake that makes a condition unevaluable, such as a - misspelt key or a syntax error, never renders a later branch or the ``otherwise`` in - its place. Works in reStructuredText and in MyST Markdown. The undocumented warning - ``if`` gives for a condition whose result is not a bool is now listed in its - documentation. + inside another directive or supplied through an include, no branch at all, a + ``when`` without a condition, an ``otherwise`` with one, a misplaced or second + ``otherwise``, an argument on ``choose``, variant data that is not configured, and a + condition that cannot be evaluated — so a mistake that makes a condition + unevaluable, such as a misspelt key or a syntax error, never renders a later branch + or the ``otherwise`` in its place. Works in reStructuredText and in MyST Markdown. + The undocumented warning ``if`` gives for a condition whose result is not a bool is + now listed in its documentation. .. _`release:8.5.0`: diff --git a/packages/sphinx-needs/docs/directives/choose.rst b/packages/sphinx-needs/docs/directives/choose.rst index 7b2dab6e4..9f2a7a700 100644 --- a/packages/sphinx-needs/docs/directives/choose.rst +++ b/packages/sphinx-needs/docs/directives/choose.rst @@ -13,7 +13,7 @@ Its branches are ``when`` and ``otherwise`` directives: when no test holds and there is no ``otherwise``, nothing is rendered. The content of every other branch is never parsed, so the needs inside it are never created. -The names and their meaning are those of ``choose`` / ``when`` / ``otherwise`` in XSLT, JSTL and MSBuild. +The names are those of ``choose`` / ``when`` / ``otherwise`` in XSLT, JSTL and MSBuild, which run their tests the same way. .. code-block:: rst @@ -164,7 +164,8 @@ The mistakes are: - A branch is supplied through an include rather than written in the body of the ``choose`` (the warning points at the branch in the included file). - A ``when`` has no condition: write the default as an ``otherwise``. -- An ``otherwise`` is given a condition. +- An ``otherwise`` is given a condition. The warning names it: + content written on the line right after ``.. otherwise::``, with no blank line between, is read as one. - The ``choose`` has more than one ``otherwise``, or an ``otherwise`` that is not its last branch. - The ``choose`` has no ``when`` or ``otherwise`` at all. - The ``choose`` is given an argument: the conditions go on the ``when`` directives. diff --git a/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py index 444500f3d..9dd0379d8 100644 --- a/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py +++ b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py @@ -128,7 +128,8 @@ def run(self) -> Sequence[nodes.Node]: placeholder = _BranchPlaceholder() placeholder.kind = kind - # an argument of only whitespace is no condition + # an argument of only whitespace is no condition: docutils and MyST already drop + # a whitespace-only argument; kept as the contract's guard has_condition = bool(self.arguments and self.arguments[0].strip()) placeholder.condition = self.arguments[0] if has_condition else None placeholder.content = self.content @@ -181,7 +182,7 @@ class ChooseDirective(SphinxDirective): The content may hold only ``when`` and ``otherwise`` directives and comments. Every mistake is warned about once, and skips the whole ``choose``: content that is neither a branch nor a comment, a branch inside another - directive or supplied through an include, + directive or supplied through an include, no branch at all, a ``when`` without a condition, an ``otherwise`` with one, an ``otherwise`` that is not the last branch or is not the only one, variant data that is not configured, @@ -214,6 +215,8 @@ class ChooseDirective(SphinxDirective): has_content = True def run(self) -> Sequence[nodes.Node]: + # docutils and MyST already drop a whitespace-only argument; kept as the + # contract's guard if self.arguments and self.arguments[0].strip(): self._warn( f"'choose' directive takes no argument, got {self.arguments[0]!r} " @@ -375,9 +378,11 @@ def _collect_branches( ) return None if branch.kind == "otherwise" and branch.condition is not None: + # the text is named: it may be content that the parser took for the + # argument (written on the line after the directive, with no blank line) self._warn( - "'otherwise' directive takes no condition; the whole choose is " - "skipped", + "'otherwise' directive takes no condition, got " + f"{branch.condition!r}; the whole choose is skipped", branch.location, ) return None diff --git a/packages/sphinx-needs/tests/test_choose_directive.py b/packages/sphinx-needs/tests/test_choose_directive.py index 35fbeacb1..fb096266c 100644 --- a/packages/sphinx-needs/tests/test_choose_directive.py +++ b/packages/sphinx-needs/tests/test_choose_directive.py @@ -29,7 +29,6 @@ ) _HAS_MYST = importlib.util.find_spec("myst_parser") is not None -_HAS_SPHINX_DESIGN = importlib.util.find_spec("sphinx_design") is not None def _project( @@ -235,6 +234,45 @@ def _included(kind: str) -> str: ), ), ), + # the check order: the condition faults, in document order, come before the count + # and the position of the `otherwise`, which come in that order + "two otherwise, then a when without a condition": _Expected( + ".. choose::\n\n" + " .. otherwise::\n\n SKIPPED_D1\n\n" + " .. otherwise::\n\n SKIPPED_D2\n\n" + " .. when::\n\n SKIPPED_FORGOTTEN_CONDITION\n", + ( + ( + "'when' directive has no condition (use 'otherwise' for the default)" + + _SKIP, + " .. when::", + ), + ), + ), + "an otherwise with a condition, a when, then a bare otherwise": _Expected( + ".. choose::\n\n" + " .. otherwise:: var.debug\n\n SKIPPED_FIRST\n\n" + " .. when:: var.arch == 'abc'\n\n SKIPPED_ABC\n\n" + " .. otherwise::\n\n SKIPPED_LAST\n", + ( + ( + "'otherwise' directive takes no condition, got 'var.debug'" + _SKIP, + " .. otherwise:: var.debug", + ), + ), + ), + # both bare: the first line ends in spaces only so that the two lines differ + "two bare otherwise and nothing else": _Expected( + ".. choose::\n\n" + " .. otherwise:: \n\n SKIPPED_D1\n\n" + " .. otherwise::\n\n SKIPPED_D2\n", + ( + ( + "'choose' directive has more than one 'otherwise'" + _SKIP, + " .. otherwise::", + ), + ), + ), # a forgotten condition on the last `when` would make a catch-all of it: refused, # since the default is written as an `otherwise` "when without a condition": _Expected( @@ -256,7 +294,7 @@ def _included(kind: str) -> str: " .. otherwise:: var.debug\n\n SKIPPED_OTHERWISE\n", ( ( - "'otherwise' directive takes no condition" + _SKIP, + "'otherwise' directive takes no condition, got 'var.debug'" + _SKIP, " .. otherwise:: var.debug", ), ), @@ -766,7 +804,6 @@ def test_choose_restores_its_depth_when_its_body_raises(test_app): _TAB_PARENT = "The parent of a 'tab-item' should be a 'tab-set'" -@pytest.mark.skipif(not _HAS_SPHINX_DESIGN, reason="needs sphinx-design") @pytest.mark.parametrize( "test_app", [ @@ -800,6 +837,10 @@ def test_tab_item_in_the_taken_when_warns_as_in_a_true_if(test_app): limitation the docs of both directives describe, with the same warning for both, and the remedy they give: a ``choose`` inside the ``tab-item`` does not warn. """ + # no skip: sphinx-design is in the shared `test` group, and if it ever leaves it, + # this test must fail rather than stop pinning the documented limitation + import sphinx_design # noqa: F401 + app = test_app app.build() warnings = build_warnings(app) @@ -1166,7 +1207,21 @@ def test_choose_in_myst(test_app): "otherwise with a condition, backticks": ( "````{choose}\n```{when} var.arch == 'xyz'\nSKIPPED_XYZ\n```\n" "```{otherwise} var.debug\nSKIPPED_OTHERWISE\n```\n````\n", - "'otherwise' directive takes no condition" + _SKIP, + "'otherwise' directive takes no condition, got 'var.debug'" + _SKIP, + "```{otherwise} var.debug", + ), + # the check order, as in reStructuredText + "two otherwise, then a when without a condition, backticks": ( + "````{choose}\n```{otherwise}\nSKIPPED_D1\n```\n```{otherwise}\nSKIPPED_D2\n```\n" + "```{when}\nSKIPPED_FORGOTTEN_CONDITION\n```\n````\n", + "'when' directive has no condition (use 'otherwise' for the default)" + _SKIP, + "```{when}", + ), + "an otherwise with a condition, a when, then a bare otherwise, backticks": ( + "````{choose}\n```{otherwise} var.debug\nSKIPPED_FIRST\n```\n" + "```{when} var.arch == 'abc'\nSKIPPED_ABC\n```\n" + "```{otherwise}\nSKIPPED_LAST\n```\n````\n", + "'otherwise' directive takes no condition, got 'var.debug'" + _SKIP, "```{otherwise} var.debug", ), "unevaluable condition, backticks": ( From cc885e18c6f7025f4dc88460869a613938fe19a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:26:10 +0000 Subject: [PATCH 14/26] =?UTF-8?q?=F0=9F=94=A7=20sphinx-needs:=20correct=20?= =?UTF-8?q?the=20comment=20on=20the=20`choose`=20argument's=20declaration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With no argument declared, docutils does not reject the directive: like MyST, it moves the text into the content, which the `choose` would then report only as a stray paragraph. The declaration stays, since it gives the clearer message; only the comment is corrected. --- .../sphinx-needs/src/sphinx_needs/directives/needchoose.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py index 9dd0379d8..7a5776aee 100644 --- a/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py +++ b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py @@ -208,8 +208,8 @@ class ChooseDirective(SphinxDirective): """ required_arguments = 0 - # declared only to be refused: with no argument declared, MyST would move the text - # into the content and docutils would reject the directive, so neither could say why + # declared only to be refused: with no argument declared, docutils and MyST both move + # the text into the content, which would then be reported only as a stray paragraph optional_arguments = 1 final_argument_whitespace = True has_content = True From a62191fe877d44ddd843f88ae1e5f779b23713d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:27:14 +0000 Subject: [PATCH 15/26] =?UTF-8?q?=F0=9F=94=A7=20sphinx-needs:=20say=20why?= =?UTF-8?q?=20each=20branch=20directive=20declares=20its=20argument?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `_BranchDirective` docstring claimed docutils would reject an `otherwise` with an error of its own if no argument were declared; measured, docutils and MyST both fold the text into the content instead, where it would be taken silently. The docstring now states the measured reason for each directive. --- .../sphinx-needs/src/sphinx_needs/directives/needchoose.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py index 7a5776aee..7662be26a 100644 --- a/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py +++ b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py @@ -101,8 +101,11 @@ class _BranchDirective(SphinxDirective): The content is not parsed here: the ``choose`` parses it if it takes the branch. Both directives declare one optional argument, and the ``choose`` checks it, so that a missing condition on a ``when``, or one on an ``otherwise``, - is warned about once, in the words of this extension, at the branch, - rather than by docutils (or MyST) with an error of its own. + is warned about once, in the words of this extension, at the branch: + a required argument would make docutils (or MyST) reject a ``when`` without one + with an error of its own, and with no argument declared at all, + both would move a condition written on an ``otherwise`` into its content, + where it would be taken, silently, as the default's first paragraph. """ branch_kind: ClassVar[_BranchKind] From a1220af511bf9f8acdda40a28b0b75f885d028dc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:37:06 +0000 Subject: [PATCH 16/26] =?UTF-8?q?=F0=9F=A7=AA=20sphinx-needs:=20pin=20the?= =?UTF-8?q?=20document=20order=20of=20the=20branch=20faults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rows (and a MyST twin) pin that a choose with two mistakes reports the first in document order: an otherwise with a condition before a when without one reports the otherwise, and a when without a condition before a stray paragraph reports the paragraph (the children are checked before the condition faults). Both orders are the contract ubCode implements too. --- .../tests/test_choose_directive.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/sphinx-needs/tests/test_choose_directive.py b/packages/sphinx-needs/tests/test_choose_directive.py index fb096266c..cb7d85845 100644 --- a/packages/sphinx-needs/tests/test_choose_directive.py +++ b/packages/sphinx-needs/tests/test_choose_directive.py @@ -299,6 +299,31 @@ def _included(kind: str) -> str: ), ), ), + # the condition faults are in document order: the otherwise's comes first here + "an otherwise with a condition, then a when without one": _Expected( + ".. choose::\n\n" + " .. otherwise:: var.debug\n\n SKIPPED_FIRST\n\n" + " .. when::\n\n SKIPPED_FORGOTTEN_CONDITION\n", + ( + ( + "'otherwise' directive takes no condition, got 'var.debug'" + _SKIP, + " .. otherwise:: var.debug", + ), + ), + ), + # the children are checked before the condition faults: the stray paragraph after + # a when without a condition is what is reported + "a when without a condition, then a stray paragraph": _Expected( + ".. choose::\n\n" + " .. when::\n\n SKIPPED_FORGOTTEN_CONDITION\n\n" + " A stray paragraph.\n", + ( + ( + _ONLY_BRANCHES + ", got " + _SKIP, + " A stray paragraph.", + ), + ), + ), "paragraph in the body": _Expected( ".. choose::\n\n" " .. when:: True\n\n SKIPPED_BRANCH\n\n" @@ -1224,6 +1249,12 @@ def test_choose_in_myst(test_app): "'otherwise' directive takes no condition, got 'var.debug'" + _SKIP, "```{otherwise} var.debug", ), + "an otherwise with a condition, then a when without one, backticks": ( + "````{choose}\n```{otherwise} var.debug\nSKIPPED_FIRST\n```\n" + "```{when}\nSKIPPED_FORGOTTEN_CONDITION\n```\n````\n", + "'otherwise' directive takes no condition, got 'var.debug'" + _SKIP, + "```{otherwise} var.debug", + ), "unevaluable condition, backticks": ( "````{choose}\n```{when} invalid !!!\nSKIPPED\n```\n" "```{otherwise}\nSKIPPED_DEFAULT\n```\n````\n", From 48d293d567bcacd27f74101aea20730cbed007ed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:14:52 +0000 Subject: [PATCH 17/26] =?UTF-8?q?=F0=9F=A7=AA=20sphinx-needs-testkit:=20re?= =?UTF-8?q?write=20a=20POSIX-spelt=20location=20on=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docutils writes the path of an included file with "/" on every platform (utils.relative_path), so on Windows a warning located in such a file starts with C:/... while the source directory is spelt C:\...; the srcdir rewrite to / missed it and the three choose rows that locate a warning in an included file failed every Windows cell of CI. build_warnings now rewrites the POSIX spelling too (a no-op elsewhere), with a unit test that gives it a Windows source directory by hand. --- .../src/sphinx_needs_testkit/_warnings.py | 12 +++++++++--- .../sphinx-needs/tests/test_testkit_warnings.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/sphinx-needs-testkit/src/sphinx_needs_testkit/_warnings.py b/packages/sphinx-needs-testkit/src/sphinx_needs_testkit/_warnings.py index c216bee7e..056914cd0 100644 --- a/packages/sphinx-needs-testkit/src/sphinx_needs_testkit/_warnings.py +++ b/packages/sphinx-needs-testkit/src/sphinx_needs_testkit/_warnings.py @@ -70,7 +70,10 @@ def build_warnings( * ANSI colour codes stripped (``strip_colors``); * the source directory rewritten to ``/``, so an assertion can name a file - without knowing which temporary directory the fixture chose; + without knowing which temporary directory the fixture chose -- in its POSIX spelling + too, because docutils writes the path of an ``.. include::``\ d file with ``/`` on + every platform (``utils.relative_path``), so on Windows a warning located in such a + file starts with ``C:/…`` where the source directory is ``C:\…``; * one entry per warning record, **including its location**, with a multi-line message kept whole rather than split into one entry per line -- see :data:`_RECORD_START` for what "record" means here and where the heuristic stops holding; @@ -101,8 +104,11 @@ def build_warnings( # through `Path`, so a caller that passes a string with a trailing separator still # gets the rewrite (`app.srcdir` is a Path and never has one) root = str(Path(srcdir)) - for separator in (os.sep, "/"): - text = text.replace(root + separator, "/") + # the POSIX spelling matters on Windows only, where `root` carries backslashes: + # an included file's location is written with "/" (see the docstring) + posix_root = root.replace("\\", "/") + for prefix in dict.fromkeys((root + os.sep, root + "/", posix_root + "/")): + text = text.replace(prefix, "/") records: list[str] = [] for line in text.splitlines(): diff --git a/packages/sphinx-needs/tests/test_testkit_warnings.py b/packages/sphinx-needs/tests/test_testkit_warnings.py index d2a3cac88..529bd82d5 100644 --- a/packages/sphinx-needs/tests/test_testkit_warnings.py +++ b/packages/sphinx-needs/tests/test_testkit_warnings.py @@ -179,6 +179,20 @@ def test_no_srcdir_means_no_rewrite() -> None: assert build_warnings(stream) == [f"{SRCDIR}/index.rst:5: WARNING: x [needs.a]"] +def test_a_posix_spelt_location_under_a_windows_srcdir_is_rewritten() -> None: + """docutils writes the path of an ``.. include::``\ d file with ``/`` on every platform + (``utils.relative_path``), so on Windows a warning located in such a file starts with + ``C:/…`` while the source directory is ``C:\\…``. The ``choose`` rows that locate a + warning in an included file failed every Windows cell of CI on exactly this before the + POSIX spelling was rewritten too. A Linux run cannot tell the two spellings apart, so + the Windows source directory is given by hand; the helper never touches the file + system.""" + stream = "C:/Users/x/src/branches.txt:1: WARNING: a message [needs.choose]\n" + assert build_warnings(stream, srcdir="C:\\Users\\x\\src") == [ + "/branches.txt:1: WARNING: a message [needs.choose]" + ] + + # ------------------------------------------------------------------------------ warning_count From dadd8c4e0e9de9b78418c3372a008aff84e44950 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:23:27 +0000 Subject: [PATCH 18/26] =?UTF-8?q?=F0=9F=A7=AA=20CI:=20upload=20coverage=20?= =?UTF-8?q?on=20pushes=20to=20master=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Codecov upload steps required github.event.pull_request.head.repo to equal the repository, a guard against fork pull requests that has no value on a push event, so since #2009 every upload on master was skipped (measured on the run for cc0ffe0: all five "upload to Codecov" steps skipped). Codecov then compared every pull request against the newest ancestor with a report, d68d10d, 35 commits back and from before that PR split sphinx-test-reports, so the reports flag read -0.74% on pull requests that never touched the package. The guard now applies to pull request events only; a push to master uploads under its own secrets. --- .github/workflows/test-extensions.yaml | 14 +++++++++----- .github/workflows/test-package.yaml | 8 ++++++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test-extensions.yaml b/.github/workflows/test-extensions.yaml index db9eb76e1..dd0a8ed32 100644 --- a/.github/workflows/test-extensions.yaml +++ b/.github/workflows/test-extensions.yaml @@ -105,8 +105,12 @@ jobs: # `-m "not bazel"` deselects the two tests the `bazel` job owns run: uv run --no-sync pytest -v packages/sphinx-mounts/tests -m "not bazel" --durations=25 --cov=sphinx_mounts --cov-report=xml:mounts.xml --cov-report=term-missing - name: "sphinx-mounts: upload to Codecov" - # dependabot PRs get no secrets; without a token the upload fails and takes the required `check` gate with it - if: inputs.upload-coverage && github.event.pull_request.head.repo.full_name == github.repository && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' + # dependabot PRs get no secrets; without a token the upload fails and takes the required `check` gate with it. + # A push to master uploads too: Codecov compares a pull request against the newest ancestor that HAS a report, + # and between #2009 (which wrote the fork guard without a push case) and here no master commit had one, so + # every pull request was compared against a base from before that PR's package split and the `reports` flag + # read -0.74% on changes that never touched it + if: inputs.upload-coverage && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -135,7 +139,7 @@ jobs: if: ${{ !cancelled() && steps.prepare.outcome == 'success' }} run: uv run --no-sync pytest -v packages/sphinx-codelinks/tests --durations=25 --cov=sphinx_codelinks --cov-report=xml:codelinks.xml --cov-report=term-missing - name: "sphinx-codelinks: upload to Codecov" - if: inputs.upload-coverage && github.event.pull_request.head.repo.full_name == github.repository && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' + if: inputs.upload-coverage && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -157,7 +161,7 @@ jobs: if: ${{ !cancelled() && steps.prepare.outcome == 'success' }} run: uv run --no-sync pytest -v packages/sphinx-test-reports/tests --durations=25 --cov=sphinx_test_reports --cov-report=xml:reports.xml --cov-report=term-missing - name: "sphinx-test-reports: upload to Codecov" - if: inputs.upload-coverage && github.event.pull_request.head.repo.full_name == github.repository && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' + if: inputs.upload-coverage && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -175,7 +179,7 @@ jobs: if: ${{ !cancelled() && steps.prepare.outcome == 'success' }} run: uv run --no-sync pytest -v packages/ub-test-reports/tests --cov=ub_test_reports --cov-report=xml:ub-test-reports.xml --cov-report=term-missing - name: "ub-test-reports: upload to Codecov" - if: inputs.upload-coverage && github.event.pull_request.head.repo.full_name == github.repository && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' + if: inputs.upload-coverage && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/test-package.yaml b/.github/workflows/test-package.yaml index e2c7c7587..c51c6c1ea 100644 --- a/.github/workflows/test-package.yaml +++ b/.github/workflows/test-package.yaml @@ -129,8 +129,12 @@ jobs: # profile on otherwise) run: uv run --no-sync pytest -v ${{ inputs.test-path }} ${{ inputs.pytest-args }} --durations=25 --cov=${{ inputs.cov-module }} --cov-report=xml --cov-report=term-missing - name: Upload to Codecov - # dependabot PRs get no secrets; without a token the upload fails and takes the required `check` gate with it - if: inputs.upload-coverage && github.event.pull_request.head.repo.full_name == github.repository && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' + # dependabot PRs get no secrets; without a token the upload fails and takes the required `check` gate with it. + # A push to master uploads too: Codecov compares a pull request against the newest ancestor that HAS a report, + # and between #2009 (which wrote the fork guard without a push case) and here no master commit had one, so + # every pull request was compared against a base from before that PR's package split and the `reports` flag + # read -0.74% on changes that never touched it + if: inputs.upload-coverage && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} From f685d614fcc7c32a98966d4512af6a2839191f3a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 09:09:49 +0000 Subject: [PATCH 19/26] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20CI:=20move=20the=20C?= =?UTF-8?q?odecov=20upload=20fix=20to=20a=20pull=20request=20of=20its=20ow?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts dadd8c4 on this branch: the change lands separately, with the review corrections (fail_ci_if_error limited to pull requests, the token descriptions, the history of the guard), so that a squash merge of this branch does not bury a workspace-level CI change in a feature commit. --- .github/workflows/test-extensions.yaml | 14 +++++--------- .github/workflows/test-package.yaml | 8 ++------ 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/.github/workflows/test-extensions.yaml b/.github/workflows/test-extensions.yaml index dd0a8ed32..db9eb76e1 100644 --- a/.github/workflows/test-extensions.yaml +++ b/.github/workflows/test-extensions.yaml @@ -105,12 +105,8 @@ jobs: # `-m "not bazel"` deselects the two tests the `bazel` job owns run: uv run --no-sync pytest -v packages/sphinx-mounts/tests -m "not bazel" --durations=25 --cov=sphinx_mounts --cov-report=xml:mounts.xml --cov-report=term-missing - name: "sphinx-mounts: upload to Codecov" - # dependabot PRs get no secrets; without a token the upload fails and takes the required `check` gate with it. - # A push to master uploads too: Codecov compares a pull request against the newest ancestor that HAS a report, - # and between #2009 (which wrote the fork guard without a push case) and here no master commit had one, so - # every pull request was compared against a base from before that PR's package split and the `reports` flag - # read -0.74% on changes that never touched it - if: inputs.upload-coverage && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + # dependabot PRs get no secrets; without a token the upload fails and takes the required `check` gate with it + if: inputs.upload-coverage && github.event.pull_request.head.repo.full_name == github.repository && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -139,7 +135,7 @@ jobs: if: ${{ !cancelled() && steps.prepare.outcome == 'success' }} run: uv run --no-sync pytest -v packages/sphinx-codelinks/tests --durations=25 --cov=sphinx_codelinks --cov-report=xml:codelinks.xml --cov-report=term-missing - name: "sphinx-codelinks: upload to Codecov" - if: inputs.upload-coverage && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + if: inputs.upload-coverage && github.event.pull_request.head.repo.full_name == github.repository && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -161,7 +157,7 @@ jobs: if: ${{ !cancelled() && steps.prepare.outcome == 'success' }} run: uv run --no-sync pytest -v packages/sphinx-test-reports/tests --durations=25 --cov=sphinx_test_reports --cov-report=xml:reports.xml --cov-report=term-missing - name: "sphinx-test-reports: upload to Codecov" - if: inputs.upload-coverage && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + if: inputs.upload-coverage && github.event.pull_request.head.repo.full_name == github.repository && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -179,7 +175,7 @@ jobs: if: ${{ !cancelled() && steps.prepare.outcome == 'success' }} run: uv run --no-sync pytest -v packages/ub-test-reports/tests --cov=ub_test_reports --cov-report=xml:ub-test-reports.xml --cov-report=term-missing - name: "ub-test-reports: upload to Codecov" - if: inputs.upload-coverage && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + if: inputs.upload-coverage && github.event.pull_request.head.repo.full_name == github.repository && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/test-package.yaml b/.github/workflows/test-package.yaml index c51c6c1ea..e2c7c7587 100644 --- a/.github/workflows/test-package.yaml +++ b/.github/workflows/test-package.yaml @@ -129,12 +129,8 @@ jobs: # profile on otherwise) run: uv run --no-sync pytest -v ${{ inputs.test-path }} ${{ inputs.pytest-args }} --durations=25 --cov=${{ inputs.cov-module }} --cov-report=xml --cov-report=term-missing - name: Upload to Codecov - # dependabot PRs get no secrets; without a token the upload fails and takes the required `check` gate with it. - # A push to master uploads too: Codecov compares a pull request against the newest ancestor that HAS a report, - # and between #2009 (which wrote the fork guard without a push case) and here no master commit had one, so - # every pull request was compared against a base from before that PR's package split and the `reports` flag - # read -0.74% on changes that never touched it - if: inputs.upload-coverage && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + # dependabot PRs get no secrets; without a token the upload fails and takes the required `check` gate with it + if: inputs.upload-coverage && github.event.pull_request.head.repo.full_name == github.repository && github.repository == 'useblocks/sphinx-needs' && github.actor != 'dependabot[bot]' uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} From 6683c0477033003eeae5cec197c328fd1e019151 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 09:45:32 +0000 Subject: [PATCH 20/26] =?UTF-8?q?=F0=9F=90=9B=20sphinx-needs:=20refuse=20a?= =?UTF-8?q?=20branch=20written=20with=20one=20colon=20in=20a=20choose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.. when: var.arch == "arm"` (one colon) is a comment in reStructuredText, and so is `.. when::var.arch == "arm"` (no space after `::`): the comment swallows the indented branch under it, comments are accepted in a `choose`, and so for `arm` the `choose` rendered its `otherwise` with no warning, which is exactly the mistake the explicit `otherwise` exists to catch (review of #2020, point 1). A comment in the body whose text, after leading whitespace, begins with `when` or `otherwise` and a colon (case-insensitive) is now refused with one warning at the comment: "'choose' directive has a comment that begins with 'when:' (a branch written with one colon? write '.. when:: '); the whole choose is skipped" (for `otherwise`, "write '.. otherwise::'"). It is a fault of a child, found in document order with the others, before the no-branch check and the condition faults. A comment that merely begins with the word ("when we migrate, drop this") is still accepted. A MyST `%` comment follows the same rule. A comment carries no line of its own (docutils gives it the line after it, MyST the `choose`'s), so the warning's line is found in the content of the `choose`: the first body-level comment line the rule matches, skipping the lines docutils reads as `when` / `otherwise` directives and, under MyST, the lines inside the fences of the body's directives. Tests: `[when with one colon]`, `[otherwise with one colon]`, `[when without the space after ::]`, the control `[a comment that starts with the word when]` (no warning, its branch taken), the order pair `[a when without a condition, then a when with one colon]`, and MyST `[when with one colon, % comment]` with its control (a row without a text is now a control in the MyST table). `choose.rst` and the changelog entry list the mistake. --- packages/sphinx-needs/docs/changelog.rst | 16 ++-- .../sphinx-needs/docs/directives/choose.rst | 2 + .../src/sphinx_needs/directives/needchoose.py | 83 ++++++++++++++++++- .../tests/test_choose_directive.py | 80 +++++++++++++++++- 4 files changed, 170 insertions(+), 11 deletions(-) diff --git a/packages/sphinx-needs/docs/changelog.rst b/packages/sphinx-needs/docs/changelog.rst index 8f869cf09..e6affdf1f 100644 --- a/packages/sphinx-needs/docs/changelog.rst +++ b/packages/sphinx-needs/docs/changelog.rst @@ -39,14 +39,14 @@ Improvements ``choose`` may contain only ``when`` and ``otherwise`` directives and comments. Every mistake warns once under the new ``needs.choose`` type and skips the whole ``choose``: content outside a branch (any need it creates is removed again), a branch - inside another directive or supplied through an include, no branch at all, a - ``when`` without a condition, an ``otherwise`` with one, a misplaced or second - ``otherwise``, an argument on ``choose``, variant data that is not configured, and a - condition that cannot be evaluated — so a mistake that makes a condition - unevaluable, such as a misspelt key or a syntax error, never renders a later branch - or the ``otherwise`` in its place. Works in reStructuredText and in MyST Markdown. - The undocumented warning ``if`` gives for a condition whose result is not a bool is - now listed in its documentation. + inside another directive or supplied through an include, a branch written with one + colon, no branch at all, a ``when`` without a condition, an ``otherwise`` with one, a + misplaced or second ``otherwise``, an argument on ``choose``, variant data that is + not configured, and a condition that cannot be evaluated — so a mistake that makes a + condition unevaluable, such as a misspelt key or a syntax error, never renders a later + branch or the ``otherwise`` in its place. Works in reStructuredText and in MyST + Markdown. The undocumented warning ``if`` gives for a condition whose result is not a + bool is now listed in its documentation. .. _`release:8.5.0`: diff --git a/packages/sphinx-needs/docs/directives/choose.rst b/packages/sphinx-needs/docs/directives/choose.rst index 9f2a7a700..150781387 100644 --- a/packages/sphinx-needs/docs/directives/choose.rst +++ b/packages/sphinx-needs/docs/directives/choose.rst @@ -160,6 +160,8 @@ The mistakes are: from a condition that is false.) - The ``choose`` contains something that is neither a ``when``, an ``otherwise`` nor a comment. A line of only punctuation, such as ``---`` between two branches, is such content too. +- A comment that begins with ``when:`` or ``otherwise:``: a branch written with one colon, + or without the space after ``::``, is a comment in reStructuredText and would hand the choice to the ``otherwise``. - A branch is written inside another directive in the ``choose`` rather than directly in it. - A branch is supplied through an include rather than written in the body of the ``choose`` (the warning points at the branch in the included file). diff --git a/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py index 7662be26a..8513e15c8 100644 --- a/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py +++ b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py @@ -26,6 +26,7 @@ from __future__ import annotations +import re from collections.abc import Sequence from itertools import islice from typing import ClassVar, Literal @@ -56,6 +57,18 @@ _BranchKind = Literal["when", "otherwise"] """The directive a branch is written with.""" +_BRANCH_LIKE_COMMENT = re.compile(r"(when|otherwise)\s*:", re.IGNORECASE) +"""The start of a comment that is a branch directive written with one colon. + +``.. when: `` (one colon) and ``.. when::`` (no space after +``::``) are comments in reStructuredText, and swallow the indented content under them; +both begin with ``when`` and a colon. Matched against the comment's text after its +leading whitespace, so a comment that merely begins with the word is not matched. +""" + +_BRANCH_DIRECTIVE_LINE = re.compile(r"(when|otherwise) ?::( |$)", re.IGNORECASE) +"""What follows ``.. `` on a line that docutils reads as a branch directive.""" + class _BranchPlaceholder(nodes.Element): """What a branch leaves in the body of its ``choose``; it never reaches a doctree. @@ -342,7 +355,21 @@ def _collect_branches( return None branches.append(child) elif isinstance(child, nodes.comment): - continue + like = _BRANCH_LIKE_COMMENT.match(child.astext().lstrip()) + if like is None: + continue + # a branch written with one colon would hand the choice to the otherwise + kind = like.group(1).lower() + write = ( + "'.. when:: '" if kind == "when" else "'.. otherwise::'" + ) + self._warn( + f"'choose' directive has a comment that begins with '{kind}:' " + f"(a branch written with one colon? write {write}); the whole " + "choose is skipped", + self._branch_like_comment_location(child), + ) + return None elif isinstance(child, nodes.system_message): reported = max( self.state.document.reporter.report_level, Reporter.WARNING_LEVEL @@ -444,6 +471,60 @@ def _location_of(self, candidates: Sequence[nodes.Node]) -> nodes.Node | str | N return node return self.get_location() + def _branch_like_comment_location( + self, comment: nodes.comment, / + ) -> nodes.Node | str | None: + """Where to report the first comment of the body that reads like a branch. + + A comment carries no line of its own (docutils and MyST give it the line + being parsed when it is appended, which is after it, or the ``choose``'s), + so its line is found in the content of the ``choose``: + the first line at the level of the body whose comment text the rule matches. + That is the line of ``comment``, the first such comment the body holds. + Under docutils the lines of the body are those not indented, + and a line that docutils reads as a ``when`` or ``otherwise`` directive + (a name, an optional space, ``::``, then a space or the end of the line) + is not a comment; + under MyST, the lines of the body are those outside the fences + of the directives in it, and a ``%`` line (or a ``+++`` block break) + is a comment. + + :param comment: The comment, used as the location if no line is found + (for one an include supplied). + :return: ``":"``, or the fallback. + """ + rst = isinstance(self.state, RSTState) + fence: str | None = None + for index, line in enumerate(self.content): + text: str | None = None + if rst: + markup = re.match(r"\.\.[ ]+(.*)", line) + text = markup.group(1) if markup else None + if text is not None and _BRANCH_DIRECTIVE_LINE.match(text): + continue + elif fence is not None: + closing = line.rstrip() + if closing.startswith(fence) and set(closing) == {fence[0]}: + fence = None + continue + elif opening := re.match(r"(`{3,}|~{3,}|:{3,})", line): + fence = opening.group(1) + continue + elif line.startswith("%"): + text = line[1:] + elif line.startswith("+++"): + text = line[3:] + if text is not None and _BRANCH_LIKE_COMMENT.match(text.lstrip()): + source, offset = self.content.info(index) + if not rst: + # MyST numbers the lines of a directive's content from 0 + offset = self.lineno + index + if source and offset is not None: + return f"{source}:{offset + 1}" + break + source, line = get_source_line(comment) + return comment if source and line else self.get_location() + def _parse_branch(self, branch: _BranchPlaceholder) -> list[nodes.Node]: """Parse the content of the branch that is taken, with section titles allowed. diff --git a/packages/sphinx-needs/tests/test_choose_directive.py b/packages/sphinx-needs/tests/test_choose_directive.py index cb7d85845..4104804da 100644 --- a/packages/sphinx-needs/tests/test_choose_directive.py +++ b/packages/sphinx-needs/tests/test_choose_directive.py @@ -202,6 +202,18 @@ def _included(kind: str) -> str: "'choose' directive may contain only 'when' and 'otherwise' directives and comments" ) _NO_BRANCH = "'choose' directive has no 'when' or 'otherwise'" + + +def _branch_like(kind: str, write: str) -> str: + """The warning about a comment that begins with ``kind`` and a colon.""" + return ( + f"'choose' directive has a comment that begins with '{kind}:' " + f"(a branch written with one colon? write {write})" + _SKIP + ) + + +_WHEN_LIKE = _branch_like("when", "'.. when:: '") +_OTHERWISE_LIKE = _branch_like("otherwise", "'.. otherwise::'") _BRANCHES_TXT = ( '.. when:: var.arch == "xyz"\n\n SKIPPED_X1_FROM_INCLUDE\n\n' ".. otherwise::\n\n SKIPPED_X1_DEFAULT_FROM_INCLUDE\n" @@ -324,6 +336,45 @@ def _included(kind: str) -> str: ), ), ), + # a branch written with one colon is a comment that swallows the content under it; + # comments are accepted, so for the variant it was written for (`abc`) the choose + # would render its otherwise, silently + "when with one colon": _Expected( + ".. choose::\n\n" + " .. when: var.arch == 'abc'\n\n SKIPPED_SWALLOWED_BRANCH\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + ((_WHEN_LIKE, " .. when: var.arch == 'abc'"),), + ), + "otherwise with one colon": _Expected( + ".. choose::\n\n" + " .. when:: var.arch == 'xyz'\n\n SKIPPED_XYZ\n\n" + " .. otherwise:\n\n SKIPPED_SWALLOWED_OTHERWISE\n", + ((_OTHERWISE_LIKE, " .. otherwise:"),), + ), + # docutils needs a space (or the end of the line) after `::` for a directive + "when without the space after ::": _Expected( + ".. choose::\n\n" + " .. when::var.arch == 'abc'\n\n SKIPPED_SWALLOWED_BRANCH\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + ((_WHEN_LIKE, " .. when::var.arch == 'abc'"),), + ), + # the control: a comment that merely begins with the word is accepted + "a comment that starts with the word when": _Expected( + ".. choose::\n\n" + " .. when we migrate, drop this\n\n" + " .. when:: var.arch == 'abc'\n\n TAKEN_AFTER_WORD_COMMENT\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + (), + taken=("TAKEN_AFTER_WORD_COMMENT",), + ), + # it is a fault of a child, found with the others in document order, before the + # condition faults: after a when without a condition, the comment is reported + "a when without a condition, then a when with one colon": _Expected( + ".. choose::\n\n" + " .. when::\n\n SKIPPED_FORGOTTEN_CONDITION\n\n" + " .. when: var.debug\n\n SKIPPED_SWALLOWED_BRANCH\n", + ((_WHEN_LIKE, " .. when: var.debug"),), + ), "paragraph in the body": _Expected( ".. choose::\n\n" " .. when:: True\n\n SKIPPED_BRANCH\n\n" @@ -1267,6 +1318,21 @@ def test_choose_in_myst(test_app): "'when' directive expression failed: 'invalid !!!'", None, ), + # a `%` line is a comment in MyST, under the same rule + "when with one colon, % comment": ( + "````{choose}\n% when: var.arch == 'abc'\n" + "```{otherwise}\nSKIPPED_OTHERWISE\n```\n````\n", + _WHEN_LIKE, + "% when: var.arch == 'abc'", + ), + # the control: accepted, and its branch is taken (no warning) + "a % comment that starts with the word when": ( + "````{choose}\n% when we migrate, drop this\n" + "```{when} var.arch == 'abc'\nTAKEN_AFTER_WORD_COMMENT\n```\n" + "```{otherwise}\nSKIPPED_OTHERWISE\n```\n````\n", + None, + None, + ), # an `{eval-rst}` block is parsed by docutils into a document of its own, # so the branch in it is not a direct child of the choose "branch inside eval-rst, backticks": ( @@ -1294,10 +1360,20 @@ def test_choose_in_myst(test_app): ids=list(_MYST_WARNINGS), indirect=["test_app"], ) -def test_choose_warnings_in_myst(test_app, text: str, line: str | None): - """The MyST spellings warn once each and fail closed, as in reStructuredText.""" +def test_choose_warnings_in_myst(test_app, text: str | None, line: str | None): + """The MyST spellings warn once each and fail closed, as in reStructuredText. + + A row without a text is a control: it gives no warning, and its branch is taken. + """ app = test_app app.build() + if text is None: + assert build_warnings(app) == [] + html = Path(app.outdir, "index.html").read_text() + assert "TAKEN_" in html + assert "SKIPPED" not in html + _assert_no_choose_nodes(app) + return (warning,) = build_warnings(app) assert text in warning, warning assert warning.endswith(" [needs.choose]"), warning From e3d037d1dc9ac934a39063a39e41d95074943c61 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 10:03:03 +0000 Subject: [PATCH 21/26] =?UTF-8?q?=F0=9F=A7=AA=20sphinx-needs:=20pin=20the?= =?UTF-8?q?=20case=20and=20spacing=20of=20the=20one-colon=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rows pin that a comment beginning with `When:` or `when :` is refused like `when:`, since ubCode mirrors the rule; and the docs bullet says what a one-colon `otherwise` does (the default vanishes) rather than claiming it hands the choice to the `otherwise`. --- packages/sphinx-needs/docs/directives/choose.rst | 3 ++- .../sphinx-needs/tests/test_choose_directive.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/sphinx-needs/docs/directives/choose.rst b/packages/sphinx-needs/docs/directives/choose.rst index 150781387..92391946a 100644 --- a/packages/sphinx-needs/docs/directives/choose.rst +++ b/packages/sphinx-needs/docs/directives/choose.rst @@ -161,7 +161,8 @@ The mistakes are: - The ``choose`` contains something that is neither a ``when``, an ``otherwise`` nor a comment. A line of only punctuation, such as ``---`` between two branches, is such content too. - A comment that begins with ``when:`` or ``otherwise:``: a branch written with one colon, - or without the space after ``::``, is a comment in reStructuredText and would hand the choice to the ``otherwise``. + or without the space after ``::``, is a comment in reStructuredText; a ``when`` written so would hand the choice + to the ``otherwise``, and an ``otherwise`` written so would make the default vanish. - A branch is written inside another directive in the ``choose`` rather than directly in it. - A branch is supplied through an include rather than written in the body of the ``choose`` (the warning points at the branch in the included file). diff --git a/packages/sphinx-needs/tests/test_choose_directive.py b/packages/sphinx-needs/tests/test_choose_directive.py index 4104804da..500424ef8 100644 --- a/packages/sphinx-needs/tests/test_choose_directive.py +++ b/packages/sphinx-needs/tests/test_choose_directive.py @@ -351,6 +351,20 @@ def _branch_like(kind: str, write: str) -> str: " .. otherwise:\n\n SKIPPED_SWALLOWED_OTHERWISE\n", ((_OTHERWISE_LIKE, " .. otherwise:"),), ), + # the rule is case-insensitive, as directive names are + "When with one colon, capitalised": _Expected( + ".. choose::\n\n" + " .. When: var.arch == 'abc'\n\n SKIPPED_SWALLOWED_BRANCH\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + ((_WHEN_LIKE, " .. When: var.arch == 'abc'"),), + ), + # and tolerates whitespace before the colon + "when with a space before one colon": _Expected( + ".. choose::\n\n" + " .. when : var.arch == 'abc'\n\n SKIPPED_SWALLOWED_BRANCH\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + ((_WHEN_LIKE, " .. when : var.arch == 'abc'"),), + ), # docutils needs a space (or the end of the line) after `::` for a directive "when without the space after ::": _Expected( ".. choose::\n\n" From c34102c6d4aa148318e9ce7eeef75472cce2232c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 10:05:48 +0000 Subject: [PATCH 22/26] =?UTF-8?q?=F0=9F=91=8C=20sphinx-needs:=20the=20one-?= =?UTF-8?q?colon=20hint=20names=20the=20MyST=20fence=20under=20MyST?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The warning about a comment that begins with `when:` or `otherwise:` told a MyST author to write `.. when:: `, which is reStructuredText. Under MyST it now says "write a '{when} ' fence" (or "an '{otherwise}' fence"); the reStructuredText spelling is unchanged. ubCode gives the same hint by syntax. The MyST row for `% when:` expects the fence hint, and a new MyST row pins the `otherwise` form. --- .../src/sphinx_needs/directives/needchoose.py | 16 +++++++++++++--- .../sphinx-needs/tests/test_choose_directive.py | 13 +++++++++++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py index 8513e15c8..7cb1e0789 100644 --- a/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py +++ b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py @@ -360,9 +360,19 @@ def _collect_branches( continue # a branch written with one colon would hand the choice to the otherwise kind = like.group(1).lower() - write = ( - "'.. when:: '" if kind == "when" else "'.. otherwise::'" - ) + # the hint follows the syntax the comment is written in + if isinstance(self.state, RSTState): + write = ( + "'.. when:: '" + if kind == "when" + else "'.. otherwise::'" + ) + else: + write = ( + "a '{when} ' fence" + if kind == "when" + else "an '{otherwise}' fence" + ) self._warn( f"'choose' directive has a comment that begins with '{kind}:' " f"(a branch written with one colon? write {write}); the whole " diff --git a/packages/sphinx-needs/tests/test_choose_directive.py b/packages/sphinx-needs/tests/test_choose_directive.py index 500424ef8..a02030118 100644 --- a/packages/sphinx-needs/tests/test_choose_directive.py +++ b/packages/sphinx-needs/tests/test_choose_directive.py @@ -214,6 +214,9 @@ def _branch_like(kind: str, write: str) -> str: _WHEN_LIKE = _branch_like("when", "'.. when:: '") _OTHERWISE_LIKE = _branch_like("otherwise", "'.. otherwise::'") +# under MyST the hint names the fence +_WHEN_LIKE_MYST = _branch_like("when", "a '{when} ' fence") +_OTHERWISE_LIKE_MYST = _branch_like("otherwise", "an '{otherwise}' fence") _BRANCHES_TXT = ( '.. when:: var.arch == "xyz"\n\n SKIPPED_X1_FROM_INCLUDE\n\n' ".. otherwise::\n\n SKIPPED_X1_DEFAULT_FROM_INCLUDE\n" @@ -1332,13 +1335,19 @@ def test_choose_in_myst(test_app): "'when' directive expression failed: 'invalid !!!'", None, ), - # a `%` line is a comment in MyST, under the same rule + # a `%` line is a comment in MyST, under the same rule; the hint names the fence "when with one colon, % comment": ( "````{choose}\n% when: var.arch == 'abc'\n" "```{otherwise}\nSKIPPED_OTHERWISE\n```\n````\n", - _WHEN_LIKE, + _WHEN_LIKE_MYST, "% when: var.arch == 'abc'", ), + "otherwise with one colon, % comment": ( + "````{choose}\n```{when} var.arch == 'xyz'\nSKIPPED_XYZ\n```\n" + "% otherwise:\n````\n", + _OTHERWISE_LIKE_MYST, + "% otherwise:", + ), # the control: accepted, and its branch is taken (no warning) "a % comment that starts with the word when": ( "````{choose}\n% when we migrate, drop this\n" From 2b6d8968401928395ec2f161df1d18998b2a2aad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 10:56:08 +0000 Subject: [PATCH 23/26] =?UTF-8?q?=F0=9F=90=9B=20sphinx-needs:=20locate=20c?= =?UTF-8?q?hoose=20warnings=20with=20an=20absolute=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docutils records an included file with `utils.relative_path(None, path)`, which is relative to the working directory whenever the two share their first two path components: a checkout under /tmp building under /tmp, or, on Windows, a checkout and %TEMP% both under C:\Users. A warning about a branch in an included file (or about anything in a `choose` that stands in one) then read `../…/branches.txt:1`, and the suite's rows that locate such a warning failed wherever the checkout lived (review of #2020, point 3). Every location the `choose` directives report now goes through one helper that makes the source absolute, as Sphinx does for a node's location: the `choose`'s own warnings, the stray-branch warning, the condition warnings the shared evaluator gives for a `when`, and the line the one-colon rule finds. A node location is left to Sphinx, which makes it absolute itself. The include check compares the two sources absolute as well. Measured from a worktree under /tmp, with this code: the three include rows failed before the change, located at `../../../../../../../../../sn_test_build_data/…/branches.txt:1`, and pass after it. A unit test pins the helper with a relative source. --- .../src/sphinx_needs/directives/needchoose.py | 35 ++++++++++++++++--- .../tests/test_choose_directive.py | 23 +++++++++++- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py index 7cb1e0789..c27864595 100644 --- a/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py +++ b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py @@ -26,6 +26,7 @@ from __future__ import annotations +import os import re from collections.abc import Sequence from itertools import islice @@ -70,6 +71,30 @@ """What follows ``.. `` on a line that docutils reads as a branch directive.""" +def _absolute_source(source: str | None, /) -> str | None: + """``source`` made absolute, as Sphinx makes the source of a node's location. + + docutils records an included file relative to the working directory + (``utils.relative_path``) whenever the two share their first two path components, + so the raw source of a directive in an included file may read ``../…``. + """ + return os.path.abspath(source) if source else source + + +def _absolute_location(location: str | nodes.Node | None, /) -> str | nodes.Node | None: + """A ``":"`` location with its source made absolute. + + Every location this module reports goes through here. + A node is returned as it is: Sphinx makes the source of a node absolute itself. + """ + if not isinstance(location, str): + return location + source, colon, line = location.rpartition(":") + if not colon or not source or source == "": + return location + return f"{_absolute_source(source)}:{line}" + + class _BranchPlaceholder(nodes.Element): """What a branch leaves in the body of its ``choose``; it never reaches a doctree. @@ -138,7 +163,7 @@ def run(self) -> Sequence[nodes.Node]: f"'{kind}' directive outside a 'choose' ({article} '{kind}' must be a " "direct child of a 'choose'); its content is skipped", "choose", - location=self.get_location(), + location=_absolute_location(self.get_location()), ) return [] @@ -260,7 +285,7 @@ def run(self) -> Sequence[nodes.Node]: branch.condition, directive="when", subtype="choose", - location=branch.location, + location=_absolute_location(branch.location), ) if taken is None: # poisoned: no later branch is evaluated or taken, nor the otherwise @@ -275,7 +300,9 @@ def _warn(self, message: str, location: str | nodes.Node | None = None, /) -> No LOGGER, message, "choose", - location=self.get_location() if location is None else location, + location=_absolute_location( + self.get_location() if location is None else location + ), ) def _parse_body(self) -> _ChooseBody: @@ -337,7 +364,7 @@ def _collect_branches( if isinstance(child, _BranchPlaceholder): # the source first: a branch an include supplies is reported as such, # also when the include stands inside another directive - if child.source != source: + if _absolute_source(child.source) != _absolute_source(source): self._warn( f"'{child.kind}' supplied through an include is not supported " "(write the branches in the body of the 'choose'); the whole " diff --git a/packages/sphinx-needs/tests/test_choose_directive.py b/packages/sphinx-needs/tests/test_choose_directive.py index a02030118..d8a800d4a 100644 --- a/packages/sphinx-needs/tests/test_choose_directive.py +++ b/packages/sphinx-needs/tests/test_choose_directive.py @@ -3,6 +3,7 @@ from __future__ import annotations import importlib.util +import os from pathlib import Path from typing import NamedTuple @@ -10,7 +11,11 @@ from docutils import nodes from sphinx_needs.data import SphinxNeedsData -from sphinx_needs.directives.needchoose import _BranchPlaceholder, _ChooseBody +from sphinx_needs.directives.needchoose import ( + _absolute_location, + _BranchPlaceholder, + _ChooseBody, +) from sphinx_needs_testkit import assert_no_warnings, build_warnings _NEEDS_TYPES = ( @@ -1444,3 +1449,19 @@ def test_choose_refuses_included_branches_in_myst(test_app): assert warning.endswith(" [needs.choose]"), warning assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() _assert_no_choose_nodes(app) + + +def test_absolute_location(): + """A ``:`` location is reported with an absolute source. + + docutils gives an included file a path relative to the working directory + whenever the two share their first two path components (a checkout under + ``/tmp`` with its builds under ``/tmp``), which would read ``../…`` in a warning. + A node is left to Sphinx, which makes its source absolute itself. + """ + relative = os.path.join("..", "x", "branches.txt") + assert _absolute_location(f"{relative}:1") == f"{os.path.abspath(relative)}:1" + absolute = os.path.abspath("index.rst") + assert _absolute_location(f"{absolute}:7") == f"{absolute}:7" + node = nodes.paragraph() + assert _absolute_location(node) is node From b521eee769ccc377945de36758be72d36e7dce31 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 10:56:14 +0000 Subject: [PATCH 24/26] =?UTF-8?q?Revert=20"=F0=9F=A7=AA=20sphinx-needs-tes?= =?UTF-8?q?tkit:=20rewrite=20a=20POSIX-spelt=20location=20on=20Windows"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 48d293d567bcacd27f74101aea20730cbed007ed. --- .../src/sphinx_needs_testkit/_warnings.py | 12 +++--------- .../sphinx-needs/tests/test_testkit_warnings.py | 14 -------------- 2 files changed, 3 insertions(+), 23 deletions(-) diff --git a/packages/sphinx-needs-testkit/src/sphinx_needs_testkit/_warnings.py b/packages/sphinx-needs-testkit/src/sphinx_needs_testkit/_warnings.py index 056914cd0..c216bee7e 100644 --- a/packages/sphinx-needs-testkit/src/sphinx_needs_testkit/_warnings.py +++ b/packages/sphinx-needs-testkit/src/sphinx_needs_testkit/_warnings.py @@ -70,10 +70,7 @@ def build_warnings( * ANSI colour codes stripped (``strip_colors``); * the source directory rewritten to ``/``, so an assertion can name a file - without knowing which temporary directory the fixture chose -- in its POSIX spelling - too, because docutils writes the path of an ``.. include::``\ d file with ``/`` on - every platform (``utils.relative_path``), so on Windows a warning located in such a - file starts with ``C:/…`` where the source directory is ``C:\…``; + without knowing which temporary directory the fixture chose; * one entry per warning record, **including its location**, with a multi-line message kept whole rather than split into one entry per line -- see :data:`_RECORD_START` for what "record" means here and where the heuristic stops holding; @@ -104,11 +101,8 @@ def build_warnings( # through `Path`, so a caller that passes a string with a trailing separator still # gets the rewrite (`app.srcdir` is a Path and never has one) root = str(Path(srcdir)) - # the POSIX spelling matters on Windows only, where `root` carries backslashes: - # an included file's location is written with "/" (see the docstring) - posix_root = root.replace("\\", "/") - for prefix in dict.fromkeys((root + os.sep, root + "/", posix_root + "/")): - text = text.replace(prefix, "/") + for separator in (os.sep, "/"): + text = text.replace(root + separator, "/") records: list[str] = [] for line in text.splitlines(): diff --git a/packages/sphinx-needs/tests/test_testkit_warnings.py b/packages/sphinx-needs/tests/test_testkit_warnings.py index 529bd82d5..d2a3cac88 100644 --- a/packages/sphinx-needs/tests/test_testkit_warnings.py +++ b/packages/sphinx-needs/tests/test_testkit_warnings.py @@ -179,20 +179,6 @@ def test_no_srcdir_means_no_rewrite() -> None: assert build_warnings(stream) == [f"{SRCDIR}/index.rst:5: WARNING: x [needs.a]"] -def test_a_posix_spelt_location_under_a_windows_srcdir_is_rewritten() -> None: - """docutils writes the path of an ``.. include::``\ d file with ``/`` on every platform - (``utils.relative_path``), so on Windows a warning located in such a file starts with - ``C:/…`` while the source directory is ``C:\\…``. The ``choose`` rows that locate a - warning in an included file failed every Windows cell of CI on exactly this before the - POSIX spelling was rewritten too. A Linux run cannot tell the two spellings apart, so - the Windows source directory is given by hand; the helper never touches the file - system.""" - stream = "C:/Users/x/src/branches.txt:1: WARNING: a message [needs.choose]\n" - assert build_warnings(stream, srcdir="C:\\Users\\x\\src") == [ - "/branches.txt:1: WARNING: a message [needs.choose]" - ] - - # ------------------------------------------------------------------------------ warning_count From cdc6b1b725fb4097fba2395fd942fb0baa64b337 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 11:13:49 +0000 Subject: [PATCH 25/26] =?UTF-8?q?=F0=9F=A7=AA=20sphinx-needs:=20build=20th?= =?UTF-8?q?e=20choose=20warning=20rows=20from=20the=20source=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docutils records an included file relative to the working directory only when the two share their first two path components, so from a CI runner the absolute-location fix was never exercised. The warning rows now build from the source directory, where every include is recorded relative, and two rows cover the other exits that can report a location in an included file: an unevaluable condition inside an included choose, and a stray branch. Each exit now has a row that fails when it loses the absolute source. --- .../src/sphinx_needs/directives/needchoose.py | 5 ++- .../tests/test_choose_directive.py | 37 +++++++++++++++++-- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py index c27864595..d9a6af7ae 100644 --- a/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py +++ b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py @@ -75,8 +75,9 @@ def _absolute_source(source: str | None, /) -> str | None: """``source`` made absolute, as Sphinx makes the source of a node's location. docutils records an included file relative to the working directory - (``utils.relative_path``) whenever the two share their first two path components, - so the raw source of a directive in an included file may read ``../…``. + (``utils.relative_path``) whenever the two share their first two path components: + a build run from the project's own directory, the common case, gives ``docs/inc.txt``, + and a test run from a checkout under ``/tmp`` gives ``../…``. """ return os.path.abspath(source) if source else source diff --git a/packages/sphinx-needs/tests/test_choose_directive.py b/packages/sphinx-needs/tests/test_choose_directive.py index d8a800d4a..76fdd18f3 100644 --- a/packages/sphinx-needs/tests/test_choose_directive.py +++ b/packages/sphinx-needs/tests/test_choose_directive.py @@ -637,6 +637,26 @@ def _branch_like(kind: str, write: str) -> str: ), # the structure is checked before any condition: a true branch written in place # before the included ones is not taken either + # the two other exits that may report a location in an included file: + # an evaluation fault inside a choose the include holds, and a stray branch + "an unevaluable when in an included choose": _Expected( + ".. include:: inc.txt\n", + (("'when' directive expression failed", " .. when:: invalid !!!"),), + extra=( + ( + "inc.txt", + ".. choose::\n\n .. when:: invalid !!!\n\n SKIPPED_INC\n\n" + " .. otherwise::\n\n SKIPPED_INC_DEFAULT\n", + ), + ), + located_in="inc.txt", + ), + "a stray when in an included file": _Expected( + ".. include:: stray.txt\n", + (("'when' directive outside a 'choose'", ".. when:: True"),), + extra=(("stray.txt", ".. when:: True\n\n SKIPPED_STRAY\n"),), + located_in="stray.txt", + ), "a branch from an include after a true branch": _Expected( ".. choose::\n\n" " .. when:: True\n\n SKIPPED_IN_PLACE\n\n" @@ -688,9 +708,16 @@ def _branch_like(kind: str, write: str) -> str: ids=list(_WARNINGS), indirect=["test_app"], ) -def test_choose_warnings(test_app, expected: _Expected): - """Each mistake warns exactly once, at the offending line, and fails closed.""" +def test_choose_warnings(test_app, expected: _Expected, monkeypatch): + """Each mistake warns exactly once, at the offending line, and fails closed. + + Built from the source directory: docutils then records an included file relative + to the working directory (``branches.txt`` rather than an absolute path), which is + what the warnings must make absolute again, and what a build from a project's + own directory gives in practice. + """ app = test_app + monkeypatch.chdir(app.srcdir) app.build() warnings = build_warnings(app) assert len(warnings) == len(expected.warnings), warnings @@ -1388,12 +1415,16 @@ def test_choose_in_myst(test_app): ids=list(_MYST_WARNINGS), indirect=["test_app"], ) -def test_choose_warnings_in_myst(test_app, text: str | None, line: str | None): +def test_choose_warnings_in_myst( + test_app, text: str | None, line: str | None, monkeypatch +): """The MyST spellings warn once each and fail closed, as in reStructuredText. A row without a text is a control: it gives no warning, and its branch is taken. + Built from the source directory, as the reStructuredText rows are. """ app = test_app + monkeypatch.chdir(app.srcdir) app.build() if text is None: assert build_warnings(app) == [] From b00e9914d8cf6be44c0a6bd9f385af67fd67f93e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 15:36:13 +0000 Subject: [PATCH 26/26] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20sphinx-needs:=20gate?= =?UTF-8?q?=20the=20choose=20body=20before=20parsing=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `choose` found its branches by parsing its whole body, so anything written there outside a branch ran before it was refused: needs were rolled back afterwards, but a label, a `needextend`, an included file or another extension's directive stayed, and a docutils message hidden by a raised `report_level` let the `otherwise` render with `-W` green (review of #2020, points 2 and 4). The `choose` now reads its body's top-level lines first (`_gate`, a pure function) and refuses the body, with one warning at the line, at the first line that is neither a branch start, a comment, nor blank. Nothing is parsed then. Only branches and comments ever reach the parser, so nothing else in the body can run. The safety rule: the gate may refuse a line the parser would accept (a refused line is never parsed; the author gets a warning), but it never passes a line the parser would execute as something else, and it never believes it is inside a branch where the parser is not. So it mirrors the parsers' own spelling rules where it accepts, and CommonMark's closing rule where a MyST branch ends. - reStructuredText (docutils 0.21 / 0.22 `states.py`, `Body.patterns`, `explicit.constructs`, `comment()`; `directives.directive` lower-cases the name): only column-0 lines start a construct; an indented line belongs to the explicit markup above it, except after an empty comment followed by a blank line, which docutils ends there (the indented block after it would be a block quote of the body: refused). A column-0 line must match the explicit markup start `^\.\.( +|$)`; a footnote or citation (`[`), target (`_`) or substitution definition (`|`) is refused; a directive (docutils' own `simplename`, an optional space, `::`, a space or the end) is a branch start if its name, in any case, is `when` or `otherwise`, and refused otherwise (`.. note::`, `.. include::`, `.. if:: False`, `.. default-role::`); what remains is a comment, refused if it begins with `when` or `otherwise` and a colon (the one-colon rule moves here, same message and hint) and, as docutils does not keep it, if it is the end-of-inclusion marker. - MyST (markdown-it-py 3 / 4 `fence`, mdit-py-plugins 0.6 `colon_fence` and `myst_blocks`, myst-parser 4 / 5 `render_fence` / `render_colon_fence`): a branch opens at the line ^ {0,3}(:{3,}|`{3,}|~{3,})[ \t]*\{(when|otherwise)\}(?=\s|$) case-insensitively (myst takes the first word of the stripped info string, so `::: {when} cond` is a branch and `:::{when}cond` is not; a backtick fence may not have a backtick in its info string), and closes at the first later line of at least as many of the same character, indented at most three spaces, with only spaces or tabs after it; an unclosed branch runs to the end. Between branches only a `%` comment and a `+++` block break (three `+` or more, mixed with spaces and tabs), each indented up to three spaces as myst-parser's `line_comment` and `block_break` accept them, and blank lines are accepted (both under the one-colon rule); anything else (a paragraph, a heading, ```) is raw HTML rather than a comment, so it is a mistake here. Any other content outside a branch is a mistake, - and the needs it would create are removed again. + refused before anything in the body is parsed, so nothing in it ever runs. A branch belongs directly in a ``choose``: one anywhere else is a mistake too, whether it is written loose in the content of another branch @@ -160,12 +160,14 @@ The mistakes are: from a condition that is false.) - The ``choose`` contains something that is neither a ``when``, an ``otherwise`` nor a comment. A line of only punctuation, such as ``---`` between two branches, is such content too. + It is refused at its line before anything in the body is parsed, + so a directive there (a need, an ``.. include::``, a false ``if``) never runs. - A comment that begins with ``when:`` or ``otherwise:``: a branch written with one colon, or without the space after ``::``, is a comment in reStructuredText; a ``when`` written so would hand the choice to the ``otherwise``, and an ``otherwise`` written so would make the default vanish. -- A branch is written inside another directive in the ``choose`` rather than directly in it. -- A branch is supplied through an include rather than written in the body of the ``choose`` - (the warning points at the branch in the included file). +- A branch is written inside another directive in the ``choose`` rather than directly in it, + or supplied through an include: the directive or the include is such content, + and the warning points at its line (the included file is never read). - A ``when`` has no condition: write the default as an ``otherwise``. - An ``otherwise`` is given a condition. The warning names it: content written on the line right after ``.. otherwise::``, with no blank line between, is read as one. @@ -175,21 +177,17 @@ The mistakes are: A ``when`` or an ``otherwise`` outside a ``choose`` warns as well, and its content is skipped. A condition whose result is not a ``bool`` warns, and its truth value is used. -A mistake that docutils or MyST already reports in the content of a ``choose``, -such as an unknown directive name, is not reported a second time; -the ``choose`` is skipped all the same. +A line that docutils or MyST would report, such as an unknown directive name, +is refused by the ``choose`` before either parses it, +so it is reported once, by the ``choose``, whatever the project's ``report_level``. .. note:: - A directive that produces no node, written directly in a ``choose``, is not detected: - it still runs, and the ``choose`` goes on. - ``default-role`` is one such directive, and so is a **false** ``if``: - it returns nothing, so the branches written inside it vanish without a warning, - unless the ``choose`` is left with no branch at all. - Nor is a MyST substitution: a ``{{ sub }}`` in a ``choose`` whose definition holds branches - is expanded in place, and its branches are taken without a warning. - Needs are the one effect of content outside a branch that is undone; - any other (a label, a ``needextend``) stays, so keep every directive inside a branch. + The body of a ``choose`` is read line by line before it is parsed, + so content outside a branch is refused even where it would leave no trace in the document: + a directive that produces no node, such as ``default-role`` or a **false** ``if``, + and a MyST substitution reference (``{{ sub }}``), which is a line of text to the ``choose``, + whatever branches its definition holds. .. note:: diff --git a/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py index d9a6af7ae..ee6c5cc04 100644 --- a/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py +++ b/packages/sphinx-needs/src/sphinx_needs/directives/needchoose.py @@ -6,11 +6,32 @@ an ``otherwise``, which takes no condition, is the default, and must be the last branch. -A branch does not parse its content. -Inside a ``choose`` body it returns a transient :class:`_BranchPlaceholder` -carrying its kind, its condition and its raw content, -and the ``choose`` parses its own body into a detached :class:`_ChooseBody` -that it never returns. +Before anything in its body is parsed, a ``choose`` reads the body's top-level lines +(:func:`_gate`) and refuses the body at the first one that is neither the start of a +branch, a comment, nor blank. So nothing written outside a branch ever runs: +a need, a label, an ``.. include::`` or another extension's directive there +is refused with a warning, never executed and undone. + +The gate's safety rule: it may refuse a line the parser would have accepted +(a refused line is never parsed, and the author gets a warning), +but it must never pass a line the parser would execute as something other than +a branch or a comment, and it must never believe it is inside a branch where the +parser is outside one (the lines it skips there would escape it). +So what it accepts mirrors the parser's own spelling rules exactly, +and where a MyST branch ends follows the CommonMark closing rule exactly. +Under MyST it reads the document's own parser configuration (front matter included; +the global configuration when the renderer does not expose it), +so that it opens a branch only with a fence kind the document's parser has +(colon, backtick or tilde), accepts ``%`` comments and ``+++`` block breaks indented +up to three spaces as myst-parser does (at any indentation when the document's parser +has its ``code`` rule disabled, as markdown-it then allows every construct), +and refuses an opener that markdown-it would take for the header of a table. +Under any other parser the ``choose`` is refused, with a warning: the gate +could not read the body, and nothing in it may run unread. + +Then the body is parsed into a detached :class:`_ChooseBody` that is never returned. +A branch does not parse its content: in the body it returns a transient +:class:`_BranchPlaceholder` carrying its kind, its condition and its raw content. Having seen every branch at once, the ``choose`` checks the structure, evaluates the conditions in order with the evaluator of the ``if`` directive (:func:`~sphinx_needs.directives.needif.evaluate_variant_condition`), @@ -29,18 +50,15 @@ import os import re from collections.abc import Sequence -from itertools import islice -from typing import ClassVar, Literal +from typing import ClassVar, Literal, NamedTuple from docutils import nodes from docutils.parsers.rst.states import RSTState from docutils.statemachine import StringList -from docutils.utils import Reporter, get_source_line from sphinx.util.docutils import SphinxDirective from sphinx.util.nodes import nested_parse_with_titles from sphinx_needs.config import NeedsSphinxConfig -from sphinx_needs.data import SphinxNeedsData from sphinx_needs.directives.needif import evaluate_variant_condition from sphinx_needs.logging import get_logger, log_warning @@ -58,7 +76,9 @@ _BranchKind = Literal["when", "otherwise"] """The directive a branch is written with.""" -_BRANCH_LIKE_COMMENT = re.compile(r"(when|otherwise)\s*:", re.IGNORECASE) +_BRANCH_KINDS: frozenset[str] = frozenset(("when", "otherwise")) + +_ONE_COLON = re.compile(r"(when|otherwise)\s*:", re.IGNORECASE) """The start of a comment that is a branch directive written with one colon. ``.. when: `` (one colon) and ``.. when::`` (no space after @@ -67,8 +87,254 @@ leading whitespace, so a comment that merely begins with the word is not matched. """ -_BRANCH_DIRECTIVE_LINE = re.compile(r"(when|otherwise) ?::( |$)", re.IGNORECASE) -"""What follows ``.. `` on a line that docutils reads as a branch directive.""" +# reStructuredText (docutils `parsers/rst/states.py`, `Body.patterns` and +# `Body.explicit.constructs`, and `directives.directive`, which lower-cases the name) +_RST_EXPLICIT = re.compile(r"\.\.( +|$)") +"""docutils' explicit markup start: ``..`` and then spaces or the end of the line.""" + +_RST_SIMPLENAME = r"(?:(?!_)\w)+(?:[-._+:](?:(?!_)\w)+)*" +_RST_DIRECTIVE = re.compile(rf"\.\.[ ]+({_RST_SIMPLENAME})[ ]?::([ ]+|$)") +"""docutils' directive line: a name, an optional space, ``::``, a space or the end.""" + +# MyST (CommonMark fences, the `colon_fence` extension, and myst-parser's +# `render_fence` / `render_colon_fence`, which take the first word of the stripped +# info string as the directive when it is `{name}`) + + +class _MystPatterns(NamedTuple): + """The line patterns of the MyST gate, for one indentation rule.""" + + opener: re.Pattern[str] + """The first line of a branch: a fence whose info string starts with the name.""" + closer: re.Pattern[str] + """A line that may close a fence: a run of one fence character (its length and + character are compared with the opener's).""" + comment: re.Pattern[str] + """A line comment (myst's ``line_comment``), with its text.""" + block_break: re.Pattern[str] + """The marker run of a block break (myst's ``block_break``): three ``+`` or more, + mixed with spaces and tabs.""" + delimiter: re.Pattern[str] + """A line markdown-it may take for the delimiter row of a table.""" + + +def _myst_patterns(indent: str, /) -> _MystPatterns: + """The MyST gate's patterns, with ``indent`` as their leading whitespace.""" + return _MystPatterns( + opener=re.compile( + indent + r"(:{3,}|`{3,}|~{3,})[ \t]*\{(when|otherwise)\}(?=\s|$)", + re.IGNORECASE, + ), + closer=re.compile(indent + r"(:+|`+|~+)[ \t]*"), + comment=re.compile(indent + r"%(.*)"), + block_break=re.compile(indent + r"\+[+ \t]*"), + delimiter=re.compile(indent + r"[|:-][|:\-\s]*"), + ) + + +# markdown-it-py's `StateBlock.is_code_block(line)` (3.0 and 4) is +# `_code_enabled and sCount - blkIndent >= 4`, and every rule the gate mirrors asks it +# (the fences and their closers, the colon fence through `mdit_py_plugins.utils`, the +# table's two lines, `line_comment`, `block_break`): with the `code` rule enabled a +# line indented four columns or more (a tab counts four) is a code block, and none of +# those constructs; with `code` disabled, indentation bounds none of them. +_MYST_INDENTED = _myst_patterns(r" {0,3}") +"""The MyST gate's patterns while the ``code`` rule is enabled (the default).""" +_MYST_UNBOUNDED = _myst_patterns(r"[ \t]*") +"""The MyST gate's patterns while the ``code`` rule is disabled.""" + +_TABLE_CELL = re.compile(r":?-+:?") + + +class _Stray(NamedTuple): + """The first top-level line of a ``choose`` body that the gate refuses.""" + + index: int + """The line's index in the body.""" + one_colon: _BranchKind | None + """The kind of branch the line is a comment for, written with one colon, if so.""" + + +def _gate( + lines: Sequence[str], + /, + *, + syntax: Literal["rst", "myst"], + colon_fence: bool = True, + fence: bool = True, + comment: bool = True, + block_break: bool = True, + code: bool = True, +) -> _Stray | None: + """The first top-level line of a ``choose`` body that the parser must not see. + + That is the first line that is neither a branch start, a comment, nor blank. + + :param lines: The body, as the directive receives it. + :param syntax: The markup the body is written in. + :param colon_fence: Whether the document's MyST parser has colon fences + (without them, a ``:::`` line opens nothing). + :param fence: Whether it has backtick and tilde fences. + :param comment: Whether it has ``%`` line comments. + :param block_break: Whether it has ``+++`` block breaks. + :param code: Whether it has indented code blocks, which bound the indentation + of every other construct. + :return: That line, or ``None`` if the body holds only branches and comments. + """ + if syntax == "rst": + return _gate_rst(lines) + return _gate_myst( + lines, + colon_fence=colon_fence, + fence=fence, + comment=comment, + block_break=block_break, + code=code, + ) + + +def _gate_rst(lines: Sequence[str], /) -> _Stray | None: + """The gate for a reStructuredText body, dedented and with tabs expanded. + + Only lines at column 0 start a construct; an indented line belongs to the + explicit markup above it (a branch's content, a comment's text), except after + an empty comment followed by a blank line, which docutils ends there, so that + the indented block after it would be a block quote of the body. + A column-0 explicit markup line is classified as docutils classifies it: + a footnote or citation (``[``), a target (``_``) or a substitution definition + (``|``) is refused, which is stricter than docutils when the rest of the line + does not complete the construct; a directive is a branch start if it is + ``when`` or ``otherwise``, and refused otherwise; anything else is a comment. + """ + owned = False + for index, line in enumerate(lines): + if not line.strip(" "): + continue + if line.startswith(" "): + if owned: + continue + return _Stray(index, None) + start = _RST_EXPLICIT.match(line) + if start is None: + return _Stray(index, None) + rest = line[start.end() :] + if rest[:1] in ("[", "_", "|"): + return _Stray(index, None) + directive = _RST_DIRECTIVE.match(line) + if directive is not None: + if directive.group(1).lower() not in _BRANCH_KINDS: + return _Stray(index, None) + owned = True + continue + if rest.startswith('end of inclusion from "'): + # docutils pops its include log for this comment rather than keeping it + return _Stray(index, None) + text = rest + if not text.strip(" "): + following = lines[index + 1] if index + 1 < len(lines) else "" + if not following.strip(" "): + # an empty comment: docutils ends it here + owned = False + continue + # the comment's text is the indented block on the next line, if any + text = following if following.startswith(" ") else "" + one_colon = _ONE_COLON.match(text.lstrip(" ")) + if one_colon is not None: + return _Stray(index, _branch_kind(one_colon.group(1))) + owned = True + return None + + +def _gate_myst( + lines: Sequence[str], + /, + *, + colon_fence: bool, + fence: bool, + comment: bool, + block_break: bool, + code: bool, +) -> _Stray | None: + """The gate for a MyST body, under the document's own parser configuration. + + A branch starts at a fence whose info string's first word is ``{when}`` or + ``{otherwise}``, indented at most three spaces: a colon fence when the document's + parser has ``colon_fence``, a backtick or tilde fence when it has ``fence`` + (a backtick fence may not have a backtick in its info string). It ends at the + first later line of at least as many of the same fence character, indented at + most three spaces, with nothing but spaces or tabs after them (the CommonMark + closing rule); an unclosed branch runs to the end. + markdown-it tries its ``table`` rule before any fence: an opener with a ``|`` + whose next line could be a table's delimiter row would be a table header, so it + is refused (stricter than markdown-it, which also requires as many cells in both + lines). Between branches, a ``%`` comment and a ``+++`` block break (three ``+`` + or more, mixed with spaces and tabs), each indented at most three spaces, are + accepted as myst-parser's ``line_comment`` and ``block_break`` accept them. + "At most three spaces" holds while the parser has its ``code`` rule (a line + indented further is a code block, and none of these constructs); without it, + markdown-it lets every one of them be indented by any spaces and tabs, + and so does the gate. + """ + patterns = _MYST_INDENTED if code else _MYST_UNBOUNDED + marker: str | None = None + for index, line in enumerate(lines): + if marker is not None: + closer = patterns.closer.fullmatch(line) + if closer is not None: + run = closer.group(1) + if run[0] == marker[0] and len(run) >= len(marker): + marker = None + continue + if not line.strip(" \t"): + continue + opener = patterns.opener.match(line) + if opener is not None: + run = opener.group(1) + # a backtick fence may not have a backtick in its info string (CommonMark) + backtick_info = run[0] == "`" and "`" in line[opener.end(1) :] + enabled = colon_fence if run[0] == ":" else fence + following = lines[index + 1] if index + 1 < len(lines) else "" + table = _table_header(line, following, patterns.delimiter) + if not backtick_info and enabled and not table: + marker = run + continue + return _Stray(index, None) + text: str | None = None + if comment and (line_comment := patterns.comment.match(line)) is not None: + text = line_comment.group(1) + elif ( + block_break + and (markers := patterns.block_break.match(line)) is not None + and markers.group(0).count("+") >= 3 + ): + text = line[markers.end() :] + if text is None: + return _Stray(index, None) + one_colon = _ONE_COLON.match(text.strip()) + if one_colon is not None: + return _Stray(index, _branch_kind(one_colon.group(1))) + return None + + +def _table_header(line: str, following: str, delimiter: re.Pattern[str], /) -> bool: + """Whether markdown-it's ``table`` rule may take ``line`` for a table's header. + + That needs a ``|`` in the line and, on the next, a delimiter row (``delimiter``: + indented as the ``code`` rule allows), made of ``|``, ``-``, ``:`` and whitespace + only, with a ``-``, and every cell between the ``|`` that is not empty of the form + ``:?-+:?``. + """ + if "|" not in line or not delimiter.fullmatch(following): + return False + if "-" not in following: + return False + cells = [cell.strip() for cell in following.split("|")] + return all(_TABLE_CELL.fullmatch(cell) for cell in cells if cell) + + +def _branch_kind(name: str, /) -> _BranchKind: + """The branch kind a directive name (in any case) stands for.""" + return "when" if name.lower() == "when" else "otherwise" def _absolute_source(source: str | None, /) -> str | None: @@ -119,15 +385,6 @@ class _BranchPlaceholder(nodes.Element): """The ``lineno`` of the branch directive.""" location: str | None """Where warnings about the branch are reported.""" - source: str | None - """The file the branch directive is written in, as docutils or MyST reports it.""" - owner: nodes.Element | None - """The node the branch directive's result is appended to. - - That is the body of its ``choose`` exactly when the branch is written directly - in it, rather than inside another directive whose content was parsed into a node - of its own. - """ class _ChooseBody(nodes.Element): @@ -178,10 +435,6 @@ def run(self) -> Sequence[nodes.Node]: placeholder.content_offset = self.content_offset placeholder.lineno = self.lineno placeholder.location = self.get_location() - placeholder.source = self.get_source_info()[0] - # docutils' `RSTState.parent` is this very node, and MyST's mock state machine - # holds the renderer's current node here, which is where MyST appends the result - placeholder.owner = self.state_machine.node return [placeholder] @@ -223,8 +476,8 @@ class ChooseDirective(SphinxDirective): The content may hold only ``when`` and ``otherwise`` directives and comments. Every mistake is warned about once, and skips the whole ``choose``: - content that is neither a branch nor a comment, a branch inside another - directive or supplied through an include, no branch at all, + content that is neither a branch nor a comment (refused before anything in the + body is parsed), a branch written with one colon, no branch at all, a ``when`` without a condition, an ``otherwise`` with one, an ``otherwise`` that is not the last branch or is not the only one, variant data that is not configured, @@ -266,7 +519,10 @@ def run(self) -> Sequence[nodes.Node]: ) return [] - branches = self._collect_branches(self.get_source_info()[0]) + if not self._passes_gate(): + return [] + + branches = self._collect_branches() if branches is None: return [] @@ -306,24 +562,109 @@ def _warn(self, message: str, location: str | nodes.Node | None = None, /) -> No ), ) + def _syntax(self) -> Literal["rst", "myst"] | None: + """The markup the body is written in (``None``: a parser of another kind).""" + if isinstance(self.state, RSTState): + return "rst" + if type(self.state).__module__.split(".", 1)[0] == "myst_parser": + return "myst" + return None + + def _passes_gate(self) -> bool: + """Read the body's top-level lines before anything in it is parsed. + + The body is refused, with one warning at the line, at the first one that is + neither the start of a branch, a comment, nor blank. + Under a parser other than docutils' and MyST's the body cannot be read, + so the ``choose`` is refused, with one warning, and nothing in it is parsed. + + :return: Whether the body may be parsed. + """ + syntax = self._syntax() + if syntax is None: + self._warn( + "'choose' directive is supported under reStructuredText and MyST " + "only; the whole choose is skipped" + ) + return False + stray = _gate(list(self.content), syntax=syntax, **self._myst_syntax()) + if stray is None: + return True + source, offset = self.content.info(stray.index) + if syntax == "myst": + # MyST numbers the lines of a directive's content from 0 + offset = self.lineno + stray.index + location = ( + f"{source}:{offset + 1}" + if source and offset is not None + else self.get_location() + ) + if stray.one_colon is not None: + kind = stray.one_colon + # the hint follows the syntax the comment is written in + if syntax == "rst": + write = ( + "'.. when:: '" if kind == "when" else "'.. otherwise::'" + ) + else: + write = ( + "a '{when} ' fence" + if kind == "when" + else "an '{otherwise}' fence" + ) + self._warn( + f"'choose' directive has a comment that begins with '{kind}:' " + f"(a branch written with one colon? write {write}); the whole " + "choose is skipped", + location, + ) + return False + # the indentation is kept: an opener indented four spaces is no branch + text = self.content[stray.index].rstrip() + shown = text if len(text) <= 40 else text[:40] + "…" + self._warn( + "'choose' directive may contain only 'when' and 'otherwise' directives " + f"and comments, got {shown!r}; the whole choose is skipped", + location, + ) + return False + + def _myst_syntax(self) -> dict[str, bool]: + """Which constructs the document's MyST parser has, for the gate. + + The document's own configuration (the global one merged with its front matter, + whose ``enable_extensions`` replaces the global list) is held only by the + renderer the directive's state belongs to; without it, the global + ``myst_enable_extensions`` and ``myst_disable_syntax`` are read. + ``disable_syntax`` can switch off a fence kind, line comments, block breaks, + and the ``code`` rule, without which indentation bounds no construct. + """ + config = getattr(getattr(self.state, "_renderer", None), "md_config", None) + if config is not None: + extensions = set(config.enable_extensions) + disabled = set(config.disable_syntax) + else: + extensions = set(getattr(self.env.config, "myst_enable_extensions", ())) + disabled = set(getattr(self.env.config, "myst_disable_syntax", ())) + return { + "colon_fence": "colon_fence" in extensions + and "colon_fence" not in disabled, + "fence": "fence" not in disabled, + "comment": "myst_line_comment" not in disabled, + "block_break": "myst_block_break" not in disabled, + "code": "code" not in disabled, + } + def _parse_body(self) -> _ChooseBody: """Parse the content into a detached node, with every branch deferred. - Because the content of every branch is deferred, - a need created while the body is parsed can only come from content - written outside a branch, which is a mistake that skips the whole ``choose``: - such needs are removed again, so that the mistake creates none. + After the gate, the body holds only branches and comments, + so nothing else runs while it is parsed. :return: The parsed body. """ body = _ChooseBody() body.document = self.state.document - - data = SphinxNeedsData(self.env) - # outside the read phase no need can be added, so there is nothing to undo - needs = None if data.needs_is_post_processed else data.get_needs_mutable() - before = 0 if needs is None else len(needs) - temp_data = self.env.temp_data depth = temp_data.get(_DEPTH_KEY, 0) temp_data[_DEPTH_KEY] = depth + 1 @@ -331,104 +672,30 @@ def _parse_body(self) -> _ChooseBody: self.state.nested_parse(self.content, self.content_offset, body) finally: temp_data[_DEPTH_KEY] = depth - - if needs is not None and len(needs) > before: - # the newest entries are the ones the body added: O(new needs) - for need_id in list(islice(reversed(needs), len(needs) - before)): - data.remove_need(need_id) - return body - def _collect_branches( - self, source: str | None, / - ) -> list[_BranchPlaceholder] | None: + def _collect_branches(self) -> list[_BranchPlaceholder] | None: """Parse the body and check its structure. - The branches must be written in the body itself: - a branch inside another directive (one whose content is parsed into a node - of its own, even if it then returns that node's children, such as a true ``if``) - is refused, and so is a branch an ``.. include::`` supplies, - so that one ``choose`` is one directive in one file. - Then every ``when`` must have a condition and the ``otherwise`` none, + Every ``when`` must have a condition and the ``otherwise`` none, and there may be one ``otherwise`` at most, as the last branch. - :param source: The file this ``choose`` is written in, - as its branches report theirs. :return: The branches, in order, or ``None`` if the body is not a valid ``choose`` (a warning has been emitted). """ - body = self._parse_body() - children = list(body.children) branches: list[_BranchPlaceholder] = [] - for index, child in enumerate(children): + for child in self._parse_body().children: if isinstance(child, _BranchPlaceholder): - # the source first: a branch an include supplies is reported as such, - # also when the include stands inside another directive - if _absolute_source(child.source) != _absolute_source(source): - self._warn( - f"'{child.kind}' supplied through an include is not supported " - "(write the branches in the body of the 'choose'); the whole " - "choose is skipped", - child.location, - ) - return None - if child.owner is not body: - self._warn( - f"'{child.kind}' directive is not a direct child of its " - "'choose' (it is inside another directive); the whole choose " - "is skipped", - child.location, - ) - return None branches.append(child) - elif isinstance(child, nodes.comment): - like = _BRANCH_LIKE_COMMENT.match(child.astext().lstrip()) - if like is None: - continue - # a branch written with one colon would hand the choice to the otherwise - kind = like.group(1).lower() - # the hint follows the syntax the comment is written in - if isinstance(self.state, RSTState): - write = ( - "'.. when:: '" - if kind == "when" - else "'.. otherwise::'" - ) - else: - write = ( - "a '{when} ' fence" - if kind == "when" - else "an '{otherwise}' fence" - ) - self._warn( - f"'choose' directive has a comment that begins with '{kind}:' " - f"(a branch written with one colon? write {write}); the whole " - "choose is skipped", - self._branch_like_comment_location(child), - ) - return None - elif isinstance(child, nodes.system_message): - reported = max( - self.state.document.reporter.report_level, Reporter.WARNING_LEVEL - ) - if child["level"] < reported: - # never shown as a problem (below the report level, or below WARNING - # however low that level is set): judge what follows it instead - # (docutils puts an INFO before the paragraph of a `---` line) - continue - # reported by docutils or MyST when it was created: skip, silently - return None - else: - offender = self._offender(children[index:]) - tagname = ( - offender.tagname if isinstance(offender, nodes.Element) else "#text" - ) + elif not isinstance(child, nodes.comment): + # past the gate only a message the parser made about a branch + # directive, or a node of an ungated parser, can be here + tagname = child.tagname if isinstance(child, nodes.Element) else "#text" self._warn( "'choose' directive may contain only 'when' and 'otherwise' " f"directives and comments, got <{tagname}>; the whole choose is " - "skipped", - self._location_of(children[index:]), + "skipped" ) return None @@ -473,96 +740,6 @@ def _collect_branches( return branches - @staticmethod - def _offender(candidates: Sequence[nodes.Node]) -> nodes.Node: - """The node a warning about the first of ``candidates`` names. - - A need directive emits a target before the need, - which carries no line and is nothing the author wrote: - such leading targets are passed over, so that the warning names the need. - - :param candidates: The offending child and the children after it. - """ - for node in candidates: - if isinstance(node, nodes.target) and not get_source_line(node)[1]: - continue - if isinstance(node, _BranchPlaceholder | nodes.comment): - break - return node - return candidates[0] - - def _location_of(self, candidates: Sequence[nodes.Node]) -> nodes.Node | str | None: - """Where to report the first of ``candidates``. - - That is the first node, in or under them, that knows its source and line: - the body is detached, so no node can inherit them from an ancestor, - and some nodes carry none of their own - (such as the target a need directive emits before the need). - - :param candidates: The offending child and the children after it. - :return: That node, or the location of the ``choose`` if none has both. - """ - for candidate in candidates: - for node in candidate.findall(nodes.Element): - source, line = get_source_line(node) - if source and line: - return node - return self.get_location() - - def _branch_like_comment_location( - self, comment: nodes.comment, / - ) -> nodes.Node | str | None: - """Where to report the first comment of the body that reads like a branch. - - A comment carries no line of its own (docutils and MyST give it the line - being parsed when it is appended, which is after it, or the ``choose``'s), - so its line is found in the content of the ``choose``: - the first line at the level of the body whose comment text the rule matches. - That is the line of ``comment``, the first such comment the body holds. - Under docutils the lines of the body are those not indented, - and a line that docutils reads as a ``when`` or ``otherwise`` directive - (a name, an optional space, ``::``, then a space or the end of the line) - is not a comment; - under MyST, the lines of the body are those outside the fences - of the directives in it, and a ``%`` line (or a ``+++`` block break) - is a comment. - - :param comment: The comment, used as the location if no line is found - (for one an include supplied). - :return: ``":"``, or the fallback. - """ - rst = isinstance(self.state, RSTState) - fence: str | None = None - for index, line in enumerate(self.content): - text: str | None = None - if rst: - markup = re.match(r"\.\.[ ]+(.*)", line) - text = markup.group(1) if markup else None - if text is not None and _BRANCH_DIRECTIVE_LINE.match(text): - continue - elif fence is not None: - closing = line.rstrip() - if closing.startswith(fence) and set(closing) == {fence[0]}: - fence = None - continue - elif opening := re.match(r"(`{3,}|~{3,}|:{3,})", line): - fence = opening.group(1) - continue - elif line.startswith("%"): - text = line[1:] - elif line.startswith("+++"): - text = line[3:] - if text is not None and _BRANCH_LIKE_COMMENT.match(text.lstrip()): - source, offset = self.content.info(index) - if not rst: - # MyST numbers the lines of a directive's content from 0 - offset = self.lineno + index - if source and offset is not None: - return f"{source}:{offset + 1}" - break - source, line = get_source_line(comment) - return comment if source and line else self.get_location() - def _parse_branch(self, branch: _BranchPlaceholder) -> list[nodes.Node]: """Parse the content of the branch that is taken, with section titles allowed. diff --git a/packages/sphinx-needs/tests/test_choose_directive.py b/packages/sphinx-needs/tests/test_choose_directive.py index 76fdd18f3..4567000a1 100644 --- a/packages/sphinx-needs/tests/test_choose_directive.py +++ b/packages/sphinx-needs/tests/test_choose_directive.py @@ -12,6 +12,8 @@ from sphinx_needs.data import SphinxNeedsData from sphinx_needs.directives.needchoose import ( + ChooseDirective, + OtherwiseDirective, _absolute_location, _BranchPlaceholder, _ChooseBody, @@ -185,27 +187,18 @@ class _Expected(NamedTuple): _SKIP = "; the whole choose is skipped" -def _not_direct(kind: str) -> str: - """The warning about a branch of the kind ``kind`` inside another directive.""" - return ( - f"'{kind}' directive is not a direct child of its 'choose' " - "(it is inside another directive)" + _SKIP - ) +def _stray(text: str) -> str: + """The warning about a line of the body that is neither a branch nor a comment. - -def _included(kind: str) -> str: - """The warning about a branch of the kind ``kind`` that an include supplies.""" + The gate refuses it before anything in the body is parsed, at its line, + and names its text. + """ return ( - f"'{kind}' supplied through an include is not supported " - "(write the branches in the body of the 'choose')" + _SKIP + "'choose' directive may contain only 'when' and 'otherwise' directives and " + f"comments, got {text!r}" + _SKIP ) -_NOT_DIRECT = _not_direct("when") -_INCLUDED_BRANCH = _included("when") -_ONLY_BRANCHES = ( - "'choose' directive may contain only 'when' and 'otherwise' directives and comments" -) _NO_BRANCH = "'choose' directive has no 'when' or 'otherwise'" @@ -337,12 +330,7 @@ def _branch_like(kind: str, write: str) -> str: ".. choose::\n\n" " .. when::\n\n SKIPPED_FORGOTTEN_CONDITION\n\n" " A stray paragraph.\n", - ( - ( - _ONLY_BRANCHES + ", got " + _SKIP, - " A stray paragraph.", - ), - ), + ((_stray("A stray paragraph."), " A stray paragraph."),), ), # a branch written with one colon is a comment that swallows the content under it; # comments are accepted, so for the variant it was written for (`abc`) the choose @@ -373,6 +361,14 @@ def _branch_like(kind: str, write: str) -> str: " .. otherwise::\n\n SKIPPED_OTHERWISE\n", ((_WHEN_LIKE, " .. when : var.arch == 'abc'"),), ), + # docutils allows one space before `::`: with two the line is a comment that would + # swallow the branch, refused by the one-colon rule + "when with two spaces before the colons": _Expected( + ".. choose::\n\n" + " .. when :: var.debug\n\n SKIPPED_SWALLOWED_BRANCH\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + ((_WHEN_LIKE, " .. when :: var.debug"),), + ), # docutils needs a space (or the end of the line) after `::` for a directive "when without the space after ::": _Expected( ".. choose::\n\n" @@ -401,54 +397,87 @@ def _branch_like(kind: str, write: str) -> str: ".. choose::\n\n" " .. when:: True\n\n SKIPPED_BRANCH\n\n" " A stray paragraph.\n", - ( - ( - _ONLY_BRANCHES + ", got " + _SKIP, - " A stray paragraph.", - ), - ), + ((_stray("A stray paragraph."), " A stray paragraph."),), ), - # exactly one warning: the branch in the note is not a stray, it is in a choose body + # a directive in the body is refused at its own line, before it runs: the branch + # in the note is never reached "note wrapping a branch": _Expected( ".. choose::\n\n" " .. note::\n\n .. when:: True\n\n SKIPPED_IN_NOTE\n", - (("got " + _SKIP, " .. note::"),), + ((_stray(".. note::"), " .. note::"),), ), - # a directive that returns the nodes of its content (a true `if`, `rst-class`) - # would hand its branches to the choose: a branch must be written directly in it + # a directive that would hand the nodes of its content to the choose (a true `if`, + # `rst-class`) is refused the same way: a branch must be written directly in it "branches inside a true if": _Expected( ".. choose::\n\n" " .. if:: var.debug\n\n" " .. when:: var.arch == 'x86'\n\n SKIPPED_X86\n\n" " .. otherwise::\n\n SKIPPED_DEFAULT_FROM_IF\n", - ((_NOT_DIRECT, " .. when:: var.arch == 'x86'"),), + ((_stray(".. if:: var.debug"), " .. if:: var.debug"),), ), "branch inside rst-class": _Expected( ".. choose::\n\n" " .. rst-class:: special\n\n" " .. when:: var.arch == 'abc'\n\n SKIPPED_FROM_RST_CLASS\n", - ((_NOT_DIRECT, " .. when:: var.arch == 'abc'"),), + ((_stray(".. rst-class:: special"), " .. rst-class:: special"),), ), - # the warnings name the kind of the branch "otherwise inside a true if": _Expected( ".. choose::\n\n" " .. when:: False\n\n SKIPPED_FALSE\n\n" " .. if:: var.debug\n\n" " .. otherwise::\n\n SKIPPED_DEFAULT_FROM_IF\n", - ((_not_direct("otherwise"), " .. otherwise::"),), + ((_stray(".. if:: var.debug"), " .. if:: var.debug"),), + ), + # a directive that produces no node used to pass unnoticed: a false `if` hid the + # branches in it and the otherwise was taken; now it is refused like any other + "a false if in the body": _Expected( + ".. choose::\n\n" + " .. if:: False\n\n" + " .. when:: True\n\n SKIPPED_IN_FALSE_IF\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + ((_stray(".. if:: False"), " .. if:: False"),), + ), + "default-role in the body": _Expected( + ".. choose::\n\n" + " .. default-role:: math\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + ((_stray(".. default-role:: math"), " .. default-role:: math"),), + ), + # a target and a substitution definition are not comments + "a label in the body": _Expected( + ".. choose::\n\n" + " .. _label_in_the_body:\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + ((_stray(".. _label_in_the_body:"), " .. _label_in_the_body:"),), + ), + "a substitution definition in the body": _Expected( + ".. choose::\n\n" + " .. |sub| replace:: text\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + ((_stray(".. |sub| replace:: text"), " .. |sub| replace:: text"),), + ), + # an empty comment ends at the blank line after it: the indented block that follows + # would be a block quote of the body, and the need in it would run + "a block quote after an empty comment": _Expected( + ".. choose::\n\n" + " ..\n\n" + " .. req:: In a block quote\n :id: REQ_QUOTED\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n", + # the stray's text keeps its indentation (the body is dedented by three) + ((_stray(" .. req:: In a block quote"), " .. req:: In a block quote"),), ), - # a line of one to three punctuation characters makes docutils emit an INFO - # message, which is never shown, before the paragraph: the paragraph is reported + # a line of one to three punctuation characters would make docutils emit an INFO + # message and a paragraph; it is refused before docutils sees it "rule line between branches": _Expected( ".. choose::\n\n" " .. when:: var.arch == 'x86'\n\n SKIPPED_X86\n\n" " ---\n\n" " .. otherwise::\n\n SKIPPED_DEFAULT\n", - (("got " + _SKIP, " ---"),), + ((_stray("---"), " ---"),), ), "three dots in the body": _Expected( ".. choose::\n\n ...\n\n .. otherwise::\n\n SKIPPED_DEFAULT\n", - (("got " + _SKIP, " ..."),), + ((_stray("..."), " ..."),), ), "when outside a choose": _Expected( "Para.\n\n.. when:: True\n\n SKIPPED_STRAY\n", @@ -478,19 +507,15 @@ def _branch_like(kind: str, write: str) -> str: (("'when' directive outside a 'choose'", " .. when:: True"),), taken=("TAKEN_OUTER",), ), - # two independent mistakes, two warnings: a choose written directly in another - # choose's body, whose taken branch holds a loose branch. The taken branch is - # parsed outside every choose body, so the loose branch is reported and cannot - # become a branch of the OUTER choose, which then has none. + # a choose written directly in another choose's body is a stray of the outer one, + # refused before it runs: one warning, and the loose branch in its taken branch is + # never reached "branch loose in the taken branch of a misplaced choose": _Expected( ".. choose::\n\n" " .. choose::\n\n" " .. when:: True\n\n" " .. when:: True\n\n SKIPPED_LOOSE\n", - ( - ("'when' directive outside a 'choose'", " .. when:: True"), - (_NO_BRANCH, ".. choose::"), - ), + ((_stray(".. choose::"), " .. choose::"),), ), "unevaluable first branch poisons the otherwise": _Expected( ".. choose::\n\n" @@ -627,16 +652,13 @@ def _branch_like(kind: str, write: str) -> str: ), conf=_CONF_NO_VARIANT_DATA, ), - # the branches of a choose are written in its body: an include may not supply them, - # and the warning points at the branch in the included file + # the branches of a choose are written in its body: an include in it is refused at + # its own line, in the host, and the included file is never read "branches from an include": _Expected( ".. choose::\n\n .. include:: branches.txt\n", - ((_INCLUDED_BRANCH, '.. when:: var.arch == "xyz"'),), + ((_stray(".. include:: branches.txt"), " .. include:: branches.txt"),), extra=(("branches.txt", _BRANCHES_TXT),), - located_in="branches.txt", ), - # the structure is checked before any condition: a true branch written in place - # before the included ones is not taken either # the two other exits that may report a location in an included file: # an evaluation fault inside a choose the include holds, and a stray branch "an unevaluable when in an included choose": _Expected( @@ -657,30 +679,64 @@ def _branch_like(kind: str, write: str) -> str: extra=(("stray.txt", ".. when:: True\n\n SKIPPED_STRAY\n"),), located_in="stray.txt", ), + # the gate and the structural checks report through the same helper, whose + # location must be absolute as well: a stray, and a branch fault, in a choose an + # include holds (docutils gives the included file a cwd-relative path) + "a stray in an included choose": _Expected( + ".. include:: stray_body.txt\n", + ((_stray("A stray paragraph."), " A stray paragraph."),), + extra=( + ( + "stray_body.txt", + ".. choose::\n\n A stray paragraph.\n\n" + " .. otherwise::\n\n SKIPPED_STRAY_BODY\n", + ), + ), + located_in="stray_body.txt", + ), + "a when without a condition in an included choose": _Expected( + ".. include:: bare_when.txt\n", + ( + ( + "'when' directive has no condition (use 'otherwise' for the default)", + " .. when::", + ), + ), + extra=( + ( + "bare_when.txt", + ".. choose::\n\n .. when::\n\n SKIPPED_BARE_IN_INCLUDE\n", + ), + ), + located_in="bare_when.txt", + ), + # the body is read before any condition: a true branch written in place before + # the include is not taken either "a branch from an include after a true branch": _Expected( ".. choose::\n\n" " .. when:: True\n\n SKIPPED_IN_PLACE\n\n" " .. include:: branches.txt\n", - ((_INCLUDED_BRANCH, '.. when:: var.arch == "xyz"'),), + ((_stray(".. include:: branches.txt"), " .. include:: branches.txt"),), extra=(("branches.txt", _BRANCHES_TXT),), - located_in="branches.txt", ), "an otherwise from an include": _Expected( ".. choose::\n\n" " .. when:: False\n\n SKIPPED_FALSE\n\n" " .. include:: otherwise.txt\n", - ((_included("otherwise"), ".. otherwise::"),), + ((_stray(".. include:: otherwise.txt"), " .. include:: otherwise.txt"),), extra=(("otherwise.txt", ".. otherwise::\n\n SKIPPED_FROM_INCLUDE\n"),), - located_in="otherwise.txt", ), - # content outside a branch is parsed with the body, so the need directive runs; - # the choose removes the need again + # content outside a branch is refused before it is parsed: the need never exists "need directly in the body": _Expected( ".. choose::\n\n" " .. req:: Directly in the choose body\n :id: REQ_DIRECT\n\n" " .. when:: True\n\n SKIPPED\n", - # named after the need, not the target without a line that it emits first - (("got " + _SKIP, " .. req:: Directly in the choose body"),), + ( + ( + _stray(".. req:: Directly in the choose body"), + " .. req:: Directly in the choose body", + ), + ), ), # a branch that is not taken is never parsed, exactly as the body of a false `if` "errors in an untaken branch are never reported": _Expected( @@ -736,26 +792,32 @@ def test_choose_warnings(test_app, expected: _Expected, monkeypatch): @pytest.mark.parametrize( - "test_app", + ("test_app", "line"), [ - _project( - ".. choose::\n\n" - " .. when:: var.arch == 'x86'\n\n SKIPPED_X86\n\n" - " ---\n\n" - " .. otherwise::\n\n SKIPPED_DEFAULT\n", - extra=(("docutils.conf", "[general]\nreport_level: 1\n"),), + ( + _project( + ".. choose::\n\n" + " .. when:: var.arch == 'x86'\n\n SKIPPED_X86\n\n" + f" {line}\n\n" + " .. otherwise::\n\n SKIPPED_DEFAULT\n", + extra=(("docutils.conf", f"[general]\nreport_level: {level}\n"),), + ), + line, ) + for level, line in ((1, "---"), (4, ".. wehn:: True")) ], - indirect=True, + ids=["a rule line, report level 1", "a misspelt directive, report level 4"], + indirect=["test_app"], ) -def test_choose_info_message_is_never_a_reported_error(test_app, monkeypatch): - """An INFO message in the body is not taken for an error docutils reported. - - With ``report_level: 1`` in the project's ``docutils.conf`` the INFO before the - paragraph of a ``---`` line is shown, but as information, not as a warning, - so the paragraph must still be reported: otherwise the choose would vanish - with ``-W`` green. ``sphinx-build`` points ``DOCUTILSCONFIG`` at the project's - ``docutils.conf``; this in-process build does it by hand. +def test_choose_refuses_a_stray_whatever_the_report_level(test_app, line, monkeypatch): + """A stray line is refused by the ``choose`` itself, before docutils parses it. + + So the project's ``report_level`` (in its ``docutils.conf``) changes nothing: + at level 1 the INFO docutils would give a ``---`` line never appears, and at + level 4, which hides every docutils error, a misspelt branch is still refused + with a warning rather than letting the ``otherwise`` render with ``-W`` green. + ``sphinx-build`` points ``DOCUTILSCONFIG`` at the project's ``docutils.conf``; + this in-process build does it by hand. """ app = test_app monkeypatch.setenv("DOCUTILSCONFIG", str(Path(app.srcdir, "docutils.conf"))) @@ -763,17 +825,17 @@ def test_choose_info_message_is_never_a_reported_error(test_app, monkeypatch): (warning,) = build_warnings(app) source = Path(app.srcdir, "index.rst").read_text() assert warning.startswith( - f"/index.rst:{_line_of(source, ' ---')}: WARNING: " + f"/index.rst:{_line_of(source, f' {line}')}: WARNING: " ), warning - assert "got " + _SKIP in warning, warning + assert _stray(line) in warning, warning assert warning.endswith(" [needs.choose]"), warning assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() - # the INFO itself was shown, so the setting took effect - assert "Unexpected possible title overline or transition" in app._status.getvalue() + # docutils never saw the line + assert "Unexpected possible title overline" not in app._status.getvalue() @pytest.mark.parametrize( - ("test_app", "error"), + ("test_app", "line", "error"), [ ( _project( @@ -781,6 +843,7 @@ def test_choose_info_message_is_never_a_reported_error(test_app, monkeypatch): " .. cas:: True\n\n SKIPPED\n\n" " .. otherwise::\n\n SKIPPED_DEFAULT\n" ), + ".. cas:: True", 'Unknown directive type "cas"', ), ( @@ -789,19 +852,27 @@ def test_choose_info_message_is_never_a_reported_error(test_app, monkeypatch): " Title\n -----\n\n" " .. when:: True\n\n SKIPPED\n" ), + "Title", "Unexpected section title", ), ], ids=["typo in a directive name", "section title in the body"], indirect=["test_app"], ) -def test_choose_body_error_reported_once(test_app, error: str): - """A mistake docutils reports in the body skips the choose and warns only once.""" +def test_choose_body_mistake_reported_once(test_app, line: str, error: str): + """A mistake in the body is refused once, by the ``choose``, at its line. + + docutils never parses the line, so its own error for it never appears. + """ app = test_app app.build() (warning,) = build_warnings(app) - assert error in warning - assert "needs.choose" not in warning + source = Path(app.srcdir, "index.rst").read_text() + assert warning.startswith( + f"/index.rst:{_line_of(source, f' {line}')}: WARNING: " + ), warning + assert _stray(line) in warning, warning + assert error not in warning html = Path(app.outdir, "index.html").read_text() assert "SKIPPED" not in html @@ -845,11 +916,12 @@ def test_choose_warnings_are_suppressible(test_app): ], indirect=True, ) -def test_choose_rollback_removes_only_the_stray_needs(test_app): - """The rollback removes the needs the body created, the newest ones, and no other. +def test_choose_body_stray_need_never_runs(test_app): + """A need written directly in the body is refused before it is created. - The needs written before the ``choose``, in its own document and in an earlier one, - are older entries of the same mapping, and must survive. + Nothing in the body runs, so there is nothing to undo: the needs written before + the ``choose``, in its own document and in an earlier one, and after it are all + there, and the stray one never existed. """ app = test_app app.build() @@ -857,7 +929,7 @@ def test_choose_rollback_removes_only_the_stray_needs(test_app): source = Path(app.srcdir, "index.rst").read_text() line = _line_of(source, " .. req:: Directly in the choose body") assert warning.startswith(f"/index.rst:{line}: WARNING: "), warning - assert "got " in warning + assert _stray(".. req:: Directly in the choose body") in warning needs = SphinxNeedsData(app.env).get_needs_view() assert sorted(needs) == ["REQ_AFTER", "REQ_BEFORE", "REQ_EARLIER"] @@ -869,11 +941,6 @@ def test_choose_rollback_removes_only_the_stray_needs(test_app): from sphinx.util.docutils import SphinxDirective -class Boom(SphinxDirective): - def run(self): - raise RuntimeError("boom") - - class Swallow(SphinxDirective): has_content = True @@ -887,7 +954,6 @@ def run(self): def setup(app): - app.add_directive("boom", Boom) app.add_directive("swallow", Swallow) """ ) @@ -900,20 +966,25 @@ def setup(app): ".. swallow::\n\n" " .. choose::\n\n" " .. otherwise::\n\n SKIPPED_X\n\n" - " .. boom::\n\n" ".. when:: True\n\n SKIPPED_LOOSE_AFTER\n", conf=_SWALLOW_CONF, ) ], indirect=True, ) -def test_choose_restores_its_depth_when_its_body_raises(test_app): +def test_choose_restores_its_depth_when_its_body_raises(test_app, monkeypatch): """An exception out of a ``choose`` body leaves no ``choose`` open behind it. - A directive of the project catches what a directive in the body raised; - the ``when`` after it is outside every ``choose`` and must still be reported, - rather than collected as a placeholder that would reach the writer. + Only the branch directives run while the body is parsed, so the exception is + made to come from one: the ``otherwise`` raises, and a directive of the project + catches it. The ``when`` after it is outside every ``choose`` and must still be + reported, rather than collected as a placeholder that would reach the writer. """ + + def boom(self): + raise RuntimeError("boom") + + monkeypatch.setattr(OtherwiseDirective, "run", boom) app = test_app app.build() (warning,) = build_warnings(app) @@ -1284,25 +1355,25 @@ def test_choose_in_myst(test_app): ), "paragraph in the body, backticks": ( "````{choose}\n```{when} True\nSKIPPED\n```\n\nA stray paragraph.\n````\n", - "got " + _SKIP, + _stray("A stray paragraph."), "A stray paragraph.", ), "paragraph in the body, colons": ( "::::{choose}\n:::{when} True\nSKIPPED\n:::\n\nA stray paragraph.\n::::\n", - "got " + _SKIP, + _stray("A stray paragraph."), None, ), # an HTML comment is raw HTML, not a comment "html comment between branches, backticks": ( "````{choose}\n```{when} False\nSKIPPED\n```\n\n\n\n" "```{otherwise}\nSKIPPED_DEFAULT\n```\n````\n", - "got " + _SKIP, + _stray(""), "", ), "html comment between branches, colons": ( "::::{choose}\n:::{when} False\nSKIPPED\n:::\n\n\n\n" ":::{otherwise}\nSKIPPED_DEFAULT\n:::\n::::\n", - "got " + _SKIP, + _stray(""), None, ), "choose with an argument, backticks": ( @@ -1388,20 +1459,32 @@ def test_choose_in_myst(test_app): None, None, ), - # an `{eval-rst}` block is parsed by docutils into a document of its own, - # so the branch in it is not a direct child of the choose + # an `{eval-rst}` block is a fence of another directive: refused at its line "branch inside eval-rst, backticks": ( "````{choose}\n```{eval-rst}\n.. when:: True\n\n SKIPPED_FROM_EVAL_RST\n```\n" "````\n", - _NOT_DIRECT, - ".. when:: True", + _stray("```{eval-rst}"), + "```{eval-rst}", ), "branch inside eval-rst, colons": ( "::::{choose}\n```{eval-rst}\n.. when:: True\n\n SKIPPED_FROM_EVAL_RST\n```\n" "::::\n", - _NOT_DIRECT, + _stray("```{eval-rst}"), None, ), + # an opener indented four spaces is a code block: refused, named with its indentation + "an opener indented four spaces": ( + "````{choose}\n :::{when} var.debug\nSKIPPED\n:::\n````\n", + _stray(" :::{when} var.debug"), + " :::{when} var.debug", + ), + # MyST takes the first word of the info string as the directive: with no space + # after the braces it is no directive, so no branch + "a branch fence without a space after the name": ( + "````{choose}\n```{when}True\nSKIPPED\n```\n````\n", + _stray("```{when}True"), + "```{when}True", + ), } @@ -1466,22 +1549,344 @@ def test_choose_warnings_in_myst( indirect=True, ) def test_choose_refuses_included_branches_in_myst(test_app): - """In MyST too, a branch an ``{include}`` supplies is refused, in the included file. + """In MyST too, an ``{include}`` in the body is refused at its line, in the host. - MyST reports the lines of an included file one late (the branch on line 1 is - reported on line 2, with colon and backtick fences alike), so only the file is - asserted. + The included file is never read. """ app = test_app app.build() (warning,) = build_warnings(app) - assert warning.startswith("/branches.txt:"), warning - assert _INCLUDED_BRANCH in warning, warning + source = Path(app.srcdir, "index.md").read_text() + line = _line_of(source, "```{include} branches.txt") + assert warning.startswith(f"/index.md:{line}: WARNING: "), warning + assert _stray("```{include} branches.txt") in warning, warning assert warning.endswith(" [needs.choose]"), warning assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() _assert_no_choose_nodes(app) +@pytest.mark.skipif(not _HAS_MYST, reason="needs myst-parser") +@pytest.mark.parametrize( + "test_app", + [ + _project( + "````{choose}\n{{ branches }}\n```{otherwise}\nSKIPPED_OTHERWISE\n```\n````\n", + conf=_CONF_MYST.replace( + "['colon_fence']", "['colon_fence', 'substitution']" + ) + + "myst_substitutions = {'branches': " + "':::{when} True\\nSKIPPED_FROM_SUBSTITUTION\\n:::'}\n", + myst=True, + ) + ], + indirect=True, +) +def test_choose_refuses_a_substitution_in_myst(test_app): + """A substitution reference in the body is a line of text to the ``choose``. + + It is refused at its line, so the branches its definition holds are never taken, + and neither is the ``otherwise``. + """ + app = test_app + app.build() + (warning,) = build_warnings(app) + source = Path(app.srcdir, "index.md").read_text() + line = _line_of(source, "{{ branches }}") + assert warning.startswith(f"/index.md:{line}: WARNING: "), warning + assert _stray("{{ branches }}") in warning, warning + assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() + _assert_no_choose_nodes(app) + + +_HOST_NEED = "```{req} Host\n:id: REQ_HOST\n:status: open\n```\n\n" + + +@pytest.mark.skipif(not _HAS_MYST, reason="needs myst-parser") +@pytest.mark.parametrize( + "test_app", + [ + _project( + _HOST_NEED + "::::{choose}\n:::{when} var.debug | var.debug\n|---|---|\n\n" + "(leak-label-table)=\n" + "```{req} Smuggled by a table\n:id: REQ_SMUGGLED_TABLE\n```\n\n" + "```{needextend} REQ_HOST\n:status: LEAKED_BY_TABLE\n```\n:::\n::::\n", + conf=_CONF_MYST, + myst=True, + ) + ], + indirect=True, +) +def test_choose_refuses_an_opener_a_table_swallows_in_myst(test_app): + """An opener with a ``|`` and a delimiter row under it is a table to markdown-it. + + markdown-it tries its ``table`` rule before any fence, so the lines after the + would-be opener are parsed in the body: the opener is refused instead, and the + need and the ``needextend`` under it never run. + """ + app = test_app + app.build() + (warning,) = build_warnings(app) + source = Path(app.srcdir, "index.md").read_text() + line = _line_of(source, ":::{when} var.debug | var.debug") + assert warning.startswith(f"/index.md:{line}: WARNING: "), warning + assert _stray(":::{when} var.debug | var.debug") in warning, warning + needs = SphinxNeedsData(app.env).get_needs_view() + assert sorted(needs) == ["REQ_HOST"] + assert needs["REQ_HOST"]["status"] == "open" + _assert_no_choose_nodes(app) + + +def _front_matter_project(front_matter: str, body: str, /) -> dict[str, object]: + """A MyST project whose root document starts with ``front_matter`` (YAML).""" + text = f"---\n{front_matter}---\n# Test\n\n{body}" + return { + "buildername": "html", + "files": [(Path("conf.py"), _CONF_MYST), (Path("index.md"), text)], + } + + +_V6D_BODY = ( + "::::{choose}\n% a comment, a paragraph here\n" + ":::{otherwise}\nSKIPPED_OTHERWISE\n:::\n::::\n\n" + "::::{choose}\n+++\n:::{otherwise}\nTAKEN_BREAK_STILL_A_COMMENT\n:::\n::::\n" +) + + +@pytest.mark.skipif(not _HAS_MYST, reason="needs myst-parser") +@pytest.mark.parametrize( + ("test_app", "stray", "taken"), + [ + ( + # the front matter replaces the global extensions: no colons + _front_matter_project( + 'myst:\n enable_extensions: ["substitution"]\n', + "`````{choose}\n:::{when} True\n" + "```{req} Smuggled by front matter\n:id: REQ_SMUGGLED\n```\n" + ":::\n`````\n", + ), + ":::{when} True", + (), + ), + ( + _project( + "::::{choose}\n```{when} True\n" + ":::{req} Smuggled by disable_syntax\n:id: REQ_SMUGGLED\n:::\n" + "```\n::::\n", + conf=_CONF_MYST + "myst_disable_syntax = ['fence']\n", + myst=True, + ), + "```{when} True", + (), + ), + ( + # the front matter disables fences, conf.py does not + _front_matter_project( + 'myst:\n disable_syntax: ["fence"]\n', + "::::{choose}\n```{when} True\n" + ":::{req} Smuggled by the front matter\n:id: REQ_SMUGGLED\n:::\n" + "```\n::::\n", + ), + "```{when} True", + (), + ), + ( + # a backtick branch in the same file is still taken + _project( + "````{choose}\n:::{when} True\n" + "```{req} Smuggled by disable_syntax\n:id: REQ_SMUGGLED\n```\n" + ":::\n````\n\n" + "````{choose}\n```{when} var.debug\nTAKEN_BACKTICK\n```\n````\n", + conf=_CONF_MYST + "myst_disable_syntax = ['colon_fence']\n", + myst=True, + ), + ":::{when} True", + ("TAKEN_BACKTICK",), + ), + ( + # the `%` line is a paragraph; the block break is still a comment + _project( + _V6D_BODY, + conf=_CONF_MYST + "myst_disable_syntax = ['myst_line_comment']\n", + myst=True, + ), + "% a comment, a paragraph here", + ("TAKEN_BREAK_STILL_A_COMMENT",), + ), + ( + _front_matter_project( + 'myst:\n disable_syntax: ["myst_line_comment"]\n', _V6D_BODY + ), + "% a comment, a paragraph here", + ("TAKEN_BREAK_STILL_A_COMMENT",), + ), + ( + # the `+++` line is a paragraph; the `%` line is still a comment + _project( + "::::{choose}\n+++\n:::{otherwise}\nSKIPPED_OTHERWISE\n:::\n::::\n\n" + "::::{choose}\n% still a comment\n" + ":::{otherwise}\nTAKEN_COMMENT_STILL_A_COMMENT\n:::\n::::\n", + conf=_CONF_MYST + "myst_disable_syntax = ['myst_block_break']\n", + myst=True, + ), + "+++", + ("TAKEN_COMMENT_STILL_A_COMMENT",), + ), + ], + ids=[ + "colon fences off in the front matter", + "fences disabled", + "fences disabled in the front matter", + "colon fences disabled", + "line comments disabled", + "line comments disabled in the front matter", + "block breaks disabled", + ], + indirect=["test_app"], +) +def test_choose_reads_the_documents_myst_config( + test_app, stray: str, taken: tuple[str, ...] +): + """A branch, a comment or a block break counts only if the document's parser has it. + + The document's configuration is the global one merged with its front matter + (whose ``enable_extensions`` replaces the global list), and ``disable_syntax`` + can switch a fence kind, line comments or block breaks off: such a line is a + stray, so a need under an opener the parser does not have, which the parser + would run, never exists; the rest of the file is read as usual. + """ + app = test_app + app.build() + (warning,) = build_warnings(app) + source = Path(app.srcdir, "index.md").read_text() + assert warning.startswith( + f"/index.md:{_line_of(source, stray)}: WARNING: " + ), warning + assert _stray(stray) in warning, warning + assert sorted(SphinxNeedsData(app.env).get_needs_view()) == [] + html = Path(app.outdir, "index.html").read_text() + assert [word for word in taken if word not in html] == [] + assert "SKIPPED" not in html + _assert_no_choose_nodes(app) + + +_C1_CLOSER = ( + "::::{choose}\n:::{when} False\nSKIPPED_WHEN\n :::\n" + "```{req} Smuggled past a closer\n:id: REQ_SMUGGLED_CLOSER\n```\n" + "```{needextend} REQ_HOST\n:status: LEAKED_BY_CLOSER\n```\n:::\n::::\n\n" +) +_C1_TABLE = ( + "::::{choose}\n:::{when} var.debug | var.debug\n |---|---|\n\n" + "```{req} Smuggled past an indented delimiter row\n:id: REQ_SMUGGLED_TABLE\n```\n" + ":::\n::::\n" +) + + +@pytest.mark.skipif(not _HAS_MYST, reason="needs myst-parser") +@pytest.mark.parametrize( + ("test_app", "strays"), + [ + ( + _project( + _HOST_NEED + _C1_CLOSER + _C1_TABLE, + conf=_CONF_MYST + "myst_disable_syntax = ['code']\n", + myst=True, + ), + ( + "```{req} Smuggled past a closer", + ":::{when} var.debug | var.debug", + ), + ), + ( + _front_matter_project( + 'myst:\n disable_syntax: ["code"]\n', _HOST_NEED + _C1_CLOSER + ), + ("```{req} Smuggled past a closer",), + ), + ], + ids=["the code rule disabled", "the code rule disabled in the front matter"], + indirect=["test_app"], +) +def test_choose_without_the_code_rule_in_myst(test_app, strays: tuple[str, ...]): + """Without markdown-it's ``code`` rule, indentation bounds no construct. + + A closer indented four spaces then closes the branch, so the fence after it is + a stray of the body; a delimiter row indented four spaces makes the opener above + it a table header. Nothing under either runs: the host need keeps its status. + """ + app = test_app + app.build() + warnings = build_warnings(app) + assert len(warnings) == len(strays), warnings + source = Path(app.srcdir, "index.md").read_text() + for warning, stray in zip(warnings, strays, strict=True): + assert warning.startswith( + f"/index.md:{_line_of(source, stray)}: WARNING: " + ), warning + assert _stray(stray) in warning, warning + needs = SphinxNeedsData(app.env).get_needs_view() + assert sorted(needs) == ["REQ_HOST"] + assert needs["REQ_HOST"]["status"] == "open" + assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() + _assert_no_choose_nodes(app) + + +@pytest.mark.skipif(not _HAS_MYST, reason="needs myst-parser") +@pytest.mark.parametrize( + "test_app", + [ + _project( + "::::{choose}\n~~~{when} var.debug\nTAKEN_TILDE\n~~~\n::::\n\n" + "::::{choose}\n % an indented comment\n" + ":::{otherwise}\nTAKEN_INDENTED_COMMENT\n:::\n::::\n\n" + "::::{choose}\n+ + +\n:::{otherwise}\nTAKEN_SPACED_BREAK\n:::\n::::\n\n" + "::::{choose}\n +++\n:::{otherwise}\nTAKEN_INDENTED_BREAK\n:::\n::::\n", + conf=_CONF_MYST, + myst=True, + ) + ], + indirect=True, +) +def test_choose_accepts_the_myst_spellings_myst_accepts(test_app): + """A tilde branch, an indented ``%`` comment and indented or spaced block breaks.""" + app = test_app + app.build() + assert_no_warnings(app) + html = Path(app.outdir, "index.html").read_text() + taken = ["TAKEN_TILDE", "TAKEN_INDENTED_COMMENT", "TAKEN_SPACED_BREAK"] + taken.append("TAKEN_INDENTED_BREAK") + assert [word for word in taken if word not in html] == [] + _assert_no_choose_nodes(app) + + +@pytest.mark.parametrize( + "test_app", + [ + _project( + ".. choose::\n\n" + " .. req:: Stray need\n :id: REQ_UNGATED\n\n" + " .. otherwise::\n\n SKIPPED_OTHERWISE\n" + ) + ], + indirect=True, +) +def test_choose_fails_closed_under_another_parser(test_app, monkeypatch): + """Under a parser the gate cannot read, the ``choose`` is refused unread.""" + monkeypatch.setattr(ChooseDirective, "_syntax", lambda self: None) + app = test_app + app.build() + (warning,) = build_warnings(app) + source = Path(app.srcdir, "index.rst").read_text() + assert warning.startswith( + f"/index.rst:{_line_of(source, '.. choose::')}: WARNING: " + ), warning + assert ( + "'choose' directive is supported under reStructuredText and MyST only" + _SKIP + in warning + ), warning + assert "SKIPPED" not in Path(app.outdir, "index.html").read_text() + assert sorted(SphinxNeedsData(app.env).get_needs_view()) == [] + + def test_absolute_location(): """A ``:`` location is reported with an absolute source. diff --git a/packages/sphinx-needs/tests/test_choose_gate.py b/packages/sphinx-needs/tests/test_choose_gate.py new file mode 100644 index 000000000..b0e388434 --- /dev/null +++ b/packages/sphinx-needs/tests/test_choose_gate.py @@ -0,0 +1,410 @@ +"""Unit tests for the gate a ``choose`` reads its body's top-level lines through. + +No Sphinx application: the gate is a pure function of the body's lines. +It must accept every spelling of a branch and a comment that the parser accepts, +and refuse, at its index, every other top-level line, so that nothing but branches +and comments is ever parsed in a ``choose`` body. +""" + +from __future__ import annotations + +import pytest + +from sphinx_needs.directives.needchoose import _gate, _Stray + +# reStructuredText, as docutils gives a directive its content: dedented, tabs expanded + +_RST_ACCEPTED: dict[str, list[str]] = { + "branches and blank lines": [ + ".. when:: var.arch == 'arm'", + "", + " ARM content.", + "", + ".. otherwise::", + "", + " Other content.", + ], + "an empty body": [], + "comments between branches": [ + ".. a comment", + ".. when:: var.debug", + "", + " Content.", + "", + ".. another comment", + ], + "a comment with an indented continuation": [ + ".. a comment", + " that goes on", + "", + " and on", + ".. otherwise::", + ], + "a condition continued on the next line": [ + ".. when:: var.arch ==", + " 'arm'", + "", + " Content.", + ], + "two spaces after the dots": [".. when:: var.debug", " Content."], + "names in another case": [ + ".. When:: var.debug", + " A.", + ".. OTHERWISE::", + " B.", + ], + "a space before the double colon": [".. when :: var.debug", " Content."], + "an empty comment followed by its indented text": [ + "..", + " the text of the comment", + ".. otherwise::", + ], + "an empty comment then a blank line then a branch": [ + "..", + "", + ".. when:: var.debug", + " Content.", + ], + "a comment that starts with the word when": [ + ".. when we migrate, drop this", + ".. when:: var.debug", + ], + "a branch whose content holds anything": [ + ".. when:: var.debug", + "", + " A paragraph.", + "", + " .. include:: other.rst", + "", + " Heading", + " -------", + ], +} + +_RST_STRAYS: dict[str, tuple[list[str], _Stray]] = { + "a paragraph": ( + [".. when:: var.debug", " Content.", "", "A stray paragraph."], + _Stray(3, None), + ), + "a target": ([".. _label:", ".. otherwise::"], _Stray(0, None)), + "a substitution definition": ( + [".. |sub| image:: picture.png", ".. otherwise::"], + _Stray(0, None), + ), + "a footnote": ([".. [1] A footnote.", ".. otherwise::"], _Stray(0, None)), + "a citation": ([".. [CIT2002] A citation.", ".. otherwise::"], _Stray(0, None)), + "an include": ( + [".. when:: var.debug", " Content.", ".. include:: other.rst"], + _Stray(2, None), + ), + "a false if": ([".. if:: False", "", " .. when:: True"], _Stray(0, None)), + "a note": ([".. note::", "", " .. when:: True"], _Stray(0, None)), + "default-role": ([".. default-role:: math", ".. otherwise::"], _Stray(0, None)), + "a misspelt branch": ([".. wehn:: True", " Content."], _Stray(0, None)), + "a heading": (["Title", "-----", "", ".. when:: True"], _Stray(0, None)), + "a transition": ([".. when:: True", " A.", "", "---"], _Stray(3, None)), + "three dots": (["...", ".. otherwise::"], _Stray(0, None)), + "an anonymous target": ( + ["__ https://example.com", ".. otherwise::"], + _Stray(0, None), + ), + "an indented first line": ( + [" A block quote.", ".. when:: True"], + _Stray(0, None), + ), + "a block quote after an empty comment": ( + ["..", "", " .. req:: A need", ".. otherwise::"], + _Stray(2, None), + ), + "the end-of-inclusion comment": ( + ['.. end of inclusion from "other.rst"', ".. otherwise::"], + _Stray(0, None), + ), + "when with one colon": ([".. when: var.debug", " Content."], _Stray(0, "when")), + "When with one colon": ([".. When: var.debug", " Content."], _Stray(0, "when")), + "when, a space, one colon": ([".. when : var.debug"], _Stray(0, "when")), + # docutils allows one space before `::`; with two the line is a comment + "when with two spaces before the double colon": ( + [".. when :: var.debug", " A."], + _Stray(0, "when"), + ), + "when without the space after the double colon": ( + [".. when::var.debug", " Content."], + _Stray(0, "when"), + ), + "otherwise with one colon": ( + [".. when:: var.debug", " A.", ".. otherwise:", " B."], + _Stray(2, "otherwise"), + ), + "an empty comment whose text is a one-colon branch": ( + ["..", " when: var.debug", ".. otherwise::"], + _Stray(0, "when"), + ), +} + +# MyST, as myst-parser gives a directive its content + +_MYST_ACCEPTED: dict[str, list[str]] = { + "colon branches": [ + ":::{when} var.arch == 'arm'", + "ARM content.", + ":::", + ":::{otherwise}", + "Other content.", + ":::", + ], + "backtick branches": [ + "```{when} var.debug", + "Content.", + "```", + "```{otherwise}", + "```", + ], + "a space before the name": ["::: {when} var.debug", "Content.", ":::"], + "a name in another case": [":::{When} var.debug", "Content.", ":::"], + "an opener indented three spaces": [" :::{when} var.debug", "Content.", ":::"], + "a closer indented three spaces": [ + ":::{when} var.debug", + "Content.", + " :::", + ":::{otherwise}", + "Other.", + ":::", + ], + "a closer followed by spaces and tabs": ["```{when} var.debug", "A.", "``` \t"], + "a longer closer": [":::{when} var.debug", "A.", "::::::"], + "a shorter fence nested in a branch": [ + "::::{when} var.debug", + ":::{note}", + "A note.", + ":::", + "::::", + ], + "a longer fence opened in a branch does not close it": [ + ":::{when} var.debug", + "::::{note} A note.", + "Content.", + ":::", + ], + "comments and block breaks": [ + "% a comment", + "+++", + "+++ a block break with text", + "++++", + ":::{when} var.debug", + ":::", + "% when we migrate, drop this", + ], + "blank lines of spaces and tabs": ["", " ", "\t", ":::{otherwise}", ":::"], + "a tilde branch": ["~~~{when} var.debug", "Content.", "~~~"], + "a backtick in a tilde fence's info": ["~~~{when} `var.debug`", "A.", "~~~"], + "an indented comment": [" % a comment", ":::{otherwise}", ":::"], + "an indented block break": [" +++", ":::{otherwise}", ":::"], + "a spaced block break": ["+ + +", "+ +\t+ text", ":::{otherwise}", ":::"], + "a closer indented four spaces does not close": [ + ":::{when} var.debug", + " :::", + "still the branch's", + ":::", + ], + # markdown-it needs a `|` in the header line for a table + "a delimiter row under an opener without a bar": [ + ":::{when} var.debug", + "|---|---|", + ":::", + ], + "an opener with a bar but no delimiter row after it": [ + ":::{when} var.a | var.b", + "", + "|---|---|", + ":::", + ], + "an unclosed branch runs to the end": [ + ":::{when} var.debug", + "Content.", + "```{include} other.md", + ], +} + +_MYST_STRAYS: dict[str, tuple[list[str], _Stray]] = { + "a paragraph": ([":::{when} var.debug", ":::", "", "A stray."], _Stray(3, None)), + "a heading": (["# Heading", ":::{otherwise}", ":::"], _Stray(0, None)), + "an html comment": ( + ["", ":::{otherwise}", ":::"], + _Stray(0, None), + ), + "eval-rst": (["```{eval-rst}", ".. when:: True", "```"], _Stray(0, None)), + "an include": (["```{include} other.md", "```"], _Stray(0, None)), + # markdown-it tries its `table` rule before any fence: a table header, no branch + "an opener a table swallows": ( + [ + ":::{when} var.debug | var.debug", + "|---|---|", + "", + "```{req} A need", + "```", + ":::", + ], + _Stray(0, None), + ), + "an opener a table with aligned cells swallows": ( + ["```{when} var.a | var.b", " :--- | ---: ", "```"], + _Stray(0, None), + ), + "no space after the name": ([":::{when}var.debug", "A.", ":::"], _Stray(0, None)), + "a backtick in a backtick fence's info": ( + ["```{when} `var.debug`", "A.", "```"], + _Stray(0, None), + ), + "an opener indented four spaces": ( + [" :::{when} var.debug", "A."], + _Stray(0, None), + ), + "an include after a closer indented three spaces": ( + [":::{when} var.debug", "Content.", " :::", "```{include} other.md", "```"], + _Stray(3, None), + ), + "a stray after a nested shorter fence's closer": ( + ["::::{when} var.debug", ":::{note}", "A.", ":::", "::::", "A stray."], + _Stray(5, None), + ), + "a closer with text after it closes nothing": ( + [":::{when} var.debug", "A.", "::: x", "```{include} other.md"], + None, + ), + "two pluses": (["++", ":::{otherwise}", ":::"], _Stray(0, None)), + "a list item": (["+ an item", ":::{otherwise}", ":::"], _Stray(0, None)), + "a comment indented four spaces": ( + [" % code", ":::{otherwise}", ":::"], + _Stray(0, None), + ), + "when with one colon": ( + ["% when: var.debug", ":::{otherwise}", ":::"], + _Stray(0, "when"), + ), + "When, a space, one colon": (["% When : var.debug"], _Stray(0, "when")), + "otherwise with one colon in a block break": ( + [":::{when} var.debug", ":::", "+++ otherwise:"], + _Stray(2, "otherwise"), + ), + "when with one colon in an indented comment": ([" % when: x"], _Stray(0, "when")), + "otherwise with one colon in a spaced block break": ( + ["+ + + otherwise:"], + _Stray(0, "otherwise"), + ), +} + + +@pytest.mark.parametrize("lines", list(_RST_ACCEPTED.values()), ids=list(_RST_ACCEPTED)) +def test_rst_gate_accepts(lines: list[str]): + """Every spelling of a branch and a comment that docutils accepts passes.""" + assert _gate(lines, syntax="rst") is None + + +@pytest.mark.parametrize( + ("lines", "stray"), list(_RST_STRAYS.values()), ids=list(_RST_STRAYS) +) +def test_rst_gate_refuses(lines: list[str], stray: _Stray): + """Every other top-level line is refused, at its index.""" + assert _gate(lines, syntax="rst") == stray + + +@pytest.mark.parametrize( + "lines", list(_MYST_ACCEPTED.values()), ids=list(_MYST_ACCEPTED) +) +def test_myst_gate_accepts(lines: list[str]): + """Every spelling of a branch fence, a comment and a block break MyST accepts passes.""" + assert _gate(lines, syntax="myst") is None + + +@pytest.mark.parametrize( + ("lines", "stray"), list(_MYST_STRAYS.values()), ids=list(_MYST_STRAYS) +) +def test_myst_gate_refuses(lines: list[str], stray: _Stray | None): + """Every other top-level line is refused, at its index. + + The gate never thinks it is inside a branch where MyST is not: a line that only + looks like a closer, with text after it, closes nothing (so what follows is + still the branch's, as it is MyST's). + """ + assert _gate(lines, syntax="myst") == stray + + +def test_myst_gate_without_colon_fence(): + """Without the ``colon_fence`` extension a ``:::`` line opens nothing: a stray.""" + lines = [":::{when} var.debug", "Content.", ":::"] + assert _gate(lines, syntax="myst", colon_fence=False) == _Stray(0, None) + assert ( + _gate(["```{when} var.debug", "```"], syntax="myst", colon_fence=False) is None + ) + + +def test_myst_gate_without_fence(): + """With ``fence`` disabled, a backtick or tilde line opens nothing: a stray.""" + for marker in ("```", "~~~"): + lines = [f"{marker}{{when}} var.debug", "Content.", marker] + assert _gate(lines, syntax="myst", fence=False) == _Stray(0, None) + assert _gate([":::{when} var.debug", ":::"], syntax="myst", fence=False) is None + + +def test_myst_gate_without_comments_or_block_breaks(): + """With ``myst_line_comment`` or ``myst_block_break`` disabled, the line is text.""" + assert _gate(["% a comment"], syntax="myst", comment=False) == _Stray(0, None) + assert _gate(["+++"], syntax="myst", block_break=False) == _Stray(0, None) + assert _gate(["% a comment", "+++"], syntax="myst") is None + + +# With markdown-it's `code` rule disabled no line is an indented code block, so +# indentation bounds none of the constructs the gate mirrors: (lines, the gate's +# answer without the rule, its answer with it, the default) +_WITHOUT_CODE: dict[str, tuple[list[str], _Stray | None, _Stray | None]] = { + "a closer indented four spaces closes": ( + [":::{when} var.debug", "A.", " :::", "A stray."], + _Stray(3, None), + None, + ), + "a closer led by a tab closes": ( + [":::{when} var.debug", "A.", "\t:::", "A stray."], + _Stray(3, None), + None, + ), + "an opener indented four spaces opens": ( + [" :::{when} var.debug", "A.", ":::"], + None, + _Stray(0, None), + ), + "a comment indented four spaces": ( + [" % a comment", ":::{otherwise}", ":::"], + None, + _Stray(0, None), + ), + "a block break indented four spaces": ( + [" +++", ":::{otherwise}", ":::"], + None, + _Stray(0, None), + ), + "an opener over a delimiter row indented four spaces": ( + [":::{when} var.a | var.b", " |---|---|", ":::"], + _Stray(0, None), + None, + ), +} + + +@pytest.mark.parametrize( + ("lines", "without_code", "with_code"), + list(_WITHOUT_CODE.values()), + ids=list(_WITHOUT_CODE), +) +def test_myst_gate_without_code( + lines: list[str], without_code: _Stray | None, with_code: _Stray | None +): + """Without the ``code`` rule, a construct indented four columns or more is live. + + markdown-it-py's ``is_code_block`` is false for every line once the ``code`` rule + is disabled, so a fence, a closer, a delimiter row, a ``%`` comment or a ``+++`` + block break counts at any indentation; with the rule (the default) the same body + gives the gate's usual answer. + """ + assert _gate(lines, syntax="myst", code=False) == without_code + assert _gate(lines, syntax="myst") == with_code