diff --git a/doc/api/python/extensions.rst b/doc/api/python/extensions.rst index 2923fdd..37020b6 100644 --- a/doc/api/python/extensions.rst +++ b/doc/api/python/extensions.rst @@ -65,6 +65,14 @@ Re-reads a document when an input from outside the source tree changes. .. automodule:: input_tracking :members: +``needs_config_state`` +------------------------ + +Re-reads a document when its sphinx-needs TOML file or the needs it imports change. + +.. automodule:: needs_config_state + :members: + ``needs_fields`` ------------------ diff --git a/doc/manual/reference/consumer-contract.rst b/doc/manual/reference/consumer-contract.rst index 83b97de..e009e7a 100644 --- a/doc/manual/reference/consumer-contract.rst +++ b/doc/manual/reference/consumer-contract.rst @@ -94,6 +94,11 @@ layout, not toggled from outside it. link's incoming side. One field is optional: ``depends_on`` (the ``@kconfig_depends`` conditions) is set only if declared here. + A change to the file's content makes every document that reads it re-read + its sources on the next incremental build (the engine's + ``needs_config_state`` extension), because sphinx-needs itself rebuilds only + the HTML when a type, link or field changes. + 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/registry-schema.rst b/doc/manual/reference/registry-schema.rst index 54406c5..13fe894 100644 --- a/doc/manual/reference/registry-schema.rst +++ b/doc/manual/reference/registry-schema.rst @@ -238,6 +238,11 @@ Each entry in ``documents:``, keyed by its id: rejected at configure time as a ``spec:`` target, because ``testreport`` correlates against ``needs.json``, and the synthesized stub is not that. + When the needs that a peer imports from this document change, the peer + reads all its sources again on the next incremental build (the engine's + ``needs_config_state`` extension). Otherwise the pages of the peer do not + show a new incoming link from this document. + ``doxygen_tag`` Opt-in; publishes this document's needs as Doxygen requirements, so a ``\verifies`` or ``\satisfies`` in any ``kind: doxygen`` peer resolves @@ -311,7 +316,13 @@ Each entry in ``documents:``, keyed by its id: ``api_reference`` Same existence/kind requirement as ``doxygen_source``, for where - ``@see`` cross-references resolve. + ``@see`` cross-references resolve. A symbol Doxygen resolved through + another document's tag file links into that document instead (Doxygen + records the tag file on the reference); ``api_reference`` takes only + the references no registry document's tag file accounts for. A + ``@see`` target that no Doxygen project documents has no reference. + It shows as a literal in the "See also" line, without a link. The + line has the targets of all ``@see`` lines of the test, in order. ``spec`` Id of the sphinx document whose exported needs a ``testreport`` document diff --git a/scripts/docrefs.py b/scripts/docrefs.py index 9b83c2d..c3b598e 100644 --- a/scripts/docrefs.py +++ b/scripts/docrefs.py @@ -659,7 +659,7 @@ def __init__( #: never a dict of empty strings. A dict with keys ``xml_dir``, #: ``doxygen_url``, ``api_url``, ``needs_json`` (each ``""`` when the #: corresponding ``testmodule:`` field is absent, e.g. no - #: ``api_reference:``). + #: ``api_reference:``), and ``tag_urls`` (see `tag_urls`). self.testmodule = testmodule #: This document's own resolved ``symbol_needs:`` block, or ``None`` #: when it has none (then the ``symbol_needs`` extension is not @@ -962,6 +962,7 @@ def _nav_href(doc_id, meta): "doxygen_url": rel_urls.get(doxygen_source, "") if doxygen_source else "", "api_url": rel_urls.get(api_reference, "") if api_reference else "", "needs_json": needs_json, + "tag_urls": tag_urls(documents, deploy, rel_urls), } symbol_needs = None @@ -987,6 +988,35 @@ def _nav_href(doc_id, meta): ) +def tag_urls(documents, deploy, rel_urls): + """``{tag file path: HTML directory URL}`` for every tag file `tagfiles` can name. + + Doxygen marks a reference it resolved through a tag file with + ``external=""``, the path exactly as ``TAGFILES`` gave it. + This map sends such a reference to the document whose tag file it is: + for a ``kind: doxygen`` peer its HTML directory relative to this + document's root (``rel_urls``), for a ``kind: doxygen-external`` peer its + absolute remote directory, and for a needs tag its Sphinx root. + """ + urls = {} + for doc_id, meta in documents.items(): + kind = meta.get("kind") + path = meta.get("path", f"html/{doc_id}") + if kind == "doxygen" and doc_id in rel_urls: + urls[(deploy / path / DOXYGEN_TAGFILE).as_posix()] = rel_urls[doc_id] + elif kind == "doxygen-external": + # Without its trailing slash: the reader appends "/". + base_dir_url = _intersphinx_target_dir(meta["remote-url"]).rstrip("/") + urls[(deploy / path / DOXYGEN_TAGFILE).as_posix()] = base_dir_url + elif ( + kind in (None, "sphinx") + and meta.get("doxygen_tag") is not None + and doc_id in rel_urls + ): + urls[(deploy / path / NEEDS_TAGFILE).as_posix()] = rel_urls[doc_id] + return urls + + def tagfiles(this_doc, deploy_dir, registry=None): """Doxygen ``TAGFILES`` value for ``this_doc``: an entry for every OTHER ``kind: doxygen`` document in the registry, so any doxygen doc can resolve diff --git a/sphinx/_extensions/_tests/test_doxygen_parser.py b/sphinx/_extensions/_tests/test_doxygen_parser.py index 2308851..4084007 100644 --- a/sphinx/_extensions/_tests/test_doxygen_parser.py +++ b/sphinx/_extensions/_tests/test_doxygen_parser.py @@ -524,3 +524,91 @@ def test_parse_memberdef_links_symbols_in_brief_details_and_body(): def test_see_to_rst_links_local_symbol_when_testspec_dir_given(): see = ET.fromstring(f"{LOCAL_REF}") assert "../testspec/group__procs.html#ga3f93" in dp.see_to_rst(see, API, SPEC) + + +# --------------------------------------------------------------------------- +# see_to_rst: text that is not in a +# +# `@see irq_offload()` gives no when no Doxygen project documents the +# symbol. see_to_rst read only the elements, so the see section gave no +# line, and the reference was lost. +# --------------------------------------------------------------------------- + +def _see(body): + return ET.fromstring(f"{body}") + + +def test_see_to_rst_renders_text_without_ref_as_literal(): + assert dp.see_to_rst(_see("irq_offload()"), API, SPEC) == "**See also:** ``irq_offload()``" + + +def test_see_to_rst_keeps_refs_and_the_text_between_them_in_order(): + line = dp.see_to_rst(_see(f"atomic_set(), {EXT_REF}, printk() "), API, SPEC) + assert line == ( + "**See also:** ``atomic_set()``, " + "`k_fifo_get() <../api/group__fifo__apis.html#ga1e2c>`__, ``printk()``" + ) + + +def test_see_to_rst_separators_between_refs_give_no_item(): + """A line with only linked refs is the same as before.""" + line = dp.see_to_rst(_see(f"{EXT_REF}, {LOCAL_REF}."), API, SPEC) + assert line == ( + "**See also:** `k_fifo_get() <../api/group__fifo__apis.html#ga1e2c>`__, " + "`get_scratch_packet() <../testspec/group__procs.html#ga3f93>`__" + ) + + +def test_see_to_rst_a_comma_in_parentheses_does_not_divide_items(): + assert dp.see_to_rst(_see("k_foo(a, b), k_bar()"), API) == ( + "**See also:** ``k_foo(a, b)``, ``k_bar()``" + ) + + +def test_see_to_rst_links_a_ref_inside_computeroutput(): + line = dp.see_to_rst(_see(f"{EXT_REF}"), API, SPEC) + assert line == "**See also:** `k_fifo_get() <../api/group__fifo__apis.html#ga1e2c>`__" + + +def test_see_to_rst_empty_section_gives_no_line(): + assert dp.see_to_rst(_see(" , "), API, SPEC) == "" + + +def test_see_to_rst_space_after_a_call_divides_items(): + assert dp.see_to_rst(_see("k_stats_query() k_stats_reset()"), API) == ( + "**See also:** ``k_stats_query()``, ``k_stats_reset()``" + ) + + +# --------------------------------------------------------------------------- +# All see sections of a member +# +# Doxygen 1.16 writes one see section for each `@see` line. Only the first was +# read, so `@see k_thread_join()` followed by `@see irq_offload()` lost the +# second reference (TSPEC-THREADS-030). +# --------------------------------------------------------------------------- + +def test_see_to_rst_joins_all_sections_in_order(): + sections = [_see(EXT_REF), _see("irq_offload()")] + assert dp.see_to_rst(sections, API, SPEC) == ( + "**See also:** `k_fifo_get() <../api/group__fifo__apis.html#ga1e2c>`__, ``irq_offload()``" + ) + + +def test_see_to_rst_no_sections_gives_no_line(): + assert dp.see_to_rst([], API, SPEC) == "" + + +def test_parse_memberdef_reads_every_see_section(): + md = ET.fromstring( + "test_join" + "Join." + "" + f"{EXT_REF}" + "irq_offload()" + "" + "" + ) + info = dp.parse_memberdef(md, "group__s", SPEC, API) + assert "k_fifo_get()" in info["see_rst"] + assert info["see_rst"].endswith(", ``irq_offload()``") diff --git a/sphinx/_extensions/_tests/test_needs_config_state.py b/sphinx/_extensions/_tests/test_needs_config_state.py new file mode 100644 index 0000000..c6a3ad0 --- /dev/null +++ b/sphinx/_extensions/_tests/test_needs_config_state.py @@ -0,0 +1,380 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""needs_config_state — incremental builds after the needs TOML or an import changes. + +sphinx-needs registers its types, links and fields with rebuild "html", so a +change to the TOML they come from did not re-read any document. The pickled +needs had no entry for a new link type, and the first need using it crashed the +build: ``KeyError: "Link type 'fulfills' does not exist in backlinks."``. + +A change to an imported needs.json (``needs_external_needs``) did not write the +importing document's pages again either, so a new incoming link from a peer's +need did not show on the page of the linked need. +""" + +import json + +from pathlib import Path + +import needs_config_state as ncs +import pytest + +_EXTENSIONS = str(Path(__file__).resolve().parents[1]) + +_TOML = """\ +[needs] +id_regex = "^[A-Z][A-Z0-9_]+" + +[[needs.types]] +directive = "req" +title = "Requirement" +prefix = "R_" + +[[needs.types]] +directive = "spec" +title = "Specification" +prefix = "S_" + +[needs.links.verifies] +incoming = "verified by" +outgoing = "verifies" +""" + +_NEW_LINK = """ +[needs.links.fulfills] +incoming = "fulfilled by" +outgoing = "fulfills" +""" + +_INDEX = """\ +Index +===== + +.. toctree:: + + other + +.. req:: A requirement + :id: REQ_001 + +.. spec:: A specification + :id: SPEC_001 + :verifies: REQ_001 +""" + +_OTHER = "Other\n=====\n" + +_OTHER_WITH_NEW_LINK = _OTHER + """ +.. spec:: A design + :id: SPEC_002 + :fulfills: REQ_001 +""" + + +def _project(root, extensions): + src = root / "src" + src.mkdir(parents=True) + (src / "conf.py").write_text( + "import sys\n" + f"sys.path.insert(0, {_EXTENSIONS!r})\n" + f"extensions = {extensions!r}\n" + "needs_from_toml = 'needs.toml'\n" + "suppress_warnings = ['config.cache']\n" + ) + (src / "needs.toml").write_text(_TOML) + (src / "index.rst").write_text(_INDEX) + (src / "other.rst").write_text(_OTHER) + return src + + +def _add_link_type_and_use_it(src): + """What order 006 did: a new link in the TOML, a new need in another page.""" + (src / "needs.toml").write_text(_TOML + _NEW_LINK) + (src / "other.rst").write_text(_OTHER_WITH_NEW_LINK) + + +# --------------------------------------------------------------------------- +# The digest +# --------------------------------------------------------------------------- + + +def test_digest_is_empty_without_a_file(tmp_path): + assert ncs.needs_config_digest(tmp_path, None) == "" + assert ncs.needs_config_digest(tmp_path, "") == "" + assert ncs.needs_config_digest(tmp_path, "missing.toml") == "" + + +def test_digest_follows_the_content_not_the_mtime(tmp_path): + toml = tmp_path / "needs.toml" + toml.write_text(_TOML) + first = ncs.needs_config_digest(tmp_path, "needs.toml") + toml.write_text(_TOML) # rewritten, same content + assert ncs.needs_config_digest(tmp_path, "needs.toml") == first + toml.write_text(_TOML + _NEW_LINK) + assert ncs.needs_config_digest(tmp_path, "needs.toml") not in ("", first) + + +def test_digest_resolves_a_relative_path_against_the_conf_dir(tmp_path): + (tmp_path / "cfg").mkdir() + (tmp_path / "needs.toml").write_text(_TOML) + rel = ncs.needs_config_digest(tmp_path / "cfg", "../needs.toml") + assert rel == ncs.needs_config_digest(tmp_path, str(tmp_path / "needs.toml")) != "" + + +# --------------------------------------------------------------------------- +# The incremental build +# --------------------------------------------------------------------------- + + +def test_incremental_build_after_a_new_link_type(make_app, tmp_path): + src = _project(tmp_path, ["sphinx_needs", "needs_config_state"]) + make_app("html", srcdir=src).build() + + _add_link_type_and_use_it(src) + app = make_app("html", srcdir=src) + app.build() # raised KeyError before the fix + + assert app.statuscode == 0 + assert "starting from a fresh environment" in app._status.getvalue() + index = (Path(app.outdir) / "index.html").read_text() + assert "fulfilled by" in index and "SPEC_002" in index + + +def test_unchanged_toml_keeps_the_cache(make_app, tmp_path): + src = _project(tmp_path, ["sphinx_needs", "needs_config_state"]) + make_app("html", srcdir=src).build() + + (src / "needs.toml").write_text(_TOML) # touched, same content + app = make_app("html", srcdir=src) + app.build() + assert "config changed" not in app._status.getvalue() + assert "0 added, 0 changed, 0 removed" in app._status.getvalue() + + +def test_removing_a_used_link_type_does_not_crash_an_importer(make_app, tmp_path): + """The other direction: a peer drops a link type its needs used. + + The importing document's pickled environment still held the peer's needs + under the old vocabulary; re-reading alone crashed it once with the same + KeyError, so the environment is dropped instead. + """ + peer = _project(tmp_path / "peer", ["sphinx_needs", "needs_config_state"]) + importer = _project(tmp_path / "importer", ["sphinx_needs", "needs_config_state"]) + toml = tmp_path / "needs.toml" + toml.write_text(_TOML) + peer_json = tmp_path / "peer" / "src" / "_build" / "html" / "needs.json" + for src, extra in ( + (peer, "needs_build_json = True\nversion = '1.0'\n"), + (importer, f"version = '1.0'\nneeds_external_needs = [{{'json_path': {str(peer_json)!r}, " + "'base_url': 'http://peer/', 'version': '1.0', 'id_prefix': 'P'}]\n"), + ): + conf = src / "conf.py" + conf.write_text(conf.read_text().replace("'needs.toml'", repr(str(toml))) + extra) + + def build_both(): + for src in (peer, importer): + app = make_app("html", srcdir=src) + app.build() + assert app.statuscode == 0 + + build_both() + _add_link_type_and_use_it(peer) + toml.write_text(_TOML + _NEW_LINK) + build_both() + toml.write_text(_TOML) + (peer / "other.rst").write_text(_OTHER) + build_both() # the importer raised KeyError here with re-reading alone + + +def test_drop_stale_environment(tmp_path): + pickle = tmp_path / ncs.ENV_PICKLE + pickle.write_bytes(b"env") + assert ncs.drop_stale_environment(tmp_path, "d1") # no stamp: stale + assert not pickle.exists() + pickle.write_bytes(b"env") + assert not ncs.drop_stale_environment(tmp_path, "d1") + assert pickle.exists() + assert ncs.drop_stale_environment(tmp_path, "d2") + assert (tmp_path / ncs.STAMP_NAME).read_text().strip() == "d2" + + +def test_control_without_the_extension_the_build_crashes(make_app, tmp_path): + """The defect, pinned against sphinx-needs as installed. + + Should this start failing after a sphinx-needs upgrade, sphinx-needs now + re-reads on a vocabulary change itself, and the extension may be obsolete. + """ + src = _project(tmp_path, ["sphinx_needs"]) + make_app("html", srcdir=src).build() + + _add_link_type_and_use_it(src) + app = make_app("html", srcdir=src) + with pytest.raises(Exception, match="does not exist in backlinks"): + app.build() + + +# --------------------------------------------------------------------------- +# Imported needs +# --------------------------------------------------------------------------- + +_PEER_INDEX = """\ +Peer +==== + +.. spec:: A specification + :id: SPEC_001 + :verifies: REQ_001 +""" + +_PEER_NEW_NEED = """ +.. spec:: A later specification + :id: SPEC_002 + :verifies: REQ_001 +""" + +_IMPORTER_INDEX = """\ +Importer +======== + +.. req:: A requirement + :id: REQ_001 +""" + + +def _needs_json(path, needs, **extra): + data = {"current_version": "1.0", "versions": {"1.0": {"needs": needs, **extra}}} + path.write_text(json.dumps(data)) + + +def test_imported_digest_is_empty_without_a_json_path_source(tmp_path): + assert ncs.imported_needs_digest(tmp_path, None) == "" + assert ncs.imported_needs_digest(tmp_path, []) == "" + assert ncs.imported_needs_digest(tmp_path, [{"json_url": "http://peer/needs.json"}]) == "" + + +def test_imported_digest_follows_the_imported_needs(tmp_path): + peer = tmp_path / "needs.json" + sources = [{"json_path": str(peer), "base_url": "http://peer"}] + missing = ncs.imported_needs_digest(tmp_path, sources) + _needs_json(peer, {"SPEC_001": {"id": "SPEC_001", "verifies": ["REQ_001"]}}) + first = ncs.imported_needs_digest(tmp_path, sources) + assert first not in ("", missing) + + # Not imported by sphinx-needs, so not part of the digest. + _needs_json( + peer, + {"SPEC_001": {"id": "SPEC_001", "verifies": ["REQ_001"], "verifies_back": ["X"]}}, + creator={"program": "other"}, + ) + assert ncs.imported_needs_digest(tmp_path, sources) == first + + _needs_json( + peer, + { + "SPEC_001": {"id": "SPEC_001", "verifies": ["REQ_001"]}, + "SPEC_002": {"id": "SPEC_002", "verifies": ["REQ_001"]}, + }, + ) + assert ncs.imported_needs_digest(tmp_path, sources) != first + + +def test_imported_digest_reads_the_configured_version(tmp_path): + peer = tmp_path / "needs.json" + data = { + "current_version": "2.0", + "versions": {"1.0": {"needs": {"A": {"id": "A"}}}, "2.0": {"needs": {}}}, + } + peer.write_text(json.dumps(data)) + pinned = ncs.imported_needs_digest(tmp_path, [{"json_path": str(peer), "version": "1.0"}]) + current = ncs.imported_needs_digest(tmp_path, [{"json_path": str(peer)}]) + assert pinned != current + + +def test_imported_digest_resolves_a_relative_path_against_the_conf_dir(tmp_path): + (tmp_path / "cfg").mkdir() + _needs_json(tmp_path / "needs.json", {"A": {"id": "A"}}) + rel = ncs.imported_needs_digest(tmp_path / "cfg", [{"json_path": "../needs.json"}]) + _needs_json(tmp_path / "needs.json", {}) + assert ncs.imported_needs_digest(tmp_path / "cfg", [{"json_path": "../needs.json"}]) != rel + + +def _peer_and_importer(tmp_path, extensions): + toml = tmp_path / "needs.toml" + toml.write_text(_TOML) + peer = tmp_path / "peer" + importer = tmp_path / "importer" + peer_json = peer / "_build" / "html" / "needs.json" + for src, index, extra in ( + (peer, _PEER_INDEX, "needs_build_json = True\n"), + (importer, _IMPORTER_INDEX, f"needs_external_needs = [{{'json_path': {str(peer_json)!r}, " + "'base_url': 'http://peer', 'version': '1.0'}]\n"), + ): + src.mkdir() + (src / "conf.py").write_text( + "import sys\n" + f"sys.path.insert(0, {_EXTENSIONS!r})\n" + f"extensions = {extensions!r}\n" + f"needs_from_toml = {str(toml)!r}\n" + "version = '1.0'\n" + "suppress_warnings = ['config.cache', 'needs.link_outgoing']\n" + extra + ) + (src / "index.rst").write_text(index) + return peer, importer + + +def _build(make_app, src): + app = make_app("html", srcdir=src) + app.build() + assert app.statuscode == 0 + return app + + +def _peer_gains_a_need(make_app, tmp_path, extensions): + """Build both, add a peer need that links to the importer's need, build both again.""" + peer, importer = _peer_and_importer(tmp_path, extensions) + _build(make_app, peer) + _build(make_app, importer) + (peer / "index.rst").write_text(_PEER_INDEX + _PEER_NEW_NEED) + _build(make_app, peer) + app = _build(make_app, importer) + return app, (Path(app.outdir) / "index.html").read_text() + + +def test_a_new_incoming_link_from_an_import_shows(make_app, tmp_path): + app, page = _peer_gains_a_need(make_app, tmp_path, ["sphinx_needs", "needs_config_state"]) + assert "starting from a fresh environment" in app._status.getvalue() + assert "SPEC_001" in page and "SPEC_002" in page # SPEC_002 was missing + + +def test_an_unchanged_import_keeps_the_cache(make_app, tmp_path): + peer, importer = _peer_and_importer(tmp_path, ["sphinx_needs", "needs_config_state"]) + _build(make_app, peer) + _build(make_app, importer) + _build(make_app, peer) # writes needs.json again, with the same needs + status = _build(make_app, importer)._status.getvalue() + assert "fresh environment" not in status + assert "0 added, 0 changed, 0 removed" in status + + +def test_a_need_the_peer_removes_goes_from_the_page(make_app, tmp_path): + peer, importer = _peer_and_importer(tmp_path, ["sphinx_needs", "needs_config_state"]) + (peer / "index.rst").write_text(_PEER_INDEX + _PEER_NEW_NEED) + _build(make_app, peer) + _build(make_app, importer) + (peer / "index.rst").write_text(_PEER_INDEX) + _build(make_app, peer) + app = _build(make_app, importer) + assert "SPEC_002" not in (Path(app.outdir) / "index.html").read_text() + + +def test_control_without_the_extension_the_new_link_is_missing(make_app, tmp_path): + """The defect, pinned against sphinx-needs as installed. + + If this starts to fail after an upgrade, Sphinx or sphinx-needs now writes + the pages again when an import changes, and this part of the extension can + be obsolete. + """ + _, page = _peer_gains_a_need(make_app, tmp_path, ["sphinx_needs"]) + assert "SPEC_001" in page and "SPEC_002" not in page diff --git a/sphinx/_extensions/_tests/test_rst_builders.py b/sphinx/_extensions/_tests/test_rst_builders.py index 3460820..b6a23bb 100644 --- a/sphinx/_extensions/_tests/test_rst_builders.py +++ b/sphinx/_extensions/_tests/test_rst_builders.py @@ -218,3 +218,22 @@ def test_build_procedure_need_rst_links_prose_but_keeps_title_plain(): title = rst.splitlines()[0] assert "<" not in title and "k_fifo_get" in title assert "`k_fifo_get() <../api/group__fifo__apis.html#ga1e2c>`__ until empty" in rst + + +def test_build_procedure_need_rst_reads_every_see_section(): + import xml.etree.ElementTree as ET + memberdef = ET.fromstring( + "" + "drain" + "Drain the queue." + "" + "k_queue_get()" + "k_queue_is_empty()" + "" + "" + "" + ) + rst = rb.build_procedure_need_rst( + memberdef, "group__queue__procedures", "queue_procedures", "../testspec", "../api" + ) + assert "**See also:** ``k_queue_get()``, ``k_queue_is_empty()``" in rst diff --git a/sphinx/_extensions/_tests/test_tag_routing.py b/sphinx/_extensions/_tests/test_tag_routing.py new file mode 100644 index 0000000..f0fca49 --- /dev/null +++ b/sphinx/_extensions/_tests/test_tag_routing.py @@ -0,0 +1,171 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""A tag-file reference links into the document whose tag file resolved it. + +Doxygen marks a symbol it resolved through a tag file with +``external=""``. The test documents sent every such reference +to the ``api_reference`` document. A test that mentions a kernel internal +(``cpu_mask_mod()``, documented by the Detailed Design and resolved through +its tag file) then linked to a page the API document does not have. +""" + +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +import doxygen_parser as dp +import test_module as tm +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "scripts")) + +import docrefs # noqa: E402 + +API = "../api" +SPEC = "../testspec" +DD = "../design" +API_TAG = "/deploy/html/api/doxygen.tag" +DD_TAG = "/deploy/html/design/doxygen.tag" +TAGS = {API_TAG: API, DD_TAG: DD} + + +def _ref(tag, name="cpu_mask_mod()", refid="cpu__mask_8c_1a9f"): + return f"{name}" + + +def _links(tags=TAGS): + return dp.RefLinks(api=API, local=SPEC, tags=tags) + + +# --------------------------------------------------------------------------- +# doxygen_parser +# --------------------------------------------------------------------------- + + +def test_reference_goes_to_the_document_of_its_tag_file(): + para = ET.fromstring(f"Calls {_ref(DD_TAG)}.") + text = dp.para_text(para, links=_links()) + assert "`cpu_mask_mod() <../design/cpu__mask_8c.html#a9f>`__" in text + assert "../api/" not in text + + +def test_api_tag_file_still_goes_to_the_api_document(): + para = ET.fromstring(f"Calls {_ref(API_TAG, 'k_fifo_get()', 'group__f_1ga1')}.") + assert "<../api/group__f.html#ga1>`__" in dp.para_text(para, links=_links()) + + +def test_tag_path_is_compared_normalised(): + para = ET.fromstring(f"{_ref('/deploy/html//design/./doxygen.tag')}") + assert "<../design/cpu__mask_8c.html#a9f>`__" in dp.para_text(para, links=_links()) + + +def test_unknown_tag_file_falls_back_to_the_api_document(): + para = ET.fromstring(f"{_ref('/elsewhere/doxygen.tag')}") + assert "<../api/cpu__mask_8c.html#a9f>`__" in dp.para_text(para, links=_links()) + + +def test_without_tags_every_tag_file_reference_goes_to_the_api_document(): + # The behaviour a caller that passes no map keeps. + para = ET.fromstring(f"{_ref(DD_TAG)}") + text = dp.para_text(para, links=dp.RefLinks(api=API, local=SPEC)) + assert "<../api/cpu__mask_8c.html#a9f>`__" in text + + +def test_see_also_follows_the_tag_file(): + see = ET.fromstring( + f"{_ref(DD_TAG)}" + f"{_ref(API_TAG, 'k_fifo_get()', 'group__f_1ga1')}" + ) + line = dp.see_to_rst(see, API, SPEC, TAGS) + assert "<../design/cpu__mask_8c.html#a9f>`__" in line + assert "<../api/group__f.html#ga1>`__" in line + + +def test_parse_memberdef_routes_brief_details_see_and_body(): + ref = _ref(DD_TAG) + md = ET.fromstring( + "test_x" + f"Verify {ref}." + f"Details {ref}." + f"{ref}" + "" + "Act" + f"Call {ref}." + "" + ) + info = dp.parse_memberdef(md, "group__s", SPEC, API, TAGS) + link = "<../design/cpu__mask_8c.html#a9f>`__" + assert link in info["brief"] + assert any(link in line for line in info["detail_lines"]) + assert link in info["see_rst"] + assert any(link in line for sect in info["body_sections"] for line in sect) + + +# --------------------------------------------------------------------------- +# test_module: the map as seen from a page +# --------------------------------------------------------------------------- + + +def test_tag_dirs_prefix_relative_urls_only(): + dirs = tm._tag_dirs( + {"/d/a.tag": "../dox-api", "/d/b.tag": "https://example.org/api", "/d/c.tag": "/abs"}, + "../../", + ) + assert dirs == { + "/d/a.tag": "../../../dox-api", + "/d/b.tag": "https://example.org/api", + "/d/c.tag": "/abs", + } + assert tm._tag_dirs(None, "../") == {} + + +# --------------------------------------------------------------------------- +# docrefs: the map from the registry +# --------------------------------------------------------------------------- + +REGISTRY = { + "groups": [{"id": "g", "title": "G"}], + "documents": { + "spec": {"kind": "sphinx", "group": "g", "builders": ["html"]}, + "requirements": { + "kind": "sphinx", "group": "g", "builders": ["html"], + "needs": {"source": "json"}, "doxygen_tag": {"types": ["req"]}, + }, + "dox-api": {"kind": "doxygen", "group": "g"}, + "dox-design": {"kind": "doxygen", "group": "g"}, + "dox-testspec": {"kind": "doxygen", "group": "g"}, + "upstream": { + "kind": "doxygen-external", "group": "g", + "remote-url": "https://example.org/doxygen/index.html", + "remote-tagfile": "https://example.org/doxygen/doxygen.tag", + }, + }, +} + + +def test_tag_urls_names_each_tag_file_with_its_document(): + deploy = Path("/b/deploy") + rel_urls = {"spec": ".", "requirements": "../requirements", "dox-api": "../dox-api", + "dox-design": "../dox-design", "dox-testspec": "../dox-testspec"} + urls = docrefs.tag_urls(REGISTRY["documents"], deploy, rel_urls) + assert urls == { + "/b/deploy/html/dox-api/doxygen.tag": "../dox-api", + "/b/deploy/html/dox-design/doxygen.tag": "../dox-design", + "/b/deploy/html/dox-testspec/doxygen.tag": "../dox-testspec", + "/b/deploy/html/requirements/needs.tag": "../requirements", + "/b/deploy/html/upstream/doxygen.tag": "https://example.org/doxygen", + } + + +def test_tag_urls_keys_are_the_paths_tagfiles_gives_doxygen(tmp_path): + # Doxygen writes external= exactly as TAGFILES names the file. + registry = tmp_path / "documents.yaml" + registry.write_text(yaml.safe_dump(REGISTRY)) + deploy = tmp_path / "deploy" + entries = docrefs.tagfiles("dox-testspec", deploy, registry=registry).split() + given = {entry.split("=", 1)[0] for entry in entries} + rel_urls = {d: "x" for d in REGISTRY["documents"] if d != "upstream"} + assert given <= set(docrefs.tag_urls(REGISTRY["documents"], deploy, rel_urls)) + assert f"{(deploy / 'html/dox-design/doxygen.tag').as_posix()}" in given diff --git a/sphinx/_extensions/doxygen_parser.py b/sphinx/_extensions/doxygen_parser.py index df5dfee..e73e827 100644 --- a/sphinx/_extensions/doxygen_parser.py +++ b/sphinx/_extensions/doxygen_parser.py @@ -3,7 +3,9 @@ # SPDX-License-Identifier: Apache-2.0 """Doxygen XML parsing — no Sphinx dependency.""" +import os import xml.etree.ElementTree as ET +from collections.abc import Iterable, Mapping from pathlib import Path from typing import NamedTuple, TypedDict @@ -57,14 +59,28 @@ class SymbolInfo(TypedDict): class RefLinks(NamedTuple): """Where a in Doxygen prose points, as HTML directory URLs. - ``api`` for a symbol Doxygen resolved through a tag file (``external=``) — - the API document the test specification references; ``local`` for one - documented in the parsed project itself, such as a shared test procedure. - An empty string leaves that kind unlinked. + A symbol Doxygen resolved through a tag file carries the tag file's path + in ``external=``; ``tags`` maps such a path to the HTML directory of the + document that tag file belongs to, so each reference goes to the project + that documents the symbol. ``api`` is for a tag-file reference ``tags`` + does not name — the API document the test specification references. + ``local`` is for a symbol documented in the parsed project itself, such as + a shared test procedure. An empty string leaves that kind unlinked. """ api: str = "" local: str = "" + tags: Mapping[str, str] | None = None + + +def _tag_base(links: RefLinks, external: str) -> str: + """The HTML directory for a reference resolved through tag file ``external``.""" + if links.tags: + wanted = os.path.normpath(external) + for tag, url in links.tags.items(): + if os.path.normpath(tag) == wanted: + return url + return links.api def ref_to_rst(ref: ET.Element, links: RefLinks | None) -> str | None: @@ -78,7 +94,8 @@ def ref_to_rst(ref: ET.Element, links: RefLinks | None) -> str | None: refid = ref.get("refid", "") if not (links and name and refid): return None - base = links.api if ref.get("external") else links.local + external = ref.get("external") + base = _tag_base(links, external) if external else links.local if not base: return None if "_1" in refid: @@ -249,28 +266,105 @@ def section_to_rst(simplesect: ET.Element, links: RefLinks | None = None) -> lis return lines -def see_to_rst(simplesect_see: ET.Element, api_html_dir: str, testspec_html_dir: str = "") -> str: - """Render a into a 'See also:' RST line. +def _see_ref(ref: ET.Element, links: RefLinks) -> str: + """One of a see section as RST, or ``""`` if it has no name.""" + name = (ref.text or "").strip() + if not name: + return "" + refid = ref.get("refid", "") + if refid and "_1" in refid: + if link := ref_to_rst(ref, links): + return link + if ref.get("kindref", "") == "member": + return f":c:func:`{name.rstrip('()').strip()}`" + return f"``{name}``" + return f":c:func:`{name.rstrip('()').strip()}`" + + +def _see_text_items(text: str) -> list[str]: + """The plain text of a see section as literals, one for each item. + + A comma divides items, but not a comma inside parentheses (``f(a, b)``). + Space after a ")" also divides items (``a() b()``). A piece with no + letter or digit is a separator, for example the ", " between two + references or a final ".", and gives no item. + """ + pieces: list[str] = [] + depth = 0 + start = 0 + for i, ch in enumerate(text): + if ch == "(": + depth += 1 + elif ch == ")": + depth = max(depth - 1, 0) + elif ch == "," and depth == 0: + pieces.append(text[start:i]) + start = i + 1 + elif ch.isspace() and depth == 0 and text[:i].rstrip().endswith(")"): + pieces.append(text[start:i]) + start = i + 1 + pieces.append(text[start:]) + items = [] + for piece in pieces: + piece = " ".join(piece.replace("`", "").split()).rstrip(".;").strip() + if any(ch.isalnum() for ch in piece): + items.append(f"``{piece}``") + return items + + +def _see_para_items(para: ET.Element, links: RefLinks) -> list[str]: + """The items of one of a see section, in the order of the source. + + A becomes a link (see `_see_ref`), also inside a . + The text between the references becomes literals (see `_see_text_items`). + """ + items: list[str] = [] + text: list[str] = [] + + def flush() -> None: + items.extend(_see_text_items("".join(text))) + text.clear() + + def walk(elem: ET.Element) -> None: + for child in elem: + if child.tag == "ref": + flush() + if item := _see_ref(child, links): + items.append(item) + else: + if child.text: + text.append(child.text) + walk(child) + if child.tail: + text.append(child.tail) + + if para.text: + text.append(para.text) + walk(para) + flush() + return items + + +def see_to_rst( + simplesect_see: ET.Element | Iterable[ET.Element], + api_html_dir: str, + testspec_html_dir: str = "", + tag_dirs: Mapping[str, str] | None = None, +) -> str: + """Render one , or all of a member, into a 'See also:' RST line. Each becomes a hyperlink (see `ref_to_rst`), a :c:func: role - (unlinked member refs), or a plain code span, depending on its attributes.""" - links = RefLinks(api=api_html_dir, local=testspec_html_dir) + (unlinked member refs), or a plain code span, depending on its attributes. + Text that is not in a becomes a literal: `@see irq_offload()` gives + no when no Doxygen project documents the symbol, and it was lost. + Doxygen 1.16 writes one see section for each `@see` line, also for lines + that follow each other, so a caller gives all sections of a member + (``findall``), not only the first. ``tag_dirs``: `RefLinks.tags`.""" + links = RefLinks(api=api_html_dir, local=testspec_html_dir, tags=tag_dirs) + sections = [simplesect_see] if ET.iselement(simplesect_see) else list(simplesect_see) refs: list[str] = [] - for ref in simplesect_see.findall("para/ref"): - name = (ref.text or "").strip() - if not name: - continue - refid = ref.get("refid", "") - kindref = ref.get("kindref", "") - if refid and "_1" in refid: - if link := ref_to_rst(ref, links): - refs.append(link) - elif kindref == "member": - func_name = name.rstrip("()").strip() - refs.append(f":c:func:`{func_name}`") - else: - refs.append(f"``{name}``") - else: - refs.append(f":c:func:`{name.rstrip('()').strip()}`") + for section in sections: + for para in section.findall("para"): + refs.extend(_see_para_items(para, links)) if refs: return "**See also:** " + ", ".join(refs) return "" @@ -342,10 +436,13 @@ def parse_memberdef( compound_id: str, testspec_html_dir: str, api_html_dir: str, + tag_dirs: Mapping[str, str] | None = None, ) -> MemberInfo: """Extract all structured fields from a element — name, source location, Doxygen URL, brief description, test ID, requirement refs, status, - see-also, and Arrange/Act/Assert body sections — and return them as a MemberInfo.""" + see-also, and Arrange/Act/Assert body sections — and return them as a MemberInfo. + + ``tag_dirs``: where a tag-file reference links, by tag file (`RefLinks.tags`).""" name = memberdef.findtext("name", "").strip() loc = memberdef.find("location") @@ -361,7 +458,7 @@ def parse_memberdef( anchor = member_id[len(prefix):] if member_id.startswith(prefix) else member_id doxygen_url = f"{testspec_html_dir}/{compound_id}.html#{anchor}" - links = RefLinks(api=api_html_dir, local=testspec_html_dir) + links = RefLinks(api=api_html_dir, local=testspec_html_dir, tags=tag_dirs) brief = para_text(memberdef.find("briefdescription/para"), links) dd = memberdef.find("detaileddescription") @@ -413,9 +510,10 @@ def parse_memberdef( elif "test_obsolete" in xid: status = "obsolete" detail_lines = detail_rst_lines(dd, links) - see_sect = dd.find(".//simplesect[@kind='see']") - if see_sect is not None: - see_rst = see_to_rst(see_sect, api_html_dir, testspec_html_dir) + # Every see section, not only the first: see `see_to_rst`. + see_sects = dd.findall(".//simplesect[@kind='see']") + if see_sects: + see_rst = see_to_rst(see_sects, api_html_dir, testspec_html_dir, tag_dirs) # Doxygen's native `\verifies` (1.16+), read beside the `@reqref` # xrefsects above; both are live while sources migrate. See diff --git a/sphinx/_extensions/needs_config_state.py b/sphinx/_extensions/needs_config_state.py new file mode 100644 index 0000000..964ea79 --- /dev/null +++ b/sphinx/_extensions/needs_config_state.py @@ -0,0 +1,159 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""Sphinx extension: re-read a document when its needs vocabulary or imports change. + +A document's need types, links and fields come from a TOML file +(``needs_from_toml``, set by ``zdocs_conf`` from ``ZDOCS_NEEDS_CONFIG``). +Sphinx re-reads every document when a config value registered with rebuild +``"env"`` changes, but sphinx-needs registers ``needs_types``, +``needs_links`` and ``needs_fields`` with rebuild ``"html"``, and the config +value ``needs_from_toml`` is only the file's path. So after the file gains a +link type, an incremental build reuses the pickled needs, which have no entry +for the new link, and the first new need that uses it crashes the build: +``KeyError: "Link type 'fulfills' does not exist in backlinks."``. A clean +build works. + +This extension registers ``zdocs_needs_config_digest``, rebuild ``"env"``, +and sets it to the SHA-256 of the file's content. A change to the file changes +the value, and Sphinx re-reads the document ("config changed"). The value is +the same for both build stages, so the stage caches stay valid while the file +does not change. + +Re-reading is not enough when a link type is REMOVED: the pickled environment +still holds needs imported from a peer's needs.json under the old vocabulary, +and the importing document crashes the same way once, although the peer's +file no longer has the link. So on a change the pickled environment is also +dropped before Sphinx loads it (``config-inited`` runs first), and the +document starts from a fresh one, as in a clean build. The digests the +environment was built with are kept beside it, in the doctree directory. + +The needs that a document imports (``needs_external_needs``) have a +similar problem. sphinx-needs loads them again on each build, but Sphinx +writes only the pages of the documents that it read. When a peer's +needs.json gains a need that links to a need of this document, the source of +the page with that need does not change. So Sphinx does not write the page +again, and the page does not show the new incoming link. For example, a +requirement page did not show "assessed by" after the test report gained +adequacy needs. + +So the extension also registers ``zdocs_imported_needs_digest``, rebuild +``"env"``: a SHA-256 over the needs that each imported file gives. A change +to it also starts from a fresh environment. This also removes the needs that +a peer no longer has. +""" + +import hashlib +import json +from pathlib import Path + +from sphinx.util import logging + +logger = logging.getLogger(__name__) + +CONFIG_NAME = "zdocs_needs_config_digest" +IMPORTS_CONFIG_NAME = "zdocs_imported_needs_digest" + +#: Beside the pickled environment: the digests it was built with. +STAMP_NAME = "zdocs-needs-config.sha256" +#: Sphinx's pickled environment in the doctree directory. +ENV_PICKLE = "environment.pickle" + + +def needs_config_digest(confdir, from_toml): + """SHA-256 of the needs TOML file, or ``""`` if none is set or it is unreadable. + + ``from_toml`` is resolved against ``confdir``, as sphinx-needs resolves it. + """ + if not from_toml: + return "" + try: + data = Path(confdir, from_toml).resolve().read_bytes() + except OSError: + return "" + return hashlib.sha256(data).hexdigest() + + +def _imported_content(json_path, version): + """The part of a needs.json that sphinx-needs imports, as canonical JSON. + + That is the needs of the imported version (``version``, else the file's + ``current_version``) and its schema, which gives the defaults. The + ``_back`` fields are not part of it: sphinx-needs does not import + them, and when two documents import each other, they would make each + document read again one more time after each change. A missing or + unreadable file gives a fixed marker, so that its return is a change too. + """ + try: + with open(json_path, encoding="utf-8") as f: + data = json.load(f) + versions = data.get("versions") or {} + entry = versions.get(version or data.get("current_version"), {}) + except (OSError, ValueError, AttributeError): + return "unreadable" + needs = { + need_id: {k: v for k, v in need.items() if not k.endswith("_back")} + for need_id, need in (entry.get("needs") or {}).items() + } + return json.dumps([needs, entry.get("needs_schema")], sort_keys=True, default=str) + + +def imported_needs_digest(confdir, external_needs): + """SHA-256 over the needs that ``needs_external_needs`` imports, or ``""`` if none. + + Only ``json_path`` sources count. A relative path is resolved against + ``confdir``, as sphinx-needs resolves it. A ``json_url`` source is not read. + """ + sha = hashlib.sha256() + count = 0 + for source in external_needs or []: + json_path = source.get("json_path") if isinstance(source, dict) else None + if not json_path: + continue + path = Path(confdir, json_path) + sha.update(f"{path}\0{_imported_content(path, source.get('version'))}\0".encode()) + count += 1 + return sha.hexdigest() if count else "" + + +def drop_stale_environment(doctreedir, digest): + """Delete the pickled environment in ``doctreedir`` unless it was built with ``digest``. + + An environment with no recorded digest (a build dir from before this + extension) counts as stale. Returns whether one was deleted; records + ``digest`` either way. + """ + doctreedir = Path(doctreedir) + stamp = doctreedir / STAMP_NAME + pickle = doctreedir / ENV_PICKLE + try: + recorded = stamp.read_text().strip() + except OSError: + recorded = None + dropped = False + if pickle.exists() and recorded != digest: + pickle.unlink() + dropped = True + if recorded != digest: + doctreedir.mkdir(parents=True, exist_ok=True) + stamp.write_text(digest + "\n") + return dropped + + +def _set_digests(app, config): + digest = needs_config_digest(app.confdir, getattr(config, "needs_from_toml", None)) + imports = imported_needs_digest(app.confdir, getattr(config, "needs_external_needs", None)) + config[CONFIG_NAME] = digest + config[IMPORTS_CONFIG_NAME] = imports + if drop_stale_environment(app.doctreedir, f"{digest}:{imports}"): + logger.info( + "needs vocabulary or imported needs changed: starting from a fresh environment" + ) + + +def setup(app): + app.add_config_value(CONFIG_NAME, "", "env") + app.add_config_value(IMPORTS_CONFIG_NAME, "", "env") + app.connect("config-inited", _set_digests) + return {"version": "0.2", "parallel_read_safe": True, "parallel_write_safe": True} diff --git a/sphinx/_extensions/rst_builders.py b/sphinx/_extensions/rst_builders.py index 352927c..ff553f0 100644 --- a/sphinx/_extensions/rst_builders.py +++ b/sphinx/_extensions/rst_builders.py @@ -174,8 +174,12 @@ def build_procedure_need_rst( testspec_html_dir, api_html_dir, need_names=None, + tag_dirs=None, ): - """Build a test_procedure needs item for one shared test procedure.""" + """Build a test_procedure needs item for one shared test procedure. + + ``tag_dirs``: where a tag-file reference links (`doxygen_parser.RefLinks.tags`). + """ from doxygen_parser import ( RefLinks, detail_rst_lines, @@ -211,14 +215,15 @@ def build_procedure_need_rst( params = [] see_rst_str = "" if dd is not None: - links = RefLinks(api=api_html_dir, local=testspec_html_dir) + links = RefLinks(api=api_html_dir, local=testspec_html_dir, tags=tag_dirs) params = extract_params(dd, links) # Was a second, hand-rolled copy of the same paragraph walk, carrying the # same list-dropping defect. One helper now, so a fix lands in both. detail_lines = detail_rst_lines(dd, links) - see_sect = dd.find(".//simplesect[@kind='see']") - if see_sect is not None: - see_rst_str = see_to_rst(see_sect, api_html_dir, testspec_html_dir) + # Every see section, not only the first: see `see_to_rst`. + see_sects = dd.findall(".//simplesect[@kind='see']") + if see_sects: + see_rst_str = see_to_rst(see_sects, api_html_dir, testspec_html_dir, tag_dirs) lines = [] lines.append(f".. {_need_name(need_names, 'procedure')}:: {title}") diff --git a/sphinx/_extensions/test_module.py b/sphinx/_extensions/test_module.py index eefff85..6cf020d 100644 --- a/sphinx/_extensions/test_module.py +++ b/sphinx/_extensions/test_module.py @@ -145,6 +145,19 @@ def _classify_inner_groups(module_cdef: ET.Element, xml_dir: Path): return suite_refids, proc_refids +def _tag_dirs(tag_urls, page_prefix): + """``testmodule_tag_urls`` as seen from the current page. + + A peer's HTML directory is relative to this document's root, so it gets + the page's ``../`` prefix, as ``api_doxygen_url`` does; an absolute URL + (a ``doxygen-external`` peer) is used as it is. + """ + return { + tag: url if "://" in url or url.startswith("/") else page_prefix + url + for tag, url in (tag_urls or {}).items() + } + + def suite_name_from_group(group_name, qualifier=""): """The ztest suite name a suite group's compoundname stands for. @@ -160,7 +173,7 @@ def suite_name_from_group(group_name, qualifier=""): def _build_suite_rst( suite_refid, xml_dir, testspec_html_dir, api_html_dir, module_path, need_names=None, - depends_field=None, suite_qualifier="", + depends_field=None, suite_qualifier="", tag_dirs=None, ): """Build RST lines for one test suite group (section heading + test_case needs). @@ -170,6 +183,10 @@ def _build_suite_rst( ``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. + + ``tag_dirs``: where a tag-file reference links, by tag file + (`doxygen_parser.RefLinks.tags`); a reference none names goes to + ``api_html_dir``. """ suite_xml = xml_dir / f"{suite_refid}.xml" if not suite_xml.exists(): @@ -187,7 +204,9 @@ def _build_suite_rst( lines.extend(group_prose) lines.append("") for memberdef in suite_cdef.findall(".//memberdef[@kind='function']"): - info = parse_memberdef(memberdef, compound_id, testspec_html_dir, api_html_dir) + info = parse_memberdef( + memberdef, compound_id, testspec_html_dir, api_html_dir, tag_dirs + ) if not info["name"]: continue with_depends = bool(depends_field) and depends_field( @@ -203,7 +222,9 @@ def _build_suite_rst( return lines -def _build_proc_group_rst(proc_refid, xml_dir, testspec_html_dir, api_html_dir, need_names=None): +def _build_proc_group_rst( + proc_refid, xml_dir, testspec_html_dir, api_html_dir, need_names=None, tag_dirs=None +): """Build RST lines for one procedure group (section heading + test_procedure needs).""" proc_xml = xml_dir / f"{proc_refid}.xml" proc_cdef = ET.parse(proc_xml).getroot().find("compounddef") @@ -222,7 +243,7 @@ def _build_proc_group_rst(proc_refid, xml_dir, testspec_html_dir, api_html_dir, lines.extend( build_procedure_need_rst( md, proc_compound_id, proc_group_name, testspec_html_dir, api_html_dir, - need_names=need_names, + need_names=need_names, tag_dirs=tag_dirs, ).splitlines() ) lines.append("") @@ -562,6 +583,7 @@ def run(self): page_prefix = "../" * page_depth testspec_html_dir = page_prefix + app.config.testspec_doxygen_url api_html_dir = page_prefix + app.config.api_doxygen_url + tag_dirs = _tag_dirs(getattr(app.config, "testmodule_tag_urls", {}), page_prefix) # testmodule_root is supplied by the engine (zdocs_conf.py, defaulting # to ZDOCS_PROJECT_BASE) — no ZEPHYR_BASE fallback: that was a # project-specific env var name in a generic engine (decision 5). @@ -619,10 +641,12 @@ def run(self): env, conditions, subject ), suite_qualifier=getattr(app.config, "testmodule_suite_qualifier", ""), + tag_dirs=tag_dirs, ) for proc_refid in proc_refids: all_rst += _build_proc_group_rst( proc_refid, xml_dir, testspec_html_dir, api_html_dir, need_names=need_names, + tag_dirs=tag_dirs, ) _maybe_dump_rst(app, env.docname, "testmodule", group_name, "\n".join(all_rst)) @@ -848,6 +872,10 @@ def setup(app): app.add_config_value("testspec_needs_json", "", "env") app.add_config_value("testspec_doxygen_url", "", "env") app.add_config_value("api_doxygen_url", "", "env") + # {Doxygen tag file path: HTML directory of its document}, from the + # registry (docrefs.tag_urls): a reference Doxygen resolved through a tag + # file links into the document that tag file belongs to. + app.add_config_value("testmodule_tag_urls", {}, "env") # requirements_url deliberately NOT registered (decision 4): it was # computed at conf_test_common.py:56 and consumed nowhere in any of the # four modules — deleted outright, not migrated and left unset. diff --git a/sphinx/zdocs_conf.py b/sphinx/zdocs_conf.py index 4dd35a2..929f975 100644 --- a/sphinx/zdocs_conf.py +++ b/sphinx/zdocs_conf.py @@ -125,6 +125,12 @@ def configure( # conditional extension list would make the two build stages' configs # differ and invalidate the doctree cache between them. "sphinx_needs", + # Re-reads the document when the needs TOML's content changes: + # sphinx-needs rebuilds only the HTML when its types, links or fields + # change, and an incremental build then crashes on a new link type. + # Also when the needs it imports from a peer's needs.json change: + # else a page does not show a new incoming link from the peer. + "needs_config_state", # The `doc_control` directive: the controlled-document header (owner, # classification, approval dates, version). Registers `signature_section` # and `releaselevel` as config values, so it must be loaded even by @@ -417,6 +423,7 @@ def configure( namespace["testmodule_xml_dir"] = testmodule["xml_dir"] namespace["testspec_doxygen_url"] = testmodule["doxygen_url"] namespace["api_doxygen_url"] = testmodule["api_url"] + namespace["testmodule_tag_urls"] = testmodule.get("tag_urls", {}) namespace["testspec_needs_json"] = testmodule["needs_json"] namespace["testmodule_root"] = str(project_base) if project_base else "" namespace["twister_output_dir"] = os.environ.get("ZDOCS_TWISTER_OUT", "")