[docs] 1# @Test suite for references of projects without a src-trace directive, TEST_CONFIG_ONLY_REFS_1, test, [IMPL_LNK_1]
2"""ubCode's config-only mode: a project with no ``src-trace`` directive still has its
3``@need-ids:`` references attached.
4
5Every case builds a copy of ``doc_test/need_id_refs`` (see ``test_need_id_refs``) whose
6``index`` has NO ``src-trace`` directive, so project ``src`` is configured but traced by
7nothing. Its ``refs.cpp`` references ``REQ_001`` (lines 1 and 3), ``REQ_002`` (line 3),
8``NOSUCH_ID`` (line 5) and ``REQ_003`` (line 7).
9"""
10
11import os
12import re
13from collections.abc import Iterator
14from pathlib import Path
15from typing import Any
16
17import pytest
18from sphinx.errors import ExtensionError
19from sphinx.testing.util import SphinxTestApp
20from sphinx.util.console import strip_colors
21from sphinx.util.parallel import parallel_available
22
23from sphinx_codelinks.analyse.analyse import SourceAnalyse
24from sphinx_needs_testkit import assert_no_warnings, build_warnings
25
26from .test_need_id_refs import (
27 _SHOWS_WARNING_TYPES,
28 DANGLING,
29 FIXTURE,
30 _build,
31 _card_links,
32 _json,
33 _MakeApp,
34 _project,
35 _refs,
36 _url,
37)
38
39INDEX = (FIXTURE / "docs" / "index.rst").read_text(encoding="utf-8")
40DIRECTIVE = ".. src-trace::\n :project: src\n"
41assert DIRECTIVE in INDEX
42#: ``index`` without its directive: project ``src`` is configuration only
43NO_DIRECTIVE = {"docs/index.rst": INDEX.replace(DIRECTIVE, "")}
44
45
46def _status(app: SphinxTestApp) -> str:
47 return strip_colors(app._status.getvalue())
48
49
50def _touch_later(path: Path) -> None:
51 later = path.stat().st_mtime + 10
52 os.utime(path, (later, later))
53
54
55def _edit(path: Path, old: str, new: str) -> None:
56 text = path.read_text(encoding="utf-8")
57 assert old in text
58 path.write_text(text.replace(old, new), encoding="utf-8")
59 _touch_later(path)
60
61
62@pytest.fixture
63def analyses(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[str]]:
64 """The names of the projects ``SourceAnalyse.run`` analysed, in order."""
65 calls: list[str] = []
66 original = SourceAnalyse.run
67
68 def counting(self: SourceAnalyse, *args: Any, **kwargs: Any) -> None:
69 calls.append(self.name)
70 original(self, *args, **kwargs)
71
72 monkeypatch.setattr(SourceAnalyse, "run", counting)
73 yield calls
74
75
76def _config_only_store(app: SphinxTestApp) -> dict[str, Any]:
77 from sphinx_codelinks.sphinx_extension.rediscovery import config_only_refs_store
78
79 return config_only_refs_store(app.env)
80
81
82def test_references_attach_without_a_directive(
83 tmp_path: Path, make_app: _MakeApp
84) -> None:
85 commit = _project(tmp_path, files=NO_DIRECTIVE)
86 app = _build(tmp_path, make_app)
87
88 refs = _refs(app)
89 assert refs["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
90 assert refs["REQ_002"] == [_url(commit, 3)]
91 assert refs["REQ_003"] == [_url(commit, 7)]
92 assert refs["SPEC_001"] is None
93 assert build_warnings(app) == [DANGLING]
94 status = _status(app)
95 assert "codelinks [src]: 1 file, 5 references\n" in status
96 assert "codelinks [src]: 4 references attached, 1 unknown" in status
97 assert _card_links(app, "later.html") == [[(_url(commit, 3), "src/refs.cpp#L3")]]
98
99
100def test_a_project_with_a_directive_is_not_scanned_again(
101 tmp_path: Path, make_app: _MakeApp, analyses: list[str]
102) -> None:
103 """The directive owns its project: the configuration pass skips it."""
104 commit = _project(tmp_path)
105 app = _build(tmp_path, make_app)
106
107 assert analyses == ["src"]
108 assert _config_only_store(app) == {}
109 assert _refs(app)["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
110 assert "codelinks [src]: 4 references attached, 1 unknown" in _status(app)
111
112
113def test_unchanged_rebuild_does_not_analyse(
114 tmp_path: Path, make_app: _MakeApp, analyses: list[str]
115) -> None:
116 commit = _project(tmp_path, files=NO_DIRECTIVE)
117 _build(tmp_path, make_app)
118 assert analyses == ["src"]
119
120 app = _build(tmp_path, make_app, freshenv=False)
121
122 assert analyses == ["src"]
123 assert "0 added, 0 changed, 0 removed" in _status(app)
124 assert _refs(app)["REQ_002"] == [_url(commit, 3)]
125
126
127def test_a_source_only_edit_is_analysed_once(
128 tmp_path: Path, make_app: _MakeApp, analyses: list[str]
129) -> None:
130 """The edit is analysed in the build that sees it; that build reads no document,
131 yet the environment is pickled (the affected page is written), so the next build
132 finds a matching fingerprint and analyses nothing."""
133 commit = _project(tmp_path, files=NO_DIRECTIVE)
134 _build(tmp_path, make_app)
135 _edit(
136 tmp_path / "src" / "refs.cpp",
137 "@need-ids: REQ_002, REQ_001",
138 "@need-ids: REQ_001",
139 )
140 analyses.clear()
141
142 edited = _build(tmp_path, make_app, freshenv=False)
143 assert analyses == ["src"]
144 assert "0 added, 0 changed, 0 removed" in _status(edited)
145 assert _refs(edited)["REQ_002"] is None
146
147 analyses.clear()
148 app = _build(tmp_path, make_app, freshenv=False)
149 assert analyses == []
150 assert _refs(app)["REQ_002"] is None
151 assert _refs(app)["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
152
153
154@pytest.mark.parametrize("change", ["edit", "new-file"])
155def test_a_source_change_refreshes_the_records(
156 tmp_path: Path, make_app: _MakeApp, change: str
157) -> None:
158 commit = _project(tmp_path, files=NO_DIRECTIVE)
159 _build(tmp_path, make_app)
160 if change == "edit":
161 _edit(
162 tmp_path / "src" / "refs.cpp",
163 "@need-ids: REQ_002, REQ_001",
164 "@need-ids: REQ_001",
165 )
166 else:
167 (tmp_path / "src" / "new.cpp").write_text(
168 "// @need-ids: SPEC_001\n", encoding="utf-8"
169 )
170 app = _build(tmp_path, make_app, freshenv=False)
171
172 refs = _refs(app)
173 if change == "edit":
174 assert refs["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
175 assert refs["REQ_002"] is None
176 assert refs["SPEC_001"] is None
177 else:
178 assert refs["REQ_002"] == [_url(commit, 3)]
179 assert refs["SPEC_001"] == [_url(commit, 1, "src/new.cpp")]
180
181
182def test_a_card_in_a_document_not_read_again_is_rewritten(
183 tmp_path: Path, make_app: _MakeApp
184) -> None:
185 """No document is read at all, and ``later`` -- hosting ``REQ_002`` -- is written:
186 its card no longer shows the reference the edit removed."""
187 commit = _project(tmp_path, files=NO_DIRECTIVE)
188 first = _build(tmp_path, make_app)
189 assert _card_links(first, "later.html") == [[(_url(commit, 3), "src/refs.cpp#L3")]]
190
191 _edit(
192 tmp_path / "src" / "refs.cpp",
193 "@need-ids: REQ_002, REQ_001",
194 "@need-ids: REQ_001",
195 )
196 app = _build(tmp_path, make_app, freshenv=False)
197
198 status = _status(app)
199 assert "0 added, 0 changed, 0 removed" in status
200 assert re.search(r"writing output\.\.\. \[[^\]]*\] later\b", status)
201 assert _card_links(app, "later.html") == []
202
203
204def test_removing_the_directive_attaches_from_the_configuration(
205 tmp_path: Path, make_app: _MakeApp
206) -> None:
207 """The build that drops the last directive of a project attaches its references
208 from the configuration pass already."""
209 commit = _project(tmp_path)
210 _build(tmp_path, make_app)
211 _edit(tmp_path / "docs" / "index.rst", DIRECTIVE, "")
212 app = _build(tmp_path, make_app, freshenv=False)
213
214 assert "0 added, 1 changed, 0 removed" in _status(app)
215 assert _refs(app)["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
216 assert set(_config_only_store(app)) == {"src"}
217
218
219def test_a_project_new_to_the_configuration_is_attached(
220 tmp_path: Path, make_app: _MakeApp
221) -> None:
222 gitlab = "https://gitlab.example.com/demo/-/blob/{commit}/{path}#L{line}"
223 commit = _project(
224 tmp_path, files={"src2/more.cpp": "// @need-ids: REQ_001\nvoid more() {}\n"}
225 )
226 _build(tmp_path, make_app)
227 toml = tmp_path / "docs" / "ubproject.toml"
228 toml.write_text(
229 toml.read_text(encoding="utf-8")
230 + (
231 "\n[codelinks.projects.two]\n"
232 f'remote_url_pattern = "{gitlab}"\n'
233 "[codelinks.projects.two.source_discover]\n"
234 'src_dir = "../src2"\n'
235 'comment_type = "cpp"\n'
236 ),
237 encoding="utf-8",
238 )
239 app = _build(tmp_path, make_app, freshenv=False)
240
241 assert _refs(app)["REQ_001"] == [
242 _url(commit, 1),
243 _url(commit, 3),
244 gitlab.format(commit=commit, path="src2/more.cpp", line=1),
245 ]
246
247
248@pytest.mark.parametrize("gate", ["empty-field", "urls-off"])
249def test_the_gate_off_scans_nothing(
250 tmp_path: Path, make_app: _MakeApp, analyses: list[str], gate: str
251) -> None:
252 if gate == "empty-field":
253 replace = (
254 "[codelinks.projects.src]\n",
255 '[codelinks.projects.src]\nref_url_field = ""\n',
256 )
257 else:
258 replace = (
259 "set_local_url = true\nset_remote_url = true\n",
260 "set_local_url = false\nset_remote_url = false\n",
261 )
262 _project(tmp_path, files=NO_DIRECTIVE, toml_replace=replace)
263 app = _build(tmp_path, make_app)
264
265 assert analyses == []
266 assert _config_only_store(app) == {}
267 assert_no_warnings(app)
268 assert set(_refs(app).values()) == {"<absent>"}
269
270
271def test_a_missing_source_directory_warns_once_and_builds(
272 tmp_path: Path, make_app: _MakeApp
273) -> None:
274 _project(
275 tmp_path,
276 files=NO_DIRECTIVE,
277 toml_replace=('src_dir = "../src"', 'src_dir = "../nosuch"'),
278 )
279 suffix = " [codelinks.need_id_ref]" if _SHOWS_WARNING_TYPES else ""
280 for freshenv in (True, False):
281 app = _build(tmp_path, make_app, freshenv=freshenv)
282 # a second application in one process also re-registers Sphinx' own nodes
283 warnings = [w for w in build_warnings(app) if "is already registered" not in w]
284 assert len(warnings) == 1, warnings
285 assert warnings[0].startswith("WARNING: codelinks [src]: ")
286 assert "nosuch" in warnings[0]
287 assert "ConfigOnlyError: source directory" in warnings[0]
288 assert warnings[0].endswith(suffix)
289 assert set(_refs(app).values()) == {None}
290
291
292@pytest.mark.skipif(
293 not parallel_available, reason="Sphinx reads sources in parallel on POSIX only"
294)
295def test_parallel_build_attaches_and_pages_the_sources(
296 tmp_path: Path, make_app: _MakeApp
297) -> None:
298 """The configuration pass runs in the main process, so under ``-j 2`` the local
299 copies and their source pages are made (a directive's too, since #2044)."""
300 _project(
301 tmp_path,
302 files=NO_DIRECTIVE,
303 toml_replace=("set_remote_url = true", "set_remote_url = false"),
304 )
305 app = _build(tmp_path, make_app, parallel=2)
306
307 assert re.search(r"reading sources\.\.\. \[\s*\d+%\] \S+ \.\. \S+", _status(app))
308 refs = _refs(app)
309 assert refs["REQ_001"] == ["src/refs.cpp#L1", "src/refs.cpp#L3"]
310 assert refs["REQ_003"] == ["../src/refs.cpp#L7"]
311 assert 'id="L-7"' in Path(app.outdir, "src", "refs.html").read_text(
312 encoding="utf-8"
313 )
314
315
316def test_one_line_needs_are_not_created_and_are_counted(
317 tmp_path: Path, make_app: _MakeApp
318) -> None:
319 """There is no directive to own them; ubCode creates none either."""
320 _project(
321 tmp_path,
322 files={
323 **NO_DIRECTIVE,
324 "src/impl.cpp": "// @implemented here, IMPL_CONFIG_ONLY\nvoid f() {}\n",
325 },
326 )
327 app = _build(tmp_path, make_app)
328
329 assert "IMPL_CONFIG_ONLY" not in _json(app)["needs"]
330 assert (
331 "codelinks [src]: 2 files, 5 references, 1 one-line need not created "
332 "(no src-trace directive)"
333 ) in _status(app)
334
335
336GITLAB = "https://gitlab.example.com/demo/-/blob/{commit}/{path}#L{line}"
337
338
339@pytest.mark.parametrize("change", ["set_remote_url", "remote_url_pattern"])
340def test_a_configuration_change_analyses_again(
341 tmp_path: Path, make_app: _MakeApp, analyses: list[str], change: str
342) -> None:
343 """The fingerprint covers the files only: a configuration change (every document is
344 read again) analyses the project again too, so its records follow the new values."""
345 commit = _project(tmp_path, files=NO_DIRECTIVE)
346 _build(tmp_path, make_app)
347 toml = tmp_path / "docs" / "ubproject.toml"
348 if change == "set_remote_url":
349 _edit(toml, "set_remote_url = true", "set_remote_url = false")
350 else:
351 _edit(
352 toml, "https://github.com/example/demo/blob/", GITLAB.split("{commit}")[0]
353 )
354 analyses.clear()
355 app = _build(tmp_path, make_app, freshenv=False)
356
357 assert analyses == ["src"]
358 if change == "set_remote_url":
359 assert _refs(app)["REQ_001"] == ["src/refs.cpp#L1", "src/refs.cpp#L3"]
360 assert _card_links(app, "later.html") == [
361 [("src/refs.html#L-3", "src/refs.cpp#L3")]
362 ]
363 else:
364 assert _refs(app)["REQ_001"] == [
365 GITLAB.format(commit=commit, path="src/refs.cpp", line=line)
366 for line in (1, 3)
367 ]
368
369
370def test_a_configuration_change_while_a_directive_owns_the_project(
371 tmp_path: Path, make_app: _MakeApp, analyses: list[str]
372) -> None:
373 """An entry ignored while a directive owned the project is not reused after a
374 configuration change once the directive goes: config-only, directive added, remote
375 URLs switched off, directive removed -- as ``-E`` would give."""
376 _project(tmp_path, files=NO_DIRECTIVE)
377 index = tmp_path / "docs" / "index.rst"
378 toml = tmp_path / "docs" / "ubproject.toml"
379 _build(tmp_path, make_app)
380 _edit(index, INDEX.replace(DIRECTIVE, ""), INDEX)
381 _build(tmp_path, make_app, freshenv=False)
382 _edit(toml, "set_remote_url = true", "set_remote_url = false")
383 _build(tmp_path, make_app, freshenv=False)
384 _edit(index, INDEX, INDEX.replace(DIRECTIVE, ""))
385 analyses.clear()
386 app = _build(tmp_path, make_app, freshenv=False)
387
388 assert analyses == ["src"]
389 assert _refs(app)["REQ_001"] == ["src/refs.cpp#L1", "src/refs.cpp#L3"]
390
391
392def _repository_wide(tmp_path: Path, path: str, *, gitignore: bool = True) -> None:
393 """The fixture with ``src_dir = ".."`` -- the whole repository, docs and their
394 ``_build`` included, no ignore rule for it -- and local URLs only, so that every
395 HTML build copies the sources into its output."""
396 _project(
397 tmp_path,
398 files=NO_DIRECTIVE if path == "config-only" else None,
399 toml_replace=('src_dir = "../src"', 'src_dir = ".."'),
400 )
401 toml = tmp_path / "docs" / "ubproject.toml"
402 text = toml.read_text(encoding="utf-8").replace(
403 "set_remote_url = true", "set_remote_url = false"
404 )
405 if not gitignore:
406 text = text.replace(
407 'comment_type = "cpp"', 'comment_type = "cpp"\ngitignore = false'
408 )
409 toml.write_text(text, encoding="utf-8")
410
411
412def _discovered(app: SphinxTestApp, path: str) -> list[str]:
413 """What the last discovery of project ``src`` listed (relative to ``src_dir``)."""
414 if path == "config-only":
415 found = _config_only_store(app)["src"].fingerprint
416 else:
417 from sphinx_codelinks.sphinx_extension.rediscovery import scope_store
418
419 found = tuple(
420 e for scope in scope_store(app.env)["index"] for e in scope.fingerprint
421 )
422 return [relative for relative, _mtime, _size in found]
423
424
425def _duplicates(app: SphinxTestApp) -> list[str]:
426 return [w for w in build_warnings(app) if "duplicate" in w]
427
428
429@pytest.mark.parametrize("path", ["directive", "config-only"])
430def test_the_build_output_is_never_traced(
431 tmp_path: Path, make_app: _MakeApp, analyses: list[str], path: str
432) -> None:
433 """A source directory containing the output directory does not trace the
434 extension's own copies: nothing is analysed again, and each reference stays one
435 entry. On the directive path the hosting document is edited once, so the directive
436 runs again with the copies present (on the fresh build its discovery runs before it
437 writes them)."""
438 _repository_wide(tmp_path, path)
439 first = _build(tmp_path, make_app)
440 assert len(_refs(first)["REQ_003"]) == 1
441 if path == "directive":
442 # now, not later: a future mtime would re-read it on every build
443 (tmp_path / "docs" / "index.rst").touch()
444 rerun = _build(tmp_path, make_app, freshenv=False)
445 assert "0 added, 1 changed, 0 removed" in _status(rerun)
446 assert len(_refs(rerun)["REQ_003"]) == 1
447 analyses.clear()
448 for _ in range(3):
449 app = _build(tmp_path, make_app, freshenv=False)
450 assert "0 added, 0 changed, 0 removed" in _status(app)
451 assert len(_refs(app)["REQ_003"]) == 1
452
453 assert analyses == []
454 assert len(list(tmp_path.rglob("*.cpp"))) == 2
455
456
457@pytest.mark.parametrize("gitignore", [True, False], ids=["gitignore", "no-gitignore"])
458@pytest.mark.parametrize(
459 "builders",
460 [("html", "dirhtml", "html", "dirhtml"), ("html", "latex", "html")],
461 ids=["html-dirhtml", "html-latex"],
462)
463@pytest.mark.parametrize("path", ["directive", "config-only"])
464def test_other_builders_output_is_never_traced(
465 tmp_path: Path,
466 make_app: _MakeApp,
467 analyses: list[str],
468 path: str,
469 builders: tuple[str, ...],
470 gitignore: bool,
471) -> None:
472 """The Makefile layout -- ``_build/<builder>`` beside a shared ``_build/doctrees``:
473 one builder never traces another's copies -- with ``gitignore = true`` because an
474 ``.ignore`` sits at the root of every builder's output and doctree directories,
475 with ``gitignore = false`` because the build
476 directory inside the documentation source directory is skipped as a whole: nothing
477 is analysed again, each reference stays one entry, and no copy is ever made of a
478 copy. (The doctree directory's half of the rule is unobservable here: no source
479 file is ever written under it.) On the directive path the hosting document is
480 edited after the first build, so the directive runs again with another builder's
481 copies present."""
482 _repository_wide(tmp_path, path, gitignore=gitignore)
483 for number, builder in enumerate(builders):
484 if number == 1 and path == "directive":
485 # now, not later: a future mtime would re-read it on every build
486 (tmp_path / "docs" / "index.rst").touch()
487 if number == 2:
488 analyses.clear()
489 app = _build(tmp_path, make_app, buildername=builder, freshenv=number == 0)
490 needs_json = Path(app.outdir, "needs.json")
491 if needs_json.exists():
492 assert len(_refs(app)["REQ_003"]) == 1, builder
493 # each builder copies the source into its own output, once: no copy of a copy
494 copies = [p for p in tmp_path.rglob("*.cpp") if "_build" in p.parts]
495 assert all(p.parts.count("_build") == 1 for p in copies), copies
496
497 assert analyses == []
498
499
500@pytest.mark.parametrize(
501 "builders",
502 [("html", "dirhtml", "html", "dirhtml"), ("html", "latex", "html")],
503 ids=["html-dirhtml", "html-latex"],
504)
505@pytest.mark.parametrize("path", ["directive", "config-only"])
506def test_two_output_trees_outside_the_docs_never_trace_each_other(
507 tmp_path: Path,
508 make_app: _MakeApp,
509 path: str,
510 builders: tuple[str, ...],
511) -> None:
512 """``src_dir = "."`` at a repository root and the output in ``build/<builder>``,
513 outside the documentation source directory, with no ignore rule for ``build/``
514 (#2071): the ``.ignore`` at the root of every builder's output and doctree
515 directories keeps every builder's copies out of
516 discovery -- nothing under ``build/`` is ever listed, no copy is made of a copy,
517 each reference stays one entry, and no one-line need is defined twice."""
518 _repository_wide(tmp_path, path)
519 name = tmp_path.name
520 for number, builder in enumerate(builders):
521 if number == 1 and path == "directive":
522 # now, not later: a future mtime would re-read it on every build
523 (tmp_path / "docs" / "index.rst").touch()
524 app = _build(
525 tmp_path,
526 make_app,
527 buildername=builder,
528 builddir=tmp_path / "build",
529 freshenv=number == 0,
530 )
531 assert _discovered(app, path) == ["src/refs.cpp"], builder
532 if Path(app.outdir, "needs.json").exists():
533 assert len(_refs(app)["REQ_003"]) == 1, builder
534 assert _duplicates(app) == [], builder
535 copies = sorted(
536 p.relative_to(tmp_path).as_posix()
537 for p in (tmp_path / "build").rglob("*.cpp")
538 )
539 paged = sorted({b for b in builders[: number + 1] if b != "latex"})
540 assert copies == [f"build/{b}/{name}/src/refs.cpp" for b in paged], builder
541
542
543@pytest.mark.parametrize("path", ["directive", "config-only"])
544def test_an_output_directory_beside_traced_sources_hides_nothing(
545 tmp_path: Path, make_app: _MakeApp, path: str
546) -> None:
547 """The output and doctree directories placed directly inside a traced source
548 directory (``docs/code/html``, ``docs/code/doctrees``): with ``gitignore = true``
549 the sources beside them are traced -- only the two directories themselves are
550 skipped (#2065's caveat, gone) -- by the directive, by the scope walk of an
551 unchanged rebuild (which re-reads nothing) and by the config-only scan."""
552 files = {
553 "docs/code/beside.cpp": (
554 "// @need-ids: REQ_001\n// @beside the output, IMPL_BESIDE, impl\n"
555 )
556 }
557 _project(
558 tmp_path,
559 toml_replace=('src_dir = "../src"', 'src_dir = "code"'),
560 files={**files, **(NO_DIRECTIVE if path == "config-only" else {})},
561 )
562 toml = tmp_path / "docs" / "ubproject.toml"
563 _edit(toml, "set_remote_url = true", "set_remote_url = false")
564 builddir = tmp_path / "docs" / "code"
565 first = _build(tmp_path, make_app, builddir=builddir)
566 unchanged = _build(tmp_path, make_app, builddir=builddir, freshenv=False)
567
568 assert "0 added, 0 changed, 0 removed" in _status(unchanged)
569 assert _discovered(unchanged, path) == ["beside.cpp"]
570 assert _refs(unchanged)["REQ_001"] == ["code/beside.cpp#L1"]
571 if path == "config-only":
572 return
573 assert "IMPL_BESIDE" in _json(first)["needs"]
574 (tmp_path / "docs" / "index.rst").touch()
575 app = _build(tmp_path, make_app, builddir=builddir, freshenv=False)
576
577 assert "0 added, 1 changed, 0 removed" in _status(app)
578 assert _json(app)["needs"]["IMPL_BESIDE"]["docname"] == "index"
579 assert _duplicates(app) == []
580 copies = sorted(p.relative_to(builddir).as_posix() for p in builddir.rglob("*.cpp"))
581 assert copies == ["beside.cpp", "html/code/beside.cpp"]
582
583
584@pytest.mark.parametrize("builder", ["html", "latex"])
585def test_the_output_and_doctree_directories_hold_an_ignore_that_is_never_traced(
586 tmp_path: Path, make_app: _MakeApp, builder: str
587) -> None:
588 """Every builder writes ``.ignore`` (``*``) at the root of its output directory and
589 of the doctree directory, on every build: restored when deleted, an identical one
590 left alone, and itself never a source -- so with ``gitignore = true`` nothing any
591 builder writes is traced."""
592 from sphinx_codelinks.source_discover.config import SourceDiscoverConfig
593 from sphinx_codelinks.sphinx_extension.rediscovery import fingerprint
594
595 _project(tmp_path, toml_replace=("set_remote_url = true", "set_remote_url = false"))
596 first = _build(tmp_path, make_app, buildername=builder)
597 roots = [Path(first.outdir), Path(first.doctreedir)]
598 markers = [root / ".ignore" for root in roots]
599 assert [m.read_bytes() for m in markers] == [b"*\n", b"*\n"]
600 left = markers[1].stat().st_mtime_ns
601 markers[0].unlink()
602
603 _build(tmp_path, make_app, buildername=builder, freshenv=False)
604
605 assert markers[0].read_bytes() == b"*\n"
606 assert markers[1].stat().st_mtime_ns == left
607 for root in roots:
608 for gitignore in (True, False):
609 listed = [
610 entry[0]
611 for entry in fingerprint(
612 SourceDiscoverConfig(root, gitignore=gitignore, comment_type="cpp"),
613 exclude=(),
614 )
615 ]
616 assert ".ignore" not in {Path(e).name for e in listed}, (root, gitignore)
617 if gitignore:
618 assert listed == [], root
619
620
621_DOWNLOAD = "\nThe implementation: :download:`impl.cpp <../src/impl.cpp>`.\n"
622
623
624def test_a_downloaded_source_in_another_builders_output_is_never_traced(
625 tmp_path: Path, make_app: _MakeApp
626) -> None:
627 """The Makefile layout, ``src_dir = ".."``, no ignore rule for ``_build``, the
628 default ``gitignore = true``, and ``index`` offering a traced source for download:
629 every HTML builder copies it into ``_downloads/``, and no builder traces another's
630 copy -- only ``src/`` is discovered, the need is defined once from ``src/impl.cpp``,
631 and the third build reads nothing."""
632 _repository_wide(tmp_path, "directive")
633 (tmp_path / "src" / "impl.cpp").write_text(
634 "// @first impl, IMPL_1, impl\n", encoding="utf-8"
635 )
636 index = tmp_path / "docs" / "index.rst"
637 index.write_text(index.read_text(encoding="utf-8") + _DOWNLOAD, encoding="utf-8")
638 name = tmp_path.name
639 for number, builder in enumerate(("html", "dirhtml", "html")):
640 if number == 1:
641 # now, not later: a future mtime would re-read it on every build
642 index.touch()
643 app = _build(tmp_path, make_app, buildername=builder, freshenv=number == 0)
644 assert list(Path(app.outdir, "_downloads").rglob("impl.cpp")), builder
645 assert _discovered(app, "directive") == ["src/impl.cpp", "src/refs.cpp"], (
646 builder
647 )
648 assert _duplicates(app) == [], builder
649 assert _json(app)["needs"]["IMPL_1"]["local-url"] == f"{name}/src/impl.cpp#L1"
650 assert "0 added, 0 changed, 0 removed" in _status(app)
651
652
653@pytest.mark.parametrize("state", ["unchanged", "failing-scan"])
654def test_a_build_with_nothing_to_redo_writes_nothing(
655 tmp_path: Path, make_app: _MakeApp, state: str
656) -> None:
657 """No document is written for a project that was not analysed again, nor for one
658 whose scan fails (it would fail again next build)."""
659 _project(
660 tmp_path,
661 files=NO_DIRECTIVE,
662 toml_replace=None
663 if state == "unchanged"
664 else ('src_dir = "../src"', 'src_dir = "../nosuch"'),
665 )
666 _build(tmp_path, make_app)
667 for _ in range(2):
668 app = _build(tmp_path, make_app, freshenv=False)
669 status = _status(app)
670 assert "0 added, 0 changed, 0 removed" in status
671 assert not re.search(r"writing output\.\.\. \[", status), status
672
673
674@pytest.mark.parametrize(
675 ("old", "new"),
676 [
677 ("return a + 1;", "return a + 12345;"),
678 ("void deep()", "void DEEP()"),
679 ("@need-ids: NOSUCH_ID", "@need-ids: NOSUCH_TWO"),
680 ],
681 ids=["code-only", "same-size-rename", "unknown-id"],
682)
683def test_an_edit_that_moves_no_reference_is_analysed_once(
684 tmp_path: Path, make_app: _MakeApp, analyses: list[str], old: str, new: str
685) -> None:
686 """No known need's references change, so no need's document is written; the root
687 document is, so the environment -- and the new scan -- is kept."""
688 commit = _project(tmp_path, files=NO_DIRECTIVE)
689 _build(tmp_path, make_app)
690 _edit(tmp_path / "src" / "refs.cpp", old, new)
691 counts = []
692 for build in range(4):
693 analyses.clear()
694 app = _build(tmp_path, make_app, freshenv=False)
695 counts.append(len(analyses))
696 if build == 0:
697 status = _status(app)
698 assert "pickling environment" in status
699 assert re.search(r"writing output\.\.\. \[[^\]]*\] index\b", status)
700 assert _refs(app)["REQ_001"] == [_url(commit, 1), _url(commit, 3)]
701
702 assert counts == [1, 0, 0, 0]
703
704
705def test_a_programming_error_in_the_scan_fails_the_build(
706 tmp_path: Path, make_app: _MakeApp, monkeypatch: pytest.MonkeyPatch
707) -> None:
708 """Only the errors discovery and the analysis raise become a warning; a bug in the
709 extension is not read as a scan failure."""
710 from sphinx_codelinks.sphinx_extension import rediscovery
711
712 def broken(*_args: Any, **_kwargs: Any) -> Any:
713 raise KeyError("not a scan failure")
714
715 monkeypatch.setattr(rediscovery, "scan_config_only_project", broken)
716 _project(tmp_path, files=NO_DIRECTIVE)
717 with pytest.raises(ExtensionError) as raised:
718 _build(tmp_path, make_app)
719 assert isinstance(raised.value.orig_exc, KeyError)
720
721
722_EARLY_EXTENSION = """
723def setup(app):
724 from sphinx_needs.data import SphinxNeedsData
725
726 def resolve(app, env):
727 SphinxNeedsData(env).get_needs_view()
728
729 app.connect("env-updated", resolve)
730 return {"parallel_read_safe": True}
731"""
732
733
734def test_the_scan_runs_before_other_env_updated_handlers(
735 tmp_path: Path, make_app: _MakeApp
736) -> None:
737 """An extension loaded before this one whose ``env-updated`` handler resolves the
738 needs (post-processing, so the attach) still sees the config-only references."""
739 commit = _project(
740 tmp_path,
741 files={**NO_DIRECTIVE, "docs/early.py": _EARLY_EXTENSION},
742 append={
743 "docs/conf.py": (
744 "import os, sys\n"
745 "sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\n"
746 'extensions.insert(1, "early")\n'
747 )
748 },
749 )
750 app = _build(tmp_path, make_app)
751
752 assert _refs(app)["REQ_001"] == [_url(commit, 1), _url(commit, 3)]