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..2923fdd 100644 --- a/doc/api/python/extensions.rst +++ b/doc/api/python/extensions.rst @@ -49,6 +49,30 @@ 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: + +``needs_fields`` +------------------ + +Which optional need fields a consumer declared. + +.. automodule:: needs_fields + :members: + ``xref_builder`` ------------------ 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/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 8b760bd..83b97de 100644 --- a/doc/manual/reference/consumer-contract.rst +++ b/doc/manual/reference/consumer-contract.rst @@ -85,6 +85,15 @@ 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. 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 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..839e584 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::`` -------------------- @@ -103,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 @@ -145,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 @@ -181,11 +241,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) @@ -193,6 +283,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-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/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/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/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-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/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/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_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/_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/_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 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/_tests/test_testreport_param.py b/sphinx/_extensions/_tests/test_testreport_param.py index e4f2383..c37f1e1 100644 --- a/sphinx/_extensions/_tests/test_testreport_param.py +++ b/sphinx/_extensions/_tests/test_testreport_param.py @@ -69,7 +69,8 @@ def test_values_are_recognised(): def test_a_failed_value_carries_the_assertion_not_the_suite_message(): - failed = [r for r in tw.parse_twister_results(XML) if r["status"] == "failed" and r.get("instance")] + results = tw.parse_twister_results(XML) + failed = [r for r in results if r["status"] == "failed" and r.get("instance")] assert len(failed) == 1 reason = failed[0]["reason"] assert "Assertion failed at CMAKE_SOURCE_DIR/src/main.c:363" in reason diff --git a/sphinx/_extensions/doxygen_parser.py b/sphinx/_extensions/doxygen_parser.py index b64faa2..df5dfee 100644 --- a/sphinx/_extensions/doxygen_parser.py +++ b/sphinx/_extensions/doxygen_parser.py @@ -20,6 +20,10 @@ "extract_params", "detail_rst_lines", "parse_memberdef", + "requirement_uids", + "kconfig_depends", + "SymbolInfo", + "parse_symbol", "load_group_index", ] @@ -35,6 +39,19 @@ 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): + name: str + kind: str + satisfies: list[str] + brief: str + source_file: str + doxygen_url: str + depends_on: list[str] + depends_label: str class RefLinks(NamedTuple): @@ -380,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: @@ -398,20 +417,15 @@ 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) + depends_label, depends_on = kconfig_depends(dd) + ibd = memberdef.find("inbodydescription") body_sections: list[list[str]] = [] if ibd is not None: @@ -431,6 +445,98 @@ 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``. + + ``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 + + depends_label, depends_on = kconfig_depends(memberdef.find("detaileddescription")) + 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, + depends_on=depends_on, + depends_label=depends_label, ) 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/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 6ac8b60..352927c 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,8 +44,19 @@ def slugify(s): "verifies": "verifies", "result_of": "result_of", "covers": "covers", + # 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.""" @@ -53,8 +65,36 @@ 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, + 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"] req_ids = info["req_ids"] @@ -72,8 +112,9 @@ def build_need_rst(info, suite_name, module_path="", suite_title="", need_names= 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}") @@ -86,6 +127,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: @@ -108,6 +151,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("") @@ -204,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("_", " ") @@ -227,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) @@ -282,6 +335,49 @@ 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, 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. ``depends_field``: set the ``depends_on`` field + (see `_depends_on_rst`). + """ + 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'])}") + depends_options, depends_body = _depends_on_rst(info, depends_field) + lines += depends_options + 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 ""), ""] + lines += depends_body + 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..d442dbd --- /dev/null +++ b/sphinx/_extensions/symbol_needs.py @@ -0,0 +1,165 @@ +# 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 needs_fields import depends_field +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, 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. ``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() + 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")) + 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 + 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), + 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}") + 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..eefff85 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,7 +11,15 @@ 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 needs_fields import depends_field, field_type from rst_builders import ( + RESULT_FIELD_ROLES, _need_name, build_need_rst, build_procedure_need_rst, @@ -21,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, ) @@ -94,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", {})} # --------------------------------------------------------------------------- @@ -133,16 +145,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 + suite_refid, xml_dir, testspec_html_dir, api_html_dir, module_path, need_names=None, + depends_field=None, suite_qualifier="", ): - """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_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) @@ -155,9 +190,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, id_scope=group_name, ).splitlines() ) lines.append("") @@ -226,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( @@ -246,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 @@ -522,6 +615,10 @@ 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 + ), + suite_qualifier=getattr(app.config, "testmodule_suite_qualifier", ""), ) for proc_refid in proc_refids: all_rst += _build_proc_group_rst( @@ -536,60 +633,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. @@ -712,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) ) @@ -799,6 +856,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"}, @@ -809,10 +869,15 @@ 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) - 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/_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: 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"]