From c4ac40e5c1b5754a4d8e9f3c316677f454c09750 Mon Sep 17 00:00:00 2001 From: Andreas Zwinkau Date: Sat, 21 Feb 2026 16:17:27 +0100 Subject: [PATCH 1/3] Zero needs does not break needs.json anymore Fix producer-side by always adding the structures. Fix consumer-side by accepting missing structure. Fixes #1569 --- sphinx_needs/external_needs.py | 15 ++++-- sphinx_needs/needsfile.py | 1 + .../doc_test/doc_needs_builder_empty/conf.py | 3 ++ .../doc_needs_builder_empty/index.rst | 4 ++ tests/test_external.py | 48 +++++++++++++++++++ tests/test_needs_builder.py | 19 ++++++++ 6 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 tests/doc_test/doc_needs_builder_empty/conf.py create mode 100644 tests/doc_test/doc_needs_builder_empty/index.rst diff --git a/sphinx_needs/external_needs.py b/sphinx_needs/external_needs.py index 0e39b4ccb..777a8de4b 100644 --- a/sphinx_needs/external_needs.py +++ b/sphinx_needs/external_needs.py @@ -105,12 +105,17 @@ def load_external_needs( data = needs_json["versions"][version] needs = data["needs"] except KeyError: - uri = source.get("json_url", source.get("json_path", "unknown")) - raise NeedsExternalException( - clean_log( - f"Version {version} not found in json file from {uri}: {list(needs_json.get('versions'))}" + if not needs_json.get("versions"): + # The versions dict is empty, so no needs were ever added. + data = {} + needs = {} + else: + uri = source.get("json_url", source.get("json_path", "unknown")) + raise NeedsExternalException( + clean_log( + f"Version {version} not found in json file from {uri}: {list(needs_json.get('versions'))}" + ) ) - ) log.debug(f"Loading {len(needs)} needs.") diff --git a/sphinx_needs/needsfile.py b/sphinx_needs/needsfile.py index 514144381..34e1676d4 100644 --- a/sphinx_needs/needsfile.py +++ b/sphinx_needs/needsfile.py @@ -179,6 +179,7 @@ def wipe_version(self, version: str) -> None: del self.needs_list["versions"][version] def _finalise(self) -> None: + self.update_or_add_version(self.current_version) # We need to rewrite some data, because this kind of data gets overwritten during needs.json import if not self.needs_config.reproducible_json: self.needs_list["created"] = datetime.now().isoformat() diff --git a/tests/doc_test/doc_needs_builder_empty/conf.py b/tests/doc_test/doc_needs_builder_empty/conf.py new file mode 100644 index 000000000..80787a07e --- /dev/null +++ b/tests/doc_test/doc_needs_builder_empty/conf.py @@ -0,0 +1,3 @@ +project = "doc_needs_builder_empty" + +extensions = ["sphinx_needs"] diff --git a/tests/doc_test/doc_needs_builder_empty/index.rst b/tests/doc_test/doc_needs_builder_empty/index.rst new file mode 100644 index 000000000..695b53689 --- /dev/null +++ b/tests/doc_test/doc_needs_builder_empty/index.rst @@ -0,0 +1,4 @@ +Empty doc +========= + +There are no needs here. diff --git a/tests/test_external.py b/tests/test_external.py index 5c147265d..8dd77f47f 100644 --- a/tests/test_external.py +++ b/tests/test_external.py @@ -261,3 +261,51 @@ def test_external_allow_type_coercion_false(test_app): assert strip_colors(app._warning.getvalue()).splitlines() == [ "WARNING: External need 'TEST_01' in 'needs.json' could not be added: 'tags' value is invalid: Invalid value for field 'tags': 'a,b,c' [needs.load_external_need]" ] + +@pytest.mark.parametrize( + "test_app", + [ + { + "buildername": "html", + "files": [ + ("index.rst", "Test\n====\n"), + ( + "conf.py", + """ +extensions = ["sphinx_needs"] +needs_external_needs = [{ + 'json_path': 'needs.json', + 'base_url': 'http://my_company.com/docs/v1/', +}] +needs_build_json = True + """, + ), + ], + "no_plantuml": True, + } + ], + indirect=True, +) +def test_external_empty_versions(test_app): + """Test external needs when the loaded needs.json has an empty versions dict.""" + json_path = Path(test_app.srcdir) / "needs.json" + json_path.write_text( + json.dumps( + { + "current_version": "0.1.0", + "project": "foo", + "project_url": "https://bar", + "versions": {} + } + ) + ) + + app = test_app + app.build() + assert app.statuscode == 0 + assert not app._warning.getvalue() + + needs_json = Path(test_app.outdir, "needs.json").read_text() + needs = json.loads(needs_json) + # the empty external needs should just be ignored without crashing. + assert "TEST_01" not in needs["versions"][""]["needs"] diff --git a/tests/test_needs_builder.py b/tests/test_needs_builder.py index e0e9aaf54..9351b0274 100644 --- a/tests/test_needs_builder.py +++ b/tests/test_needs_builder.py @@ -115,3 +115,22 @@ def test_needs_html_and_json(test_app): need_data_1 = needs_1["versions"]["1.0"]["needs"] need_data_2 = needs_2["versions"]["1.0"]["needs"] assert need_data_1 == need_data_2 + +@pytest.mark.parametrize( + "test_app", + [{"buildername": "needs", "srcdir": "doc_test/doc_needs_builder_empty"}], + indirect=True, +) +def test_doc_needs_builder_empty(test_app): + app = test_app + app.build() + + needs_list = json.loads(Path(app.outdir, "needs.json").read_text()) + assert "current_version" in needs_list + assert needs_list["current_version"] == "" + + version = needs_list["current_version"] + assert "versions" in needs_list + assert version in needs_list["versions"] + assert needs_list["versions"][version]["needs_amount"] == 0 + assert needs_list["versions"][version]["needs"] == {} From fef3754595c3f4b1f26078308a85fa1f4e2a52d2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:22:30 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_external.py | 3 ++- tests/test_needs_builder.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_external.py b/tests/test_external.py index 8dd77f47f..d4a910b0e 100644 --- a/tests/test_external.py +++ b/tests/test_external.py @@ -262,6 +262,7 @@ def test_external_allow_type_coercion_false(test_app): "WARNING: External need 'TEST_01' in 'needs.json' could not be added: 'tags' value is invalid: Invalid value for field 'tags': 'a,b,c' [needs.load_external_need]" ] + @pytest.mark.parametrize( "test_app", [ @@ -295,7 +296,7 @@ def test_external_empty_versions(test_app): "current_version": "0.1.0", "project": "foo", "project_url": "https://bar", - "versions": {} + "versions": {}, } ) ) diff --git a/tests/test_needs_builder.py b/tests/test_needs_builder.py index 9351b0274..33a28b1b1 100644 --- a/tests/test_needs_builder.py +++ b/tests/test_needs_builder.py @@ -116,6 +116,7 @@ def test_needs_html_and_json(test_app): need_data_2 = needs_2["versions"]["1.0"]["needs"] assert need_data_1 == need_data_2 + @pytest.mark.parametrize( "test_app", [{"buildername": "needs", "srcdir": "doc_test/doc_needs_builder_empty"}], @@ -128,7 +129,7 @@ def test_doc_needs_builder_empty(test_app): needs_list = json.loads(Path(app.outdir, "needs.json").read_text()) assert "current_version" in needs_list assert needs_list["current_version"] == "" - + version = needs_list["current_version"] assert "versions" in needs_list assert version in needs_list["versions"] From 2f29f76a29b81e4667076958c2d6c98d8498ea9a Mon Sep 17 00:00:00 2001 From: Andreas Zwinkau Date: Sat, 21 Feb 2026 20:05:23 +0100 Subject: [PATCH 3/3] Adapt another test case due to behavior change --- tests/__snapshots__/test_needimport.ambr | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/__snapshots__/test_needimport.ambr b/tests/__snapshots__/test_needimport.ambr index fb9f874eb..63435e653 100644 --- a/tests/__snapshots__/test_needimport.ambr +++ b/tests/__snapshots__/test_needimport.ambr @@ -2116,6 +2116,12 @@ dict({ 'current_version': '1.0', 'versions': dict({ + '1.0': dict({ + 'needs': dict({ + }), + 'needs_amount': 0, + 'needs_defaults_removed': True, + }), }), }) # ---