From b28a50bc15b2148699e096adc5635b2d44379b3f Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Tue, 29 Sep 2026 15:16:16 +0200 Subject: [PATCH 1/4] feat: needs: emit a need per API symbol that satisfies a requirement The API Doxygen project carries Doxygen 1.16 `\satisfies` on its symbols (191 of them in the safety docset, 300 requirement refs), but the parser read only ``, and the Sphinx API document emitted no needs. A requirement page could show what verifies it, never what implements it. A `kind: sphinx` document with a registry block symbol_needs: doxygen_source: now loads a new `symbol_needs` extension. Its `.. symbolneeds::` directive (optional argument: one Doxygen group; without it the whole project, sectioned by group or file) emits one need per symbol with ``: titled with the symbol, id `-`, the kind, brief, declaring file and a link to its Doxygen page in the body, and a link to each requirement. Type and link are engine roles, `implementation` and `satisfies`, named by the consumer (defaults `impl`, `satisfies`; `symbolneeds_need_types` / `symbolneeds_need_links`), as ADR-0009 has it for the test directives. With the document also publishing `needs: {source: json}`, a requirement's page lists the symbols under the link's incoming name next to "verified by". Without the block nothing is loaded, so existing consumers are unchanged. A `\satisfies` naming an unknown UID is caught the way a `verifies` is: Doxygen writes the same `requirement_` refid for it, so the XML cannot tell, but sphinx-needs reports the unknown outgoing link, and Doxygen's own warning trips the stage-2 gate. The fixture is real Doxygen 1.16.1 output with one such UID, and the test asserts the warning. The registry validates the block at configure time, docrefs resolves its XML dir and HTML URL, and add_docs_from_registry adds the same stage-2 edge to the Doxygen document as testmodule's. The input tracking that testmodule/testreport use moves to its own `input_tracking` extension, so the new directive re-reads when the XML changes without loading test_module. doxygen_parser's `\verifies` reading becomes `requirement_uids(memberdef, relation)`, shared with `\satisfies`. (Also wraps one over-long line in the parameterized-test tests.) Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- cmake/registry.cmake | 9 + doc/api/python/extensions.rst | 16 ++ doc/manual/reference/consumer-contract.rst | 8 + doc/manual/reference/directives-and-roles.rst | 60 ++++- doc/manual/reference/registry-schema.rst | 30 +++ scripts/docrefs.py | 72 +++++ .../_tests/fixtures/doxygen-symbols/Doxyfile | 13 + .../doxygen-symbols/group__queue__apis.xml | 126 +++++++++ .../_tests/fixtures/doxygen-symbols/index.xml | 24 ++ .../_tests/fixtures/doxygen-symbols/queue.h | 53 ++++ .../fixtures/doxygen-symbols/queue_8h.xml | 56 ++++ .../_tests/fixtures/doxygen-symbols/req.dox | 7 + .../fixtures/doxygen-symbols/req_8dox.xml | 14 + .../_tests/roots/test-symbolneeds/api.rst | 4 + .../_tests/roots/test-symbolneeds/conf.py | 31 +++ .../_tests/roots/test-symbolneeds/index.rst | 7 + .../roots/test-symbolneeds/requirements.rst | 12 + .../_extensions/_tests/test_symbol_needs.py | 245 ++++++++++++++++++ .../_tests/test_testreport_param.py | 3 +- sphinx/_extensions/doxygen_parser.py | 84 +++++- sphinx/_extensions/input_tracking.py | 69 +++++ sphinx/_extensions/rst_builders.py | 51 +++- sphinx/_extensions/symbol_needs.py | 153 +++++++++++ sphinx/_extensions/test_module.py | 65 +---- sphinx/zdocs_conf.py | 7 + 25 files changed, 1141 insertions(+), 78 deletions(-) create mode 100644 sphinx/_extensions/_tests/fixtures/doxygen-symbols/Doxyfile create mode 100644 sphinx/_extensions/_tests/fixtures/doxygen-symbols/group__queue__apis.xml create mode 100644 sphinx/_extensions/_tests/fixtures/doxygen-symbols/index.xml create mode 100644 sphinx/_extensions/_tests/fixtures/doxygen-symbols/queue.h create mode 100644 sphinx/_extensions/_tests/fixtures/doxygen-symbols/queue_8h.xml create mode 100644 sphinx/_extensions/_tests/fixtures/doxygen-symbols/req.dox create mode 100644 sphinx/_extensions/_tests/fixtures/doxygen-symbols/req_8dox.xml create mode 100644 sphinx/_extensions/_tests/roots/test-symbolneeds/api.rst create mode 100644 sphinx/_extensions/_tests/roots/test-symbolneeds/conf.py create mode 100644 sphinx/_extensions/_tests/roots/test-symbolneeds/index.rst create mode 100644 sphinx/_extensions/_tests/roots/test-symbolneeds/requirements.rst create mode 100644 sphinx/_extensions/_tests/test_symbol_needs.py create mode 100644 sphinx/_extensions/input_tracking.py create mode 100644 sphinx/_extensions/symbol_needs.py diff --git a/cmake/registry.cmake b/cmake/registry.cmake index e912898..fa5db9a 100644 --- a/cmake/registry.cmake +++ b/cmake/registry.cmake @@ -307,6 +307,15 @@ function(add_docs_from_registry) endforeach() endif() + # symbol_needs: reads the Doxygen document's deploy/xml// the same + # way testmodule does, so the same stage-2 edge. + string(JSON _zdocs_symbol_dox_src GET "${_zdocs_entry}" "symbol_needs_doxygen_source") + if(NOT _zdocs_symbol_dox_src STREQUAL "") + foreach(_zdocs_builder ${_zdocs_builders}) + add_dependencies(${_zdocs_id}-${_zdocs_builder} ${_zdocs_symbol_dox_src}) + endforeach() + endif() + string(JSON _zdocs_testmodule_spec GET "${_zdocs_entry}" "testmodule_spec") if(NOT _zdocs_testmodule_spec STREQUAL "") add_dependencies(${_zdocs_id}-index ${_zdocs_testmodule_spec}-index) diff --git a/doc/api/python/extensions.rst b/doc/api/python/extensions.rst index c7d1b09..7dca4fe 100644 --- a/doc/api/python/extensions.rst +++ b/doc/api/python/extensions.rst @@ -49,6 +49,22 @@ directives. .. automodule:: test_module :members: +``symbol_needs`` +------------------ + +The ``.. symbolneeds::`` directive. + +.. automodule:: symbol_needs + :members: + +``input_tracking`` +-------------------- + +Re-reads a document when an input from outside the source tree changes. + +.. automodule:: input_tracking + :members: + ``xref_builder`` ------------------ diff --git a/doc/manual/reference/consumer-contract.rst b/doc/manual/reference/consumer-contract.rst index 8b760bd..3303682 100644 --- a/doc/manual/reference/consumer-contract.rst +++ b/doc/manual/reference/consumer-contract.rst @@ -85,6 +85,14 @@ layout, not toggled from outside it. per-document (every document in a set is handed an import of every other's needs, so they all have to agree on what a need type means). + It must declare every need type, link and field the engine's directives + emit under the names you map their roles to: the test directives' three + types, three links and custom fields, and — for a document with a + ``symbol_needs:`` block — the ``implementation`` type and ``satisfies`` + link (:doc:`directives-and-roles`). Declaring the ``satisfies`` link in + this project-wide file is also what lets a requirements document show the + link's incoming side. + This variable is real and load-bearing — every sample and fixture in this repository that uses sphinx-needs sets it — but it is not mentioned alongside the others in ``cmake/zdocs.cmake``'s own "Consumer configuration" diff --git a/doc/manual/reference/directives-and-roles.rst b/doc/manual/reference/directives-and-roles.rst index 3a3211c..0cc4267 100644 --- a/doc/manual/reference/directives-and-roles.rst +++ b/doc/manual/reference/directives-and-roles.rst @@ -5,9 +5,10 @@ What a document author writes in RST. Every directive here is loaded for every document by :external+zdocs-api:py:func:`zdocs_conf.configure`, whether or not the document uses it — an extension list that varied by document would make the two build stages' configuration differ and invalidate the shared -doctree cache. The ``testmodule``/``testreport``/``twisterinfo`` directives are -the one exception: they load only for a document whose registry entry carries -a ``testmodule:`` block (:doc:`registry-schema`). +doctree cache. The ``testmodule``/``testreport``/``twisterinfo`` directives and +``symbolneeds`` are the exceptions: they load only for a document whose +registry entry carries a ``testmodule:`` or ``symbol_needs:`` block +(:doc:`registry-schema`). ``.. doc_control::`` -------------------- @@ -193,6 +194,59 @@ the parser matches on, and must be spelled exactly): ... } +``.. symbolneeds::`` +-------------------- + +One need per API symbol (function, macro, ...) carrying a Doxygen 1.16 +``\satisfies ``, linked to the requirement needs those UIDs name. Loads +only for a document whose registry entry has a ``symbol_needs:`` block +(:doc:`registry-schema`), which also names the Doxygen document whose XML is +read. Source: :external+zdocs-api:py:mod:`symbol_needs`. + +.. code-block:: rst + + .. symbolneeds:: + + .. symbolneeds:: queue_apis + +Without an argument, every annotated symbol in the Doxygen project is +emitted, in sections headed by the group (or file) that documents it. With a +Doxygen group name, only that group's symbols, with no heading. Each symbol is +emitted once. A symbol without ``\satisfies`` gets no need. + +Each need is titled with the symbol name and has the id +``-``, where ```` is the need type's name in upper case +(``IMPL-k_queue_init``). Its body gives the kind, the brief, a link to the +symbol's Doxygen page and the file it is declared in; no custom field is +needed for them. The need type and link are engine roles, named by the +consumer like the test directives' (ADR-0009): + +.. code-block:: python + + symbolneeds_need_types = {"implementation": "impl"} # the defaults + symbolneeds_need_links = {"satisfies": "satisfies"} + +Declare both in ``needs_config.toml``; the link's ``incoming`` name is what a +requirement's page shows: + +.. code-block:: toml + + [[needs.types]] + directive = "impl" + title = "Implementation" + prefix = "IMPL_" + + [needs.links.satisfies] + outgoing = "satisfies" + incoming = "satisfied by" + +A ``\satisfies`` naming a UID that no requirement need has is reported like a +dangling ``verifies``: sphinx-needs warns "unknown outgoing link" in the +stage-2 build (the XML cannot tell, since Doxygen writes a +``requirement_`` refid for any UID), and Doxygen's own "Reference to +unknown requirement" warning fails the Doxygen document through +``ZDOCS_DOXYGEN_WARN_FAIL_PATTERNS`` (:doc:`consumer-contract`). + Doxylink prefixes ----------------- diff --git a/doc/manual/reference/registry-schema.rst b/doc/manual/reference/registry-schema.rst index fbf05a8..54406c5 100644 --- a/doc/manual/reference/registry-schema.rst +++ b/doc/manual/reference/registry-schema.rst @@ -268,6 +268,36 @@ Each entry in ``documents:``, keyed by its id: ``crossref: false`` on either side removes the entry, as for any tag file. Needs Doxygen 1.16 or newer. +``symbol_needs`` + Opt-in sub-block; loads the ``symbolneeds`` directive + (:doc:`directives-and-roles`) into this document, which emits one need per + API symbol that carries a Doxygen ``\satisfies``, linked to the + requirements it names. One key, required: + + .. code-block:: yaml + + api-documentation: + kind: sphinx + builders: [html] + needs: + source: json # so peers import the symbol needs + symbol_needs: + doxygen_source: dox-safety-api # a kind: doxygen document + + ``doxygen_source`` names the ``kind: doxygen`` document whose XML + (``deploy/xml//``, so the top-level ``doxygen_xml: true`` is needed) + holds the symbols; every stage-2 builder of this document waits for it, as + for ``testmodule.doxygen_source``. Its HTML is what each need links to. + Only a ``kind: sphinx`` document may carry the block; a missing or unknown + key, a non-mapping value, or a ``doxygen_source`` that is not an existing + ``kind: doxygen`` document is a configure-time error. + + Add ``needs: {source: json}`` as shown: it is what makes every peer import + the symbol needs, so a requirement authored in another document lists its + implementing symbols ("satisfied by", or whatever incoming name your + ``needs_config.toml`` gives the link) beside its verifying test cases. + Without the block, nothing changes: the extension is not loaded. + ``testmodule`` Opt-in sub-block (see :doc:`../explanation/testmodule-and-twister` and :doc:`directives-and-roles`); its presence is the sole trigger that loads diff --git a/scripts/docrefs.py b/scripts/docrefs.py index 0c74fa5..9b83c2d 100644 --- a/scripts/docrefs.py +++ b/scripts/docrefs.py @@ -147,6 +147,50 @@ def _registry(registry): _TESTMODULE_KEYS = ("doxygen_source", "api_reference", "spec") +#: Allowed keys inside a document's ``symbol_needs:`` block. ``doxygen_source`` +#: is required: it is the only thing the block says. +_SYMBOL_NEEDS_KEYS = ("doxygen_source",) + + +def _validate_symbol_needs(doc_id, kind, block, documents): + """Raise ``ValueError`` unless ``symbol_needs:`` is usable on ``doc_id``.""" + if kind != "sphinx": + raise ValueError( + f"docrefs: document '{doc_id}' has 'symbol_needs:' but is kind " + f"'{kind}' — only a 'kind: sphinx' document emits needs" + ) + if not isinstance(block, dict): + raise ValueError( + f"docrefs: document '{doc_id}' has symbol_needs '{block}' — use a " + f"mapping such as '{{doxygen_source: }}'" + ) + unknown = sorted(set(block) - set(_SYMBOL_NEEDS_KEYS)) + if unknown: + raise ValueError( + f"docrefs: document '{doc_id}' has unknown key(s) {unknown} in its " + f"'symbol_needs:' block — allowed keys are {_SYMBOL_NEEDS_KEYS}" + ) + ref_id = block.get("doxygen_source") + if ref_id is None: + raise ValueError( + f"docrefs: document '{doc_id}' has 'symbol_needs:' without " + f"'doxygen_source:' — name the kind: doxygen document whose XML " + f"holds the symbols" + ) + ref_meta = documents.get(ref_id) + if ref_meta is None: + raise ValueError( + f"docrefs: document '{doc_id}' has symbol_needs.doxygen_source: " + f"'{ref_id}', which does not exist in the registry" + ) + ref_kind = ref_meta.get("kind", "sphinx") + if ref_kind != "doxygen": + raise ValueError( + f"docrefs: document '{doc_id}' has symbol_needs.doxygen_source: " + f"'{ref_id}', which is kind '{ref_kind}', not 'doxygen'" + ) + + #: Allowed keys inside a document's ``doxygen_tag:`` block. _DOXYGEN_TAG_KEYS = ("types",) @@ -269,6 +313,11 @@ def _validate(data): if doxygen_tag is not None: _validate_doxygen_tag(doc_id, meta, kind, doxygen_tag) + # -- symbol_needs: one need per API symbol with \satisfies -------- + symbol_needs = meta.get("symbol_needs") + if symbol_needs is not None: + _validate_symbol_needs(doc_id, kind, symbol_needs, data.get("documents", {})) + # -- testmodule: sub-block (step 27) ----------------------------- # # Rejected at CONFIGURE time (rule 6), naming the offending document, @@ -593,6 +642,7 @@ def __init__( rel_urls=None, deploy_dirs=None, testmodule=None, + symbol_needs=None, ): self.reference_groups = reference_groups self.intersphinx_mapping = intersphinx_mapping @@ -611,6 +661,11 @@ def __init__( #: corresponding ``testmodule:`` field is absent, e.g. no #: ``api_reference:``). self.testmodule = testmodule + #: This document's own resolved ``symbol_needs:`` block, or ``None`` + #: when it has none (then the ``symbol_needs`` extension is not + #: loaded). A dict with ``xml_dir`` (the Doxygen document's XML) and + #: ``doxygen_url`` (its HTML, relative to this document's root). + self.symbol_needs = symbol_needs def _resolve_external_url(url, external_base_url): @@ -909,6 +964,15 @@ def _nav_href(doc_id, meta): "needs_json": needs_json, } + symbol_needs = None + symbol_block = this_meta.get("symbol_needs") + if symbol_block is not None: + source = symbol_block["doxygen_source"] + symbol_needs = { + "xml_dir": str(deploy / "xml" / source), + "doxygen_url": rel_urls.get(source, ""), + } + return Refs( reference_groups, intersphinx_mapping, @@ -919,6 +983,7 @@ def _nav_href(doc_id, meta): rel_urls=rel_urls, deploy_dirs=deploy_dirs, testmodule=testmodule, + symbol_needs=symbol_needs, ) @@ -1176,6 +1241,10 @@ def manifest(registry=None): never its own spec), so this cannot cycle; it is deliberately NOT generalised to every needs-importer/publisher pair, which CAN cycle and is its own, deferred step. + * ``symbol_needs_doxygen_source`` — this document's own + ``symbol_needs:`` block's ``doxygen_source`` (``None`` without the + block). Wired like ``testmodule_doxygen_source``: every stage-2 + builder of the document waits for that Doxygen document. * ``doxygen_tag`` — ``True`` when the document declares ``doxygen_tag:``. ``add_docs_from_registry`` then adds the ``-needstag`` target (see :func:`needs_tag`). @@ -1195,6 +1264,9 @@ def manifest(registry=None): "remote_tagfile": meta.get("remote-tagfile"), "testmodule_doxygen_source": testmodule.get("doxygen_source"), "testmodule_spec": testmodule.get("spec"), + "symbol_needs_doxygen_source": (meta.get("symbol_needs") or {}).get( + "doxygen_source" + ), "doxygen_tag": meta.get("doxygen_tag") is not None, } ) diff --git a/sphinx/_extensions/_tests/fixtures/doxygen-symbols/Doxyfile b/sphinx/_extensions/_tests/fixtures/doxygen-symbols/Doxyfile new file mode 100644 index 0000000..890372f --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/doxygen-symbols/Doxyfile @@ -0,0 +1,13 @@ +PROJECT_NAME = zdocs-symbol-needs-fixture +INPUT = src +FILE_PATTERNS = *.h *.dox +OUTPUT_DIRECTORY = out +GENERATE_HTML = NO +GENERATE_LATEX = NO +GENERATE_XML = YES +GENERATE_REQUIREMENTS = YES +QUIET = YES +STRIP_FROM_PATH = src +# Regenerate: doxygen Doxyfile (with queue.h and req.dox under src/), then copy +# index.xml, group__queue__apis.xml, queue_8h.xml and req_8dox.xml from out/xml here. +ALIASES += "kconfig_depends{1}=\xrefitem kconfig_depends \"Depends on\" \"Kconfig dependencies\" \1" diff --git a/sphinx/_extensions/_tests/fixtures/doxygen-symbols/group__queue__apis.xml b/sphinx/_extensions/_tests/fixtures/doxygen-symbols/group__queue__apis.xml new file mode 100644 index 0000000..2ac9972 --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/doxygen-symbols/group__queue__apis.xml @@ -0,0 +1,126 @@ + + + + queue_apis + Queue APIs + + + void + void k_queue_init + (struct k_queue *q) + k_queue_init + + struct k_queue * + q + + + + + +Initialize a queue. + + + + +q + + +Address of the queue. + + + + + + + + + + + void + void k_queue_append + (struct k_queue *q, void *data) + k_queue_append + + struct k_queue * + q + + + void * + data + + + + + + +Append an element to the end of a queue. + + + + +q + + +Address of the queue. + + + + +data + + +Address of the data item. + + + +Depends onCONFIG_ASSERT +(CONFIG_USERSPACE && !CONFIG_NO_SYSCALLS) || CONFIG_TEST + + + + + + + + void + void k_queue_cancel_wait + (struct k_queue *q) + k_queue_cancel_wait + + struct k_queue * + q + + +Not annotated: must not become a need. + + + + + + + + + + + K_QUEUE_MAX + 8 + + + + +Maximum number of items; references a UID no requirement defines. + + + + + + + + + +Queue kernel objects. + + + + + diff --git a/sphinx/_extensions/_tests/fixtures/doxygen-symbols/index.xml b/sphinx/_extensions/_tests/fixtures/doxygen-symbols/index.xml new file mode 100644 index 0000000..f567ea6 --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/doxygen-symbols/index.xml @@ -0,0 +1,24 @@ + + + queue.h + K_QUEUE_MAX + k_queue_init + k_queue_append + k_queue_cancel_wait + k_queue_is_empty + + req.dox + + queue_apis + k_queue_init + k_queue_append + k_queue_cancel_wait + K_QUEUE_MAX + + kconfig_depends + + REQ-QUEUE-1 + + REQ-QUEUE-2 + + diff --git a/sphinx/_extensions/_tests/fixtures/doxygen-symbols/queue.h b/sphinx/_extensions/_tests/fixtures/doxygen-symbols/queue.h new file mode 100644 index 0000000..f21ed09 --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/doxygen-symbols/queue.h @@ -0,0 +1,53 @@ +/** + * @file queue.h + * @brief Queue API. + */ + +/** + * @defgroup queue_apis Queue APIs + * @brief Queue kernel objects. + * @{ + */ + +/** + * @brief Initialize a queue. + * + * @param q Address of the queue. + * + * @satisfies REQ-QUEUE-1 + */ +void k_queue_init(struct k_queue *q); + +/** + * @brief Append an element to the end of a queue. + * + * @param q Address of the queue. + * @param data Address of the data item. + * + * @satisfies REQ-QUEUE-1 + * @satisfies REQ-QUEUE-2 + * @kconfig_depends{CONFIG_ASSERT} + * @kconfig_depends{(CONFIG_USERSPACE && !CONFIG_NO_SYSCALLS) || CONFIG_TEST} + */ +void k_queue_append(struct k_queue *q, void *data); + +/** + * @brief Maximum number of items; references a UID no requirement defines. + * + * @satisfies REQ-QUEUE-99 + */ +#define K_QUEUE_MAX 8 + +/** + * @brief Not annotated: must not become a need. + */ +void k_queue_cancel_wait(struct k_queue *q); + +/** @} */ + +/** + * @brief Query a queue to see if it has data available; outside any group. + * + * @satisfies REQ-QUEUE-2 + */ +int k_queue_is_empty(struct k_queue *q); diff --git a/sphinx/_extensions/_tests/fixtures/doxygen-symbols/queue_8h.xml b/sphinx/_extensions/_tests/fixtures/doxygen-symbols/queue_8h.xml new file mode 100644 index 0000000..53b3c2b --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/doxygen-symbols/queue_8h.xml @@ -0,0 +1,56 @@ + + + + queue.h + + K_QUEUE_MAX + + + k_queue_init + k_queue_append + k_queue_cancel_wait + + int + int k_queue_is_empty + (struct k_queue *q) + k_queue_is_empty + + struct k_queue * + q + + + + + +Query a queue to see if it has data available; outside any group. + + + + + + + + + +Queue API. + + + + + + + +voidk_queue_init(structk_queue*q); + +voidk_queue_append(structk_queue*q,void*data); + +#defineK_QUEUE_MAX8 + +voidk_queue_cancel_wait(structk_queue*q); + + +intk_queue_is_empty(structk_queue*q); + + + + diff --git a/sphinx/_extensions/_tests/fixtures/doxygen-symbols/req.dox b/sphinx/_extensions/_tests/fixtures/doxygen-symbols/req.dox new file mode 100644 index 0000000..97883dc --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/doxygen-symbols/req.dox @@ -0,0 +1,7 @@ +/** +\requirement REQ-QUEUE-1 Queue initialisation +The kernel shall initialise a queue. + +\requirement REQ-QUEUE-2 Queue append +The kernel shall append an item to a queue. +*/ diff --git a/sphinx/_extensions/_tests/fixtures/doxygen-symbols/req_8dox.xml b/sphinx/_extensions/_tests/fixtures/doxygen-symbols/req_8dox.xml new file mode 100644 index 0000000..4d3bfbf --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/doxygen-symbols/req_8dox.xml @@ -0,0 +1,14 @@ + + + + req.dox + + + + + + + + + + diff --git a/sphinx/_extensions/_tests/roots/test-symbolneeds/api.rst b/sphinx/_extensions/_tests/roots/test-symbolneeds/api.rst new file mode 100644 index 0000000..c13c3e1 --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-symbolneeds/api.rst @@ -0,0 +1,4 @@ +API +=== + +.. symbolneeds:: diff --git a/sphinx/_extensions/_tests/roots/test-symbolneeds/conf.py b/sphinx/_extensions/_tests/roots/test-symbolneeds/conf.py new file mode 100644 index 0000000..c5170bc --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-symbolneeds/conf.py @@ -0,0 +1,31 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) # _extensions/ + +_FIXTURES = Path(__file__).resolve().parents[2] / "fixtures" + +extensions = ["sphinx_needs", "symbol_needs"] +master_doc = "index" +exclude_patterns = ["_build"] + +# The consumer's vocabulary: a requirement type, and the engine's +# `implementation` role under its default name `impl`. +needs_types = [ + dict(directive="req", title="Requirement", prefix="REQ_", color="#FDEBD0", style="node"), + dict(directive="impl", title="Implementation", prefix="IMPL_", color="#E2EFDA", style="node"), +] +needs_id_regex = r"^[A-Za-z][A-Za-z0-9_-]+" +needs_links = { + "satisfies": {"description": "satisfies", "incoming": "satisfied by", "outgoing": "satisfies"}, +} +needs_build_json = True +suppress_warnings = ["config.cache"] + +# Real Doxygen 1.16.1 output (fixtures/doxygen-symbols/Doxyfile). +symbolneeds_xml_dir = str(_FIXTURES / "doxygen-symbols") +symbolneeds_doxygen_url = "api" diff --git a/sphinx/_extensions/_tests/roots/test-symbolneeds/index.rst b/sphinx/_extensions/_tests/roots/test-symbolneeds/index.rst new file mode 100644 index 0000000..4b60ebe --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-symbolneeds/index.rst @@ -0,0 +1,7 @@ +Symbol needs +============ + +.. toctree:: + + requirements + api diff --git a/sphinx/_extensions/_tests/roots/test-symbolneeds/requirements.rst b/sphinx/_extensions/_tests/roots/test-symbolneeds/requirements.rst new file mode 100644 index 0000000..6d99507 --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-symbolneeds/requirements.rst @@ -0,0 +1,12 @@ +Requirements +============ + +.. req:: Queue initialisation + :id: REQ-QUEUE-1 + + The kernel shall initialise a queue. + +.. req:: Queue append + :id: REQ-QUEUE-2 + + The kernel shall append an item to a queue. diff --git a/sphinx/_extensions/_tests/test_symbol_needs.py b/sphinx/_extensions/_tests/test_symbol_needs.py new file mode 100644 index 0000000..c52b74d --- /dev/null +++ b/sphinx/_extensions/_tests/test_symbol_needs.py @@ -0,0 +1,245 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""symbolneeds — one need per API symbol carrying a Doxygen ``\\satisfies``. + +The fixture is real Doxygen 1.16.1 XML (fixtures/doxygen-symbols/Doxyfile): +three grouped symbols and one ungrouped function with ``\\satisfies``, one +grouped function without, and a macro satisfying ``REQ-QUEUE-99``, which no +``\\requirement`` defines. Doxygen warned about that one, and still wrote a +``requirement_REQ-QUEUE-99`` refid, indistinguishable from a real one. +""" + +import json +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +import doxygen_parser as dp +import pytest +import rst_builders as rb +import symbol_needs as sn +from conftest import FIXTURES + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "scripts")) + +import docrefs # noqa: E402 + +_ROOTS = Path(__file__).parent / "roots" +XML = FIXTURES / "doxygen-symbols" + + +def _member(name, compound="group__queue__apis"): + root = ET.parse(XML / f"{compound}.xml").getroot() + return next(md for md in root.iter("memberdef") if md.findtext("name") == name) + + +def _ids(lines): + return [line.split(":id:")[1].strip() for line in lines if line.strip().startswith(":id:")] + + +# --------------------------------------------------------------------------- +# doxygen_parser +# --------------------------------------------------------------------------- + + +def test_satisfies_uids_in_source_order(): + assert dp.requirement_uids(_member("k_queue_append"), "satisfies") == [ + "REQ-QUEUE-1", + "REQ-QUEUE-2", + ] + + +def test_satisfies_is_not_read_as_verifies(): + assert dp.parse_memberdef(_member("k_queue_append"), "group__queue__apis", "", "")[ + "req_ids" + ] == [] + + +def test_parse_symbol(): + info = dp.parse_symbol(_member("k_queue_init"), "../api") + assert info["name"] == "k_queue_init" + assert info["kind"] == "function" + assert info["satisfies"] == ["REQ-QUEUE-1"] + assert info["brief"] == "Initialize a queue." + assert info["source_file"] == "queue.h (line 19)" + assert info["doxygen_url"] == ( + "../api/group__queue__apis.html#ga7119309c1016c8ac5ea22768916c4b8a" + ) + + +def test_an_ungrouped_symbol_links_to_its_file_page(): + info = dp.parse_symbol(_member("k_queue_is_empty", "queue_8h"), "api") + assert info["doxygen_url"].startswith("api/queue_8h.html#") + + +# --------------------------------------------------------------------------- +# rst_builders +# --------------------------------------------------------------------------- + + +def test_symbol_need_uses_the_role_names(): + info = dp.parse_symbol(_member("k_queue_append"), "api") + default = rb.build_symbol_need_rst(info) + assert default.startswith(".. impl:: k_queue_append\n :id: IMPL-k_queue_append\n") + assert " :satisfies: REQ-QUEUE-1; REQ-QUEUE-2" in default + + renamed = rb.build_symbol_need_rst( + info, {"implementation": "code_unit", "satisfies": "realises"} + ) + assert renamed.startswith(".. code_unit:: k_queue_append\n :id: CODE_UNIT-k_queue_append\n") + assert " :realises: REQ-QUEUE-1; REQ-QUEUE-2" in renamed + + +def test_symbol_need_body_has_kind_link_and_file(): + rst = rb.build_symbol_need_rst(dp.parse_symbol(_member("K_QUEUE_MAX"), "api")) + assert "`Define K_QUEUE_MAX in Doxygen prose points, as HTML directory URLs. @@ -398,18 +410,11 @@ def parse_memberdef( if see_sect is not None: see_rst = see_to_rst(see_sect, api_html_dir, testspec_html_dir) - # Doxygen's native `\verifies` (1.16+): a child of the memberdef - # itself, not of the description, with the UID only in each requirement's - # refid. Read beside the `@reqref` xrefsects above; both are live while - # sources migrate. - # - # The refid is NOT proof the requirement exists: Doxygen synthesizes it from - # the UID string whether or not any `\requirement` defines it, so a typo is - # byte-identical here to a real link. Only Doxygen's warning ("Reference to - # unknown requirement") tells them apart. - for req in memberdef.findall("verifies/requirement"): - uid = req.get("refid", "").removeprefix("requirement_") - if uid and uid not in req_ids: + # Doxygen's native `\verifies` (1.16+), read beside the `@reqref` + # xrefsects above; both are live while sources migrate. See + # requirement_uids for why its refids prove nothing about existence. + for uid in requirement_uids(memberdef, "verifies"): + if uid not in req_ids: req_ids.append(uid) ibd = memberdef.find("inbodydescription") @@ -434,6 +439,61 @@ def parse_memberdef( ) +def requirement_uids(memberdef: ET.Element, relation: str) -> list[str]: + """The requirement UIDs of Doxygen's native ``\\verifies`` or ``\\satisfies``. + + ``relation`` is the element name, ``verifies`` or ``satisfies``: a child of + the memberdef itself, not of its description, with the UID only in each + ````'s refid, in source order, each UID once. + + The refid is NOT proof the requirement exists: Doxygen synthesizes + ``requirement_`` from the UID string whether or not any + ``\\requirement`` defines it, so a typo is byte-identical here to a real + link. Doxygen's "Reference to unknown requirement" warning tells them apart, + and so does the link target's absence among the needs, which sphinx-needs + reports for the link these UIDs become. + """ + uids: list[str] = [] + for req in memberdef.findall(f"{relation}/requirement"): + uid = req.get("refid", "").removeprefix("requirement_") + if uid and uid not in uids: + uids.append(uid) + return uids + + +def parse_symbol(memberdef: ET.Element, html_dir: str) -> SymbolInfo: + """An API symbol (function, macro, ...) as a need's data: name, kind, the + requirements it satisfies, its brief, where it is declared, and its page in + the Doxygen HTML under ``html_dir``. + + The page is the one Doxygen documents the member on, which its id names: + ``_1``, the same shape `ref_to_rst` links. A member in a + group is documented on the group's page, not the header's. + """ + member_id = memberdef.get("id", "") + doxygen_url = "" + if html_dir and "_1" in member_id: + cut = member_id.rfind("_1") + doxygen_url = f"{html_dir}/{member_id[:cut]}.html#{member_id[cut + 2:]}" + + loc = memberdef.find("location") + source_file = "" + if loc is not None: + fpath = loc.get("declfile") or loc.get("file", "") + line = loc.get("declline") or loc.get("line", "") + if fpath: + source_file = f"{fpath} (line {line})" if line else fpath + + return SymbolInfo( + name=memberdef.findtext("name", "").strip(), + kind=memberdef.get("kind", ""), + satisfies=requirement_uids(memberdef, "satisfies"), + brief=para_text(memberdef.find("briefdescription/para"), RefLinks(local=html_dir)), + source_file=source_file, + doxygen_url=doxygen_url, + ) + + def load_group_index(xml_dir: Path) -> dict[str, str]: """Parse Doxygen's index.xml and return a dict mapping each group's name to its refid, which is used as the filename stem for the group's XML file.""" diff --git a/sphinx/_extensions/input_tracking.py b/sphinx/_extensions/input_tracking.py new file mode 100644 index 0000000..7cb7f73 --- /dev/null +++ b/sphinx/_extensions/input_tracking.py @@ -0,0 +1,69 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""Sphinx extension: track a document's inputs from outside the source tree. + +The testmodule, testreport, twisterinfo and symbolneeds directives read files +Sphinx knows nothing about: Doxygen XML, twister's XML/JSON, a spec's +needs.json. Sphinx re-reads a document only when a tracked input is missing or +NEWER than the document's last read. That is not enough here: twister output +often arrives with an older mtime (a CI artifact or cache restored with its +timestamps, a copy that preserves them), and the report then stays stale +without a warning. So each input's signature is recorded at read time, and a +document is re-read whenever a signature differs, in either direction. + +Loaded by the extensions that use it (``app.setup_extension``), which Sphinx +does once however many ask. +""" + +import os + + +def _input_signature(path): + """(mtime_ns, size) of ``path``, or None if it does not exist.""" + try: + st = os.stat(path) + except OSError: + return None + return (st.st_mtime_ns, st.st_size) + + +def _note_input(env, path): + """Track ``path`` as an input of the document being read.""" + path = str(path) + env.note_dependency(path) + inputs = getattr(env, "zdocs_report_inputs", None) + if inputs is None: + inputs = env.zdocs_report_inputs = {} + inputs.setdefault(env.docname, {})[path] = _input_signature(path) + + +def _outdated_by_input_change(app, env, added, changed, removed): + """``env-get-outdated``: documents whose recorded inputs changed.""" + return [ + docname + for docname, paths in getattr(env, "zdocs_report_inputs", {}).items() + if docname not in removed + and any(_input_signature(path) != sig for path, sig in paths.items()) + ] + + +def _purge_inputs(app, env, docname): + getattr(env, "zdocs_report_inputs", {}).pop(docname, None) + + +def _merge_inputs(app, env, docnames, other): + theirs = getattr(other, "zdocs_report_inputs", {}) + if not hasattr(env, "zdocs_report_inputs"): + env.zdocs_report_inputs = {} + for docname in docnames: + if docname in theirs: + env.zdocs_report_inputs[docname] = theirs[docname] + + +def setup(app): + app.connect("env-get-outdated", _outdated_by_input_change) + app.connect("env-purge-doc", _purge_inputs) + app.connect("env-merge-info", _merge_inputs) + return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True} diff --git a/sphinx/_extensions/rst_builders.py b/sphinx/_extensions/rst_builders.py index 6ac8b60..fabee5c 100644 --- a/sphinx/_extensions/rst_builders.py +++ b/sphinx/_extensions/rst_builders.py @@ -5,10 +5,10 @@ """RST string builders — no Sphinx dependency.""" # This file emits sphinx-needs directive/link names for the `case` / -# `procedure` / `result` need-type roles and the `verifies` / `result_of` / -# `covers` link roles via the `need_names` role->name mapping (zdocs step 26, -# zdocs-design-twister.md §12), defaulting to the original literal names when -# a role is absent from the mapping. +# `procedure` / `result` / `implementation` need-type roles and the `verifies` +# / `result_of` / `covers` / `satisfies` link roles via the `need_names` +# role->name mapping (zdocs step 26, zdocs-design-twister.md §12), defaulting +# to the literal names below when a role is absent from the mapping. import logging import re from collections import Counter @@ -21,6 +21,7 @@ "build_need_rst", "build_procedure_need_rst", "build_result_rst", + "build_symbol_need_rst", "build_scenario_table", ] @@ -43,6 +44,9 @@ def slugify(s): "verifies": "verifies", "result_of": "result_of", "covers": "covers", + # symbolneeds: an API symbol, and the requirements it satisfies. + "implementation": "impl", + "satisfies": "satisfies", } @@ -282,6 +286,45 @@ def _values_rst(r): return lines +def symbol_need_id(name, need_names=None): + """The need id of API symbol ``name``: ``-``, e.g. ``IMPL-k_queue_init``. + + Prefixed with the consumer's own name for the need type, so the id says + what the need is in the project's vocabulary, and a symbol's need can never + collide with a requirement or test case of the same spelling. + """ + prefix = re.sub(r"[^A-Za-z0-9]+", "_", _need_name(need_names, "implementation")).upper() + return f"{prefix}-{name}" + + +def build_symbol_need_rst(info, need_names=None): + """Build the RST block for one API symbol's need (the ``implementation`` role). + + ``info`` is a `doxygen_parser.parse_symbol` result. The requirements it + satisfies become the ``satisfies`` link, so each requirement's page lists + the symbol under the link's incoming name, beside its verifying test cases. + The kind, declaration and Doxygen page go in the body, where no field has + to be declared for them. + """ + name = info["name"] + lines = [ + f".. {_need_name(need_names, 'implementation')}:: {name}", + f" :id: {symbol_need_id(name, need_names)}", + ] + if info["satisfies"]: + lines.append(f" :{_need_name(need_names, 'satisfies')}: {'; '.join(info['satisfies'])}") + lines.append("") + + kind = info["kind"] or "symbol" + head = f"{kind.capitalize()} ``{name}``" + if info["doxygen_url"]: + head = f"`{kind.capitalize()} {name} <{info['doxygen_url']}>`__" + lines += [f" {head}" + (f" — {info['brief']}" if info["brief"] else ""), ""] + if info["source_file"]: + lines += [f" **Declared in:** ``{info['source_file']}``", ""] + return "\n".join(lines) + + def build_scenario_table(testcase_yaml_path): """Return RST lines for a list-table of scenarios from testcase.yaml.""" try: diff --git a/sphinx/_extensions/symbol_needs.py b/sphinx/_extensions/symbol_needs.py new file mode 100644 index 0000000..0378c4c --- /dev/null +++ b/sphinx/_extensions/symbol_needs.py @@ -0,0 +1,153 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""Sphinx extension: the symbolneeds directive — one need per API symbol that +satisfies a requirement. + +A Doxygen 1.16 ``\\satisfies `` on a function or macro becomes, in the XML, +a ```` child of its memberdef. This directive turns each such symbol +into a need (the ``implementation`` role) linked to those requirements (the +``satisfies`` role), so a requirement's page shows what implements it next to +what verifies it. + +Loaded only for a document whose registry entry has a ``symbol_needs:`` block, +which also names the Doxygen document whose XML is read. +""" + +import xml.etree.ElementTree as ET +from pathlib import Path + +from docutils import nodes +from docutils.parsers.rst import Directive +from docutils.statemachine import ViewList +from doxygen_parser import load_group_index, parse_symbol +from input_tracking import _note_input +from rst_builders import build_symbol_need_rst + +from sphinx.util import logging + +logger = logging.getLogger(__name__) + +#: Compound kinds whose XML holds member definitions a symbol need can come +#: from. Pages, directories and requirements hold none. +_MEMBER_COMPOUNDS = ("group", "file", "namespace", "struct", "union", "class") + + +def _need_names_from_config(config): + return { + **getattr(config, "symbolneeds_need_types", {}), + **getattr(config, "symbolneeds_need_links", {}), + } + + +def _compounds(xml_dir, group=None): + """``[(refid, title)]`` of the compounds to read: one group, or all of them.""" + if group is not None: + refid = load_group_index(xml_dir).get(group) + return [(refid, group)] if refid else [] + root = ET.parse(xml_dir / "index.xml").getroot() + return [ + (c.get("refid"), c.findtext("name", c.get("refid"))) + for c in root.findall("compound") + if c.get("kind") in _MEMBER_COMPOUNDS + ] + + +def symbol_needs_rst(xml_dir, html_dir, group=None, need_names=None, note_input=None): + """RST lines for the symbol needs of ``group`` (or the whole project). + + With no group, the needs are sectioned by compound (group or file), each + heading taken from the compound's title. A member is emitted once, where + Doxygen defines it, however many compounds list it. ``note_input`` is + called with every XML file read. + """ + xml_dir = Path(xml_dir) + lines, seen = [], set() + for refid, name in _compounds(xml_dir, group): + xml = xml_dir / f"{refid}.xml" + if note_input: + note_input(xml) + if not xml.exists(): + logger.warning(f"symbolneeds: compound XML not found: {xml}") + continue + cdef = ET.parse(xml).getroot().find("compounddef") + blocks = [] + for md in cdef.iter("memberdef"): + if md.find("satisfies") is None or md.get("id") in seen: + continue + seen.add(md.get("id")) + blocks += build_symbol_need_rst(parse_symbol(md, html_dir), need_names).splitlines() + blocks.append("") + if not blocks: + continue + if group is None: + title = cdef.findtext("title") or name + lines += [title, "-" * len(title), ""] + lines += blocks + return lines + + +class SymbolNeedsDirective(Directive): + """Emit one need per API symbol carrying a Doxygen ``\\satisfies``. + + Usage:: + + .. symbolneeds:: + + .. symbolneeds:: queue_apis + + The optional argument is a Doxygen group name; without it every annotated + symbol in the project is emitted, sectioned by group or file. + """ + + required_arguments = 0 + optional_arguments = 1 + has_content = False + option_spec = {} + + def run(self): + group = self.arguments[0].strip() if self.arguments else None + env = self.state.document.settings.env + config = env.app.config + xml_dir = Path(config.symbolneeds_xml_dir) + _note_input(env, xml_dir / "index.xml") + if not (xml_dir / "index.xml").is_file(): + msg = f"symbolneeds: Doxygen XML not found: {xml_dir / 'index.xml'}" + logger.warning(msg, location=(env.docname, self.lineno)) + return [nodes.paragraph(text="[symbolneeds: Doxygen XML not found: index.xml]")] + + page_prefix = "../" * (len(Path(env.docname).parts) - 1) + doxygen_url = config.symbolneeds_doxygen_url + html_dir = page_prefix + doxygen_url if doxygen_url else "" + try: + rst = symbol_needs_rst( + xml_dir, html_dir, group, _need_names_from_config(config), + note_input=lambda path: _note_input(env, path), + ) + except (OSError, ET.ParseError) as exc: + logger.warning(f"symbolneeds: cannot read {xml_dir}: {exc}") + return [nodes.paragraph(text="[symbolneeds: cannot read the Doxygen XML]")] + if group is not None and not rst: + logger.warning( + f"symbolneeds: group '{group}' not found or has no symbol with \\satisfies", + location=(env.docname, self.lineno), + ) + return [] + + container = nodes.container() + self.state.nested_parse( + ViewList(rst, source=""), self.content_offset, container, + match_titles=True, + ) + return container.children + + +def setup(app): + app.add_config_value("symbolneeds_xml_dir", "", "env") + app.add_config_value("symbolneeds_doxygen_url", "", "env") + app.add_config_value("symbolneeds_need_types", {"implementation": "impl"}, "env") + app.add_config_value("symbolneeds_need_links", {"satisfies": "satisfies"}, "env") + app.setup_extension("input_tracking") + app.add_directive("symbolneeds", SymbolNeedsDirective) + return {"version": "0.1", "parallel_read_safe": True} diff --git a/sphinx/_extensions/test_module.py b/sphinx/_extensions/test_module.py index ea851c3..3dcb7f0 100644 --- a/sphinx/_extensions/test_module.py +++ b/sphinx/_extensions/test_module.py @@ -3,7 +3,6 @@ # SPDX-License-Identifier: Apache-2.0 """Sphinx extension: testmodule and testreport directives (Route B — sphinx-needs).""" -import os import xml.etree.ElementTree as ET from collections import Counter, defaultdict from pathlib import Path @@ -12,6 +11,12 @@ from docutils.parsers.rst import Directive, directives from docutils.statemachine import ViewList from doxygen_parser import detail_rst_lines, load_group_index, parse_memberdef +from input_tracking import ( # noqa: F401 (the other hooks are re-exported for tests) + _merge_inputs, + _note_input, + _outdated_by_input_change, + _purge_inputs, +) from rst_builders import ( _need_name, build_need_rst, @@ -536,60 +541,6 @@ def run(self): # TestReportDirective # --------------------------------------------------------------------------- -# --------------------------------------------------------------------------- -# Inputs outside the source tree -# -# testreport and twisterinfo read the twister XML/JSON and the spec's -# needs.json. Sphinx re-reads a document only when a tracked input is missing -# or NEWER than the document's last read. That is not enough here: twister -# output often arrives with an older mtime (a CI artifact or cache restored -# with its timestamps, a copy that preserves them). The report then stays -# stale without a warning. So each input's signature is recorded at read time, -# and a document is re-read whenever a signature differs, in either direction. -# --------------------------------------------------------------------------- - -def _input_signature(path): - """(mtime_ns, size) of ``path``, or None if it does not exist.""" - try: - st = os.stat(path) - except OSError: - return None - return (st.st_mtime_ns, st.st_size) - - -def _note_input(env, path): - """Track ``path`` as an input of the document being read.""" - path = str(path) - env.note_dependency(path) - inputs = getattr(env, "zdocs_report_inputs", None) - if inputs is None: - inputs = env.zdocs_report_inputs = {} - inputs.setdefault(env.docname, {})[path] = _input_signature(path) - - -def _outdated_by_input_change(app, env, added, changed, removed): - """``env-get-outdated``: documents whose recorded inputs changed.""" - return [ - docname - for docname, paths in getattr(env, "zdocs_report_inputs", {}).items() - if docname not in removed - and any(_input_signature(path) != sig for path, sig in paths.items()) - ] - - -def _purge_inputs(app, env, docname): - getattr(env, "zdocs_report_inputs", {}).pop(docname, None) - - -def _merge_inputs(app, env, docnames, other): - theirs = getattr(other, "zdocs_report_inputs", {}) - if not hasattr(env, "zdocs_report_inputs"): - env.zdocs_report_inputs = {} - for docname in docnames: - if docname in theirs: - env.zdocs_report_inputs[docname] = theirs[docname] - - class TestReportDirective(Directive): """ Emit sphinx-needs test_result nodes from a twister_report.xml. @@ -812,7 +763,5 @@ def setup(app): app.add_directive("testmodule", TestModuleDirective) app.add_directive("testreport", TestReportDirective) app.add_directive("twisterinfo", TwisterInfoDirective) - app.connect("env-get-outdated", _outdated_by_input_change) - app.connect("env-purge-doc", _purge_inputs) - app.connect("env-merge-info", _merge_inputs) + app.setup_extension("input_tracking") return {"version": "0.2", "parallel_read_safe": True} diff --git a/sphinx/zdocs_conf.py b/sphinx/zdocs_conf.py index 90be3be..4dd35a2 100644 --- a/sphinx/zdocs_conf.py +++ b/sphinx/zdocs_conf.py @@ -166,6 +166,10 @@ def configure( testmodule = refs.testmodule if refs else None if testmodule is not None: all_extensions.append("test_module") + # Same opt-in shape: a `symbol_needs:` block loads the symbolneeds directive. + symbol_needs = refs.symbol_needs if refs else None + if symbol_needs is not None: + all_extensions.append("symbol_needs") # Version, from this document's scoped git tags in the CONSUMING repository # (or the VERSION env override) — the same resolver the Doxygen side uses, so @@ -418,3 +422,6 @@ def configure( namespace["twister_output_dir"] = os.environ.get("ZDOCS_TWISTER_OUT", "") namespace["twisterinfo_project_name"] = project namespace["twisterinfo_project_version"] = version + if symbol_needs is not None: + namespace["symbolneeds_xml_dir"] = symbol_needs["xml_dir"] + namespace["symbolneeds_doxygen_url"] = symbol_needs["doxygen_url"] From b5657719e11a715cdae5cd217865413d52153a42 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Tue, 29 Sep 2026 15:20:21 +0200 Subject: [PATCH 2/4] feat: doxygen: read @kconfig_depends into a depends_on need field Safety sources are getting `@kconfig_depends{}`, an alias for `\xrefitem kconfig_depends "Depends on" "Kconfig dependencies" \1`, which a Doxygen input filter adds to code built under a CONFIG condition. A test case or API function that exists only with CONFIG_ASSERT should say so on its need, and the parser ignored the xrefsect. doxygen_parser.kconfig_depends() reads it into `depends_on` on test cases (parse_memberdef) and API symbols (parse_symbol). The Doxygen 1.16.1 shape, checked with real runs (fixture fixtures/doxygen-kconfig): adjacent commands share one xrefsect with a per condition, commands apart get one xrefsect each, and like @testid they can sit inside the last list item, so the whole description is searched by id prefix `kconfig_depends_`. Each condition is kept verbatim, once, in order, without Doxygen's trailing space. `&&`, `!` and parentheses survive, and `\,` arrives as `,`. The @reqref/@testid loop now skips these xrefsects explicitly, rather than relying on the id not containing "reqrefs". Both need builders render the conditions in the body, labelled with the alias's own title ("Depends on: CONFIG_ASSERT"), because the consumer's layout decides which fields show. They set the `depends_on` field, the conditions joined with "; ", only when the consumer's sphinx-needs schema declares it; otherwise every need would warn "Unknown option". A string field is the declaration to use. An array field is used only while no condition contains ; | or , (sphinx-needs splits an array value there, so `A || B` would become pieces); a need with such a condition gets a warning instead of a wrong value. That check is in the new needs_fields module. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- doc/api/python/extensions.rst | 8 + .../howto/render-test-specifications.rst | 2 + doc/manual/reference/consumer-contract.rst | 3 +- doc/manual/reference/directives-and-roles.rst | 30 +++ .../_tests/fixtures/doxygen-kconfig/Doxyfile | 14 ++ .../doxygen-kconfig/group__kd__suite.xml | 141 +++++++++++++ .../_tests/fixtures/doxygen-kconfig/index.xml | 25 +++ .../_tests/fixtures/doxygen-kconfig/kd.c | 60 ++++++ .../_tests/test_kconfig_depends.py | 190 ++++++++++++++++++ sphinx/_extensions/doxygen_parser.py | 46 +++++ sphinx/_extensions/needs_fields.py | 61 ++++++ sphinx/_extensions/rst_builders.py | 40 +++- sphinx/_extensions/symbol_needs.py | 18 +- sphinx/_extensions/test_module.py | 19 +- 14 files changed, 646 insertions(+), 11 deletions(-) create mode 100644 sphinx/_extensions/_tests/fixtures/doxygen-kconfig/Doxyfile create mode 100644 sphinx/_extensions/_tests/fixtures/doxygen-kconfig/group__kd__suite.xml create mode 100644 sphinx/_extensions/_tests/fixtures/doxygen-kconfig/index.xml create mode 100644 sphinx/_extensions/_tests/fixtures/doxygen-kconfig/kd.c create mode 100644 sphinx/_extensions/_tests/test_kconfig_depends.py create mode 100644 sphinx/_extensions/needs_fields.py diff --git a/doc/api/python/extensions.rst b/doc/api/python/extensions.rst index 7dca4fe..2923fdd 100644 --- a/doc/api/python/extensions.rst +++ b/doc/api/python/extensions.rst @@ -65,6 +65,14 @@ Re-reads a document when an input from outside the source tree changes. .. automodule:: input_tracking :members: +``needs_fields`` +------------------ + +Which optional need fields a consumer declared. + +.. automodule:: needs_fields + :members: + ``xref_builder`` ------------------ diff --git a/doc/manual/howto/render-test-specifications.rst b/doc/manual/howto/render-test-specifications.rst index 430d211..d926fa7 100644 --- a/doc/manual/howto/render-test-specifications.rst +++ b/doc/manual/howto/render-test-specifications.rst @@ -69,6 +69,8 @@ Everything the directives emit — three need types (case/procedure/result), three link types, and up to nine custom fields — must be declared in ``needs_config.toml`` (``ZDOCS_NEEDS_CONFIG``), or sphinx-needs rejects the need with an ``Unknown option``/``Unknown need type`` warning per occurrence. +The ``depends_on`` field (Kconfig conditions from ``@kconfig_depends``) is the +exception: the directives set it only when you declare it. Renaming the three roles away from the engine defaults, if you want project vocabulary rather than ``test_case``/``verifies``/etc., is a matching pair of ``conf.py`` dicts — see :doc:`../reference/directives-and-roles`. diff --git a/doc/manual/reference/consumer-contract.rst b/doc/manual/reference/consumer-contract.rst index 3303682..83b97de 100644 --- a/doc/manual/reference/consumer-contract.rst +++ b/doc/manual/reference/consumer-contract.rst @@ -91,7 +91,8 @@ layout, not toggled from outside it. ``symbol_needs:`` block — the ``implementation`` type and ``satisfies`` link (:doc:`directives-and-roles`). Declaring the ``satisfies`` link in this project-wide file is also what lets a requirements document show the - link's incoming side. + link's incoming side. One field is optional: ``depends_on`` (the + ``@kconfig_depends`` conditions) is set only if declared here. This variable is real and load-bearing — every sample and fixture in this repository that uses sphinx-needs sets it — but it is not mentioned diff --git a/doc/manual/reference/directives-and-roles.rst b/doc/manual/reference/directives-and-roles.rst index 0cc4267..11563a0 100644 --- a/doc/manual/reference/directives-and-roles.rst +++ b/doc/manual/reference/directives-and-roles.rst @@ -182,11 +182,41 @@ the parser matches on, and must be spelled exactly): ALIASES += "testid{1}=\xrefitem testids \"Test ID\" \"Test IDs\" \1" ALIASES += "reqref{1}=\xrefitem reqrefs \"Requirement\" \"Requirements\" \1" +A third alias is optional. ``@kconfig_depends{}`` records the +Kconfig condition a test case (or, with ``symbolneeds``, an API symbol) is +built under; the key ``kconfig_depends`` is what the parser matches, the titles +are yours and the first one labels the rendered line: + +.. code-block:: text + + ALIASES += "kconfig_depends{1}=\xrefitem kconfig_depends \"Depends on\" \"Kconfig dependencies\" \1" + +Every condition is kept verbatim (``(CONFIG_A && !CONFIG_B) || CONFIG_C``; +write a comma as ``\,``), once, in source order. It renders in the need's body +("Depends on: ``CONFIG_ASSERT``") on ``testmodule`` and ``symbolneeds`` needs, +and fills the optional field ``depends_on`` — the conditions joined with +``"; "`` — if, and only if, your ``needs_config.toml`` declares it. Declare it +as a string field: + +.. code-block:: toml + + [needs.fields.depends_on] + description = "Kconfig conditions the need depends on" + nullable = true + [needs.fields.depends_on.schema] + type = "string" + +Left undeclared, no need gets the field and nothing warns. Declared as an +``array``, it works only while no condition contains ``;``, ``|`` or ``,``, +where sphinx-needs splits an array value; a need with such a condition gets no +field and a warning instead. + .. code-block:: c /** * @reqref{DUTY_001} * @see acme_widget_init() + * @kconfig_depends{CONFIG_WIDGET_PROBE} * @testid{WIDGET-PROBE-001} */ ZTEST(widget_probe_suite, test_widget_reports_initial_value) diff --git a/sphinx/_extensions/_tests/fixtures/doxygen-kconfig/Doxyfile b/sphinx/_extensions/_tests/fixtures/doxygen-kconfig/Doxyfile new file mode 100644 index 0000000..0bc4c4e --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/doxygen-kconfig/Doxyfile @@ -0,0 +1,14 @@ +# Regenerate: doxygen Doxyfile with kd.c as src/a.c, then copy index.xml and +# group__kd__suite.xml from out/xml here. +PROJECT_NAME = kd +INPUT = src +OUTPUT_DIRECTORY = out +STRIP_FROM_PATH = src +GENERATE_HTML = NO +GENERATE_LATEX = NO +GENERATE_XML = YES +EXTRACT_ALL = YES +QUIET = YES +ALIASES += "kconfig_depends{1}=\xrefitem kconfig_depends \"Depends on\" \"Kconfig dependencies\" \1" +ALIASES += "reqref{1}=\xrefitem reqrefs \"Requirement\" \"Requirements\" \1" +ALIASES += "testid{1}=\xrefitem testids \"Test ID\" \"Test IDs\" \1" diff --git a/sphinx/_extensions/_tests/fixtures/doxygen-kconfig/group__kd__suite.xml b/sphinx/_extensions/_tests/fixtures/doxygen-kconfig/group__kd__suite.xml new file mode 100644 index 0000000..d8116be --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/doxygen-kconfig/group__kd__suite.xml @@ -0,0 +1,141 @@ + + + + kd_suite + Kconfig-dependent test cases + + + void + void test_one + (void) + test_one + + void + + +One condition, next to a test id and a requirement. + + +Depends onCONFIG_ASSERT +RequirementREQ-1 +Test IDTC-KD-001 + + + + + + + + void + void test_two + (void) + test_two + + void + + +Two adjacent commands. + + +Depends ondefined(CONFIG_HW_STACK_PROTECTION) && !defined(CONFIG_ARCH_POSIX) +CONFIG_USERSPACE +Test IDTC-KD-002 + + + + + + + + void + void test_three + (void) + test_three + + void + + +Operators and parentheses. + + +Depends on(CONFIG_A && !CONFIG_B) || CONFIG_C +Test IDTC-KD-003 + + + + + + + + void + void test_four + (void) + test_four + + void + + +Details ending in a list. + + +Expected result: +a +b +Depends onCONFIG_X + + + + + + + + + + void + void test_five + (void) + test_five + + void + + +Commands that are not adjacent. + + +Depends onCONFIG_FIRST + +Some prose between them. +Depends onIS_ENABLED(CONFIG_A, CONFIG_B) +Test IDTC-KD-005 + + + + + + + + void + void test_six + (void) + test_six + + void + + +No condition. + + +RequirementREQ-2 + + + + + + + + + + + + + diff --git a/sphinx/_extensions/_tests/fixtures/doxygen-kconfig/index.xml b/sphinx/_extensions/_tests/fixtures/doxygen-kconfig/index.xml new file mode 100644 index 0000000..5149058 --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/doxygen-kconfig/index.xml @@ -0,0 +1,25 @@ + + + a.c + test_one + test_two + test_three + test_four + test_five + test_six + + kd_suite + test_one + test_two + test_three + test_four + test_five + test_six + + kconfig_depends + + reqrefs + + testids + + diff --git a/sphinx/_extensions/_tests/fixtures/doxygen-kconfig/kd.c b/sphinx/_extensions/_tests/fixtures/doxygen-kconfig/kd.c new file mode 100644 index 0000000..2273c91 --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/doxygen-kconfig/kd.c @@ -0,0 +1,60 @@ +/** @file */ + +/** + * @defgroup kd_suite Kconfig-dependent test cases + * @{ + */ + +/** + * @brief One condition, next to a test id and a requirement. + * @kconfig_depends{CONFIG_ASSERT} + * @reqref{REQ-1} + * @testid{TC-KD-001} + */ +void test_one(void) {} + +/** + * @brief Two adjacent commands. + * @kconfig_depends{defined(CONFIG_HW_STACK_PROTECTION) && !defined(CONFIG_ARCH_POSIX)} + * @kconfig_depends{CONFIG_USERSPACE} + * @testid{TC-KD-002} + */ +void test_two(void) {} + +/** + * @brief Operators and parentheses. + * @kconfig_depends{(CONFIG_A && !CONFIG_B) || CONFIG_C} + * @testid{TC-KD-003} + */ +void test_three(void) {} + +/** + * @brief Details ending in a list. + * + * Expected result: + * - a + * - b + * + * @kconfig_depends{CONFIG_X} + */ +void test_four(void) {} + +/** + * @brief Commands that are not adjacent. + * + * @kconfig_depends{CONFIG_FIRST} + * + * Some prose between them. + * + * @kconfig_depends{IS_ENABLED(CONFIG_A\, CONFIG_B)} + * @testid{TC-KD-005} + */ +void test_five(void) {} + +/** + * @brief No condition. + * @reqref{REQ-2} + */ +void test_six(void) {} + +/** @} */ diff --git a/sphinx/_extensions/_tests/test_kconfig_depends.py b/sphinx/_extensions/_tests/test_kconfig_depends.py new file mode 100644 index 0000000..d7a5146 --- /dev/null +++ b/sphinx/_extensions/_tests/test_kconfig_depends.py @@ -0,0 +1,190 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""``@kconfig_depends{}`` -> the ``depends_on`` need field. + +The alias is ``\\xrefitem kconfig_depends "Depends on" "Kconfig dependencies" \\1``. +fixtures/doxygen-kconfig is real Doxygen 1.16.1 output of kd.c: one condition; +two adjacent commands (one xrefsect, a each); operators and parentheses; +a command inside a trailing list; two commands apart (two xrefsects); and an +escaped comma, which arrives as a plain one. +""" + +import json +import xml.etree.ElementTree as ET +from pathlib import Path +from types import SimpleNamespace + +import doxygen_parser as dp +import needs_fields +import pytest +import rst_builders as rb +import test_module as tm +from conftest import FIXTURES + +_ROOTS = Path(__file__).parent / "roots" +XML = FIXTURES / "doxygen-kconfig" +GROUP = "group__kd__suite" + + +def _member(name): + root = ET.parse(XML / f"{GROUP}.xml").getroot() + return next(md for md in root.iter("memberdef") if md.findtext("name") == name) + + +def _info(name): + return dp.parse_memberdef(_member(name), GROUP, "testspec", "api") + + +# --------------------------------------------------------------------------- +# doxygen_parser +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("name", "conditions"), + [ + ("test_one", ["CONFIG_ASSERT"]), + ( + "test_two", + [ + "defined(CONFIG_HW_STACK_PROTECTION) && !defined(CONFIG_ARCH_POSIX)", + "CONFIG_USERSPACE", + ], + ), + ("test_three", ["(CONFIG_A && !CONFIG_B) || CONFIG_C"]), + ("test_four", ["CONFIG_X"]), + ("test_five", ["CONFIG_FIRST", "IS_ENABLED(CONFIG_A, CONFIG_B)"]), + ("test_six", []), + ], +) +def test_conditions_are_read_verbatim(name, conditions): + label, found = dp.kconfig_depends(_member(name).find("detaileddescription")) + assert found == conditions + assert label == ("Depends on" if conditions else "") + assert _info(name)["depends_on"] == conditions + + +def test_conditions_are_not_read_as_requirements_or_ids(): + one = _info("test_one") + assert one["req_ids"] == ["REQ-1"] + assert one["test_id"] == "TC-KD-001" + assert _info("test_two")["req_ids"] == [] + assert _info("test_five")["test_id"] == "TC-KD-005" + + +def test_conditions_stay_out_of_the_prose(): + assert not any("CONFIG_" in line for line in _info("test_four")["detail_lines"]) + + +def test_a_symbol_carries_its_conditions(): + root = ET.parse(FIXTURES / "doxygen-symbols" / "group__queue__apis.xml").getroot() + md = next(m for m in root.iter("memberdef") if m.findtext("name") == "k_queue_append") + info = dp.parse_symbol(md, "api") + assert info["depends_on"] == [ + "CONFIG_ASSERT", + "(CONFIG_USERSPACE && !CONFIG_NO_SYSCALLS) || CONFIG_TEST", + ] + assert info["depends_label"] == "Depends on" + + +# --------------------------------------------------------------------------- +# rst_builders +# --------------------------------------------------------------------------- + + +def test_test_case_need_with_the_field(): + rst = rb.build_need_rst(_info("test_two"), "kd_suite", depends_field=True) + assert ( + " :depends_on: defined(CONFIG_HW_STACK_PROTECTION) && " + "!defined(CONFIG_ARCH_POSIX); CONFIG_USERSPACE" + ) in rst + assert " **Depends on:** ``defined(CONFIG_HW_STACK_PROTECTION)" in rst + + +def test_test_case_need_without_the_field_still_says_so(): + rst = rb.build_need_rst(_info("test_one"), "kd_suite") + assert ":depends_on:" not in rst + assert " **Depends on:** ``CONFIG_ASSERT``" in rst + + +def test_no_conditions_no_line(): + rst = rb.build_need_rst(_info("test_six"), "kd_suite", depends_field=True) + assert "depends_on" not in rst and "Depends on" not in rst + + +def test_a_hand_built_info_without_the_keys_still_builds(): + info = {k: v for k, v in _info("test_one").items() if not k.startswith("depends")} + assert "Depends on" not in rb.build_need_rst(info, "kd_suite", depends_field=True) + + +def test_suite_rst_asks_per_need(): + asked = [] + + def decide(conditions, subject): + asked.append(subject) + return bool(conditions) + + lines = tm._build_suite_rst(GROUP, XML, "testspec", "api", "tests/kd", depends_field=decide) + assert " :depends_on: CONFIG_ASSERT" in lines + assert asked[0] == "testmodule: kd_suite/test_one" + assert sum(line.startswith(" :depends_on:") for line in lines) == 5 + + +# --------------------------------------------------------------------------- +# needs_fields: set the field only where the consumer declared it +# --------------------------------------------------------------------------- + + +def _env(kind): + field = SimpleNamespace(type=kind) + schema = SimpleNamespace(get_extra_field=lambda name: field if kind else None) + return SimpleNamespace(_needs_schema=schema) + + +def test_undeclared_field_is_not_set(): + assert needs_fields.depends_field(_env(None), ["CONFIG_A"], "x") is False + # No sphinx-needs schema at all (a stand-in env): not set either. + assert needs_fields.depends_field(SimpleNamespace(), ["CONFIG_A"], "x") is False + + +def test_string_field_is_set(): + assert needs_fields.depends_field(_env("string"), ["A || B", "F(A, B)"], "x") is True + + +def test_array_field_refuses_conditions_it_would_split(monkeypatch): + warnings = [] + monkeypatch.setattr(needs_fields.logger, "warning", lambda msg, *a, **k: warnings.append(msg)) + assert needs_fields.depends_field(_env("array"), ["CONFIG_A", "CONFIG_B"], "x") is True + assert needs_fields.depends_field(_env("array"), ["A || B"], "x: fn") is False + assert len(warnings) == 1 and "x: fn" in warnings[0] and "string" in warnings[0] + + +# --------------------------------------------------------------------------- +# Directive: the field in needs.json when declared; no warning when not +# --------------------------------------------------------------------------- + +_STRING_FIELD = {"depends_on": {"schema": {"type": "string"}, "nullable": True}} + + +@pytest.mark.sphinx( + "html", srcdir=str(_ROOTS / "test-symbolneeds"), confoverrides={"needs_fields": _STRING_FIELD} +) +def test_declared_field_reaches_needs_json(app, warning): + app.build() + needs = json.loads((Path(app.outdir) / "needs.json").read_text()) + needs = needs["versions"][needs["current_version"]]["needs"] + assert needs["IMPL-k_queue_append"]["depends_on"] == ( + "CONFIG_ASSERT; (CONFIG_USERSPACE && !CONFIG_NO_SYSCALLS) || CONFIG_TEST" + ) + assert needs["IMPL-k_queue_init"]["depends_on"] is None + assert "Unknown option" not in warning.getvalue() + + +@pytest.mark.sphinx("html", srcdir=str(_ROOTS / "test-symbolneeds")) +def test_undeclared_field_renders_without_warning(app, warning): + app.build() + html = (Path(app.outdir) / "api.html").read_text() + assert "Depends on:" in html and "CONFIG_ASSERT" in html + assert "Unknown option" not in warning.getvalue() diff --git a/sphinx/_extensions/doxygen_parser.py b/sphinx/_extensions/doxygen_parser.py index 1b6fbb9..df5dfee 100644 --- a/sphinx/_extensions/doxygen_parser.py +++ b/sphinx/_extensions/doxygen_parser.py @@ -21,6 +21,7 @@ "detail_rst_lines", "parse_memberdef", "requirement_uids", + "kconfig_depends", "SymbolInfo", "parse_symbol", "load_group_index", @@ -38,6 +39,8 @@ class MemberInfo(TypedDict): detail_lines: list[str] see_rst: str body_sections: list[list[str]] + depends_on: list[str] + depends_label: str class SymbolInfo(TypedDict): @@ -47,6 +50,8 @@ class SymbolInfo(TypedDict): brief: str source_file: str doxygen_url: str + depends_on: list[str] + depends_label: str class RefLinks(NamedTuple): @@ -392,6 +397,8 @@ def parse_memberdef( # is why this stayed hidden. for xrefsect in dd.iter("xrefsect"): xid = xrefsect.get("id", "") + if xid.startswith(KCONFIG_DEPENDS + "_"): + continue # a condition, never an id or a requirement: kconfig_depends() xpara = xrefsect.find("xrefdescription/para") xdesc = "".join(xpara.itertext()).strip() if xpara is not None else "" if "testids" in xid: @@ -417,6 +424,8 @@ def parse_memberdef( if uid not in req_ids: req_ids.append(uid) + depends_label, depends_on = kconfig_depends(dd) + ibd = memberdef.find("inbodydescription") body_sections: list[list[str]] = [] if ibd is not None: @@ -436,9 +445,43 @@ def parse_memberdef( detail_lines=detail_lines, see_rst=see_rst, body_sections=body_sections, + depends_on=depends_on, + depends_label=depends_label, ) +#: The ``\xrefitem`` key of ``@kconfig_depends{}``: the Kconfig +#: condition a test case or API symbol is built under. +KCONFIG_DEPENDS = "kconfig_depends" + + +def kconfig_depends(dd: ET.Element | None) -> tuple[str, list[str]]: + """``(label, conditions)`` of the ``kconfig_depends`` xrefitems in ``dd``. + + ``@kconfig_depends{}`` is an alias for ``\\xrefitem + kconfig_depends "Depends on" "Kconfig dependencies" ``. Doxygen + 1.16 merges adjacent commands into ONE xrefsect with a ```` per + condition, while commands elsewhere in the comment get xrefsects of their + own; like ``@testid``, they can sit in the last paragraph or in the last + list item, so the whole description is searched. Each condition is kept + verbatim (``(CONFIG_A && !CONFIG_B) || CONFIG_C``), once, in source order, + without the trailing space Doxygen adds. ``label`` is the xrefitem's title + as the consumer's alias spells it ("Depends on"), or ``""`` if there is none. + """ + label, conditions = "", [] + if dd is None: + return label, conditions + for xrefsect in dd.iter("xrefsect"): + if not xrefsect.get("id", "").startswith(KCONFIG_DEPENDS + "_"): + continue + label = label or elem_text(xrefsect.find("xreftitle")) + for para in xrefsect.findall("xrefdescription/para"): + condition = "".join(para.itertext()).strip() + if condition and condition not in conditions: + conditions.append(condition) + return label, conditions + + def requirement_uids(memberdef: ET.Element, relation: str) -> list[str]: """The requirement UIDs of Doxygen's native ``\\verifies`` or ``\\satisfies``. @@ -484,6 +527,7 @@ def parse_symbol(memberdef: ET.Element, html_dir: str) -> SymbolInfo: if fpath: source_file = f"{fpath} (line {line})" if line else fpath + depends_label, depends_on = kconfig_depends(memberdef.find("detaileddescription")) return SymbolInfo( name=memberdef.findtext("name", "").strip(), kind=memberdef.get("kind", ""), @@ -491,6 +535,8 @@ def parse_symbol(memberdef: ET.Element, html_dir: str) -> SymbolInfo: brief=para_text(memberdef.find("briefdescription/para"), RefLinks(local=html_dir)), source_file=source_file, doxygen_url=doxygen_url, + depends_on=depends_on, + depends_label=depends_label, ) diff --git a/sphinx/_extensions/needs_fields.py b/sphinx/_extensions/needs_fields.py new file mode 100644 index 0000000..1c4b224 --- /dev/null +++ b/sphinx/_extensions/needs_fields.py @@ -0,0 +1,61 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""Optional need fields: set one only where the consumer declared it. + +A need type, a link and a field are the consumer's vocabulary +(``needs_config.toml``). The engine can fill some fields the consumer may not +want; setting an undeclared one makes sphinx-needs warn "Unknown option" on +every need. So an optional field is set only when the running sphinx-needs +schema has it, and how it is declared decides whether its value survives. +""" + +from sphinx.util import logging + +logger = logging.getLogger(__name__) + +#: The Kconfig conditions of a test case or API symbol +#: (``@kconfig_depends{}``), joined with ``"; "``. +DEPENDS_ON = "depends_on" + +#: sphinx-needs splits a directive's value for an ``array`` field at these. +_ARRAY_DELIMITERS = frozenset(";|,") + + +def field_type(env, name): + """The declared schema type of need field ``name``, or ``None`` if undeclared.""" + try: + from sphinx_needs.data import SphinxNeedsData + + field = SphinxNeedsData(env).get_schema().get_extra_field(name) + except Exception: # no sphinx-needs, or no schema yet: nothing is declared + return None + return field.type if field is not None else None + + +def depends_field(env, conditions, subject): + """Whether to set ``depends_on`` for ``subject``'s ``conditions``. + + Declare it as a string field: the value is the conditions joined with + ``"; "``, verbatim. An ``array`` field works too, as long as no condition + contains one of ``; | ,`` — sphinx-needs would split ``A || B`` or + ``IS_ENABLED(A, B)`` into pieces — so such a need gets no field and a + warning instead of a silently wrong value. + """ + if not conditions: + return False + kind = field_type(env, DEPENDS_ON) + if kind == "string": + return True + if kind == "array": + split = [c for c in conditions if _ARRAY_DELIMITERS & set(c)] + if split: + logger.warning( + f"{subject}: '{DEPENDS_ON}' is declared as an array, which " + f"sphinx-needs splits at ';', '|' and ',' — not set for " + f"{split!r}; declare it as a string field" + ) + return False + return True + return False diff --git a/sphinx/_extensions/rst_builders.py b/sphinx/_extensions/rst_builders.py index fabee5c..b7af7af 100644 --- a/sphinx/_extensions/rst_builders.py +++ b/sphinx/_extensions/rst_builders.py @@ -57,8 +57,32 @@ def _need_name(need_names, role): return _DEFAULT_NEED_NAMES[role] -def build_need_rst(info, suite_name, module_path="", suite_title="", need_names=None): - """Build the RST block for a single test_case need.""" +def _depends_on_rst(info, depends_field): + """``(option lines, body lines)`` for a need's Kconfig conditions. + + The body line always renders, labelled as the consumer's alias titles the + xrefitem ("Depends on"), because a consumer's need layout decides which + fields show. The ``depends_on`` field is set only when ``depends_field`` + says the consumer declared it: an undeclared option is an "Unknown option" + warning per need. The conditions are joined with ``"; "``, which no Kconfig + expression contains. + """ + conditions = info.get("depends_on") or [] + if not conditions: + return [], [] + options = [f" :depends_on: {'; '.join(conditions)}"] if depends_field else [] + label = info.get("depends_label") or "Depends on" + body = [f" **{label}:** " + "; ".join(f"``{c}``" for c in conditions), ""] + return options, body + + +def build_need_rst( + info, suite_name, module_path="", suite_title="", need_names=None, depends_field=False +): + """Build the RST block for a single test_case need. + + ``depends_field``: set the ``depends_on`` field (see `_depends_on_rst`). + """ name = info["name"] test_id = info["test_id"] req_ids = info["req_ids"] @@ -90,6 +114,8 @@ def build_need_rst(info, suite_name, module_path="", suite_title="", need_names= lines.append(f" :status: {status}") if req_ids: lines.append(f" :{_need_name(need_names, 'verifies')}: {'; '.join(req_ids)}") + depends_options, depends_body = _depends_on_rst(info, depends_field) + lines += depends_options lines.append("") if brief: @@ -112,6 +138,8 @@ def build_need_rst(info, suite_name, module_path="", suite_title="", need_names= lines.append(f" {sline}" if sline else "") lines.append("") + lines += depends_body + if source_file and doxygen_url: lines.append(f" **Source:** `{source_file} <{doxygen_url}>`__") lines.append("") @@ -297,14 +325,15 @@ def symbol_need_id(name, need_names=None): return f"{prefix}-{name}" -def build_symbol_need_rst(info, need_names=None): +def build_symbol_need_rst(info, need_names=None, depends_field=False): """Build the RST block for one API symbol's need (the ``implementation`` role). ``info`` is a `doxygen_parser.parse_symbol` result. The requirements it satisfies become the ``satisfies`` link, so each requirement's page lists the symbol under the link's incoming name, beside its verifying test cases. The kind, declaration and Doxygen page go in the body, where no field has - to be declared for them. + to be declared for them. ``depends_field``: set the ``depends_on`` field + (see `_depends_on_rst`). """ name = info["name"] lines = [ @@ -313,6 +342,8 @@ def build_symbol_need_rst(info, need_names=None): ] if info["satisfies"]: lines.append(f" :{_need_name(need_names, 'satisfies')}: {'; '.join(info['satisfies'])}") + depends_options, depends_body = _depends_on_rst(info, depends_field) + lines += depends_options lines.append("") kind = info["kind"] or "symbol" @@ -320,6 +351,7 @@ def build_symbol_need_rst(info, need_names=None): if info["doxygen_url"]: head = f"`{kind.capitalize()} {name} <{info['doxygen_url']}>`__" lines += [f" {head}" + (f" — {info['brief']}" if info["brief"] else ""), ""] + lines += depends_body if info["source_file"]: lines += [f" **Declared in:** ``{info['source_file']}``", ""] return "\n".join(lines) diff --git a/sphinx/_extensions/symbol_needs.py b/sphinx/_extensions/symbol_needs.py index 0378c4c..d442dbd 100644 --- a/sphinx/_extensions/symbol_needs.py +++ b/sphinx/_extensions/symbol_needs.py @@ -23,6 +23,7 @@ from docutils.statemachine import ViewList from doxygen_parser import load_group_index, parse_symbol from input_tracking import _note_input +from needs_fields import depends_field from rst_builders import build_symbol_need_rst from sphinx.util import logging @@ -54,13 +55,17 @@ def _compounds(xml_dir, group=None): ] -def symbol_needs_rst(xml_dir, html_dir, group=None, need_names=None, note_input=None): +def symbol_needs_rst( + xml_dir, html_dir, group=None, need_names=None, note_input=None, depends_field=None +): """RST lines for the symbol needs of ``group`` (or the whole project). With no group, the needs are sectioned by compound (group or file), each heading taken from the compound's title. A member is emitted once, where Doxygen defines it, however many compounds list it. ``note_input`` is - called with every XML file read. + called with every XML file read. ``depends_field(conditions, subject)`` + decides whether a need gets the ``depends_on`` field + (`needs_fields.depends_field`); without it, none does. """ xml_dir = Path(xml_dir) lines, seen = [], set() @@ -77,7 +82,11 @@ def symbol_needs_rst(xml_dir, html_dir, group=None, need_names=None, note_input= if md.find("satisfies") is None or md.get("id") in seen: continue seen.add(md.get("id")) - blocks += build_symbol_need_rst(parse_symbol(md, html_dir), need_names).splitlines() + info = parse_symbol(md, html_dir) + with_depends = bool(depends_field) and depends_field( + info["depends_on"], f"symbolneeds: {info['name']}" + ) + blocks += build_symbol_need_rst(info, need_names, with_depends).splitlines() blocks.append("") if not blocks: continue @@ -124,6 +133,9 @@ def run(self): rst = symbol_needs_rst( xml_dir, html_dir, group, _need_names_from_config(config), note_input=lambda path: _note_input(env, path), + depends_field=lambda conditions, subject: depends_field( + env, conditions, subject + ), ) except (OSError, ET.ParseError) as exc: logger.warning(f"symbolneeds: cannot read {xml_dir}: {exc}") diff --git a/sphinx/_extensions/test_module.py b/sphinx/_extensions/test_module.py index 3dcb7f0..442add9 100644 --- a/sphinx/_extensions/test_module.py +++ b/sphinx/_extensions/test_module.py @@ -17,6 +17,7 @@ _outdated_by_input_change, _purge_inputs, ) +from needs_fields import depends_field from rst_builders import ( _need_name, build_need_rst, @@ -139,9 +140,14 @@ def _classify_inner_groups(module_cdef: ET.Element, xml_dir: Path): def _build_suite_rst( - suite_refid, xml_dir, testspec_html_dir, api_html_dir, module_path, need_names=None + suite_refid, xml_dir, testspec_html_dir, api_html_dir, module_path, need_names=None, + depends_field=None, ): - """Build RST lines for one test suite group (section heading + test_case needs).""" + """Build RST lines for one test suite group (section heading + test_case needs). + + ``depends_field(conditions, subject)`` decides whether a need gets the + ``depends_on`` field (`needs_fields.depends_field`); without it, none does. + """ suite_xml = xml_dir / f"{suite_refid}.xml" if not suite_xml.exists(): logger.warning(f"testmodule: suite XML not found: {suite_xml}") @@ -160,9 +166,13 @@ def _build_suite_rst( info = parse_memberdef(memberdef, compound_id, testspec_html_dir, api_html_dir) if not info["name"]: continue + with_depends = bool(depends_field) and depends_field( + info["depends_on"], f"testmodule: {suite_name}/{info['name']}" + ) lines.extend( build_need_rst( - info, suite_name, module_path, suite_title, need_names=need_names + info, suite_name, module_path, suite_title, need_names=need_names, + depends_field=with_depends, ).splitlines() ) lines.append("") @@ -527,6 +537,9 @@ def run(self): all_rst += _build_suite_rst( suite_refid, xml_dir, testspec_html_dir, api_html_dir, module_path, need_names=need_names, + depends_field=lambda conditions, subject: depends_field( + env, conditions, subject + ), ) for proc_refid in proc_refids: all_rst += _build_proc_group_rst( From b398c8b869c105247385335996a792501cc92c26 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Tue, 29 Sep 2026 17:19:14 +0200 Subject: [PATCH 3/4] feat: testmodule: derive a need's suite from a qualified group name A test case need's `suite` was its inner suite group's Doxygen name, and testreport finds the test case for a twister result by (suite, function) (SpecLookup). Two test modules can declare the same ZTEST_SUITE: Zephyr's tests/kernel/workq/user_work and work_queue both declare workqueue_api. They cannot share one group, because both module pages would then render every test in it and the need ids would collide. With a group per module, though, the suite field no longer named the ztest suite, and no result could find its case. A new config value `testmodule_suite_qualifier` (default "", today's behaviour) fixes this. When it is set, e.g. to "__", the suite is the part of the group name after the qualifier's last occurrence: kernel_workq_user_work_module__workqueue_api gives workqueue_api. A name without the qualifier, or with nothing after it, is used whole. A consumer sets it in the conf.py of the document holding the testmodule directives, after configure(), like testmodule_need_types. The suite heading's fallback (a group without a title) uses the derived suite too. The fallback id of a case without @testid, testspec--, keeps the whole group name, not the suite. Two modules can have a function of the same name in the same suite (the fixture's test_shared). Scoped by the suite, both would get testspec-workqueue_api-test_shared, and sphinx-needs would fail the build with a duplicate id. The group name is unique by construction. A twister result of such a function is still ambiguous by (suite, function), and testreport skips it with the existing warning. That is the same as for any pair two modules document. The qualifier splits only the group name the suite is derived from. The (suite, function) key is unchanged, and it now sees the real suite name. The reference page says so. Tests: fixtures/doxygen-qualifier (hand-written in Doxygen's shape, two modules with groups m1__workqueue_api and m2__workqueue_api) and a Sphinx root that renders both modules on one page. Every need carries suite workqueue_api, the four ids are distinct, and load_spec_lookup on the built needs.json maps each module's result to its own case. The unset and no-occurrence cases are unchanged. On the previous engine the new tests fail (9 of 11: the helper does not exist, and the suite stays m1__workqueue_api), and the two unset-qualifier guards pass. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- doc/manual/reference/directives-and-roles.rst | 30 +++++ .../group__m1____workqueue__api.xml | 25 ++++ .../doxygen-qualifier/group__m1__module.xml | 8 ++ .../group__m2____workqueue__api.xml | 25 ++++ .../doxygen-qualifier/group__m2__module.xml | 8 ++ .../fixtures/doxygen-qualifier/index.xml | 15 +++ .../roots/test-testmodule-qualifier/conf.py | 52 ++++++++ .../roots/test-testmodule-qualifier/index.rst | 8 ++ .../_tests/test_suite_qualifier.py | 121 ++++++++++++++++++ sphinx/_extensions/rst_builders.py | 11 +- sphinx/_extensions/test_module.py | 28 +++- 11 files changed, 325 insertions(+), 6 deletions(-) create mode 100644 sphinx/_extensions/_tests/fixtures/doxygen-qualifier/group__m1____workqueue__api.xml create mode 100644 sphinx/_extensions/_tests/fixtures/doxygen-qualifier/group__m1__module.xml create mode 100644 sphinx/_extensions/_tests/fixtures/doxygen-qualifier/group__m2____workqueue__api.xml create mode 100644 sphinx/_extensions/_tests/fixtures/doxygen-qualifier/group__m2__module.xml create mode 100644 sphinx/_extensions/_tests/fixtures/doxygen-qualifier/index.xml create mode 100644 sphinx/_extensions/_tests/roots/test-testmodule-qualifier/conf.py create mode 100644 sphinx/_extensions/_tests/roots/test-testmodule-qualifier/index.rst create mode 100644 sphinx/_extensions/_tests/test_suite_qualifier.py diff --git a/doc/manual/reference/directives-and-roles.rst b/doc/manual/reference/directives-and-roles.rst index 11563a0..804c703 100644 --- a/doc/manual/reference/directives-and-roles.rst +++ b/doc/manual/reference/directives-and-roles.rst @@ -104,6 +104,36 @@ locate that module's ``testcase.yaml`` for the rendered scenario table. Every ``ZTEST``/``ZTEST_SUITE``/... in the named group and its inner suite/procedure groups becomes one need each — nothing is written by hand per test case. +A test case need's ``suite`` field is its inner suite group's name, so name +that group after the ``ZTEST_SUITE``: ``testreport`` finds the test case for a +twister result by the pair (suite, function). Two test modules that declare the +same ztest suite (Zephyr's workq ``user_work`` and ``work_queue`` both declare +``workqueue_api``) cannot share one group, though, because then both module +pages render every test in it and the need ids collide. So give each module its +own group, and set a qualifier in the ``conf.py`` of the document that holds +the ``testmodule`` directives, after the ``configure()`` call: + +.. code-block:: python + + testmodule_suite_qualifier = "__" + +.. code-block:: c + + /** @defgroup kernel_workq_user_work_module__workqueue_api workqueue_api ZTest suite + * @ingroup kernel_workq_user_work_module */ + +The suite is the part of the group name after the qualifier's **last** +occurrence (here ``workqueue_api``). A group name without the qualifier is used +whole, and so is every name when the value is unset (the default, ``""``). The +qualifier splits only the Doxygen group name the suite is derived from. The +(suite, function) key a result is correlated by does not change, and it now +sees the real suite name, which is the point. The fallback id of a test case +without ``@testid``, ``testspec--``, keeps the whole group +name: two modules may have a function of the same name in the same suite, and +their ids must not collide. A result of such a function is still ambiguous by +(suite, function), and ``testreport`` skips it with a warning, as it does for +any pair that two modules document. + .. code-block:: rst .. testreport:: twister_report.xml diff --git a/sphinx/_extensions/_tests/fixtures/doxygen-qualifier/group__m1____workqueue__api.xml b/sphinx/_extensions/_tests/fixtures/doxygen-qualifier/group__m1____workqueue__api.xml new file mode 100644 index 0000000..8af4851 --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/doxygen-qualifier/group__m1____workqueue__api.xml @@ -0,0 +1,25 @@ + + + + m1__workqueue_api + workqueue_api ZTest suite + + test_workq_user_mode + test_workq_user_mode in m1. + + + Test ID + TSPEC-WQ-001 + + + + + + test_shared + test_shared in m1. + + + + + + diff --git a/sphinx/_extensions/_tests/fixtures/doxygen-qualifier/group__m1__module.xml b/sphinx/_extensions/_tests/fixtures/doxygen-qualifier/group__m1__module.xml new file mode 100644 index 0000000..74ebe02 --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/doxygen-qualifier/group__m1__module.xml @@ -0,0 +1,8 @@ + + + + m1_module + m1 Test Module + m1__workqueue_api + + diff --git a/sphinx/_extensions/_tests/fixtures/doxygen-qualifier/group__m2____workqueue__api.xml b/sphinx/_extensions/_tests/fixtures/doxygen-qualifier/group__m2____workqueue__api.xml new file mode 100644 index 0000000..f6efcea --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/doxygen-qualifier/group__m2____workqueue__api.xml @@ -0,0 +1,25 @@ + + + + m2__workqueue_api + workqueue_api ZTest suite + + test_workq_start + test_workq_start in m2. + + + Test ID + TSPEC-WQ-002 + + + + + + test_shared + test_shared in m2. + + + + + + diff --git a/sphinx/_extensions/_tests/fixtures/doxygen-qualifier/group__m2__module.xml b/sphinx/_extensions/_tests/fixtures/doxygen-qualifier/group__m2__module.xml new file mode 100644 index 0000000..5b5de6d --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/doxygen-qualifier/group__m2__module.xml @@ -0,0 +1,8 @@ + + + + m2_module + m2 Test Module + m2__workqueue_api + + diff --git a/sphinx/_extensions/_tests/fixtures/doxygen-qualifier/index.xml b/sphinx/_extensions/_tests/fixtures/doxygen-qualifier/index.xml new file mode 100644 index 0000000..eb1dc97 --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/doxygen-qualifier/index.xml @@ -0,0 +1,15 @@ + + + + m1_module + + + m1__workqueue_api + + + m2_module + + + m2__workqueue_api + + diff --git a/sphinx/_extensions/_tests/roots/test-testmodule-qualifier/conf.py b/sphinx/_extensions/_tests/roots/test-testmodule-qualifier/conf.py new file mode 100644 index 0000000..24dd5a4 --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-testmodule-qualifier/conf.py @@ -0,0 +1,52 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) # _extensions/ + +_FIXTURES = Path(__file__).resolve().parents[2] / "fixtures" + +# Point the testcase.yaml lookup at a directory that has none, so +# build_scenario_table() returns [] and these roots stay focused on the +# directive itself. This used to set ZEPHYR_BASE: step 27 removed that +# fallback from test_module.py (the engine now supplies testmodule_root +# from ZDOCS_PROJECT_BASE), which left the env line inert but +# authoritative-looking -- the exact trap step 24a cleaned up elsewhere. +testmodule_root = str(_FIXTURES) + +extensions = ["sphinx_needs", "test_module"] +master_doc = "index" +exclude_patterns = ["_build"] + +needs_types = [ + dict(directive="test_case", title="Test Case", prefix="TCASE_", + color="#E2EFDA", style="node"), + dict(directive="test_procedure", title="Test Procedure", prefix="TPROC_", + color="#D6E4F7", style="node"), +] +_str_field = {"schema": {"type": "string"}, "nullable": True} +needs_fields = { + "test_function": {**_str_field}, + "test_module": {**_str_field}, + "suite": {**_str_field}, + "suite_title": {**_str_field}, +} +needs_id_regex = r"^[A-Za-z][A-Za-z0-9_-]+" +needs_links = { + "verifies": {"description": "verifies", "incoming": "verified by", "outgoing": "verifies"}, +} +needs_build_json = True +suppress_warnings = ["needs.link_outgoing", "config.cache"] + +testmodule_xml_dir = str(_FIXTURES / "doxygen-qualifier") + +testspec_doxygen_url = "testspec" +api_doxygen_url = "api" + +# Both modules declare ztest suite `workqueue_api`, so each gives it its own +# Doxygen group, `__workqueue_api`; the need's suite is the part after +# the qualifier. +testmodule_suite_qualifier = "__" diff --git a/sphinx/_extensions/_tests/roots/test-testmodule-qualifier/index.rst b/sphinx/_extensions/_tests/roots/test-testmodule-qualifier/index.rst new file mode 100644 index 0000000..385091e --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-testmodule-qualifier/index.rst @@ -0,0 +1,8 @@ +Test Spec +========= + +.. testmodule:: m1_module + :module: tests/m1 + +.. testmodule:: m2_module + :module: tests/m2 diff --git a/sphinx/_extensions/_tests/test_suite_qualifier.py b/sphinx/_extensions/_tests/test_suite_qualifier.py new file mode 100644 index 0000000..ad54864 --- /dev/null +++ b/sphinx/_extensions/_tests/test_suite_qualifier.py @@ -0,0 +1,121 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""``testmodule_suite_qualifier``: a suite group's Doxygen name vs the ztest suite. + +Two test modules may declare the same ZTEST_SUITE (Zephyr's workq user_work and +work_queue both declare ``workqueue_api``). One Doxygen group for both would put +every test on both module pages, so each module gets its own group, +``__``, and the qualifier ``"__"`` tells testmodule the need's +``suite`` is the part after it: the (suite, function) key a twister result is +correlated by must see the real suite name. + +fixtures/doxygen-qualifier is hand-written in Doxygen's shape: modules m1 and +m2, each with a group ``m__workqueue_api``; m1 has test_workq_user_mode +(TSPEC-WQ-001), m2 test_workq_start (TSPEC-WQ-002), and both a test_shared +without @testid, so it takes the fallback id. +""" + +import json +from pathlib import Path + +import pytest +import test_module as tm +from conftest import FIXTURES +from twister_reader import load_spec_lookup + +_ROOTS = Path(__file__).parent / "roots" +XML = FIXTURES / "doxygen-qualifier" +M1 = "group__m1____workqueue__api" + + +def _field(lines, name): + return [line.split(":", 2)[2].strip() for line in lines if line.startswith(f" :{name}:")] + + +# --------------------------------------------------------------------------- +# suite_name_from_group +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "group, qualifier, suite", + [ + ("kernel_workq_user_work_module__workqueue_api", "__", "workqueue_api"), + ("a__b__workqueue_api", "__", "workqueue_api"), # the LAST occurrence + ("workqueue_api", "__", "workqueue_api"), # no occurrence + ("m1__", "__", "m1__"), # nothing after it + ("m1__workqueue_api", "", "m1__workqueue_api"), # unset + ], +) +def test_suite_name_from_group(group, qualifier, suite): + assert tm.suite_name_from_group(group, qualifier) == suite + + +# --------------------------------------------------------------------------- +# _build_suite_rst +# --------------------------------------------------------------------------- + +def test_qualifier_gives_the_need_the_real_suite(): + lines = tm._build_suite_rst(M1, XML, "testspec", "api", "tests/m1", suite_qualifier="__") + assert _field(lines, "suite") == ["workqueue_api", "workqueue_api"] + + +def test_fallback_id_keeps_the_group_name(): + # test_shared exists in both modules; the suite name alone would give both + # `testspec-workqueue_api-test_shared`. + lines = tm._build_suite_rst(M1, XML, "testspec", "api", "tests/m1", suite_qualifier="__") + assert _field(lines, "id") == ["TSPEC-WQ-001", "testspec-m1__workqueue_api-test_shared"] + + +def test_unset_qualifier_keeps_the_group_name_as_suite(): + lines = tm._build_suite_rst(M1, XML, "testspec", "api", "tests/m1") + assert _field(lines, "suite") == ["m1__workqueue_api", "m1__workqueue_api"] + assert _field(lines, "id") == ["TSPEC-WQ-001", "testspec-m1__workqueue_api-test_shared"] + + +def test_group_without_the_qualifier_is_unchanged(): + lines = tm._build_suite_rst( + "group__queue__api", FIXTURES / "doxygen", "t", "a", "tests/kernel/queue", + suite_qualifier="__", + ) + assert set(_field(lines, "suite")) == {"queue_api"} + + +# --------------------------------------------------------------------------- +# Directive: two modules, one ztest suite, results correlated per module +# --------------------------------------------------------------------------- + +def _needs(app): + data = json.loads((Path(app.outdir) / "needs.json").read_text()) + return data["versions"][data["current_version"]]["needs"] + + +@pytest.mark.sphinx("html", srcdir=str(_ROOTS / "test-testmodule-qualifier")) +def test_two_modules_same_suite(app, warning): + app.build() + needs = _needs(app) + assert sorted(needs) == [ + "TSPEC-WQ-001", "TSPEC-WQ-002", + "testspec-m1__workqueue_api-test_shared", "testspec-m2__workqueue_api-test_shared", + ] + assert {n["suite"] for n in needs.values()} == {"workqueue_api"} + assert needs["TSPEC-WQ-001"]["test_module"] == "tests/m1" + assert needs["TSPEC-WQ-002"]["test_module"] == "tests/m2" + assert "already exists" not in warning.getvalue() + + # The report's (suite, function) key, as twister names them. + lookup = load_spec_lookup(Path(app.outdir) / "needs.json") + assert lookup.find("workqueue_api", "workq_user_mode")["id"] == "TSPEC-WQ-001" + assert lookup.find("workqueue_api", "workq_start")["id"] == "TSPEC-WQ-002" + + +@pytest.mark.sphinx( + "html", srcdir=str(_ROOTS / "test-testmodule-qualifier"), + confoverrides={"testmodule_suite_qualifier": ""}, +) +def test_directive_without_qualifier_is_todays_behaviour(app): + app.build() + assert {n["suite"] for n in _needs(app).values()} == { + "m1__workqueue_api", "m2__workqueue_api", + } diff --git a/sphinx/_extensions/rst_builders.py b/sphinx/_extensions/rst_builders.py index b7af7af..1aea33b 100644 --- a/sphinx/_extensions/rst_builders.py +++ b/sphinx/_extensions/rst_builders.py @@ -77,11 +77,15 @@ def _depends_on_rst(info, depends_field): def build_need_rst( - info, suite_name, module_path="", suite_title="", need_names=None, depends_field=False + info, suite_name, module_path="", suite_title="", need_names=None, depends_field=False, + id_scope=None, ): """Build the RST block for a single test_case need. ``depends_field``: set the ``depends_on`` field (see `_depends_on_rst`). + ``id_scope``: what the fallback id ``testspec--`` is scoped + by — the suite's Doxygen group name, which differs from ``suite_name`` under + a `testmodule_suite_qualifier`; defaults to ``suite_name``. """ name = info["name"] test_id = info["test_id"] @@ -100,8 +104,9 @@ def build_need_rst( if test_id: need_id = test_id else: - need_id = f"testspec-{suite_name}-{name}" - logger.warning(f"testmodule: {suite_name}/{name} has no @testid annotation") + scope = id_scope or suite_name + need_id = f"testspec-{scope}-{name}" + logger.warning(f"testmodule: {scope}/{name} has no @testid annotation") lines = [f".. {_need_name(need_names, 'case')}:: {title}"] lines.append(f" :id: {need_id}") diff --git a/sphinx/_extensions/test_module.py b/sphinx/_extensions/test_module.py index 442add9..589b26b 100644 --- a/sphinx/_extensions/test_module.py +++ b/sphinx/_extensions/test_module.py @@ -139,21 +139,39 @@ def _classify_inner_groups(module_cdef: ET.Element, xml_dir: Path): return suite_refids, proc_refids +def suite_name_from_group(group_name, qualifier=""): + """The ztest suite name a suite group's compoundname stands for. + + With a `testmodule_suite_qualifier`, the part after its LAST occurrence + (``kernel_workq_user_work_module__workqueue_api`` -> ``workqueue_api`` for + ``"__"``), so two test modules declaring the same ZTEST_SUITE can give it + distinct Doxygen groups. Without a qualifier, without an occurrence of it, + or with nothing after it, the whole name. + """ + suite = group_name.rsplit(qualifier, 1)[-1] if qualifier else group_name + return suite or group_name + + def _build_suite_rst( suite_refid, xml_dir, testspec_html_dir, api_html_dir, module_path, need_names=None, - depends_field=None, + depends_field=None, suite_qualifier="", ): """Build RST lines for one test suite group (section heading + test_case needs). ``depends_field(conditions, subject)`` decides whether a need gets the ``depends_on`` field (`needs_fields.depends_field`); without it, none does. + + ``suite_qualifier`` (`testmodule_suite_qualifier`): the need's ``suite`` is + the group name after it (`suite_name_from_group`); the fallback id keeps the + whole group name, which is unique where the suite name need not be. """ suite_xml = xml_dir / f"{suite_refid}.xml" if not suite_xml.exists(): logger.warning(f"testmodule: suite XML not found: {suite_xml}") return [] suite_cdef = ET.parse(suite_xml).getroot().find("compounddef") - suite_name = suite_cdef.findtext("compoundname", suite_refid) + group_name = suite_cdef.findtext("compoundname", suite_refid) + suite_name = suite_name_from_group(group_name, suite_qualifier) compound_id = suite_cdef.get("id", suite_refid) suite_title = suite_cdef.findtext("title", suite_name) @@ -172,7 +190,7 @@ def _build_suite_rst( lines.extend( build_need_rst( info, suite_name, module_path, suite_title, need_names=need_names, - depends_field=with_depends, + depends_field=with_depends, id_scope=group_name, ).splitlines() ) lines.append("") @@ -540,6 +558,7 @@ def run(self): depends_field=lambda conditions, subject: depends_field( env, conditions, subject ), + suite_qualifier=getattr(app.config, "testmodule_suite_qualifier", ""), ) for proc_refid in proc_refids: all_rst += _build_proc_group_rst( @@ -763,6 +782,9 @@ def setup(app): app.add_config_value("twisterinfo_project_name", "", "env") app.add_config_value("twisterinfo_project_version", "", "env") app.add_config_value("dump_generated_rst", "", "env") + # Separator in a suite group's Doxygen name: the need's `suite` is the part + # after its last occurrence (suite_name_from_group). Empty = the whole name. + app.add_config_value("testmodule_suite_qualifier", "", "env") app.add_config_value( "testmodule_need_types", {"case": "test_case", "procedure": "test_procedure", "result": "test_result"}, From 27eca98e143a26f301577d179fe282034f3fbff3 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Sat, 3 Oct 2026 01:58:56 +0200 Subject: [PATCH 4/4] feat: twister: say whether a result's build met its depends_on, and why it skipped A test case's depends_on (@kconfig_depends) says under which Kconfig condition it is meaningful; a result only linked to its case, so whether the condition held in the build that produced the result was not recorded. And a skipped result carried twister's reason only, which does not tell a skip the specification explains from one it does not. testreport now sets two result fields: - depends_met: yes / no / n/a. The case's conditions (all must hold; the "; " of depends_on is an and) are evaluated against the .config twister keeps for that build, //// /zephyr/.config (or the --detailed-test-id layout), looked up exactly, never in another scenario's directory. CONFIG_X and defined(CONFIG_X) are true when the symbol has any value, not "is not set"; !, &&, || and parentheses combine them. n/a for no condition, no .config, or a condition outside that grammar (another macro, IS_ENABLED(), a comparison): nothing is guessed, and the build warns once per case and condition. Each .config read is a tracked input. - skip_class, on skipped results: build-only (built only), platform (a memory region overflowed, or filtered by platform), config (ztest skip and depends_met no), unexplained (anything else). A parameterized test's class comes from its values' common reason. Both are field roles with names the consumer may choose (testreport_need_fields, defaults depends_met / skip_class), merged into the role->name mapping, and set only where the consumer's sphinx-needs schema declares the field; ADR-0009 notes the two result fields as the exception to literal field names. The spec lookup now reads a case's depends_on (string or array). On the safety docset (twister-out-b4b, 5386 results): depends_met yes 595, no 424, n/a 4367, no unparseable condition; skipped 610 = config 416, platform 156, build-only 6, unexplained 32; no result ran although its condition was false. Tests: fixtures/twister-depends (one module on two boards, .config files with the feature set on one), the grammar and its rejections, .config reading and lookup, every class, the assessment of each fixture result, the field names by role, and a Sphinx root that renames both fields and checks needs.json and the single warning. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- .../decisions/0009-need-type-role-mapping.rst | 5 + doc/manual/reference/directives-and-roles.rst | 29 ++ .../demo/kernel.demo.big/zephyr/.config | 4 + .../kernel/demo/kernel.demo/zephyr/.config | 5 + .../fixtures/twister-depends/needs.json | 52 ++++ .../demo/kernel.demo.soak/zephyr/.config | 4 + .../kernel/demo/kernel.demo/zephyr/.config | 5 + .../fixtures/twister-depends/twister.json | 82 +++++ .../twister-depends/twister_report.xml | 28 ++ .../roots/test-testreport-depends/conf.py | 56 ++++ .../roots/test-testreport-depends/index.rst | 5 + .../_tests/test_testreport_depends.py | 280 ++++++++++++++++++ sphinx/_extensions/rst_builders.py | 20 +- sphinx/_extensions/test_module.py | 93 +++++- sphinx/_extensions/twister_reader.py | 218 ++++++++++++++ 15 files changed, 878 insertions(+), 8 deletions(-) create mode 100644 sphinx/_extensions/_tests/fixtures/twister-depends/mps2_an385/zephyr_gnu/tests/kernel/demo/kernel.demo.big/zephyr/.config create mode 100644 sphinx/_extensions/_tests/fixtures/twister-depends/mps2_an385/zephyr_gnu/tests/kernel/demo/kernel.demo/zephyr/.config create mode 100644 sphinx/_extensions/_tests/fixtures/twister-depends/needs.json create mode 100644 sphinx/_extensions/_tests/fixtures/twister-depends/qemu_x86/zephyr_gnu/tests/kernel/demo/kernel.demo.soak/zephyr/.config create mode 100644 sphinx/_extensions/_tests/fixtures/twister-depends/qemu_x86/zephyr_gnu/tests/kernel/demo/kernel.demo/zephyr/.config create mode 100644 sphinx/_extensions/_tests/fixtures/twister-depends/twister.json create mode 100644 sphinx/_extensions/_tests/fixtures/twister-depends/twister_report.xml create mode 100644 sphinx/_extensions/_tests/roots/test-testreport-depends/conf.py create mode 100644 sphinx/_extensions/_tests/roots/test-testreport-depends/index.rst create mode 100644 sphinx/_extensions/_tests/test_testreport_depends.py diff --git a/doc/manual/explanation/decisions/0009-need-type-role-mapping.rst b/doc/manual/explanation/decisions/0009-need-type-role-mapping.rst index be87948..b88725b 100644 --- a/doc/manual/explanation/decisions/0009-need-type-role-mapping.rst +++ b/doc/manual/explanation/decisions/0009-need-type-role-mapping.rst @@ -36,6 +36,11 @@ methodology vocabulary; the fields hanging off them are the engine's own data model. The distinction is visible in a generated needs table, where literal field names sit beside a mapped link name. +Two fields were later made roles too: a test result's ``depends_met`` and +``skip_class`` (``testreport_need_fields``). They are verdicts a project's +review filters and reports by, not a record of what twister wrote, so a +consumer names them like the types and links. + Consequences ------------ diff --git a/doc/manual/reference/directives-and-roles.rst b/doc/manual/reference/directives-and-roles.rst index 804c703..839e584 100644 --- a/doc/manual/reference/directives-and-roles.rst +++ b/doc/manual/reference/directives-and-roles.rst @@ -176,6 +176,35 @@ result, which takes its status from them and lists the values that did not pass (:doc:`../explanation/testmodule-and-twister`). No need type or field is added for this; the values render in the need's body. +Each result also says whether its build met the test case's ``depends_on`` +(the ``@kconfig_depends`` conditions below), and each skipped result why it was +skipped. Both are read from the run, not from the spec alone: + +``depends_met`` + ``yes`` or ``no``: the case's conditions, all of which must hold, evaluated + against the ``.config`` twister kept for that build + (``////zephyr/.config`` under the + report's directory). ``CONFIG_X`` and ``defined(CONFIG_X)`` are true when + the symbol has a value (``=y``, a number, a string; not ``is not set``); + ``!``, ``&&``, ``||`` and parentheses combine them. ``n/a`` when the case + has no condition, the build's ``.config`` is not there, or a condition uses + anything else (another macro, ``IS_ENABLED()``, a comparison): its value is + not known from ``.config``, so none is guessed, and the build warns once per + case and condition. A result that passed with ``no`` ran although its + condition was false. +``skip_class`` + On skipped results only. ``build-only``: twister built the test but did not + run it. ``platform``: a memory region overflowed, or the platform was + filtered out. ``config``: ztest skipped it and ``depends_met`` is ``no``. + ``unexplained``: anything else. + +Both are set only where your ``needs_config.toml`` declares them (string +fields), under names you may choose, like the need types and links: + +.. code-block:: python + + testreport_need_fields = {"depends_met": "depends_met", "skip_class": "skip_class"} # defaults + Both directives **soft-fail** to a short "not found" paragraph when their input is absent, rather than failing the build — a documentation build outrunning its test run is a normal pipeline state. ``testmodule`` does diff --git a/sphinx/_extensions/_tests/fixtures/twister-depends/mps2_an385/zephyr_gnu/tests/kernel/demo/kernel.demo.big/zephyr/.config b/sphinx/_extensions/_tests/fixtures/twister-depends/mps2_an385/zephyr_gnu/tests/kernel/demo/kernel.demo.big/zephyr/.config new file mode 100644 index 0000000..ee8b934 --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/twister-depends/mps2_an385/zephyr_gnu/tests/kernel/demo/kernel.demo.big/zephyr/.config @@ -0,0 +1,4 @@ +# +# Automatically generated file; DO NOT EDIT. +# +CONFIG_MULTITHREADING=y diff --git a/sphinx/_extensions/_tests/fixtures/twister-depends/mps2_an385/zephyr_gnu/tests/kernel/demo/kernel.demo/zephyr/.config b/sphinx/_extensions/_tests/fixtures/twister-depends/mps2_an385/zephyr_gnu/tests/kernel/demo/kernel.demo/zephyr/.config new file mode 100644 index 0000000..2f43fdb --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/twister-depends/mps2_an385/zephyr_gnu/tests/kernel/demo/kernel.demo/zephyr/.config @@ -0,0 +1,5 @@ +# +# Automatically generated file; DO NOT EDIT. +# +CONFIG_MULTITHREADING=y +# CONFIG_DEMO_FEATURE is not set diff --git a/sphinx/_extensions/_tests/fixtures/twister-depends/needs.json b/sphinx/_extensions/_tests/fixtures/twister-depends/needs.json new file mode 100644 index 0000000..3e81a92 --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/twister-depends/needs.json @@ -0,0 +1,52 @@ +{ + "current_version": "1.0", + "versions": { + "1.0": { + "needs": { + "TSPEC-DEMO-001": { + "id": "TSPEC-DEMO-001", + "type": "test_case", + "title": "needs feature", + "test_function": "test_needs_feature", + "test_module": "tests/kernel/demo", + "suite": "demo_suite", + "suite_title": "Demo suite", + "verifies": [], + "depends_on": "CONFIG_DEMO_FEATURE" + }, + "TSPEC-DEMO-002": { + "id": "TSPEC-DEMO-002", + "type": "test_case", + "title": "local macro", + "test_function": "test_local_macro", + "test_module": "tests/kernel/demo", + "suite": "demo_suite", + "suite_title": "Demo suite", + "verifies": [], + "depends_on": "Z_DEMO_LOCAL_MACRO; CONFIG_DEMO_FEATURE" + }, + "TSPEC-DEMO-003": { + "id": "TSPEC-DEMO-003", + "type": "test_case", + "title": "plain", + "test_function": "test_plain", + "test_module": "tests/kernel/demo", + "suite": "demo_suite", + "suite_title": "Demo suite", + "verifies": [] + }, + "TSPEC-DEMO-004": { + "id": "TSPEC-DEMO-004", + "type": "test_case", + "title": "ran anyway", + "test_function": "test_ran_anyway", + "test_module": "tests/kernel/demo", + "suite": "demo_suite", + "suite_title": "Demo suite", + "verifies": [], + "depends_on": "!defined(CONFIG_DEMO_FEATURE) && defined(CONFIG_MULTITHREADING)" + } + } + } + } +} \ No newline at end of file diff --git a/sphinx/_extensions/_tests/fixtures/twister-depends/qemu_x86/zephyr_gnu/tests/kernel/demo/kernel.demo.soak/zephyr/.config b/sphinx/_extensions/_tests/fixtures/twister-depends/qemu_x86/zephyr_gnu/tests/kernel/demo/kernel.demo.soak/zephyr/.config new file mode 100644 index 0000000..ee8b934 --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/twister-depends/qemu_x86/zephyr_gnu/tests/kernel/demo/kernel.demo.soak/zephyr/.config @@ -0,0 +1,4 @@ +# +# Automatically generated file; DO NOT EDIT. +# +CONFIG_MULTITHREADING=y diff --git a/sphinx/_extensions/_tests/fixtures/twister-depends/qemu_x86/zephyr_gnu/tests/kernel/demo/kernel.demo/zephyr/.config b/sphinx/_extensions/_tests/fixtures/twister-depends/qemu_x86/zephyr_gnu/tests/kernel/demo/kernel.demo/zephyr/.config new file mode 100644 index 0000000..46c5e17 --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/twister-depends/qemu_x86/zephyr_gnu/tests/kernel/demo/kernel.demo/zephyr/.config @@ -0,0 +1,5 @@ +# +# Automatically generated file; DO NOT EDIT. +# +CONFIG_MULTITHREADING=y +CONFIG_DEMO_FEATURE=y diff --git a/sphinx/_extensions/_tests/fixtures/twister-depends/twister.json b/sphinx/_extensions/_tests/fixtures/twister-depends/twister.json new file mode 100644 index 0000000..9e1c0de --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/twister-depends/twister.json @@ -0,0 +1,82 @@ +{ + "environment": { + "run_date": "2026-10-03T10:00:00+00:00", + "zephyr_version": "4.4.0", + "toolchain": "zephyr", + "os": "linux" + }, + "testsuites": [ + { + "name": "kernel.demo", + "platform": "qemu_x86", + "path": "tests/kernel/demo", + "toolchain": "zephyr/gnu", + "status": "passed", + "testcases": [ + { + "identifier": "kernel.demo.demo_suite.needs_feature", + "status": "passed" + }, + { + "identifier": "kernel.demo.demo_suite.ran_anyway", + "status": "passed" + }, + { + "identifier": "kernel.demo.demo_suite.local_macro", + "status": "skipped" + }, + { + "identifier": "kernel.demo.demo_suite.plain", + "status": "passed" + } + ] + }, + { + "name": "kernel.demo.soak", + "platform": "qemu_x86", + "path": "tests/kernel/demo", + "toolchain": "zephyr/gnu", + "status": "passed", + "testcases": [ + { + "identifier": "kernel.demo.soak.demo_suite.plain", + "status": "not run" + } + ] + }, + { + "name": "kernel.demo", + "platform": "mps2/an385", + "path": "tests/kernel/demo", + "toolchain": "zephyr/gnu", + "status": "passed", + "testcases": [ + { + "identifier": "kernel.demo.demo_suite.needs_feature", + "status": "skipped" + }, + { + "identifier": "kernel.demo.demo_suite.ran_anyway", + "status": "passed" + }, + { + "identifier": "kernel.demo.demo_suite.local_macro", + "status": "skipped" + } + ] + }, + { + "name": "kernel.demo.big", + "platform": "mps2/an385", + "path": "tests/kernel/demo", + "toolchain": "zephyr/gnu", + "status": "passed", + "testcases": [ + { + "identifier": "kernel.demo.big.demo_suite.plain", + "status": "skipped" + } + ] + } + ] +} \ No newline at end of file diff --git a/sphinx/_extensions/_tests/fixtures/twister-depends/twister_report.xml b/sphinx/_extensions/_tests/fixtures/twister-depends/twister_report.xml new file mode 100644 index 0000000..cda2603 --- /dev/null +++ b/sphinx/_extensions/_tests/fixtures/twister-depends/twister_report.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sphinx/_extensions/_tests/roots/test-testreport-depends/conf.py b/sphinx/_extensions/_tests/roots/test-testreport-depends/conf.py new file mode 100644 index 0000000..f7c45df --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-testreport-depends/conf.py @@ -0,0 +1,56 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) # _extensions/ + +_FIXTURES = Path(__file__).resolve().parents[2] / "fixtures" + +extensions = ["sphinx_needs", "test_module"] +master_doc = "index" +exclude_patterns = ["_build"] + +needs_types = [ + dict(directive="test_result", title="Test Result", prefix="TRESULT_", + color="#FCE4D6", style="node"), + dict(directive="test_case", title="Test Case", prefix="TCASE_", + color="#E2EFDA", style="node"), +] +_str_field = {"schema": {"type": "string"}, "nullable": True} +# The two result fields under the consumer's own names, sharing no substring +# with the engine's defaults (depends_met, skip_class). +testreport_need_fields = {"depends_met": "cond_held", "skip_class": "omission_kind"} +needs_fields = { + "platform": {**_str_field}, + "scenario": {**_str_field}, + "twister_id": {**_str_field}, + "execution_time": {**_str_field}, + "reason": {**_str_field}, + "test_function": {**_str_field}, + "test_module": {**_str_field}, + "suite": {**_str_field}, + "suite_title": {**_str_field}, + "depends_on": {**_str_field}, + "cond_held": {**_str_field}, + "omission_kind": {**_str_field}, +} +needs_id_regex = r"^[A-Za-z][A-Za-z0-9_-]+" +needs_links = { + "result_of": {"description": "result of", "incoming": "has results", "outgoing": "result of"}, + "covers": {"description": "covers", "incoming": "covered by", "outgoing": "covers"}, + "verifies": {"description": "verifies", "incoming": "verified by", "outgoing": "verifies"}, +} +needs_external_needs = [{ + "json_path": str(_FIXTURES / "twister-depends" / "needs.json"), + "base_url": "http://localhost/", + "version": "1.0", +}] +twister_output_dir = str(_FIXTURES / "twister-depends") +testspec_needs_json = str(_FIXTURES / "twister-depends" / "needs.json") +testspec_doxygen_url = "testspec" +api_doxygen_url = "api" +needs_build_json = True +suppress_warnings = ["needs.link_outgoing", "needs.external_link_outgoing", "config.cache"] diff --git a/sphinx/_extensions/_tests/roots/test-testreport-depends/index.rst b/sphinx/_extensions/_tests/roots/test-testreport-depends/index.rst new file mode 100644 index 0000000..11f54f6 --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-testreport-depends/index.rst @@ -0,0 +1,5 @@ +Demo results +============ + +.. testreport:: twister_report.xml + :path: tests/kernel/demo diff --git a/sphinx/_extensions/_tests/test_testreport_depends.py b/sphinx/_extensions/_tests/test_testreport_depends.py new file mode 100644 index 0000000..588ea85 --- /dev/null +++ b/sphinx/_extensions/_tests/test_testreport_depends.py @@ -0,0 +1,280 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""testreport: did a result's build meet its case's depends_on, and why was it skipped. + +``depends_met`` is evaluated against the build's own ``.config``, which twister +keeps beside each build; ``skip_class`` sorts each skipped result into +config / platform / build-only / unexplained. Fixture: fixtures/twister-depends, +one module on two platforms, the feature symbol set on qemu_x86 only. +""" + +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest +import rst_builders as rb +import test_module as tm +import twister_reader as tw +from conftest import FIXTURES + +_ROOTS = Path(__file__).parent / "roots" +DATA = FIXTURES / "twister-depends" +QEMU_CFG = DATA / "qemu_x86/zephyr_gnu/tests/kernel/demo/kernel.demo/zephyr/.config" +MPS2_CFG = DATA / "mps2_an385/zephyr_gnu/tests/kernel/demo/kernel.demo/zephyr/.config" + + +# --------------------------------------------------------------------------- +# .config +# --------------------------------------------------------------------------- + + +def test_read_kconfig_takes_every_value_and_skips_not_set(tmp_path): + cfg = tmp_path / ".config" + cfg.write_text( + "#\n# CONFIG_OFF is not set\nCONFIG_Y=y\nCONFIG_M=m\nCONFIG_N=0\n" + 'CONFIG_S=""\nCONFIG_HEX=0x10\n CONFIG_INDENTED=y\n' + ) + assert tw.read_kconfig(cfg) == {"CONFIG_Y", "CONFIG_M", "CONFIG_N", "CONFIG_S", "CONFIG_HEX"} + + +def test_find_build_config_in_the_path_layout(): + path = tw.find_build_config( + DATA, "mps2/an385", "zephyr/gnu", "tests/kernel/demo", "kernel.demo" + ) + assert path == MPS2_CFG + + +def test_find_build_config_keeps_only_after_last_pardir(tmp_path): + cfg = tmp_path / "p/zephyr_gnu/acme/tests/x/acme.x/zephyr/.config" + cfg.parent.mkdir(parents=True) + cfg.write_text("CONFIG_A=y\n") + assert tw.find_build_config(tmp_path, "p", "zephyr/gnu", "../acme/tests/x", "acme.x") == cfg + + +def test_find_build_config_detailed_test_id_layout(tmp_path): + cfg = tmp_path / "p/zephyr_gnu/acme.x/zephyr/.config" + cfg.parent.mkdir(parents=True) + cfg.write_text("CONFIG_A=y\n") + assert tw.find_build_config(tmp_path, "p", "zephyr/gnu", "tests/x", "acme.x") == cfg + + +def test_find_build_config_takes_no_other_scenarios_config(): + # find_handler_log falls back to a single other run directory; this must not. + assert tw.find_build_config(DATA, "qemu_x86", "zephyr/gnu", "tests/kernel/demo", + "kernel.demo.missing") is None + + +# --------------------------------------------------------------------------- +# The condition grammar +# --------------------------------------------------------------------------- + +SYMBOLS = {"CONFIG_A", "CONFIG_B"} + + +@pytest.mark.parametrize( + "condition, expected", + [ + ("CONFIG_A", True), + ("CONFIG_C", False), + ("!CONFIG_A", False), + ("defined(CONFIG_A)", True), + ("defined CONFIG_C", False), + ("!defined(CONFIG_C)", True), + ("defined(CONFIG_A) && !defined(CONFIG_B)", False), + ("CONFIG_C || CONFIG_A && !CONFIG_C", True), # && binds tighter + ("(CONFIG_C || CONFIG_A) && !CONFIG_B", False), + ("!(CONFIG_C || CONFIG_B)", False), + (" CONFIG_A&&CONFIG_B ", True), + ], +) +def test_evaluate_condition(condition, expected): + assert tw.evaluate_condition(condition, SYMBOLS) is expected + + +@pytest.mark.parametrize( + "condition", + [ + "IS_ENABLED(CONFIG_A)", + "Z_MUTEX_PI_ENABLED", + "CONFIG_MP_MAX_NUM_CPUS > 1", + "CONFIG_A == 0", + "defined(TICK_IRQ)", + "(CONFIG_A", + "CONFIG_A CONFIG_B", + "CONFIG_A &&", + "", + ], +) +def test_unparseable_conditions_raise(condition): + with pytest.raises(tw.UnparseableCondition): + tw.evaluate_condition(condition, SYMBOLS) + + +def test_depends_met_values(): + assert tw.depends_met(["CONFIG_A", "!CONFIG_C"], SYMBOLS) == ("yes", []) + assert tw.depends_met(["CONFIG_A", "CONFIG_C"], SYMBOLS) == ("no", []) + assert tw.depends_met([], SYMBOLS) == ("n/a", []) + assert tw.depends_met(["CONFIG_A"], None) == ("n/a", []) # no .config + + +def test_depends_met_never_guesses_past_an_unparseable_condition(): + # CONFIG_C is false, but the whole cannot be judged from .config alone. + assert tw.depends_met(["Z_LOCAL", "CONFIG_C"], SYMBOLS) == ("n/a", ["Z_LOCAL"]) + + +def test_spec_lookup_reads_depends_on_as_conditions(tmp_path): + lookup = tw.load_spec_lookup(DATA / "needs.json") + assert lookup.find("demo_suite", "local_macro")["depends_on"] == [ + "Z_DEMO_LOCAL_MACRO", "CONFIG_DEMO_FEATURE", + ] + assert lookup.find("demo_suite", "plain")["depends_on"] == [] + needs = tmp_path / "needs.json" + needs.write_text(json.dumps({"versions": {"1": {"needs": {"T1": { + "type": "test_case", "test_function": "test_x", "depends_on": ["CONFIG_A", "CONFIG_B"], + }}}}})) + assert tw.load_spec_lookup(needs).find("", "x")["depends_on"] == ["CONFIG_A", "CONFIG_B"] + + +# --------------------------------------------------------------------------- +# skip_class +# --------------------------------------------------------------------------- + + +def _skipped(reason, **kw): + return {"status": "skipped", "reason": reason, **kw} + + +@pytest.mark.parametrize( + "result, met, expected", + [ + (_skipped("ztest skip"), "no", "config"), + (_skipped("ztest skip"), "yes", "unexplained"), + (_skipped("ztest skip"), "n/a", "unexplained"), + (_skipped("RAM overflow"), "no", "platform"), + (_skipped("FLASH overflow"), "n/a", "platform"), + (_skipped("Not in testsuite platform allow list"), "n/a", "platform"), + (_skipped("built only"), "no", "build-only"), + (_skipped("Test was built only"), "n/a", "build-only"), + (_skipped("not supported"), "no", "unexplained"), + ({"status": "passed", "reason": ""}, "no", None), + ({"status": "failed", "reason": "boom"}, "no", None), + ], +) +def test_skip_class(result, met, expected): + assert tw.skip_class(result, met) == expected + + +def test_skip_class_of_a_parameterized_test_reads_its_values(): + values = [{"status": "skipped", "reason": "ztest skip"}] * 3 + result = _skipped("3 values: 3 skipped", values=values) + assert tw.skip_class(result, "no") == "config" + + +# --------------------------------------------------------------------------- +# The directive's assessment, on the fixture run +# --------------------------------------------------------------------------- + + +def _assessed(note=None): + meta = tw.load_twister_meta(DATA / "twister.json") + results = tw.parse_twister_results( + DATA / "twister_report.xml", path_filter="tests/kernel/demo", + suite_paths=tw.testsuite_paths(meta), + ) + lookup = tw.load_spec_lookup(DATA / "needs.json") + bad = tm._assess_results(results, lookup, meta, DATA, note_input=note) + return {(r["platform"], r["scenario"], r["function"]): r for r in results}, bad + + +def test_assessment_of_every_result(): + results, _ = _assessed() + got = {k: (r["depends_met"], r.get("skip_class")) for k, r in results.items()} + assert got == { + ("qemu_x86", "kernel.demo", "needs_feature"): ("yes", None), + ("qemu_x86", "kernel.demo", "ran_anyway"): ("no", None), + ("qemu_x86", "kernel.demo", "local_macro"): ("n/a", "unexplained"), + ("qemu_x86", "kernel.demo", "plain"): ("n/a", None), + ("qemu_x86", "kernel.demo.soak", "plain"): ("n/a", "build-only"), + ("mps2/an385", "kernel.demo", "needs_feature"): ("no", "config"), + ("mps2/an385", "kernel.demo", "ran_anyway"): ("yes", None), + ("mps2/an385", "kernel.demo", "local_macro"): ("n/a", "unexplained"), + ("mps2/an385", "kernel.demo.big", "plain"): ("n/a", "platform"), + } + + +def test_assessment_reports_each_unparseable_condition_once_and_notes_configs(): + noted = [] + _, bad = _assessed(noted.append) + assert bad == {("TSPEC-DEMO-002", "Z_DEMO_LOCAL_MACRO")} + # Only builds of conditioned cases are read, each .config once. + assert sorted(noted) == sorted([QEMU_CFG, MPS2_CFG]) + + +def test_assessment_without_twister_json_is_not_applicable(): + results = tw.parse_twister_results(DATA / "twister_report.xml") + tm._assess_results(results, tw.load_spec_lookup(DATA / "needs.json"), None, DATA) + assert {r["depends_met"] for r in results} == {"n/a"} + + +# --------------------------------------------------------------------------- +# RST: the fields, by role +# --------------------------------------------------------------------------- + +_RESULT = { + "platform": "p", "scenario": "s", "function": "f", "twister_id": "s.f", "time": "0", + "status": "skipped", "reason": "ztest skip", "depends_met": "no", "skip_class": "config", +} + + +def test_result_rst_sets_the_declared_fields_under_the_default_names(): + rst = rb.build_result_rst(_RESULT, "T-1", "m", fields={"depends_met", "skip_class"}) + assert " :depends_met: no" in rst + assert " :skip_class: config" in rst + + +def test_result_rst_sets_no_undeclared_field(): + rst = rb.build_result_rst(_RESULT, "T-1", "m", fields={"skip_class"}) + assert "depends_met" not in rst + assert rb.build_result_rst(_RESULT, "T-1", "m").count("config") == 0 + + +def test_result_rst_uses_the_consumers_names(): + names = {"depends_met": "cond_held", "skip_class": "omission_kind"} + rst = rb.build_result_rst( + _RESULT, "T-1", "m", need_names=names, fields={"depends_met", "skip_class"} + ) + assert " :cond_held: no" in rst and " :omission_kind: config" in rst + assert "depends_met" not in rst and "skip_class" not in rst + + +def test_need_names_from_config_merges_the_field_mapping(): + config = SimpleNamespace(testreport_need_fields={"skip_class": "omission_kind"}) + names = tm._need_names_from_config(SimpleNamespace(config=config)) + assert rb._need_name(names, "skip_class") == "omission_kind" + assert rb._need_name(names, "depends_met") == "depends_met" + + +# --------------------------------------------------------------------------- +# Directive: the fields reach needs.json, under the consumer's names +# --------------------------------------------------------------------------- + + +@pytest.mark.sphinx("html", srcdir=str(_ROOTS / "test-testreport-depends")) +def test_directive_sets_the_fields_and_warns_about_the_unparseable(app, warning): + app.build() + data = json.loads((Path(app.outdir) / "needs.json").read_text()) + needs = next(iter(data["versions"].values()))["needs"] + results = {n["id"]: n for n in needs.values() if n["type"] == "test_result"} + config = results["TR-mps2-an385-kernel-demo-TSPEC-DEMO-001"] + assert (config["cond_held"], config["omission_kind"]) == ("no", "config") + ran = results["TR-qemu-x86-kernel-demo-TSPEC-DEMO-004"] + assert ran["status"] == "passed" and ran["cond_held"] == "no" + assert ran.get("omission_kind") in (None, "") + assert results["TR-mps2-an385-kernel-demo-big-TSPEC-DEMO-003"]["omission_kind"] == "platform" + log = warning.getvalue() + assert "TSPEC-DEMO-002: depends_on condition 'Z_DEMO_LOCAL_MACRO'" in log + assert log.count("depends_on condition") == 1 + assert "Unknown option" not in log diff --git a/sphinx/_extensions/rst_builders.py b/sphinx/_extensions/rst_builders.py index 1aea33b..352927c 100644 --- a/sphinx/_extensions/rst_builders.py +++ b/sphinx/_extensions/rst_builders.py @@ -47,8 +47,16 @@ def slugify(s): # symbolneeds: an API symbol, and the requirements it satisfies. "implementation": "impl", "satisfies": "satisfies", + # testreport: result FIELDS, named by the consumer too + # (`testreport_need_fields`). Whether the build met the case's + # depends_on, and why a skipped result was skipped. + "depends_met": "depends_met", + "skip_class": "skip_class", } +#: The result-field roles `build_result_rst` can set (see its ``fields``). +RESULT_FIELD_ROLES = ("depends_met", "skip_class") + def _need_name(need_names, role): """Resolve a need-type/link ROLE to its consumer-configured NAME.""" @@ -241,8 +249,13 @@ def build_procedure_need_rst( return "\n".join(lines) -def build_result_rst(r, spec_id, test_module, req_ids=None, need_names=None): - """Build RST block for one test_result need.""" +def build_result_rst(r, spec_id, test_module, req_ids=None, need_names=None, fields=()): + """Build RST block for one test_result need. + + ``fields``: the result-field roles (`RESULT_FIELD_ROLES`) to set, under + their names from ``need_names``, where ``r`` has a value for them — the + ones the consumer declared; an undeclared option warns per need. + """ need_id = f"TR-{slugify(r['platform'])}-{slugify(r['scenario'])}-{spec_id}" fn = r["function"] title = (fn[5:] if fn.startswith("test_") else fn).replace("_", " ") @@ -264,6 +277,9 @@ def build_result_rst(r, spec_id, test_module, req_ids=None, need_names=None): lines.append(f" :{_need_name(need_names, 'covers')}: {'; '.join(req_ids)}") if r["reason"]: lines.append(f" :reason: {r['reason']}") + for role in RESULT_FIELD_ROLES: + if role in fields and r.get(role): + lines.append(f" :{_need_name(need_names, role)}: {r[role]}") lines.append("") if r.get("values"): lines += _values_rst(r) diff --git a/sphinx/_extensions/test_module.py b/sphinx/_extensions/test_module.py index 589b26b..eefff85 100644 --- a/sphinx/_extensions/test_module.py +++ b/sphinx/_extensions/test_module.py @@ -17,8 +17,9 @@ _outdated_by_input_change, _purge_inputs, ) -from needs_fields import depends_field +from needs_fields import depends_field, field_type from rst_builders import ( + RESULT_FIELD_ROLES, _need_name, build_need_rst, build_procedure_need_rst, @@ -27,12 +28,16 @@ ) from sphinx.util import logging from twister_reader import ( + depends_met, + find_build_config, find_handler_log, fold_parameterized_results, load_spec_lookup, load_twister_meta, parse_twister_results, + read_kconfig, scenario_selected, + skip_class, testcase_statuses, testsuite_paths, ) @@ -100,7 +105,8 @@ def _need_names_from_config(app): so this only changes behaviour for those stand-ins, not for a real build. """ return {**getattr(app.config, "testmodule_need_types", {}), - **getattr(app.config, "testmodule_need_links", {})} + **getattr(app.config, "testmodule_need_links", {}), + **getattr(app.config, "testreport_need_fields", {})} # --------------------------------------------------------------------------- @@ -259,8 +265,61 @@ def _spec_info(spec_lookup, suite, fn): return None -def _build_results_rst(suite_order, func_order, grouped, spec_lookup, need_names=None): - """Build RST lines for all test_result needs, grouped into one section per suite.""" +def _assess_results(results, spec_lookup, tw_meta, run_dir, note_input=None): + """Set ``depends_met`` on every result and ``skip_class`` on every skipped one. + + ``depends_met`` says whether the build that produced a result met its test + case's ``depends_on`` (`twister_reader.depends_met`), read from that + build's ``.config`` under ``run_dir`` (`find_build_config`; the testsuite's + toolchain and path come from ``tw_meta``, the run's twister.json). + ``note_input(path)`` is called for each ``.config`` read. + + Returns ``{(case id, condition)}`` for the conditions that could not be + evaluated, for the caller to warn about once each. + """ + suites = { + (ts.get("platform", ""), ts.get("name", "")): ts + for ts in (tw_meta or {}).get("testsuites", []) + } + configs, unparseable = {}, set() + for r in results: + info = spec_lookup.find(r["suite"], r["function"]) + conditions = (info or {}).get("depends_on") or [] + symbols = None + if conditions and (ts := suites.get((r["platform"], r["scenario"]))): + path = find_build_config( + run_dir, r["platform"], ts.get("toolchain", ""), ts.get("path", ""), + r["scenario"], + ) + if path is not None: + if path not in configs: + if note_input: + note_input(path) + configs[path] = read_kconfig(path) + symbols = configs[path] + met, bad = depends_met(conditions, symbols) + unparseable.update((info["id"], c) for c in bad) + r["depends_met"] = met + if (cls := skip_class(r, met)) is not None: + r["skip_class"] = cls + return unparseable + + +def _declared_result_fields(env, need_names): + """The result-field roles whose consumer-named field sphinx-needs has declared.""" + return { + role for role in RESULT_FIELD_ROLES + if field_type(env, _need_name(need_names, role)) is not None + } + + +def _build_results_rst( + suite_order, func_order, grouped, spec_lookup, need_names=None, fields=(), +): + """Build RST lines for all test_result needs, grouped into one section per suite. + + ``fields``: the result-field roles to set (`rst_builders.build_result_rst`). + """ lines = [] for suite in suite_order: suite_title = next( @@ -279,7 +338,8 @@ def _build_results_rst(suite_order, func_order, grouped, spec_lookup, need_names continue for r in grouped[(suite, fn)]: lines += build_result_rst( - r, info["id"], info["test_module"], info.get("req_ids"), need_names=need_names + r, info["id"], info["test_module"], info.get("req_ids"), + need_names=need_names, fields=fields, ).splitlines() lines.append("") return lines @@ -695,10 +755,24 @@ def run(self): if not results: return [nodes.paragraph(text="[testreport: no matching results]")] + # Each build's .config lives in the run directory the report is in. + unparseable = _assess_results( + results, spec_lookup, tw_meta, Path(xml_path).parent, + note_input=lambda path: _note_input(env, path), + ) + for case_id, condition in sorted(unparseable): + logger.warning( + f"testreport: {case_id}: depends_on condition {condition!r} is not " + f"a Kconfig expression zdocs evaluates — its results get depends_met n/a" + ) + suite_order, func_order, grouped = _group_results(results) twister_out_dir = getattr(app.config, "twister_output_dir", "") all_rst = ( - _build_results_rst(suite_order, func_order, grouped, spec_lookup, need_names=need_names) + _build_results_rst( + suite_order, func_order, grouped, spec_lookup, need_names=need_names, + fields=_declared_result_fields(env, need_names), + ) + _build_summary_table_rst(grouped, spec_lookup, need_names=need_names) + _build_exec_logs_rst(twister_out_dir, module_filter, path_filter) ) @@ -795,6 +869,13 @@ def setup(app): {"verifies": "verifies", "result_of": "result_of", "covers": "covers"}, "env", ) + # Result FIELD roles -> names; a field is set only where the consumer + # declared it under that name (see _declared_result_fields). + app.add_config_value( + "testreport_need_fields", + {"depends_met": "depends_met", "skip_class": "skip_class"}, + "env", + ) app.add_directive("testmodule", TestModuleDirective) app.add_directive("testreport", TestReportDirective) app.add_directive("twisterinfo", TwisterInfoDirective) diff --git a/sphinx/_extensions/twister_reader.py b/sphinx/_extensions/twister_reader.py index 861e5f2..d3c74d7 100644 --- a/sphinx/_extensions/twister_reader.py +++ b/sphinx/_extensions/twister_reader.py @@ -27,6 +27,12 @@ "SpecLookup", "load_spec_lookup", "find_handler_log", + "find_build_config", + "read_kconfig", + "UnparseableCondition", + "evaluate_condition", + "depends_met", + "skip_class", "load_twister_meta", ] @@ -361,12 +367,26 @@ def load_spec_lookup(json_path, need_names=None): "suite": need.get("suite", ""), "suite_title": need.get("suite_title", ""), "req_ids": need.get(verifies_link, []), + "depends_on": _conditions(need.get("depends_on")), } for need_id, need in needs.items() if need.get("type") == case_type and need.get("test_function") ) +def _conditions(value): + """A need's ``depends_on`` as a list of conditions. + + zdocs writes it as a string with the conditions joined by ``"; "``; a + consumer that declares it as an array gets a list from sphinx-needs. + """ + if not value: + return [] + if isinstance(value, str): + return [c.strip() for c in value.split("; ") if c.strip()] + return [str(c).strip() for c in value if str(c).strip()] + + def _out_dir_segment(test_path): """Convert a twister.json ``path`` into the output-directory segment. @@ -414,6 +434,204 @@ def find_handler_log(twister_out_dir, platform, toolchain, test_path, scenario_n return None +def find_build_config(twister_out_dir, platform, toolchain, test_path, scenario_name): + """Return the Path to the Kconfig ``.config`` of a (platform, scenario) build, or None. + + Twister keeps each build under the run directory `find_handler_log` + describes, with the build's ``zephyr/.config`` in it. Unlike the log, the + configuration is looked up only where twister puts it (the path layout, + or the flat ``--detailed-test-id`` one): a ``.config`` of another scenario + would answer for a build it did not come from. + """ + run_dir = Path(twister_out_dir) / platform.replace("/", "_") / toolchain.replace("/", "_") + for base in (run_dir / _out_dir_segment(test_path) / scenario_name, run_dir / scenario_name): + config = base / "zephyr" / ".config" + if config.is_file(): + return config + return None + + +_KCONFIG_SET = re.compile(r"^(CONFIG_[A-Za-z0-9_]+)=") + + +def read_kconfig(path): + """The Kconfig symbols a ``.config`` sets, as a set of names. + + A symbol is set when it has a value (``=y``, ``=m``, a number, a string); + ``# CONFIG_X is not set`` and a symbol that is absent are not. + """ + symbols = set() + for line in Path(path).read_text(errors="replace").splitlines(): + if m := _KCONFIG_SET.match(line): + symbols.add(m.group(1)) + return symbols + + +class UnparseableCondition(ValueError): + """A ``depends_on`` condition outside the grammar `evaluate_condition` reads.""" + + +_CONDITION_TOKEN = re.compile(r"\s*(&&|\|\||!|\(|\)|defined\b|CONFIG_[A-Za-z0-9_]+\b)") + + +def _tokens(condition): + tokens, pos = [], 0 + text = condition.strip() + while pos < len(text): + m = _CONDITION_TOKEN.match(text, pos) + if not m: + raise UnparseableCondition(condition) + tokens.append(m.group(1)) + pos = m.end() + while pos < len(text) and text[pos].isspace(): + pos += 1 + return tokens + + +def evaluate_condition(condition, symbols): + """Whether Kconfig ``condition`` holds for the set ``symbols`` (`read_kconfig`). + + The grammar: ``CONFIG_X`` and ``defined(CONFIG_X)`` (or ``defined CONFIG_X``) + are true iff the symbol is set; ``!``, ``&&``, ``||`` (in C's precedence) + and parentheses combine them. Anything else — another macro, + ``IS_ENABLED()``, a comparison, a number — raises `UnparseableCondition`: + its value in the build is not known from ``.config`` alone. + """ + tokens = _tokens(condition) + if not tokens: + raise UnparseableCondition(condition) + pos = 0 + + def peek(): + return tokens[pos] if pos < len(tokens) else None + + def take(expected=None): + nonlocal pos + tok = peek() + if tok is None or (expected is not None and tok != expected): + raise UnparseableCondition(condition) + pos += 1 + return tok + + def primary(): + tok = take() + if tok == "!": + return not primary() + if tok == "(": + value = disjunction() + take(")") + return value + if tok == "defined": + if peek() == "(": + take("(") + name = take() + take(")") + else: + name = take() + if not name.startswith("CONFIG_"): + raise UnparseableCondition(condition) + return name in symbols + if tok.startswith("CONFIG_"): + return tok in symbols + raise UnparseableCondition(condition) + + def conjunction(): + value = primary() + while peek() == "&&": + take() + rhs = primary() + value = value and rhs + return value + + def disjunction(): + value = conjunction() + while peek() == "||": + take() + rhs = conjunction() + value = value or rhs + return value + + result = disjunction() + if pos != len(tokens): + raise UnparseableCondition(condition) + return result + + +#: `depends_met` values. +MET, NOT_MET, UNKNOWN = "yes", "no", "n/a" + + +def depends_met(conditions, symbols): + """``(value, unparseable)`` for a test case's ``depends_on`` in one build. + + ``conditions`` are the case's conditions, all of which must hold (the + ``"; "`` of ``depends_on`` is an and); ``symbols`` is the build's set + Kconfig symbols, or None when its ``.config`` was not found. The value is + ``"yes"`` or ``"no"``, or ``"n/a"`` when the case has no condition, the + build has no ``.config``, or a condition is outside `evaluate_condition`'s + grammar — then ``unparseable`` lists those conditions, and no value is + guessed, even when another condition is false. + """ + conditions = [c for c in (conditions or []) if c] + if not conditions or symbols is None: + return UNKNOWN, [] + values, unparseable = [], [] + for condition in conditions: + try: + values.append(evaluate_condition(condition, symbols)) + except UnparseableCondition: + unparseable.append(condition) + if unparseable: + return UNKNOWN, unparseable + return (MET if all(values) else NOT_MET), [] + + +#: `skip_class` values. +SKIP_CONFIG, SKIP_PLATFORM, SKIP_BUILD_ONLY, SKIP_UNEXPLAINED = ( + "config", "platform", "build-only", "unexplained", +) + +#: ztest's own skip (``ztest_test_skip()``), as twister reports it. +_ZTEST_SKIP = "ztest skip" +#: A build twister did not run (``build_only``; twister.json: "Test was built only"). +_BUILD_ONLY = re.compile(r"^(test was )?built only$", re.IGNORECASE) +#: The platform could not take the build: a memory region overflowed, or +#: twister filtered the platform out. +_PLATFORM = re.compile(r"\b(RAM|FLASH|ROM) overflow\b|\bplatform\b", re.IGNORECASE) + + +def _skip_reason(result): + """The skip reason of a result; for a parameterized test, its values' common one.""" + values = result.get("values") + if values: + reasons = {v.get("reason", "") for v in values if v.get("status") == "skipped"} + if len(reasons) == 1: + return reasons.pop() + return result.get("reason", "") + + +def skip_class(result, met): + """The class of a skipped result (None for any other), given its `depends_met`. + + ``build-only``: twister built the test but did not run it. ``platform``: + the platform could not take it (a memory region overflowed, or it was + filtered by platform). ``config``: ztest skipped it and the case's + ``depends_on`` is false in the build. ``unexplained``: anything else, + including a ztest skip whose condition holds, cannot be evaluated, or is + not recorded. + """ + if result.get("status") != "skipped": + return None + reason = " ".join(_skip_reason(result).split()) + if _BUILD_ONLY.match(reason): + return SKIP_BUILD_ONLY + if _PLATFORM.search(reason): + return SKIP_PLATFORM + if reason == _ZTEST_SKIP and met == NOT_MET: + return SKIP_CONFIG + return SKIP_UNEXPLAINED + + def load_twister_meta(json_path): """Load and validate twister.json; return the dict.""" with open(json_path) as f: