From e1442c5fefc4adbca9e3fab0c6fbc547290b2ee8 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Sat, 3 Oct 2026 01:23:00 +0200 Subject: [PATCH 1/5] fix: sphinx: re-read a document when its needs TOML changes An incremental build after needs_config.toml gained a link type crashed in sphinx-needs: KeyError: "Link type 'fulfills' does not exist in backlinks." A clean build worked. sphinx-needs registers needs_types, needs_links and needs_fields with rebuild "html", and needs_from_toml is only the file's path, so a change to the file re-read no document. The pickled needs had no backlink entry for the new link, and the first need that used it (here an imported one, from a peer's needs.json) broke the write phase. The removal of a link type that needs used crashed the same way: the importing document's pickled environment still held a peer's needs under the old vocabulary. A new engine extension, needs_config_state, always loaded after sphinx_needs, registers zdocs_needs_config_digest (rebuild "env") and sets it at config-inited to the SHA-256 of the TOML file's content, resolved against the conf dir as sphinx-needs resolves it. A change to the content re-reads the document; an unchanged file, even rewritten, keeps the cache, and both build stages see the same value. The extension also records the digest an environment was built with (zdocs-needs-config.sha256 in the doctree directory). At config-inited, before Sphinx loads the environment, it deletes a pickled environment built with another digest or with none, so the document starts fresh, as in a clean build. Reproduced on the safety docset with the extension removed: a probe link type in needs_config.toml and one need using it; requirements-html then failed with "Link type 'probes' does not exist in backlinks". With the extension, doc-index + all-docs pass after the link type is added and again after it is removed. Unit tests: the digest (unset, missing, content not mtime, relative path), an incremental build that adds a link type and uses it, removal by a peer with an importing document, the stamp logic, an unchanged file keeping the cache, and a control: without the extension the build raises the KeyError. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- doc/api/python/extensions.rst | 8 + doc/manual/reference/consumer-contract.rst | 5 + .../_tests/test_needs_config_state.py | 209 ++++++++++++++++++ sphinx/_extensions/needs_config_state.py | 96 ++++++++ sphinx/zdocs_conf.py | 4 + 5 files changed, 322 insertions(+) create mode 100644 sphinx/_extensions/_tests/test_needs_config_state.py create mode 100644 sphinx/_extensions/needs_config_state.py diff --git a/doc/api/python/extensions.rst b/doc/api/python/extensions.rst index 2923fdd..40dc7a6 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 changes. + +.. 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/sphinx/_extensions/_tests/test_needs_config_state.py b/sphinx/_extensions/_tests/test_needs_config_state.py new file mode 100644 index 0000000..721ce76 --- /dev/null +++ b/sphinx/_extensions/_tests/test_needs_config_state.py @@ -0,0 +1,209 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""needs_config_state — an incremental build after the needs TOML gains a link type. + +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."``. +""" + +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() diff --git a/sphinx/_extensions/needs_config_state.py b/sphinx/_extensions/needs_config_state.py new file mode 100644 index 0000000..f5a0f03 --- /dev/null +++ b/sphinx/_extensions/needs_config_state.py @@ -0,0 +1,96 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""Sphinx extension: re-read a document when its needs vocabulary changes. + +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 digest the +environment was built with is kept beside it, in the doctree directory. +""" + +import hashlib +from pathlib import Path + +from sphinx.util import logging + +logger = logging.getLogger(__name__) + +CONFIG_NAME = "zdocs_needs_config_digest" + +#: Beside the pickled environment: the digest 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 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_digest(app, config): + digest = needs_config_digest(app.confdir, getattr(config, "needs_from_toml", None)) + config[CONFIG_NAME] = digest + if drop_stale_environment(app.doctreedir, digest): + logger.info("needs vocabulary changed: starting from a fresh environment") + + +def setup(app): + app.add_config_value(CONFIG_NAME, "", "env") + app.connect("config-inited", _set_digest) + return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True} diff --git a/sphinx/zdocs_conf.py b/sphinx/zdocs_conf.py index 4dd35a2..f152c02 100644 --- a/sphinx/zdocs_conf.py +++ b/sphinx/zdocs_conf.py @@ -125,6 +125,10 @@ 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. + "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 From 3da21bd0cd04a6534a56dbd1e69cd246bfd15617 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Thu, 1 Oct 2026 00:44:43 +0200 Subject: [PATCH 2/5] fix: sphinx: re-read a document when the needs it imports change An incremental build left "assessed by" off the ZEP-SRS-5-5 page of the safety requirements. The requirements document imports the needs of the test report (needs_external_needs). The imported needs.json gained adequacy needs that link to the requirement, but the source of the requirement page did not change. sphinx-needs loads the imports again on each build, but Sphinx writes only the pages of the documents that it read. So the page was not written again, and the new incoming link did not show. aea18a5 and 36802e4 cover only a change of the needs TOML. needs_config_state now also registers zdocs_imported_needs_digest (rebuild "env"). At config-inited, it is set to a SHA-256 over the needs that each json_path source of needs_external_needs gives: the needs of the imported version and its schema, without the _back fields. sphinx-needs does not import those fields. When two documents import each other, those fields would also make each document read again one more time after each change. A change of the digest drops the pickled environment, as a change of the TOML does, so the document starts from a fresh one. This also removes the needs that a peer no longer has. The stamp beside the environment now holds both digests, so each build dir starts once from a fresh environment after this change. Reproduced first. In a minimal project (peer and importer), a new peer need that verifies an importer need did not show on the importer page after an incremental build. On the safety docset (bdoc-p4-ef), a build without ZDOCS_COVERAGE_OUT and then an incremental build with it left the page without "assessed by", although requirements/needs.json had the assesses_back entry. With the fix, the same sequence shows "assessed by". In the next make with no change, the stage-1 builds of 4 documents that import the test report started from a fresh environment once: they run before the test report's stage 1, so they saw its new needs.json only then. The make after that changed no digest. Unit tests: the digest (no source, json_url only, the imported needs, _back fields and creator ignored, the configured version, a relative path), an incremental build where a peer gains a need and one where it removes one, an unchanged import that keeps the cache, and a control: without the extension, the new link does not show. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- doc/api/python/extensions.rst | 2 +- doc/manual/reference/registry-schema.rst | 5 + .../_tests/test_needs_config_state.py | 173 +++++++++++++++++- sphinx/_extensions/needs_config_state.py | 81 +++++++- sphinx/zdocs_conf.py | 2 + 5 files changed, 252 insertions(+), 11 deletions(-) diff --git a/doc/api/python/extensions.rst b/doc/api/python/extensions.rst index 40dc7a6..37020b6 100644 --- a/doc/api/python/extensions.rst +++ b/doc/api/python/extensions.rst @@ -68,7 +68,7 @@ Re-reads a document when an input from outside the source tree changes. ``needs_config_state`` ------------------------ -Re-reads a document when its sphinx-needs TOML file changes. +Re-reads a document when its sphinx-needs TOML file or the needs it imports change. .. automodule:: needs_config_state :members: diff --git a/doc/manual/reference/registry-schema.rst b/doc/manual/reference/registry-schema.rst index 54406c5..cfb0db2 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 diff --git a/sphinx/_extensions/_tests/test_needs_config_state.py b/sphinx/_extensions/_tests/test_needs_config_state.py index 721ce76..c6a3ad0 100644 --- a/sphinx/_extensions/_tests/test_needs_config_state.py +++ b/sphinx/_extensions/_tests/test_needs_config_state.py @@ -2,14 +2,20 @@ # # SPDX-License-Identifier: Apache-2.0 -"""needs_config_state — an incremental build after the needs TOML gains a link type. +"""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 @@ -207,3 +213,168 @@ def test_control_without_the_extension_the_build_crashes(make_app, tmp_path): 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/needs_config_state.py b/sphinx/_extensions/needs_config_state.py index f5a0f03..964ea79 100644 --- a/sphinx/_extensions/needs_config_state.py +++ b/sphinx/_extensions/needs_config_state.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: Apache-2.0 -"""Sphinx extension: re-read a document when its needs vocabulary changes. +"""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``). @@ -26,11 +26,26 @@ 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 digest the -environment was built with is kept beside it, in the doctree directory. +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 @@ -38,8 +53,9 @@ logger = logging.getLogger(__name__) CONFIG_NAME = "zdocs_needs_config_digest" +IMPORTS_CONFIG_NAME = "zdocs_imported_needs_digest" -#: Beside the pickled environment: the digest it was built with. +#: 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" @@ -59,6 +75,48 @@ def needs_config_digest(confdir, from_toml): 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``. @@ -83,14 +141,19 @@ def drop_stale_environment(doctreedir, digest): return dropped -def _set_digest(app, config): +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 - if drop_stale_environment(app.doctreedir, digest): - logger.info("needs vocabulary changed: starting from a fresh environment") + 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.connect("config-inited", _set_digest) - return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True} + 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/zdocs_conf.py b/sphinx/zdocs_conf.py index f152c02..d565fc3 100644 --- a/sphinx/zdocs_conf.py +++ b/sphinx/zdocs_conf.py @@ -128,6 +128,8 @@ def configure( # 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` From b4755fd935b8403bf75f342a06f9303029007985 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Sat, 3 Oct 2026 01:41:43 +0200 Subject: [PATCH 3/5] fix: testmodule: link a tag-file reference into its own document A symbol Doxygen resolved through a tag file carries the tag file's path in external=. ref_to_rst sent every such reference to the api_reference document (doxygen_parser.py, `links.api if ref.get("external")`). Once the safety docset's Detailed Design documented kernel internals, the cpu_mask test's mention of cpu_mask_mod() resolved through the Detailed Design's tag file and rendered as a dead link into the Safety API (dox-zephyr-safety-api/cpu__mask_8c.html), accepted in doc-check since. docrefs.tag_urls maps each tag file the registry can name to its document's HTML directory: a kind: doxygen peer relative to this document's root, a doxygen-external peer by its remote directory, a needs tag by its Sphinx root. The keys are the paths tagfiles() hands Doxygen, which is what external= repeats. The testmodule block carries the map, zdocs_conf sets it as testmodule_tag_urls, and the testmodule directive prefixes the relative ones for the page as it does api_doxygen_url. RefLinks gains `tags`; ref_to_rst looks the reference's tag file up (paths compared normalised) and falls back to `api` for a tag file the map does not name, which is also what a caller without a map gets. parse_memberdef, see_to_rst and build_procedure_need_rst pass it on. On the safety docset the link now goes to dox-zephyr-safety-detailed-design/cpu__mask_8c.html#ac8b9..., and doc-check is OK without the accepted finding. Unit tests: routing by tag file in prose, see-also and a whole memberdef, normalised paths, the fallbacks, the page prefix, and docrefs' map, including that its keys are the paths tagfiles() writes. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- doc/manual/reference/registry-schema.rst | 5 +- scripts/docrefs.py | 32 +++- sphinx/_extensions/_tests/test_tag_routing.py | 171 ++++++++++++++++++ sphinx/_extensions/doxygen_parser.py | 48 +++-- sphinx/_extensions/rst_builders.py | 10 +- sphinx/_extensions/test_module.py | 36 +++- sphinx/zdocs_conf.py | 1 + 7 files changed, 283 insertions(+), 20 deletions(-) create mode 100644 sphinx/_extensions/_tests/test_tag_routing.py diff --git a/doc/manual/reference/registry-schema.rst b/doc/manual/reference/registry-schema.rst index cfb0db2..508ba26 100644 --- a/doc/manual/reference/registry-schema.rst +++ b/doc/manual/reference/registry-schema.rst @@ -316,7 +316,10 @@ 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. ``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_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..a296493 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 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,11 +266,17 @@ 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: +def see_to_rst( + simplesect_see: ET.Element, + api_html_dir: str, + testspec_html_dir: str = "", + tag_dirs: Mapping[str, str] | None = None, +) -> str: """Render a 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. + ``tag_dirs``: `RefLinks.tags`.""" + links = RefLinks(api=api_html_dir, local=testspec_html_dir, tags=tag_dirs) refs: list[str] = [] for ref in simplesect_see.findall("para/ref"): name = (ref.text or "").strip() @@ -342,10 +365,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 +387,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") @@ -415,7 +441,7 @@ def parse_memberdef( 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) + see_rst = see_to_rst(see_sect, 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/rst_builders.py b/sphinx/_extensions/rst_builders.py index 352927c..b403332 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,14 @@ 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) + see_rst_str = see_to_rst(see_sect, 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 d565fc3..929f975 100644 --- a/sphinx/zdocs_conf.py +++ b/sphinx/zdocs_conf.py @@ -423,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", "") From 3c48faae245f5338e4e122d46e8527cfba7f82c9 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Thu, 1 Oct 2026 00:55:46 +0200 Subject: [PATCH 4/5] fix: testmodule: render a see-also item without a ref as a literal see_to_rst() read only the elements of a see section. When no Doxygen project documents a symbol, `@see irq_offload()` gives no , only the text irq_offload(). So the see section gave no "See also" line, and the reference was lost. On the safety docset, the "See also" line of TSPEC-COMMON-068, -069, -075 and TSPEC-LOGGING-008 was empty. In a section with refs, a name without a ref was lost too, for example log_stack_usage() after k_thread_foreach(). see_to_rst() now reads each of the section in the order of the source. A gives the same RST as before, also inside a . The text between the refs is divided into items at the commas that are not in parentheses, and at the space after a ")". Each item becomes a literal. A piece with no letter or digit, for example the ", " between two refs or a final ".", gives no item. So a line that has only refs is the same as before. Measured on the testspec XML of the safety docset, for the 912 needs of the test specification with a see section: 617 give the same line, 287 that gave no line give one now, and 8 get more items. In these 8, the links do not change and stay in the same order. The 4 needs above now show "See also: irq_offload()" or "See also: printk()". Unit tests: text without a ref, refs and text in order, separators, a comma in parentheses, a space after a call, a ref in a computeroutput, a section with only separators. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- doc/manual/reference/registry-schema.rst | 4 +- .../_extensions/_tests/test_doxygen_parser.py | 54 ++++++++++ sphinx/_extensions/doxygen_parser.py | 99 ++++++++++++++++--- 3 files changed, 140 insertions(+), 17 deletions(-) diff --git a/doc/manual/reference/registry-schema.rst b/doc/manual/reference/registry-schema.rst index 508ba26..6843aa6 100644 --- a/doc/manual/reference/registry-schema.rst +++ b/doc/manual/reference/registry-schema.rst @@ -319,7 +319,9 @@ Each entry in ``documents:``, keyed by its id: ``@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. + 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. ``spec`` Id of the sphinx document whose exported needs a ``testreport`` document diff --git a/sphinx/_extensions/_tests/test_doxygen_parser.py b/sphinx/_extensions/_tests/test_doxygen_parser.py index 2308851..4ec2e85 100644 --- a/sphinx/_extensions/_tests/test_doxygen_parser.py +++ b/sphinx/_extensions/_tests/test_doxygen_parser.py @@ -524,3 +524,57 @@ 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()``" + ) diff --git a/sphinx/_extensions/doxygen_parser.py b/sphinx/_extensions/doxygen_parser.py index a296493..216df09 100644 --- a/sphinx/_extensions/doxygen_parser.py +++ b/sphinx/_extensions/doxygen_parser.py @@ -266,6 +266,85 @@ def section_to_rst(simplesect: ET.Element, links: RefLinks | None = None) -> lis return lines +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, api_html_dir: str, @@ -275,25 +354,13 @@ def see_to_rst( """Render a 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. + 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. ``tag_dirs``: `RefLinks.tags`.""" links = RefLinks(api=api_html_dir, local=testspec_html_dir, tags=tag_dirs) 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 para in simplesect_see.findall("para"): + refs.extend(_see_para_items(para, links)) if refs: return "**See also:** " + ", ".join(refs) return "" From b369988a0f1f8b965a9f8bf56801157fcaca5c4f Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Thu, 1 Oct 2026 01:02:03 +0200 Subject: [PATCH 5/5] fix: testmodule: read every see section of a test parse_memberdef() and build_procedure_need_rst() read only the first see section of a member (dd.find). Doxygen 1.16 writes one see section for each `@see` line, also for lines that follow each other. So a test with `@see k_thread_join()` and then `@see irq_offload()` showed only k_thread_join(). On safety main, 5 references to a suite group were hidden this way: irq_offload() in TSPEC-MEMPROT-019 and TSPEC-THREADS-030, -049, -050 and -053. Both callers now give all see sections (dd.findall) to see_to_rst(), which takes one section or a list of them. The line has the items of all sections, in the order of the source. Measured on the testspec XML of the safety docset: 353 needs of the test specification have more than one see section, and all 353 get more items. No need gets an item two times. Unit tests: two sections joined in order, an empty list, parse_memberdef with two sections (the TSPEC-THREADS-030 shape), and a procedure with two sections. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- doc/manual/reference/registry-schema.rst | 3 +- .../_extensions/_tests/test_doxygen_parser.py | 34 +++++++++++++++++++ .../_extensions/_tests/test_rst_builders.py | 19 +++++++++++ sphinx/_extensions/doxygen_parser.py | 23 ++++++++----- sphinx/_extensions/rst_builders.py | 7 ++-- 5 files changed, 73 insertions(+), 13 deletions(-) diff --git a/doc/manual/reference/registry-schema.rst b/doc/manual/reference/registry-schema.rst index 6843aa6..13fe894 100644 --- a/doc/manual/reference/registry-schema.rst +++ b/doc/manual/reference/registry-schema.rst @@ -321,7 +321,8 @@ Each entry in ``documents:``, keyed by its id: 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. + 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/sphinx/_extensions/_tests/test_doxygen_parser.py b/sphinx/_extensions/_tests/test_doxygen_parser.py index 4ec2e85..4084007 100644 --- a/sphinx/_extensions/_tests/test_doxygen_parser.py +++ b/sphinx/_extensions/_tests/test_doxygen_parser.py @@ -578,3 +578,37 @@ 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_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/doxygen_parser.py b/sphinx/_extensions/doxygen_parser.py index 216df09..e73e827 100644 --- a/sphinx/_extensions/doxygen_parser.py +++ b/sphinx/_extensions/doxygen_parser.py @@ -5,7 +5,7 @@ """Doxygen XML parsing — no Sphinx dependency.""" import os import xml.etree.ElementTree as ET -from collections.abc import Mapping +from collections.abc import Iterable, Mapping from pathlib import Path from typing import NamedTuple, TypedDict @@ -346,21 +346,25 @@ def walk(elem: ET.Element) -> None: def see_to_rst( - simplesect_see: ET.Element, + simplesect_see: ET.Element | Iterable[ET.Element], api_html_dir: str, testspec_html_dir: str = "", tag_dirs: Mapping[str, str] | None = None, ) -> str: - """Render a into a 'See also:' RST line. + """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. 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. - ``tag_dirs``: `RefLinks.tags`.""" + 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 para in simplesect_see.findall("para"): - refs.extend(_see_para_items(para, links)) + 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 "" @@ -506,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, tag_dirs) + # 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/rst_builders.py b/sphinx/_extensions/rst_builders.py index b403332..ff553f0 100644 --- a/sphinx/_extensions/rst_builders.py +++ b/sphinx/_extensions/rst_builders.py @@ -220,9 +220,10 @@ def build_procedure_need_rst( # 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, tag_dirs) + # 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}")