Skip to content

Commit ff4ae90

Browse files
authored
🐛 fix warnings for duplicate needs in parallel builds (#1223)
Change duplicate need feedback from raising exceptions to emitting Sphinx warnings, e.g. ``` path/to/page.rst:4: WARNING: A need with ID STORY_PAGE_1 already exists, title: 'duplicate'. [needs.duplicate_id] ``` and ensure warning is also emitted during parallel builds Note, although there was already a parallel build test in the test suite, actually it was not running parallel because there were not enough documents in the test project (see sphinx-doc/sphinx#12796), so one more document is added.
1 parent 2045121 commit ff4ae90

File tree

8 files changed

+96
-48
lines changed

8 files changed

+96
-48
lines changed

sphinx_needs/api/exceptions.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,6 @@ class NeedsNoIdException(SphinxError):
1919
pass
2020

2121

22-
class NeedsDuplicatedId(SphinxError):
23-
pass
24-
25-
2622
class NeedsStatusNotAllowed(SphinxError):
2723
pass
2824

sphinx_needs/api/need.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from sphinx_needs.api.configuration import NEEDS_CONFIG
1717
from sphinx_needs.api.exceptions import (
1818
NeedsConstraintNotAllowed,
19-
NeedsDuplicatedId,
2019
NeedsInvalidException,
2120
NeedsInvalidOption,
2221
NeedsNoIdException,
@@ -312,19 +311,24 @@ def run():
312311

313312
if need_id in SphinxNeedsData(env).get_or_create_needs():
314313
if id:
315-
raise NeedsDuplicatedId(
316-
f"A need with ID {need_id} already exists! "
317-
f"This is not allowed. Document {docname}[{lineno}] Title: {title}."
318-
)
314+
message = f"A need with ID {need_id} already exists, " f"title: {title!r}."
319315
else: # this is a generated ID
320-
raise NeedsDuplicatedId(
316+
_title = " ".join(title)
317+
message = (
321318
"Needs could not generate a unique ID for a need with "
322-
"the title '{}' because another need had the same title. "
319+
f"the title {_title!r} because another need had the same title. "
323320
"Either supply IDs for the requirements or ensure the "
324321
"titles are different. NOTE: If title is being generated "
325322
"from the content, then ensure the first sentence of the "
326-
"requirements are different.".format(" ".join(title))
323+
"requirements are different."
327324
)
325+
logger.warning(
326+
message + " [needs.duplicate_id]",
327+
type="needs",
328+
subtype="duplicate_id",
329+
location=(docname, lineno) if docname else None,
330+
)
331+
return []
328332

329333
# Trim title if it is too long
330334
max_length = needs_config.max_title_length

sphinx_needs/data.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
from typing import TYPE_CHECKING, Literal, TypedDict
88

9+
from sphinx.util.logging import getLogger
10+
911
if TYPE_CHECKING:
1012
from docutils.nodes import Element, Text
1113
from sphinx.application import Sphinx
@@ -15,6 +17,9 @@
1517
from sphinx_needs.services.manager import ServiceManager
1618

1719

20+
LOGGER = getLogger(__name__)
21+
22+
1823
class NeedsFilterType(TypedDict):
1924
filter: str
2025
"""Filter string."""
@@ -612,7 +617,7 @@ def get_or_create_umls(self) -> dict[str, NeedsUmlType]:
612617

613618

614619
def merge_data(
615-
_app: Sphinx, env: BuildEnvironment, _docnames: list[str], other: BuildEnvironment
620+
_app: Sphinx, env: BuildEnvironment, docnames: list[str], other: BuildEnvironment
616621
) -> None:
617622
"""
618623
Performs data merge of parallel executed workers.
@@ -621,13 +626,32 @@ def merge_data(
621626
Needs to update env manually for all data Sphinx-Needs collect during read phase
622627
"""
623628

624-
# Update global needs dict
625-
needs = SphinxNeedsData(env).get_or_create_needs()
626-
other_needs = SphinxNeedsData(other).get_or_create_needs()
627-
needs.update(other_needs)
628629
if SphinxNeedsData(other).has_export_filters:
629630
SphinxNeedsData(env).has_export_filters = True
630631

632+
# Update needs
633+
needs = SphinxNeedsData(env).get_or_create_needs()
634+
other_needs = SphinxNeedsData(other).get_or_create_needs()
635+
for other_id, other_need in other_needs.items():
636+
if other_id in needs:
637+
# we only want to warn if the need comes from one of the docs parsed in this worker
638+
_docname = other_need["docname"]
639+
if _docname in docnames:
640+
message = (
641+
f"A need with ID {other_id} already exists, "
642+
f"title: {other_need['title']!r}."
643+
)
644+
LOGGER.warning(
645+
message + " [needs.duplicate_id]",
646+
type="needs",
647+
subtype="duplicate_id",
648+
location=(_docname, other_need["lineno"]) if _docname else None,
649+
)
650+
else:
651+
needs[other_id] = other_need
652+
653+
# update other data
654+
631655
def _merge(name: str, is_complex_dict: bool = False) -> None:
632656
# Update global needs dict
633657
if not hasattr(env, name):

sphinx_needs/external_needs.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from sphinx.environment import BuildEnvironment
1212

1313
from sphinx_needs.api import add_external_need, del_need
14-
from sphinx_needs.api.exceptions import NeedsDuplicatedId
1514
from sphinx_needs.config import NeedsSphinxConfig
1615
from sphinx_needs.data import SphinxNeedsData
1716
from sphinx_needs.logging import get_logger
@@ -30,7 +29,7 @@ def get_target_template(target_url: str) -> Template:
3029
return mem_template
3130

3231

33-
def load_external_needs(app: Sphinx, env: BuildEnvironment, _docname: str) -> None:
32+
def load_external_needs(app: Sphinx, env: BuildEnvironment, docname: str) -> None:
3433
"""Load needs from configured external sources."""
3534
needs_config = NeedsSphinxConfig(app.config)
3635
for source in needs_config.external_needs:
@@ -159,10 +158,14 @@ def load_external_needs(app: Sphinx, env: BuildEnvironment, _docname: str) -> No
159158
# delete the already existing external need from api need
160159
del_need(app, ext_need_id)
161160
else:
162-
raise NeedsDuplicatedId(
163-
f'During external needs handling, an identical ID was detected: {ext_need_id} \
164-
from needs_external_needs url: {source["base_url"]}'
161+
log.warning(
162+
f'During external needs handling, an identical ID was detected: {ext_need_id} '
163+
f'from needs_external_needs url: {source["base_url"]} [needs.duplicate_id]',
164+
type="needs",
165+
subtype="duplicate_id",
166+
location=docname if docname else None,
165167
)
168+
return None
166169

167170
add_external_need(app, **need_params)
168171

tests/doc_test/parallel_doc/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ PARALLEL TEST DOCUMENT
77
page_2
88
page_3
99
page_4
10+
page_5
1011

1112
.. spec:: Command line interface
1213
:id: SP_TOO_001
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Page 5
2+
======
3+
4+
.. story:: duplicate
5+
:id: STORY_PAGE_1

tests/test_broken_doc.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
1-
import pytest
1+
import os
22

3-
from sphinx_needs.api.need import NeedsDuplicatedId
3+
import pytest
4+
from sphinx.testing.util import SphinxTestApp
5+
from sphinx.util.console import strip_colors
46

57

68
@pytest.mark.parametrize(
79
"test_app",
8-
[{"buildername": "html", "srcdir": "doc_test/broken_doc"}],
10+
[{"buildername": "html", "srcdir": "doc_test/broken_doc", "no_plantuml": True}],
911
indirect=True,
1012
)
11-
def test_doc_build_html(test_app):
12-
with pytest.raises(NeedsDuplicatedId):
13-
app = test_app
14-
15-
app.build()
16-
html = (app.outdir / "index.html").read_text()
17-
assert "<h1>BROKEN DOCUMENT" in html
18-
assert "SP_TOO_001" in html
13+
def test_doc_build_html(test_app: SphinxTestApp):
14+
test_app.build()
15+
warnings = (
16+
strip_colors(test_app._warning.getvalue())
17+
.replace(str(test_app.srcdir) + os.path.sep, "<srcdir>/")
18+
.strip()
19+
)
20+
assert (
21+
warnings
22+
== "<srcdir>/index.rst:11: WARNING: A need with ID SP_TOO_001 already exists, title: 'Command line interface'. [needs.duplicate_id]"
23+
)
24+
html = (test_app.outdir / "index.html").read_text()
25+
assert "<h1>BROKEN DOCUMENT" in html
26+
assert "SP_TOO_001" in html

tests/test_parallel_execution.py

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,38 @@
1+
import os
12
from pathlib import Path
23

34
import pytest
5+
from sphinx.util.console import strip_colors
46

57

68
@pytest.mark.parametrize(
79
"test_app",
8-
[{"buildername": "html", "srcdir": "doc_test/parallel_doc", "parallel": 4}],
10+
[
11+
{
12+
"buildername": "html",
13+
"srcdir": "doc_test/parallel_doc",
14+
"parallel": 4,
15+
"no_plantuml": True,
16+
}
17+
],
918
indirect=True,
1019
)
1120
def test_doc_build_html(test_app):
1221
app = test_app
1322
app.build()
14-
html = Path(app.outdir, "index.html").read_text()
15-
assert app.statuscode == 0
16-
assert "<h1>PARALLEL TEST DOCUMENT" in html
17-
assert "SP_TOO_001" in html
18-
23+
warnings = (
24+
strip_colors(app._warning.getvalue())
25+
.replace(str(app.srcdir) + os.path.sep, "<srcdir>/")
26+
.strip()
27+
)
28+
assert (
29+
warnings
30+
== "<srcdir>/page_5.rst:4: WARNING: A need with ID STORY_PAGE_1 already exists, title: 'duplicate'. [needs.duplicate_id]"
31+
)
1932

20-
@pytest.mark.parametrize(
21-
"test_app",
22-
[{"buildername": "html", "srcdir": "doc_test/parallel_doc", "parallel": 4}],
23-
indirect=True,
24-
)
25-
def test_doc_parallel_build_html(test_app):
26-
app = test_app
27-
app.build()
28-
assert app.statuscode == 0
33+
index_html = Path(app.outdir, "index.html").read_text()
34+
assert "<h1>PARALLEL TEST DOCUMENT" in index_html
35+
assert "SP_TOO_001" in index_html
2936

3037
page3_html = Path(app.outdir, "page_3.html").read_text()
3138
assert "Page 3" in page3_html

0 commit comments

Comments
 (0)