Skip to content

Commit 6e9359a

Browse files
authored
πŸ‘Œ Improve need part processing (#1469)
The `need_part`/`np` role is not a reference and should not use an `XRefRole` subclass (see #1437). In addition, the processing now correctly handles and warns on need parts that are not part of a need and `need` roles that reference an unknown part.
1 parent fa75f22 commit 6e9359a

File tree

7 files changed

+99
-61
lines changed

7 files changed

+99
-61
lines changed

β€Žpyproject.tomlβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ disable_error_code = ["no-redef"]
144144

145145
legacy_tox_ini = """
146146
[tox]
147-
envlist = py10
147+
envlist = py310
148148
149149
[testenv]
150150
usedevelop = true

β€Žsphinx_needs/logging.pyβ€Ž

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def get_logger(name: str) -> SphinxLoggerAdapter:
4242
"load_external_need",
4343
"load_service_need",
4444
"mpl",
45+
"part",
4546
"title",
4647
"uml",
4748
"unknown_external_keys",
@@ -81,6 +82,7 @@ def get_logger(name: str) -> SphinxLoggerAdapter:
8182
"load_external_need": "Failed to load an external need",
8283
"load_service_need": "Failed to load a service need",
8384
"mpl": "Matplotlib required but not installed",
85+
"part": "Error processing need part",
8486
"title": "Error creating need title",
8587
"uml": "Error in processing of UML diagram",
8688
"unknown_external_keys": "Unknown keys found in external need data",

β€Žsphinx_needs/needs.pyβ€Ž

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@
114114
from sphinx_needs.roles.need_func import NeedFunc, NeedFuncRole, process_need_func
115115
from sphinx_needs.roles.need_incoming import NeedIncoming, process_need_incoming
116116
from sphinx_needs.roles.need_outgoing import NeedOutgoing, process_need_outgoing
117-
from sphinx_needs.roles.need_part import NeedPart, process_need_part
117+
from sphinx_needs.roles.need_part import NeedPart, NeedPartRole, process_need_part
118118
from sphinx_needs.roles.need_ref import NeedRef, process_need_ref
119119
from sphinx_needs.services.github import GithubService
120120
from sphinx_needs.services.open_needs import OpenNeedsService
@@ -242,19 +242,8 @@ def setup(app: Sphinx) -> dict[str, Any]:
242242
),
243243
)
244244

245-
app.add_role(
246-
"need_part",
247-
NeedsXRefRole(
248-
nodeclass=NeedPart, innernodeclass=nodes.inline, warn_dangling=True
249-
),
250-
)
251-
# Shortcut for need_part
252-
app.add_role(
253-
"np",
254-
NeedsXRefRole(
255-
nodeclass=NeedPart, innernodeclass=nodes.inline, warn_dangling=True
256-
),
257-
)
245+
app.add_role("need_part", NeedPartRole())
246+
app.add_role("np", NeedPartRole()) # Shortcut for need_part
258247

259248
app.add_role(
260249
"need_count",

β€Žsphinx_needs/roles/need_part.pyβ€Ž

Lines changed: 68 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,72 @@
33
---------------
44
Provides the ability to mark specific parts of a need with an own id.
55
6-
Most voodoo is done in need.py
7-
86
"""
97

108
from __future__ import annotations
119

1210
import hashlib
1311
import re
1412
from collections.abc import Iterable
15-
from typing import cast
1613

1714
from docutils import nodes
1815
from sphinx.application import Sphinx
1916
from sphinx.environment import BuildEnvironment
17+
from sphinx.util.docutils import SphinxRole
2018
from sphinx.util.nodes import make_refnode
2119

2220
from sphinx_needs.data import NeedsInfoType, NeedsPartType
2321
from sphinx_needs.logging import get_logger, log_warning
2422
from sphinx_needs.nodes import Need
2523

26-
log = get_logger(__name__)
24+
LOGGER = get_logger(__name__)
2725

2826

2927
class NeedPart(nodes.Inline, nodes.Element):
30-
pass
28+
@property
29+
def title(self) -> str:
30+
"""Return the title of the part."""
31+
return self.attributes["title"] # type: ignore[no-any-return]
32+
33+
@property
34+
def part_id(self) -> str:
35+
"""Return the ID of the part."""
36+
return self.attributes["part_id"] # type: ignore[no-any-return]
37+
38+
@property
39+
def need_id(self) -> str | None:
40+
"""Return the ID of the need this part belongs to."""
41+
return self.attributes["need_id"] # type: ignore[no-any-return]
42+
43+
@need_id.setter
44+
def need_id(self, value: str) -> None:
45+
"""Set the ID of the need this part belongs to."""
46+
self.attributes["need_id"] = value
47+
48+
49+
_PART_PATTERN = re.compile(r"\(([\w-]+)\)(.*)", re.DOTALL)
50+
51+
52+
class NeedPartRole(SphinxRole):
53+
"""
54+
Role for need parts, which are sub-needs of a need.
55+
It is used to mark parts of a need with an own id.
56+
"""
57+
58+
def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]:
59+
# note self.text is the content of the role, with backslash escapes removed
60+
# TODO perhaps in a future change we should allow escaping parentheses in the part id?
61+
# and also strip (unescaped) space before/after the title
62+
result = _PART_PATTERN.match(self.text)
63+
if result:
64+
id_ = result.group(1)
65+
title = result.group(2)
66+
else:
67+
id_ = hashlib.sha1(self.text.encode("UTF-8")).hexdigest().upper()[:3]
68+
title = self.text
69+
part = NeedPart(title=title, part_id=id_, need_id=None)
70+
self.set_source_info(part)
71+
return [part], []
3172

3273

3374
def process_need_part(
@@ -36,10 +77,16 @@ def process_need_part(
3677
fromdocname: str,
3778
found_nodes: list[nodes.Element],
3879
) -> None:
39-
pass
40-
41-
42-
part_pattern = re.compile(r"\(([\w-]+)\)(.*)", re.DOTALL)
80+
# note this is called after needs have been processed and parts collected.
81+
for node in found_nodes:
82+
assert isinstance(node, NeedPart), "Expected NeedPart node"
83+
if node.need_id is None:
84+
log_warning(
85+
LOGGER,
86+
"Need part not associated with a need.",
87+
"part",
88+
node,
89+
)
4390

4491

4592
def create_need_from_part(need: NeedsInfoType, part: NeedsPartType) -> NeedsInfoType:
@@ -67,58 +114,43 @@ def update_need_with_parts(
67114
) -> None:
68115
app = env.app
69116
for part_node in part_nodes:
70-
content = cast(str, part_node.children[0].children[0]) # ->inline->Text
71-
result = part_pattern.match(content)
72-
if result:
73-
inline_id = result.group(1)
74-
part_content = result.group(2)
75-
else:
76-
part_content = content
77-
inline_id = (
78-
hashlib.sha1(part_content.encode("UTF-8")).hexdigest().upper()[:3]
79-
)
117+
part_id = part_node.part_id
80118

81119
if "parts" not in need:
82120
need["parts"] = {}
83121

84-
if inline_id in need["parts"]:
122+
if part_id in need["parts"]:
85123
log_warning(
86-
log,
124+
LOGGER,
87125
"part_need id {} in need {} is already taken. need_part may get overridden.".format(
88-
inline_id, need["id"]
126+
part_id, need["id"]
89127
),
90128
"duplicate_part_id",
91129
part_node,
92130
)
93131

94-
need["parts"][inline_id] = {
95-
"id": inline_id,
96-
"content": part_content,
132+
need["parts"][part_id] = {
133+
"id": part_id,
134+
"content": part_node.title,
97135
"links": [],
98136
"links_back": [],
99137
}
100138

101-
part_id_ref = "{}.{}".format(need["id"], inline_id)
102-
139+
part_node.need_id = need["id"]
140+
part_id_ref = "{}.{}".format(need["id"], part_id)
103141
part_node["reftarget"] = part_id_ref
104142

105-
part_text_node = nodes.Text(part_content)
106-
107-
part_node.children = []
108143
node_need_part_line = nodes.inline(ids=[part_id_ref], classes=["need-part"])
109-
node_need_part_line.append(part_text_node)
144+
node_need_part_line.append(nodes.Text(part_node.title))
110145

111146
if docname := need["docname"]:
112-
part_id_show = inline_id
113-
part_link_text = f" {part_id_show}"
114-
part_link_node = nodes.Text(part_link_text)
115-
116147
part_ref_node = make_refnode(
117-
app.builder, docname, docname, part_id_ref, part_link_node
148+
app.builder, docname, docname, part_id_ref, nodes.Text(f" {part_id}")
118149
)
119150
part_ref_node["classes"] += ["needs-id"]
120151
node_need_part_line.append(part_ref_node)
121152

153+
part_node.children = []
122154
part_node.append(node_need_part_line)
123155

124156

β€Žsphinx_needs/roles/need_ref.pyβ€Ž

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,24 @@ def process_need_ref(
8585
need_id_full = node_need_ref["reftarget"]
8686
need_id_main, need_id_part = split_need_id(need_id_full)
8787

88-
if need_id_main in all_needs:
88+
if need_id_main not in all_needs:
89+
log_warning(
90+
log,
91+
f"linked need {node_need_ref['reftarget']} not found",
92+
"link_ref",
93+
location=node_need_ref,
94+
)
95+
elif (
96+
need_id_part is not None
97+
and need_id_part not in all_needs[need_id_main]["parts"]
98+
):
99+
log_warning(
100+
log,
101+
f"linked need part {node_need_ref['reftarget']} not found",
102+
"link_ref",
103+
location=node_need_ref,
104+
)
105+
else:
89106
target_need = all_needs[need_id_main]
90107

91108
dict_need = transform_need_to_dict(
@@ -162,12 +179,4 @@ def process_need_ref(
162179
)
163180
new_node_ref["classes"].append(target_need["external_css"])
164181

165-
else:
166-
log_warning(
167-
log,
168-
f"linked need {node_need_ref['reftarget']} not found",
169-
"link_ref",
170-
location=node_need_ref,
171-
)
172-
173182
node_need_ref.replace_self(new_node_ref)

β€Žtests/doc_test/doc_need_parts/index.rstβ€Ž

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ NEED PARTS
2323

2424
Part in nested need: :need_part:`(nested_id)something`
2525

26+
:np:`hallo`
2627

2728
:need:`SP_TOO_001.1`
2829

@@ -31,3 +32,5 @@ NEED PARTS
3132
:need:`SP_TOO_001.awesome_id`
3233

3334
:need:`My custom link name <SP_TOO_001.awesome_id>`
35+
36+
:need:`SP_TOO_001.unknown_part`

β€Žtests/test_need_parts.pyβ€Ž

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ def test_doc_need_parts(test_app, snapshot):
1919
app._warning.getvalue().replace(str(app.srcdir) + os.sep, "srcdir/")
2020
).splitlines()
2121
# print(warnings)
22-
assert warnings == []
22+
assert warnings == [
23+
"srcdir/index.rst:26: WARNING: Need part not associated with a need. [needs.part]",
24+
"srcdir/index.rst:36: WARNING: linked need part SP_TOO_001.unknown_part not found [needs.link_ref]",
25+
]
2326

2427
html = Path(app.outdir, "index.html").read_text()
2528
assert (

0 commit comments

Comments
Β (0)