diff --git a/docs/changelog.rst b/docs/changelog.rst index 42353e6d1..2a487f924 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,13 +4,28 @@ Changelog ========= +.. _`release:unreleased`: + +Unreleased +---------- + +:Released: Unreleased + +Bug fixes +......... + +- 🐛 Sort need link and backlink lists in ``needs.json`` and HTML output using + natural ordering (e.g. ``REQ_2`` < ``REQ_9`` < ``REQ_10``), so build outputs + are reproducible regardless of need load order (e.g. when using + :ref:`needs_external_needs`) (:issue:`1371`) + .. _`release:8.0.0`: 8.0.0 ----- -:Released: Unreleased -:Full Changelog: `v7.0.0...v8.0.0 `__ +:Released: 19.03.2026 +:Full Changelog: `v7.0.0...v8.0.0 `__ This release introduces **conditional link assessment** — the ability to attach :ref:`filter_string` conditions to links that are checked against the target need at build time. diff --git a/sphinx_needs/directives/need.py b/sphinx_needs/directives/need.py index c67308ff4..18cdb94aa 100644 --- a/sphinx_needs/directives/need.py +++ b/sphinx_needs/directives/need.py @@ -499,6 +499,11 @@ def resolve_links( message = f"Need '{need.id}' has unknown outgoing link '{need_link.to_filter_string()}' in field '{link_type}'" _emit_link_warning(need, message, "link_outgoing") + # Sort link lists alphabetically so that outputs (needs.json, HTML) are + # deterministic and reproducible, regardless of needs/external_needs load order. + for need in needs.values(): + need.sort_links() + def _emit_link_warning(need: NeedItem, message: str, subtype: WarningSubTypes) -> None: """Emit a warning for a link issue, using the appropriate location.""" diff --git a/sphinx_needs/need_item.py b/sphinx_needs/need_item.py index bb7ebf0d6..c85193fb2 100644 --- a/sphinx_needs/need_item.py +++ b/sphinx_needs/need_item.py @@ -9,6 +9,7 @@ # For NeedItem, we allow mutability, but only for values, i.e. it should not allow adding or removing keys. from __future__ import annotations +import re from collections.abc import Iterable, Iterator, Mapping, Sequence from dataclasses import dataclass, field from itertools import chain @@ -363,6 +364,28 @@ def to_link_string(self) -> str: return f"{base}{open_b}{self.condition}{close_b}" +_NATURAL_SORT_RE = re.compile(r"(\d+)") + + +def _natural_sort_key(value: str) -> list[str | int]: + """Build a sort key for natural ordering: digit runs are compared as ints. + + For example, ``REQ_2`` < ``REQ_9`` < ``REQ_10`` (instead of the default + lexicographic ``REQ_10`` < ``REQ_2`` < ``REQ_9``). The result alternates + between str and int, always starting with a str, so mixed-type comparisons + are well-defined. + """ + return [ + int(part) if i % 2 else part + for i, part in enumerate(_NATURAL_SORT_RE.split(value)) + ] + + +def _link_natural_sort_key(link: NeedLink) -> list[str | int]: + """Natural sort key for a :class:`NeedLink`, based on its filter string.""" + return _natural_sort_key(link.to_filter_string()) + + class NeedItem: """A class representing a single need item.""" @@ -875,6 +898,23 @@ def reset_backlinks(self) -> None: for k in part.backlinks: part.backlinks[k] = [] + def sort_links(self) -> None: + """Sort all link and backlink lists in place using natural ordering. + + Sorts outgoing links, backlinks, and part backlinks by the link's + string representation, treating embedded digit sequences as integers + so that e.g. ``REQ_2`` < ``REQ_9`` < ``REQ_10``. This makes the order + of linked need IDs deterministic, regardless of the order in which + they were added or the iteration order of the needs collection. + """ + for value in self._links.values(): + value.sort(key=_link_natural_sort_key) + for value in self._backlinks.values(): + value.sort(key=_link_natural_sort_key) + for part in self._parts.values(): + for value in part.backlinks.values(): + value.sort(key=_link_natural_sort_key) + def add_backlink(self, link_type: str, backlink: str | NeedLink) -> None: """Add a backlink to the need.""" if link_type not in self._backlinks: diff --git a/tests/__snapshots__/test_dynamic_functions.ambr b/tests/__snapshots__/test_dynamic_functions.ambr index d5f5e5020..fa84b9231 100644 --- a/tests/__snapshots__/test_dynamic_functions.ambr +++ b/tests/__snapshots__/test_dynamic_functions.ambr @@ -90,9 +90,9 @@ 'id': 'CON_SPEC_1', 'lineno': 13, 'links': list([ + 'CON-REQ-3', 'CON_REQ_1', 'CON_REQ_2', - 'CON-REQ-3', ]), 'section_name': 'LINKS FROM CONTENT', 'sections': list([ @@ -109,9 +109,9 @@ 'id': 'CON_SPEC_2', 'lineno': 23, 'links': list([ + 'CON-REQ-3', 'CON_REQ_1', 'CON_REQ_2', - 'CON-REQ-3', ]), 'section_name': 'LINKS FROM CONTENT', 'sections': list([ diff --git a/tests/__snapshots__/test_external.ambr b/tests/__snapshots__/test_external.ambr index c34b6f097..bcd559dba 100644 --- a/tests/__snapshots__/test_external.ambr +++ b/tests/__snapshots__/test_external.ambr @@ -604,8 +604,8 @@ 'id': 'SPEC_1', 'lineno': 12, 'links': list([ - 'REQ_1', 'EXT_TEST_01', + 'REQ_1', ]), 'section_name': 'TEST DOCUMENT EXTERNAL', 'sections': list([ diff --git a/tests/__snapshots__/test_field_defaults.ambr b/tests/__snapshots__/test_field_defaults.ambr index aa0011e41..89508a56a 100644 --- a/tests/__snapshots__/test_field_defaults.ambr +++ b/tests/__snapshots__/test_field_defaults.ambr @@ -21,8 +21,8 @@ 'SPEC_3', ]), 'link2': list([ - 'SPEC_2', 'SPEC_1', + 'SPEC_2', ]), 'link2_back': list([ 'SPEC_1', diff --git a/tests/__snapshots__/test_link_conditions.ambr b/tests/__snapshots__/test_link_conditions.ambr index dede524b7..8850835c3 100644 --- a/tests/__snapshots__/test_link_conditions.ambr +++ b/tests/__snapshots__/test_link_conditions.ambr @@ -65,15 +65,15 @@ 'id': 'REQ_001', 'lineno': 7, 'links_back': list([ - 'EXT_COND_PASS', 'EXT_COND_NONE', + 'EXT_COND_PASS', + 'IMP_COND_NONE', + 'IMP_COND_PASS', 'SPEC_001', 'SPEC_003', 'SPEC_004', 'SPEC_005', 'SPEC_006', - 'IMP_COND_PASS', - 'IMP_COND_NONE', ]), 'section_name': 'Requirements', 'sections': list([ @@ -92,8 +92,8 @@ 'lineno': 11, 'links_back': list([ 'EXT_COND_FAIL', - 'SPEC_002', 'IMP_COND_FAIL', + 'SPEC_002', ]), 'section_name': 'Requirements', 'sections': list([ @@ -836,15 +836,15 @@ 'id': 'REQ_001', 'lineno': 7, 'links_back': list([ - 'EXT_COND_PASS', 'EXT_COND_NONE', + 'EXT_COND_PASS', + 'IMP_COND_NONE', + 'IMP_COND_PASS', 'SPEC_001', 'SPEC_003', 'SPEC_004', 'SPEC_005', 'SPEC_006', - 'IMP_COND_PASS', - 'IMP_COND_NONE', ]), 'section_name': 'Requirements', 'sections': list([ @@ -863,8 +863,8 @@ 'lineno': 11, 'links_back': list([ 'EXT_COND_FAIL', - 'SPEC_002', 'IMP_COND_FAIL', + 'SPEC_002', ]), 'section_name': 'Requirements', 'sections': list([ diff --git a/tests/__snapshots__/test_list2need.ambr b/tests/__snapshots__/test_list2need.ambr index 9cf645ad4..1d314b6fd 100644 --- a/tests/__snapshots__/test_list2need.ambr +++ b/tests/__snapshots__/test_list2need.ambr @@ -426,8 +426,8 @@ 'NEED-2', ]), 'links_back': list([ - 'NEED-Z', 'NEED-4', + 'NEED-Z', ]), 'max_amount': None, 'max_content_lines': None, diff --git a/tests/__snapshots__/test_need_constraints.ambr b/tests/__snapshots__/test_need_constraints.ambr index 6f144bbb9..9133e02d4 100644 --- a/tests/__snapshots__/test_need_constraints.ambr +++ b/tests/__snapshots__/test_need_constraints.ambr @@ -37,8 +37,8 @@ 'links': list([ ]), 'links_back': list([ - 'SP_109F4', 'SP_3EBFA', + 'SP_109F4', ]), 'max_amount': None, 'max_content_lines': None, diff --git a/tests/__snapshots__/test_needextend.ambr b/tests/__snapshots__/test_needextend.ambr index a143a3d9b..5453a14c9 100644 --- a/tests/__snapshots__/test_needextend.ambr +++ b/tests/__snapshots__/test_needextend.ambr @@ -36,8 +36,8 @@ 'lineno': 4, 'links': list([ 'REQ_A_1', - 'REQ_D_1', 'REQ_B_1', + 'REQ_D_1', ]), 'links_back': list([ ]), diff --git a/tests/__snapshots__/test_needimport.ambr b/tests/__snapshots__/test_needimport.ambr index 63435e653..7700fd5e7 100644 --- a/tests/__snapshots__/test_needimport.ambr +++ b/tests/__snapshots__/test_needimport.ambr @@ -315,8 +315,8 @@ 'is_import': True, 'lineno': 4, 'links': list([ - 'OWN_ID_123', 'IMPL_01', + 'OWN_ID_123', ]), 'parent_need': 'T_5CCAA', 'parent_needs': list([ @@ -679,8 +679,8 @@ 'is_import': True, 'lineno': 16, 'links': list([ - 'collapsed_OWN_ID_123', 'collapsed_IMPL_01', + 'collapsed_OWN_ID_123', ]), 'parent_need': 'collapsed_T_5CCAA', 'parent_needs': list([ @@ -1127,8 +1127,8 @@ 'is_import': True, 'lineno': 9, 'links': list([ - 'hidden_OWN_ID_123', 'hidden_IMPL_01', + 'hidden_OWN_ID_123', ]), 'parent_need': 'hidden_T_5CCAA', 'parent_needs': list([ @@ -1593,8 +1593,8 @@ 'is_import': True, 'lineno': 23, 'links': list([ - 'test_OWN_ID_123', 'test_IMPL_01', + 'test_OWN_ID_123', ]), 'parent_need': 'test_T_5CCAA', 'parent_needs': list([ diff --git a/tests/__snapshots__/test_needs_sort.ambr b/tests/__snapshots__/test_needs_sort.ambr new file mode 100644 index 000000000..60b51824e --- /dev/null +++ b/tests/__snapshots__/test_needs_sort.ambr @@ -0,0 +1,391 @@ +# serializer version: 1 +# name: test_links_sort[alphabetical_outgoing] + dict({ + 'REQ_A': dict({ + 'links_back': list([ + 'SPEC_1', + ]), + }), + 'REQ_B': dict({ + 'links_back': list([ + 'SPEC_1', + ]), + }), + 'REQ_C': dict({ + 'links_back': list([ + 'SPEC_1', + ]), + }), + 'SPEC_1': dict({ + 'links': list([ + 'REQ_A', + 'REQ_B', + 'REQ_C', + ]), + }), + }) +# --- +# name: test_links_sort[alphabetical_outgoing].1 + dict({ + 'REQ_A': list([ + 'SPEC_1', + ]), + 'REQ_B': list([ + 'SPEC_1', + ]), + 'REQ_C': list([ + 'SPEC_1', + ]), + 'SPEC_1': list([ + 'REQ_A', + 'REQ_B', + 'REQ_C', + ]), + }) +# --- +# name: test_links_sort[backlinks_sorted] + dict({ + 'REQ_TARGET': dict({ + 'links_back': list([ + 'SPEC_1', + 'SPEC_2', + 'SPEC_10', + ]), + }), + 'SPEC_1': dict({ + 'links': list([ + 'REQ_TARGET', + ]), + }), + 'SPEC_10': dict({ + 'links': list([ + 'REQ_TARGET', + ]), + }), + 'SPEC_2': dict({ + 'links': list([ + 'REQ_TARGET', + ]), + }), + }) +# --- +# name: test_links_sort[backlinks_sorted].1 + dict({ + 'REQ_TARGET': list([ + 'SPEC_1', + 'SPEC_2', + 'SPEC_10', + ]), + 'SPEC_1': list([ + 'REQ_TARGET', + ]), + 'SPEC_10': list([ + 'REQ_TARGET', + ]), + 'SPEC_2': list([ + 'REQ_TARGET', + ]), + }) +# --- +# name: test_links_sort[custom_link_types] + dict({ + 'REQ_A': dict({ + 'derives_back': list([ + 'SPEC_1', + ]), + 'implements_back': list([ + 'SPEC_1', + ]), + }), + 'REQ_B': dict({ + 'derives_back': list([ + 'SPEC_1', + ]), + 'implements_back': list([ + 'SPEC_1', + ]), + }), + 'REQ_C': dict({ + 'implements_back': list([ + 'SPEC_1', + ]), + }), + 'SPEC_1': dict({ + 'derives': list([ + 'REQ_A', + 'REQ_B', + ]), + 'implements': list([ + 'REQ_A', + 'REQ_B', + 'REQ_C', + ]), + }), + }) +# --- +# name: test_links_sort[custom_link_types].1 + dict({ + 'REQ_A': list([ + 'SPEC_1', + 'SPEC_1', + ]), + 'REQ_B': list([ + 'SPEC_1', + 'SPEC_1', + ]), + 'REQ_C': list([ + 'SPEC_1', + ]), + 'SPEC_1': list([ + 'REQ_A', + 'REQ_B', + 'REQ_C', + 'REQ_A', + 'REQ_B', + ]), + }) +# --- +# name: test_links_sort[mixed_alphanumeric] + dict({ + 'REQ_A1': dict({ + 'links_back': list([ + 'SPEC_1', + ]), + }), + 'REQ_A10': dict({ + 'links_back': list([ + 'SPEC_1', + ]), + }), + 'REQ_A2': dict({ + 'links_back': list([ + 'SPEC_1', + ]), + }), + 'REQ_B1': dict({ + 'links_back': list([ + 'SPEC_1', + ]), + }), + 'SPEC_1': dict({ + 'links': list([ + 'REQ_A1', + 'REQ_A2', + 'REQ_A10', + 'REQ_B1', + ]), + }), + }) +# --- +# name: test_links_sort[mixed_alphanumeric].1 + dict({ + 'REQ_A1': list([ + 'SPEC_1', + ]), + 'REQ_A10': list([ + 'SPEC_1', + ]), + 'REQ_A2': list([ + 'SPEC_1', + ]), + 'REQ_B1': list([ + 'SPEC_1', + ]), + 'SPEC_1': list([ + 'REQ_A1', + 'REQ_A2', + 'REQ_A10', + 'REQ_B1', + ]), + }) +# --- +# name: test_links_sort[natural_outgoing] + dict({ + 'REQ_1': dict({ + 'links_back': list([ + 'SPEC_1', + ]), + }), + 'REQ_10': dict({ + 'links_back': list([ + 'SPEC_1', + ]), + }), + 'REQ_100': dict({ + 'links_back': list([ + 'SPEC_1', + ]), + }), + 'REQ_11': dict({ + 'links_back': list([ + 'SPEC_1', + ]), + }), + 'REQ_2': dict({ + 'links_back': list([ + 'SPEC_1', + ]), + }), + 'REQ_9': dict({ + 'links_back': list([ + 'SPEC_1', + ]), + }), + 'SPEC_1': dict({ + 'links': list([ + 'REQ_1', + 'REQ_2', + 'REQ_9', + 'REQ_10', + 'REQ_11', + 'REQ_100', + ]), + }), + }) +# --- +# name: test_links_sort[natural_outgoing].1 + dict({ + 'REQ_1': list([ + 'SPEC_1', + ]), + 'REQ_10': list([ + 'SPEC_1', + ]), + 'REQ_100': list([ + 'SPEC_1', + ]), + 'REQ_11': list([ + 'SPEC_1', + ]), + 'REQ_2': list([ + 'SPEC_1', + ]), + 'REQ_9': list([ + 'SPEC_1', + ]), + 'SPEC_1': list([ + 'REQ_1', + 'REQ_2', + 'REQ_9', + 'REQ_10', + 'REQ_11', + 'REQ_100', + ]), + }) +# --- +# name: test_links_sort[reproducible_json_disabled] + dict({ + 'REQ_A': dict({ + 'links_back': list([ + 'SPEC_2', + 'SPEC_10', + ]), + }), + 'REQ_B': dict({ + 'links_back': list([ + 'SPEC_2', + 'SPEC_10', + ]), + }), + 'REQ_C': dict({ + 'links_back': list([ + 'SPEC_10', + ]), + }), + 'SPEC_10': dict({ + 'links': list([ + 'REQ_A', + 'REQ_B', + 'REQ_C', + ]), + }), + 'SPEC_2': dict({ + 'links': list([ + 'REQ_A', + 'REQ_B', + ]), + }), + }) +# --- +# name: test_links_sort[reproducible_json_disabled].1 + dict({ + 'REQ_A': list([ + 'SPEC_2', + 'SPEC_10', + ]), + 'REQ_B': list([ + 'SPEC_2', + 'SPEC_10', + ]), + 'REQ_C': list([ + 'SPEC_10', + ]), + 'SPEC_10': list([ + 'REQ_A', + 'REQ_B', + 'REQ_C', + ]), + 'SPEC_2': list([ + 'REQ_A', + 'REQ_B', + ]), + }) +# --- +# name: test_links_sort[reproducible_json_enabled] + dict({ + 'REQ_A': dict({ + 'links_back': list([ + 'SPEC_2', + 'SPEC_10', + ]), + }), + 'REQ_B': dict({ + 'links_back': list([ + 'SPEC_2', + 'SPEC_10', + ]), + }), + 'REQ_C': dict({ + 'links_back': list([ + 'SPEC_10', + ]), + }), + 'SPEC_10': dict({ + 'links': list([ + 'REQ_A', + 'REQ_B', + 'REQ_C', + ]), + }), + 'SPEC_2': dict({ + 'links': list([ + 'REQ_A', + 'REQ_B', + ]), + }), + }) +# --- +# name: test_links_sort[reproducible_json_enabled].1 + dict({ + 'REQ_A': list([ + 'SPEC_2', + 'SPEC_10', + ]), + 'REQ_B': list([ + 'SPEC_2', + 'SPEC_10', + ]), + 'REQ_C': list([ + 'SPEC_10', + ]), + 'SPEC_10': list([ + 'REQ_A', + 'REQ_B', + 'REQ_C', + ]), + 'SPEC_2': list([ + 'REQ_A', + 'REQ_B', + ]), + }) +# --- diff --git a/tests/fixtures/sort_links.yml b/tests/fixtures/sort_links.yml new file mode 100644 index 000000000..5148a06b4 --- /dev/null +++ b/tests/fixtures/sort_links.yml @@ -0,0 +1,192 @@ +alphabetical_outgoing: + conf: | + extensions = ["sphinx_needs"] + version = "1.0" + needs_build_json = True + needs_builder_filter = "True" + rst: | + Sorted Links Test + ================= + + .. req:: REQ A + :id: REQ_A + + .. req:: REQ B + :id: REQ_B + + .. req:: REQ C + :id: REQ_C + + .. spec:: SPEC 1 + :id: SPEC_1 + :links: REQ_C, REQ_A, REQ_B + +natural_outgoing: + conf: | + extensions = ["sphinx_needs"] + version = "1.0" + needs_build_json = True + needs_builder_filter = "True" + rst: | + Natural Sort Test + ================= + + .. req:: REQ 1 + :id: REQ_1 + + .. req:: REQ 2 + :id: REQ_2 + + .. req:: REQ 9 + :id: REQ_9 + + .. req:: REQ 10 + :id: REQ_10 + + .. req:: REQ 11 + :id: REQ_11 + + .. req:: REQ 100 + :id: REQ_100 + + .. spec:: SPEC mixed + :id: SPEC_1 + :links: REQ_100, REQ_2, REQ_11, REQ_1, REQ_10, REQ_9 + +mixed_alphanumeric: + conf: | + extensions = ["sphinx_needs"] + version = "1.0" + needs_build_json = True + needs_builder_filter = "True" + rst: | + Mixed Alphanumeric Sort Test + ============================ + + .. req:: REQ A1 + :id: REQ_A1 + + .. req:: REQ A2 + :id: REQ_A2 + + .. req:: REQ A10 + :id: REQ_A10 + + .. req:: REQ B1 + :id: REQ_B1 + + .. spec:: SPEC mixed + :id: SPEC_1 + :links: REQ_B1, REQ_A10, REQ_A1, REQ_A2 + +backlinks_sorted: + conf: | + extensions = ["sphinx_needs"] + version = "1.0" + needs_build_json = True + needs_builder_filter = "True" + rst: | + Sorted Backlinks Test + ===================== + + .. req:: REQ target + :id: REQ_TARGET + + .. spec:: SPEC 10 + :id: SPEC_10 + :links: REQ_TARGET + + .. spec:: SPEC 2 + :id: SPEC_2 + :links: REQ_TARGET + + .. spec:: SPEC 1 + :id: SPEC_1 + :links: REQ_TARGET + +custom_link_types: + conf: | + extensions = ["sphinx_needs"] + version = "1.0" + needs_build_json = True + needs_builder_filter = "True" + needs_extra_links = [ + {"option": "implements", "incoming": "implemented by", "outgoing": "implements"}, + {"option": "derives", "incoming": "derived by", "outgoing": "derives from"}, + ] + rst: | + Custom Link Types Test + ====================== + + .. req:: REQ A + :id: REQ_A + + .. req:: REQ B + :id: REQ_B + + .. req:: REQ C + :id: REQ_C + + .. spec:: SPEC 1 + :id: SPEC_1 + :implements: REQ_C, REQ_A, REQ_B + :derives: REQ_B, REQ_A + +# The following two cases share identical RST content and only differ in +# ``needs_reproducible_json``: their snapshots must therefore be identical, +# proving that link sorting is unconditional with respect to this flag. +reproducible_json_enabled: + conf: | + extensions = ["sphinx_needs"] + version = "1.0" + needs_build_json = True + needs_builder_filter = "True" + needs_reproducible_json = True + rst: | + Reproducible JSON Toggle Test + ============================= + + .. req:: REQ A + :id: REQ_A + + .. req:: REQ B + :id: REQ_B + + .. req:: REQ C + :id: REQ_C + + .. spec:: SPEC 10 + :id: SPEC_10 + :links: REQ_C, REQ_A, REQ_B + + .. spec:: SPEC 2 + :id: SPEC_2 + :links: REQ_B, REQ_A + +reproducible_json_disabled: + conf: | + extensions = ["sphinx_needs"] + version = "1.0" + needs_build_json = True + needs_builder_filter = "True" + needs_reproducible_json = False + rst: | + Reproducible JSON Toggle Test + ============================= + + .. req:: REQ A + :id: REQ_A + + .. req:: REQ B + :id: REQ_B + + .. req:: REQ C + :id: REQ_C + + .. spec:: SPEC 10 + :id: SPEC_10 + :links: REQ_C, REQ_A, REQ_B + + .. spec:: SPEC 2 + :id: SPEC_2 + :links: REQ_B, REQ_A diff --git a/tests/schema/__snapshots__/test_schema.ambr b/tests/schema/__snapshots__/test_schema.ambr index 0cb4a6873..32c1491b9 100644 --- a/tests/schema/__snapshots__/test_schema.ambr +++ b/tests/schema/__snapshots__/test_schema.ambr @@ -829,8 +829,8 @@ 'links': list([ ]), 'links_back': list([ - 'SPEC_SAFE_UNSAFE_FEAT', 'SPEC_SAFE_ADD_UNSAFE_FEAT', + 'SPEC_SAFE_UNSAFE_FEAT', ]), 'max_amount': None, 'max_content_lines': None, @@ -986,8 +986,8 @@ 'links': list([ ]), 'links_back': list([ - 'SPEC_SAFE_ADD_UNSAFE_FEAT', 'SPEC_SAFE', + 'SPEC_SAFE_ADD_UNSAFE_FEAT', ]), 'max_amount': None, 'max_content_lines': None, diff --git a/tests/test_needs_sort.py b/tests/test_needs_sort.py new file mode 100644 index 000000000..30e829df8 --- /dev/null +++ b/tests/test_needs_sort.py @@ -0,0 +1,111 @@ +"""Tests that need links and backlinks are sorted in needs.json and HTML output. + +Regression tests for https://github.com/useblocks/sphinx-needs/issues/1371. +Sorting uses natural ordering so that ``REQ_2`` < ``REQ_9`` < ``REQ_10``. +""" + +from __future__ import annotations + +import json +import re +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest +from sphinx.testing.util import SphinxTestApp + +_LINK_FIELD_TYPES = {"links", "backlinks"} + + +def _link_field_names(needs_json: dict[str, Any]) -> set[str]: + """Return the set of fields whose ``field_type`` is a link or backlink. + + Reads from the ``needs_schema`` embedded in the needs.json output. + Falls back to a small default set if no schema is present. + """ + versions = needs_json.get("versions", {}) + if not versions: + return {"links", "links_back", "parent_needs", "parent_needs_back"} + schema = next(iter(versions.values())).get("needs_schema", {}) + properties = schema.get("properties", {}) + return { + name + for name, params in properties.items() + if params.get("field_type") in _LINK_FIELD_TYPES + } + + +def _link_fields_per_need( + needs_data: dict[str, Any], link_fields: set[str] +) -> dict[str, dict[str, list[str]]]: + """Extract only the (non-empty) link/backlink fields from each need.""" + result: dict[str, dict[str, list[str]]] = {} + for need_id, need in needs_data.items(): + per_need = { + field: need[field] for field in sorted(link_fields) if need.get(field) + } + if per_need: + result[need_id] = per_need + return result + + +def _html_link_refs(html: str, need_ids: set[str]) -> dict[str, list[str]]: + """Extract the rendered link references per source need from HTML. + + The rendered output for each link section uses ``title=""`` + on the anchor (set by sphinx-needs' role implementations) and the target + need ID as the link text. This returns a dict mapping each source need to + the ordered list of targets it links to (or that link to it, for the + backlinks section). Self-references (a need's own header link) and + non-need anchors (e.g. section permalinks) are skipped. + """ + pattern = re.compile( + r'href="#(?P[^"]+)" title="(?P[^"]+)">' + r"(?P[^<]+)" + ) + refs: dict[str, list[str]] = {} + for m in pattern.finditer(html): + source = m.group("source") + target = m.group("target") + text = m.group("text") + if source not in need_ids: + # Not a need's link section (e.g. section heading permalink). + continue + if source == target == text: + # Self-reference (need's own header): skip. + continue + refs.setdefault(source, []).append(target) + return refs + + +@pytest.mark.fixture_file("fixtures/sort_links.yml") +def test_links_sort( + tmpdir: Path, + content: dict[str, Any], + make_app: Callable[..., SphinxTestApp], + write_fixture_files: Callable[[Path, dict[str, Any]], None], + snapshot, +) -> None: + """Need links and backlinks are sorted in both needs.json and HTML output. + + Sorting uses natural ordering so embedded numbers compare as ints, and is + unconditional with respect to ``needs_reproducible_json`` — see the + ``reproducible_json_enabled`` / ``reproducible_json_disabled`` fixtures + whose link snapshots must be byte-identical. + """ + write_fixture_files(tmpdir, content) + app: SphinxTestApp = make_app(srcdir=Path(tmpdir), freshenv=True) + app.build() + assert app.statuscode == 0 + + needs_json = json.loads(Path(app.outdir, "needs.json").read_text("utf8")) + versions = needs_json["versions"] + needs_data = next(iter(versions.values()))["needs"] + link_fields = _link_field_names(needs_json) + assert _link_fields_per_need(needs_data, link_fields) == snapshot + + html = Path(app.outdir, "index.html").read_text("utf8") + assert _html_link_refs(html, set(needs_data)) == snapshot + + app.cleanup()