[docs] 1# @Test suite for one one-line need per id when src-trace scopes overlap, TEST_ONE_NEED_PER_ID_1, test, [IMPL_LNK_1]
2"""A one-line need two ``src-trace`` directives both find is created once (#2042).
3
4Every case builds a copy of ``doc_test/need_id_refs`` (see ``test_need_id_refs``) with
5``src/impl.cpp`` holding the one-line need ``IMPL_1``, and ``index``'s directive narrowed
6to ``:file: refs.cpp`` unless a case says otherwise, so that only the directives a case
7adds find ``IMPL_1``. The first owner keeps an id -- the directive read first, a
8hand-written need, whatever defined it -- and every other directive finding it skips it
9with one ``codelinks.duplicate_need`` warning at the marker's line. A document that
10skipped a need is read again when the owner's document changes or goes, so the need
11moves instead of vanishing.
12"""
13
14import hashlib
15import re
16from pathlib import Path
17
18import pytest
19from sphinx.testing.util import SphinxTestApp
20from sphinx.util.console import strip_colors
21from sphinx.util.parallel import parallel_available
22
23from sphinx_needs_testkit import build_warnings
24
25from .test_need_id_refs import (
26 _SHOWS_WARNING_TYPES,
27 DANGLING,
28 _build,
29 _json,
30 _MakeApp,
31 _project,
32)
33from .test_rediscovery import _scoped, _status, _touch_later
34from .test_url_links import _card_links as _field_links
35
36IMPL = "// @first impl, IMPL_1, impl, [REQ_001]\nvoid impl() {}\n"
37#: the files every case starts from: index traces refs.cpp only
38FILES = {
39 **_scoped(":file: refs.cpp"),
40 "src/impl.cpp": IMPL,
41 "src/other.cpp": "// nothing to trace\n",
42}
43
44
45def _trace(option: str = ":file: impl.cpp", project: str = "src") -> str:
46 return f"\n.. src-trace::\n :project: {project}\n {option}\n"
47
48
49def _duplicate(
50 owner: str,
51 docname: str,
52 location: str = "src/impl.cpp:1",
53 need_id: str = "IMPL_1",
54) -> str:
55 suffix = " [codelinks.duplicate_need]" if _SHOWS_WARNING_TYPES else ""
56 cure = (
57 "give the markers distinct ids"
58 if owner == docname
59 else "narrow one directive's scope"
60 )
61 return (
62 f"{location}: WARNING: one-line need {need_id!r} is already defined in document "
63 f"{owner!r}: not created again by the src-trace directive in {docname!r} "
64 f"({cure}){suffix}"
65 )
66
67
68def _duplicates(app: SphinxTestApp) -> list[str]:
69 return [w for w in build_warnings(app) if "is already defined" in w]
70
71
72def _owner(app: SphinxTestApp, need_id: str = "IMPL_1") -> str | None:
73 """The document ``need_id`` is defined in, per ``needs.json`` (``None``: no need)."""
74 need = _json(app)["needs"].get(need_id)
75 return None if need is None else need["docname"]
76
77
78def _cards(app: SphinxTestApp, page: str) -> int:
79 html = Path(app.outdir, page).read_text(encoding="utf-8")
80 return len(re.findall(r'id="IMPL_1"', html))
81
82
83def _rewrite(path: Path, old: str, new: str) -> None:
84 text = path.read_text(encoding="utf-8")
85 assert old in text
86 path.write_text(text.replace(old, new), encoding="utf-8")
87 _touch_later(path)
88
89
90#: a one-line style without ``id``: Sphinx-Needs generates it from the type's prefix and
91#: the title (local URLs off here; on, in the #2082 case)
92IDLESS = {
93 "toml_extra": (
94 "\n[codelinks.projects.src.analyse.oneline_comment_style]\n"
95 'needs_fields = [{ name = "title" }, { name = "type", default = "impl" }]\n'
96 ),
97 "toml_replace": ("set_local_url = true", "set_local_url = false"),
98}
99IDLESS_FILES = {**FILES, "src/impl.cpp": "// @first impl\nvoid impl() {}\n"}
100#: what Sphinx-Needs generates for ``first impl``: the ``impl`` type's prefix (``I_``) and
101#: the first five characters of the title's SHA-1 (``needs_id_length``), upper case
102GENERATED = "I_" + hashlib.sha1(b"first impl").hexdigest().upper()[:5]
103
104
105def test_two_directives_in_one_document_define_the_need_once(
106 tmp_path: Path, make_app: _MakeApp
107) -> None:
108 """``index`` traces the whole project and then ``impl.cpp`` again: one need, one
109 warning at the marker's line, and the build goes on (it used to abort with
110 ``duplicate_id``)."""
111 _project(
112 tmp_path,
113 files={**FILES, **_scoped(None)},
114 append={"docs/index.rst": _trace()},
115 )
116 app = _build(tmp_path, make_app)
117
118 assert _owner(app) == "index"
119 assert _duplicates(app) == [_duplicate("index", "index")]
120 assert _cards(app, "index.html") == 1
121
122
123def test_two_documents_the_earlier_sorting_one_defines_it(
124 tmp_path: Path, make_app: _MakeApp
125) -> None:
126 """Sphinx reads in sorted order: ``page1`` defines ``IMPL_1``, ``page2`` skips it."""
127 _project(
128 tmp_path,
129 files=FILES,
130 append={"docs/page1.rst": _trace(), "docs/page2.rst": _trace()},
131 )
132 app = _build(tmp_path, make_app)
133
134 assert _owner(app) == "page1"
135 assert _duplicates(app) == [_duplicate("page1", "page2")]
136 # read time, before the attach's warning
137 assert build_warnings(app) == [_duplicate("page1", "page2"), DANGLING]
138 assert (_cards(app, "page1.html"), _cards(app, "page2.html")) == (1, 0)
139
140
141def test_the_need_moves_when_its_owner_is_removed(
142 tmp_path: Path, make_app: _MakeApp
143) -> None:
144 """``page2`` skipped ``IMPL_1``; once ``page1`` is gone, ``page2`` is read again
145 and defines it."""
146 _project(
147 tmp_path,
148 files=FILES,
149 append={"docs/page1.rst": _trace(), "docs/page2.rst": _trace()},
150 )
151 assert _owner(_build(tmp_path, make_app)) == "page1"
152
153 (tmp_path / "docs" / "page1.rst").unlink()
154 app = _build(tmp_path, make_app, freshenv=False)
155
156 assert "0 added, 2 changed, 1 removed" in _status(app) # index: its glob toctree
157 assert re.search(r"reading sources\.\.\. \[[^\]]*\] page2\b", _status(app))
158 assert _owner(app) == "page2"
159 assert _duplicates(app) == []
160
161
162def test_the_need_moves_when_its_owner_no_longer_traces_it(
163 tmp_path: Path, make_app: _MakeApp
164) -> None:
165 """``page1``'s directive is narrowed to ``other.cpp``: ``page2`` is read again in
166 the same build and defines ``IMPL_1``."""
167 _project(
168 tmp_path,
169 files=FILES,
170 append={"docs/page1.rst": _trace(), "docs/page2.rst": _trace()},
171 )
172 _build(tmp_path, make_app)
173
174 _rewrite(tmp_path / "docs" / "page1.rst", ":file: impl.cpp", ":file: other.cpp")
175 app = _build(tmp_path, make_app, freshenv=False)
176
177 assert "0 added, 2 changed, 0 removed" in _status(app)
178 assert _owner(app) == "page2"
179 assert _duplicates(app) == []
180
181
182def test_the_need_moves_when_its_owner_loses_the_file_to_an_ignore_rule(
183 tmp_path: Path, make_app: _MakeApp
184) -> None:
185 """A ``.gitignore`` takes ``impl.cpp`` out of ``page1``'s directory scope: ``page1``
186 is read again because its scope changed (no dependency of it changed), and so is
187 ``page2``, which skipped the need."""
188 _project(
189 tmp_path,
190 files=FILES,
191 append={
192 "docs/page1.rst": _trace(":directory: ."),
193 "docs/page2.rst": _trace(),
194 },
195 )
196 assert _owner(_build(tmp_path, make_app)) == "page1"
197
198 (tmp_path / "src" / ".gitignore").write_text("impl.cpp\n", encoding="utf-8")
199 app = _build(tmp_path, make_app, freshenv=False)
200
201 assert "0 added, 2 changed, 0 removed" in _status(app)
202 assert _owner(app) == "page2"
203 assert _duplicates(app) == []
204
205
206def test_the_earlier_sorting_directive_takes_the_need_when_both_are_read(
207 tmp_path: Path, make_app: _MakeApp
208) -> None:
209 """An incremental build keeps the incumbent: ``page2`` defined ``IMPL_1`` before
210 ``page1`` traced it, and ``page1``, read alone, defers. Once both are read in one
211 build, the earlier-sorting ``page1`` defines it -- what a fresh build gives -- since
212 ``page2``'s old need is about to be purged."""
213 _project(tmp_path, files=FILES, append={"docs/page2.rst": _trace()})
214 assert _owner(_build(tmp_path, make_app)) == "page2"
215
216 page1 = tmp_path / "docs" / "page1.rst"
217 page1.write_text(page1.read_text(encoding="utf-8") + _trace(), encoding="utf-8")
218 _touch_later(page1)
219 incumbent = _build(tmp_path, make_app, freshenv=False)
220 assert "0 added, 1 changed, 0 removed" in _status(incumbent)
221 assert _owner(incumbent) == "page2"
222 assert _duplicates(incumbent) == [_duplicate("page2", "page1")]
223
224 for page in ("page1", "page2"):
225 path = tmp_path / "docs" / f"{page}.rst"
226 path.write_text(path.read_text(encoding="utf-8") + "\n", encoding="utf-8")
227 _touch_later(path)
228 app = _build(tmp_path, make_app, freshenv=False)
229
230 assert "0 added, 2 changed, 0 removed" in _status(app)
231 assert _owner(app) == "page1"
232 assert _duplicates(app) == [_duplicate("page1", "page2")]
233
234
235def test_a_hand_written_need_keeps_its_id(tmp_path: Path, make_app: _MakeApp) -> None:
236 """Whatever defined the id first owns it -- here an rst need in ``page1``; the
237 directive in ``page2`` skips the marker (it used to abort with ``duplicate_id``)."""
238 _project(
239 tmp_path,
240 files=FILES,
241 append={
242 "docs/page1.rst": "\n.. impl:: Hand-written\n :id: IMPL_1\n",
243 "docs/page2.rst": _trace(),
244 },
245 )
246 app = _build(tmp_path, make_app)
247
248 assert _owner(app) == "page1"
249 assert _json(app)["needs"]["IMPL_1"]["title"] == "Hand-written"
250 assert _duplicates(app) == [_duplicate("page1", "page2")]
251
252
253def test_an_unchanged_owner_rereads_nothing(tmp_path: Path, make_app: _MakeApp) -> None:
254 """The skipping document is read again only when its owner changes or goes."""
255 _project(
256 tmp_path,
257 files=FILES,
258 append={"docs/page1.rst": _trace(), "docs/page2.rst": _trace()},
259 )
260 _build(tmp_path, make_app)
261 app = _build(tmp_path, make_app, freshenv=False)
262
263 assert "0 added, 0 changed, 0 removed" in _status(app)
264 assert _owner(app) == "page1"
265
266
267def test_nested_projects_define_the_need_once(
268 tmp_path: Path, make_app: _MakeApp
269) -> None:
270 """Ids are global: ``src`` and ``sub`` (``src/sub``) both find ``sub/x.cpp``; the
271 project read first defines the need, the other skips it -- no configuration
272 error."""
273 _project(
274 tmp_path,
275 files={**_scoped(None), "src/sub/x.cpp": IMPL},
276 append={"docs/later.rst": _trace("", project="sub")},
277 toml_extra=(
278 "\n[codelinks.projects.sub]\n"
279 'remote_url_pattern = "https://github.com/example/demo/blob/{commit}/{path}#L{line}"\n'
280 "\n[codelinks.projects.sub.source_discover]\n"
281 'src_dir = "../src/sub"\n'
282 'comment_type = "cpp"\n'
283 ),
284 )
285 app = _build(tmp_path, make_app)
286
287 assert _owner(app) == "index"
288 assert _duplicates(app) == [_duplicate("index", "later", "src/sub/x.cpp:1")]
289
290
291def test_the_summary_line_counts_the_skipped_needs(
292 tmp_path: Path, make_app: _MakeApp
293) -> None:
294 _project(
295 tmp_path,
296 files=FILES,
297 append={"docs/page1.rst": _trace(), "docs/page2.rst": _trace()},
298 )
299 app = _build(tmp_path, make_app)
300
301 lines = re.findall(r"codelinks \[src\]: [^\n]*", _status(app))
302 # one line per directive (index, page1, page2) and the attach's; only page2 skipped
303 assert len(lines) == 4
304 assert [line for line in lines if "skipped" in line] == [
305 "codelinks [src]: 1 file, 1 marker, 1 skipped (already defined)"
306 ]
307
308
309@pytest.mark.skipif(
310 not parallel_available, reason="Sphinx reads sources in parallel on POSIX only"
311)
312def test_parallel_directives_in_two_chunks_leave_one_need(
313 tmp_path: Path, make_app: _MakeApp
314) -> None:
315 """``-j 2`` reads ``index .. page2`` and ``page3 .. sub/deep`` in two workers, which
316 cannot see each other's needs: both directives define ``IMPL_1``, and Sphinx-Needs'
317 merge keeps one and warns ``needs.duplicate_id`` -- accepted, and documented."""
318 _project(
319 tmp_path,
320 files=FILES,
321 append={"docs/page1.rst": _trace(), "docs/page3.rst": _trace()},
322 )
323 app = _build(tmp_path, make_app, parallel=2)
324
325 status = strip_colors(app._status.getvalue())
326 assert re.search(r"reading sources\.\.\. \[\s*\d+%\] \S+ \.\. \S+", status)
327 assert _owner(app) in {"page1", "page3"}
328 merged = [w for w in build_warnings(app) if "IMPL_1 already exists" in w]
329 assert len(merged) == 1, build_warnings(app)
330 if _SHOWS_WARNING_TYPES:
331 assert merged[0].endswith("[needs.duplicate_id]")
332 assert _duplicates(app) == []
333
334
335def test_a_scope_record_pickled_before_deferred_existed_still_loads() -> None:
336 """An environment pickled by an earlier release holds ``ScopeRecord`` objects
337 without the ``deferred`` field; they load, and defer nothing."""
338 import pickle
339
340 from sphinx_codelinks.sphinx_extension.rediscovery import ScopeRecord
341
342 record = ScopeRecord(project="src", kind="file", target="a.cpp", fingerprint=())
343 object.__delattr__(record, "deferred") # the state an older class pickled
344 assert "deferred" not in vars(record)
345
346 loaded = pickle.loads(pickle.dumps(record))
347 assert loaded.deferred == ()
348 assert loaded == ScopeRecord(
349 project="src", kind="file", target="a.cpp", fingerprint=()
350 )
351
352
353@pytest.mark.parametrize("layout", ["one-document", "two-documents"])
354def test_a_generated_id_is_defined_once(
355 tmp_path: Path, make_app: _MakeApp, layout: str
356) -> None:
357 """A one-line style without ``id``: the id Sphinx-Needs would generate is checked
358 like a written one (it used to abort with ``Unique ID could not be generated``)."""
359 if layout == "one-document":
360 files = {**IDLESS_FILES, **_scoped(None)}
361 append = {"docs/index.rst": _trace()}
362 owner, other = "index", "index"
363 else:
364 files = IDLESS_FILES
365 append = {"docs/page1.rst": _trace(), "docs/page2.rst": _trace()}
366 owner, other = "page1", "page2"
367 _project(tmp_path, files=files, append=append, **IDLESS) # type: ignore[arg-type]
368 app = _build(tmp_path, make_app)
369
370 needs = _json(app)["needs"]
371 assert [n for n, need in needs.items() if need["title"] == "first impl"] == [
372 GENERATED
373 ]
374 assert _owner(app, GENERATED) == owner
375 assert _duplicates(app) == [_duplicate(owner, other, need_id=GENERATED)]
376
377
378def test_a_generated_id_with_local_urls_links_its_source_page(
379 tmp_path: Path, make_app: _MakeApp
380) -> None:
381 """A one-line style without ``id`` and local URLs on (no remote URL): the build
382 goes on (it used to abort with ``KeyError: 'id'``), the card's local link names the
383 source page, and that page's ``[docs]`` anchor links back to the generated id
384 (#2082)."""
385 _project(
386 tmp_path,
387 files={**IDLESS_FILES, **_scoped(None)},
388 toml_extra=IDLESS["toml_extra"],
389 toml_replace=("set_remote_url = true", "set_remote_url = false"),
390 )
391 app = _build(tmp_path, make_app)
392
393 assert build_warnings(app) == [DANGLING]
394 needs = _json(app)["needs"]
395 assert needs[GENERATED]["local-url"] == "src/impl.cpp#L1"
396 index = Path(app.outdir, "index.html").read_text(encoding="utf-8")
397 assert _field_links(index, "local-url") == ["src/impl.html#L-1"]
398 page = Path(app.outdir, "src", "impl.html").read_text(encoding="utf-8")
399 assert re.findall(
400 r'<a class="viewcode-back" href="([^"]*)">\[docs\]</a>', page
401 ) == [f"../index.html#{GENERATED}"]
402
403
404@pytest.mark.parametrize("generated", [False, True], ids=["written-id", "generated-id"])
405def test_a_directive_moved_to_another_document(
406 tmp_path: Path, make_app: _MakeApp, generated: bool
407) -> None:
408 """No overlap at any time: ``page2``'s trace moves to ``page1`` in one incremental
409 build. ``page1`` is read first, while ``page2``'s old need is still in the store; it
410 is about to be purged, so ``page1`` defines the need (this used to abort with
411 ``duplicate_id``)."""
412 need_id = GENERATED if generated else "IMPL_1"
413 _project(
414 tmp_path,
415 files=IDLESS_FILES if generated else FILES,
416 append={"docs/page2.rst": _trace()},
417 **(IDLESS if generated else {}), # type: ignore[arg-type]
418 )
419 assert _owner(_build(tmp_path, make_app), need_id) == "page2"
420
421 _rewrite(tmp_path / "docs" / "page2.rst", ":file: impl.cpp", ":file: other.cpp")
422 page1 = tmp_path / "docs" / "page1.rst"
423 page1.write_text(page1.read_text(encoding="utf-8") + _trace(), encoding="utf-8")
424 _touch_later(page1)
425 app = _build(tmp_path, make_app, freshenv=False)
426
427 assert "0 added, 2 changed, 0 removed" in _status(app)
428 assert _owner(app, need_id) == "page1"
429 assert _duplicates(app) == []