From 14207733d019bd80b770c55a151e8f4614ef30b7 Mon Sep 17 00:00:00 2001 From: Anton Krivoborodov Date: Tue, 20 Oct 2026 09:32:58 +0000 Subject: [PATCH] fix: resolve links in templates after Needs are frozen get_needs_mutable() raises once Sphinx-Needs has post-processed the Needs. The score-report-pie handler runs in doctree-resolved, where the status and coverage filter functions call req_in_report_version for a scoped report. For a comp_req without valid_from that follows derived_from through _get_available_needs() and crashed the build. Use the read-only view once the Needs are frozen. --- .../score_sphinx_needs_templates/__init__.py | 17 +++++++-- .../tests/test_needs_templates.py | 37 +++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/extensions/score_sphinx_needs_templates/__init__.py b/src/extensions/score_sphinx_needs_templates/__init__.py index 6d445c884..1a7f046a3 100644 --- a/src/extensions/score_sphinx_needs_templates/__init__.py +++ b/src/extensions/score_sphinx_needs_templates/__init__.py @@ -10,6 +10,7 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +from collections.abc import Mapping from pathlib import Path from sphinx.application import Sphinx @@ -39,7 +40,7 @@ def _base_need_id(need_id: str) -> str: return need_id.split("[", 1)[0] -def _find_need(needs: dict[str, NeedItem], need_id: str) -> NeedItem | None: +def _find_need(needs: Mapping[str, NeedItem], need_id: str) -> NeedItem | None: """Find a Need by its address, tolerating version-qualified collection keys.""" base_id = _base_need_id(need_id) for candidate_id in (need_id, base_id): @@ -55,14 +56,22 @@ def _find_need(needs: dict[str, NeedItem], need_id: str) -> NeedItem | None: return None -def _get_available_needs() -> dict[str, NeedItem]: +def _get_available_needs() -> Mapping[str, NeedItem]: """Return the current Needs, including temporarily removed Needs.""" if _build_environment is None: # The render helpers are registered before ``builder-inited`` captures # the environment. There is no Need collection to query before then. return {} - needs = SphinxNeedsData(_build_environment).get_needs_mutable() + needs_data = SphinxNeedsData(_build_environment) + # ``get_needs_mutable()`` raises once the Needs are frozen, which is the + # case in the write phase (e.g. ``doctree-resolved`` handlers). Calling + # ``get_needs_view()`` earlier would freeze them prematurely. + needs: Mapping[str, NeedItem] = ( + needs_data.get_needs_view() + if needs_data.needs_is_post_processed + else needs_data.get_needs_mutable() + ) if _temporarily_removed_needs: # ``env.clear_doc()`` removes the Needs belonging to the page being # reread. Preserve the live collection and overlay the saved entries @@ -99,7 +108,7 @@ class _LinkedNeeds: @staticmethod def _resolve_backlinks( - needs: dict[str, NeedItem], + needs: Mapping[str, NeedItem], source: NeedItem | None, need_id: str, link_type: str, diff --git a/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py b/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py index a90a7db73..0e32bed2e 100644 --- a/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py +++ b/src/extensions/score_sphinx_needs_templates/tests/test_needs_templates.py @@ -82,6 +82,43 @@ def test_backlinks_merge_indexed_and_new_outgoing_links( assert [need["id"] for need in linked] == ["TC-old", "TC-new"] +class FakeNeedsData: + """``SphinxNeedsData`` double that rejects mutable access once frozen.""" + + def __init__(self, _env: object, *, frozen: bool, needs: dict[str, FakeNeed]): + self.needs_is_post_processed = frozen + self._needs = needs + + def get_needs_mutable(self) -> dict[str, FakeNeed]: + if self.needs_is_post_processed: + raise RuntimeError("Needs have already been post-processed and frozen.") + return self._needs + + def get_needs_view(self) -> dict[str, FakeNeed]: + return self._needs + + +@add_test_properties( + partially_verifies=[], + test_type="interface-test", + derivation_technique="explorative-testing", +) +@pytest.mark.parametrize("frozen", [False, True]) +def test_get_available_needs_works_before_and_after_freeze( + monkeypatch: pytest.MonkeyPatch, frozen: bool +) -> None: + """Write-phase handlers (``doctree-resolved``) resolve links after the freeze.""" + needs = {"REQ": FakeNeed("REQ")} + monkeypatch.setattr(templates, "_build_environment", object()) + monkeypatch.setattr( + templates, + "SphinxNeedsData", + lambda env: FakeNeedsData(env, frozen=frozen, needs=needs), + ) + + assert list(templates._get_available_needs()) == ["REQ"] # type: ignore[reportPrivateUsage] + + @add_test_properties( partially_verifies=[], test_type="interface-test",