[docs] 1# @Test suite for the source copies and pages as build state, TEST_SOURCE_PAGES_1, test, [IMPL_LNK_1]
2"""The source copies and their pages are build state (#2070, the pages half of #2044).
3
4What to copy and page is recorded in the environment when a document is read (or when a
5project without a directive is scanned), and every HTML build copies and pages from that
6record at ``html-collect-pages`` -- whether or not the directive's document is read
7again, under ``-j N`` too, and for HTML builders only.
8
9Every case builds a copy of ``doc_test/need_id_refs`` (see ``test_need_id_refs``) with
10local URLs only, plus ``src/impl.cpp`` holding the one-line need ``IMPL_1``. On the
11``directive`` path ``index`` traces the whole project, so ``src/refs.cpp`` (referenced
12only) and ``src/impl.cpp`` (a need) are copied and paged; on the ``config-only`` path no
13directive traces it, so only the referenced ``src/refs.cpp`` is.
14"""
15
16import os
17import pickle
18import re
19import shutil
20from pathlib import Path
21
22import pytest
23from sphinx.testing.util import SphinxTestApp
24from sphinx.util.parallel import parallel_available
25
26from sphinx_needs_testkit import build_warnings
27
28from .test_config_only_refs import NO_DIRECTIVE, _edit, _status
29from .test_need_id_refs import (
30 _SHOWS_WARNING_TYPES,
31 DANGLING,
32 GITHUB,
33 _build,
34 _card_links,
35 _MakeApp,
36 _project,
37)
38from .test_rediscovery import _scoped
39from .test_url_links import _card_links as _field_links
40from .test_url_links import _project as _url_project
41
42IMPL = "// @first impl, IMPL_1, impl\nvoid impl() {}\n"
43LOCAL_ONLY = ("set_remote_url = true", "set_remote_url = false")
44#: the copies (and so the pages) each path makes
45COPIES = {
46 "directive": ["src/impl.cpp", "src/refs.cpp"],
47 "config-only": ["src/refs.cpp"],
48}
49#: the ``[docs]`` back-link of a source page
50DOCS_LINK = re.compile(r'<a class="viewcode-back" href="([^"]*)">\[docs\]</a>')
51
52
53def _local_project(
54 root: Path, path: str, append: dict[str, str] | None = None, **files: str
55) -> None:
56 """The fixture with local URLs only and ``src/impl.cpp``; ``index`` without its
57 directive on the ``config-only`` path."""
58 if path == "config-only":
59 files = {**NO_DIRECTIVE, **files}
60 _project(
61 root,
62 toml_replace=LOCAL_ONLY,
63 files={"src/impl.cpp": IMPL, **files},
64 append=append,
65 )
66
67
68def _copies(outdir: Path) -> list[str]:
69 """The source copies in ``outdir``, POSIX and relative to it."""
70 return sorted(p.relative_to(outdir).as_posix() for p in outdir.rglob("*.cpp"))
71
72
73def _page(outdir: Path, copy: str, builder: str = "html") -> Path:
74 """The page of ``copy``: ``src/x.html`` (``src/x/index.html`` for ``dirhtml``)."""
75 stem = Path(outdir, copy).with_suffix("")
76 return stem / "index.html" if builder == "dirhtml" else stem.with_suffix(".html")
77
78
79def _paged(app: SphinxTestApp, builder: str = "html") -> list[str]:
80 """The copies in the output that have a source page beside them."""
81 outdir = Path(app.outdir)
82 return [c for c in _copies(outdir) if _page(outdir, c, builder).is_file()]
83
84
85def _recording(
86 make_app: _MakeApp, root: Path, **kwargs: object
87) -> tuple[SphinxTestApp, list[str]]:
88 """A built application, and the source pages (``src/…``) it wrote."""
89 app = make_app(srcdir=root / "docs", **kwargs)
90 written: list[str] = []
91
92 def record(_app: object, pagename: str, *_args: object) -> None:
93 if pagename.startswith("src/"):
94 written.append(pagename)
95
96 app.connect("html-page-context", record)
97 app.build()
98 return app, written
99
100
101@pytest.mark.parametrize("path", ["directive", "config-only"])
102def test_a_cleaned_output_directory_gets_its_copies_and_pages_back(
103 tmp_path: Path, make_app: _MakeApp, path: str
104) -> None:
105 """The HTML output directory removed, the doctrees kept: the next build reads
106 nothing, and every copy and page is back -- the card's local link resolves to a
107 file (it was a dead link)."""
108 _local_project(tmp_path, path)
109 first = _build(tmp_path, make_app)
110 assert _paged(first) == COPIES[path]
111 shutil.rmtree(first.outdir)
112
113 app = _build(tmp_path, make_app, freshenv=False)
114
115 assert "0 added, 0 changed, 0 removed" in _status(app)
116 assert _copies(Path(app.outdir)) == COPIES[path]
117 assert _paged(app) == COPIES[path]
118 [row] = _card_links(app, "index.html")
119 assert [href for href, _name in row] == ["src/refs.html#L-1", "src/refs.html#L-3"]
120 assert all(Path(app.outdir, href.split("#")[0]).is_file() for href, _ in row)
121 if path == "directive":
122 index = Path(app.outdir, "index.html").read_text(encoding="utf-8")
123 assert _field_links(index, "local-url") == ["src/impl.html#L-1"]
124
125
126@pytest.mark.parametrize("path", ["directive", "config-only"])
127def test_two_builders_sharing_the_doctrees_each_get_the_copies_and_pages(
128 tmp_path: Path, make_app: _MakeApp, path: str
129) -> None:
130 """``html``, then ``dirhtml`` on the same ``_build/doctrees`` (the Makefile
131 layout): the second builder's output has its copies and pages too."""
132 _local_project(tmp_path, path)
133 html = _build(tmp_path, make_app)
134 dirhtml = _build(tmp_path, make_app, buildername="dirhtml", freshenv=False)
135
136 assert _paged(html) == COPIES[path]
137 assert _copies(Path(dirhtml.outdir)) == COPIES[path]
138 assert _paged(dirhtml, "dirhtml") == COPIES[path]
139
140
141@pytest.mark.parametrize("builder", ["html", "dirhtml"])
142@pytest.mark.parametrize("path", ["directive", "config-only"])
143def test_an_unchanged_build_rewrites_no_copy_or_page(
144 tmp_path: Path, make_app: _MakeApp, path: str, builder: str
145) -> None:
146 """A build that reads nothing keeps every copy and page, and rewrites none of them:
147 the copies and pages are up to date -- ``dirhtml``'s pages at its own output path
148 (Sphinx 7.4 stops at "no targets are out of date" before collecting any page; 9.x
149 collects, and codelinks yields none)."""
150 _local_project(tmp_path, path)
151 first = _build(tmp_path, make_app, buildername=builder)
152 outdir = Path(first.outdir)
153 files = [outdir / c for c in COPIES[path]] + [
154 _page(outdir, c, builder) for c in COPIES[path]
155 ]
156 stamps = [f.stat().st_mtime_ns for f in files]
157
158 app, written = _recording(make_app, tmp_path, buildername=builder, freshenv=False)
159
160 assert "0 added, 0 changed, 0 removed" in _status(app)
161 assert _paged(app, builder) == COPIES[path]
162 assert written == []
163 assert [f.stat().st_mtime_ns for f in files] == stamps
164
165
166def test_a_page_whose_need_moved_to_another_document_is_written_again(
167 tmp_path: Path, make_app: _MakeApp
168) -> None:
169 """``page1`` defines ``IMPL_1`` and ``page2`` defers to it; ``page1`` removed, ``page2``
170 is read again and defines it. The source is untouched, yet the page is written again:
171 its ``[docs]`` link names the new document."""
172 trace = "\n.. src-trace::\n :project: src\n :file: impl.cpp\n"
173 _local_project(
174 tmp_path,
175 "directive",
176 append={"docs/page1.rst": trace, "docs/page2.rst": trace},
177 **_scoped(":file: refs.cpp"),
178 )
179 first = _build(tmp_path, make_app)
180 page = Path(first.outdir, "src", "impl.html")
181 assert DOCS_LINK.findall(page.read_text(encoding="utf-8")) == [
182 "../page1.html#IMPL_1"
183 ]
184
185 (tmp_path / "docs" / "page1.rst").unlink()
186 app = _build(tmp_path, make_app, freshenv=False)
187
188 assert re.search(r"reading sources\.\.\. \[[^\]]*\] page2\b", _status(app))
189 assert DOCS_LINK.findall(page.read_text(encoding="utf-8")) == [
190 "../page2.html#IMPL_1"
191 ]
192
193
194def test_the_source_page_css_is_on_source_pages_only(
195 tmp_path: Path, make_app: _MakeApp
196) -> None:
197 _local_project(tmp_path, "directive")
198 app = _build(tmp_path, make_app)
199
200 css = "_static/source_tracing/ub_sct.css"
201 assert css in Path(app.outdir, "src", "impl.html").read_text(encoding="utf-8")
202 assert css not in Path(app.outdir, "index.html").read_text(encoding="utf-8")
203
204
205@pytest.mark.parametrize("path", ["directive", "config-only"])
206def test_a_source_edit_shows_in_the_copy_and_the_page(
207 tmp_path: Path, make_app: _MakeApp, path: str
208) -> None:
209 _local_project(tmp_path, path)
210 _build(tmp_path, make_app)
211 source = tmp_path / "src" / "refs.cpp"
212 _edit(source, "return a + 1;", "return a + 12345;")
213
214 app = _build(tmp_path, make_app, freshenv=False)
215
216 assert Path(app.outdir, "src", "refs.cpp").read_bytes() == source.read_bytes()
217 page = Path(app.outdir, "src", "refs.html").read_text(encoding="utf-8")
218 assert "12345" in page
219
220
221@pytest.mark.parametrize("builder", ["html", "dirhtml"])
222def test_the_back_link_follows_the_builders_uri_rule(
223 tmp_path: Path, make_app: _MakeApp, builder: str
224) -> None:
225 """The ``[docs]`` link of a source page is the builder's own relative URI to the
226 need's document: ``dirhtml`` writes ``src/impl/index.html`` and ``sub/deep/``."""
227 _local_project(
228 tmp_path,
229 "directive",
230 **_scoped(":file: impl.cpp"),
231 **{"src/deep.cpp": "// @deep impl, IMPL_DEEP, impl\n"},
232 )
233 deep = tmp_path / "docs" / "sub" / "deep.rst"
234 deep.write_text(
235 deep.read_text(encoding="utf-8")
236 + "\n.. src-trace::\n :project: src\n :file: deep.cpp\n",
237 encoding="utf-8",
238 )
239 app = _build(tmp_path, make_app, buildername=builder)
240
241 def links(copy: str) -> list[str]:
242 page = _page(Path(app.outdir), copy, builder).read_text(encoding="utf-8")
243 return DOCS_LINK.findall(page)
244
245 if builder == "html":
246 assert links("src/impl.cpp") == ["../index.html#IMPL_1"]
247 assert links("src/deep.cpp") == ["../sub/deep.html#IMPL_DEEP"]
248 else:
249 assert links("src/impl.cpp") == ["../../#IMPL_1"]
250 assert links("src/deep.cpp") == ["../../sub/deep/#IMPL_DEEP"]
251
252
253def test_two_documents_paging_one_file_merge_their_anchors(
254 tmp_path: Path, make_app: _MakeApp
255) -> None:
256 """Two projects over one source directory, each with its own one-line style, trace
257 one file from two documents: one copy, one page, each need's ``[docs]`` link."""
258 _local_project(
259 tmp_path,
260 "directive",
261 **_scoped(":file: both.cpp"),
262 **{
263 "src/both.cpp": (
264 "// @in index, IMPL_1, impl\n// [[in later, IMPL_2, impl]]\nint x;\n"
265 )
266 },
267 )
268 toml = tmp_path / "docs" / "ubproject.toml"
269 toml.write_text(
270 toml.read_text(encoding="utf-8")
271 + (
272 "\n[codelinks.projects.two]\n"
273 f'remote_url_pattern = "{GITHUB}"\n'
274 "[codelinks.projects.two.source_discover]\n"
275 'src_dir = "../src"\n'
276 'comment_type = "cpp"\n'
277 "[codelinks.projects.two.analyse.oneline_comment_style]\n"
278 'start_sequence = "[["\n'
279 'end_sequence = "]]"\n'
280 ),
281 encoding="utf-8",
282 )
283 later = tmp_path / "docs" / "later.rst"
284 later.write_text(
285 later.read_text(encoding="utf-8")
286 + "\n.. src-trace::\n :project: two\n :file: both.cpp\n",
287 encoding="utf-8",
288 )
289 app = _build(tmp_path, make_app)
290
291 assert _paged(app) == ["src/both.cpp"]
292 page = Path(app.outdir, "src", "both.html").read_text(encoding="utf-8")
293 assert DOCS_LINK.findall(page) == ["../index.html#IMPL_1", "../later.html#IMPL_2"]
294
295
296#: project ``two``: ``src`` again, with its own one-line style (``[[...]]``), so that two
297#: documents create needs from one file
298TWO = (
299 "\n[codelinks.projects.two]\n"
300 "[codelinks.projects.two.source_discover]\n"
301 'src_dir = "../src"\n'
302 'comment_type = "cpp"\n'
303 "[codelinks.projects.two.analyse.oneline_comment_style]\n"
304 'start_sequence = "[["\n'
305 'end_sequence = "]]"\n'
306)
307BOTH = "// @in later, IMPL_1, impl\n// [[in another, IMPL_2, impl]]\nint x;\n"
308
309
310def _trace(project: str) -> str:
311 return f"\n.. src-trace::\n :project: {project}\n :file: both.cpp\n"
312
313
314def _shared_page(root: Path, *, page1: bool) -> None:
315 """``src/both.cpp`` paged from ``later`` (``IMPL_1``, project ``src``) and, with
316 ``page1``, from ``page1`` too (``IMPL_2``, project ``two``); ``index`` traces
317 nothing, so its re-reads (a glob toctree) touch no page."""
318 _local_project(
319 root,
320 "directive",
321 append={"docs/later.rst": _trace("src")}
322 | ({"docs/page1.rst": _trace("two")} if page1 else {}),
323 **NO_DIRECTIVE,
324 **{"src/both.cpp": BOTH},
325 )
326 toml = root / "docs" / "ubproject.toml"
327 toml.write_text(toml.read_text(encoding="utf-8") + TWO, encoding="utf-8")
328
329
330def _both_links(app: SphinxTestApp) -> list[str]:
331 page = Path(app.outdir, "src", "both.html").read_text(encoding="utf-8")
332 return DOCS_LINK.findall(page)
333
334
335def test_a_document_that_stops_tracing_a_shared_file_leaves_its_page(
336 tmp_path: Path, make_app: _MakeApp
337) -> None:
338 """``later`` and ``page1`` both page ``src/both.cpp``; ``page1``'s directive removed
339 (the document edited, the file untouched): the page is written again without
340 ``page1``'s ``[docs]`` link, although the remaining link names a document not read."""
341 _shared_page(tmp_path, page1=True)
342 first = _build(tmp_path, make_app)
343 assert _both_links(first) == ["../later.html#IMPL_1", "../page1.html#IMPL_2"]
344
345 page1 = tmp_path / "docs" / "page1.rst"
346 _edit(page1, _trace("two"), "")
347 app = _build(tmp_path, make_app, freshenv=False)
348
349 assert "0 added, 1 changed, 0 removed" in _status(app)
350 assert _both_links(app) == ["../later.html#IMPL_1"]
351
352
353def test_a_new_document_tracing_a_paged_file_adds_its_back_link(
354 tmp_path: Path, make_app: _MakeApp
355) -> None:
356 """``src/both.cpp`` is paged from ``later``; a NEW document traces it too (the file
357 untouched): the page is written again with the new document's ``[docs]`` link."""
358 _shared_page(tmp_path, page1=False)
359 first = _build(tmp_path, make_app)
360 assert _both_links(first) == ["../later.html#IMPL_1"]
361
362 (tmp_path / "docs" / "added.rst").write_text(
363 "Added\n=====\n" + _trace("two"), encoding="utf-8"
364 )
365 app = _build(tmp_path, make_app, freshenv=False)
366
367 assert "1 added" in _status(app)
368 assert _both_links(app) == ["../later.html#IMPL_1", "../added.html#IMPL_2"]
369
370
371@pytest.mark.parametrize("same_size", [True, False], ids=["same-size", "other-size"])
372@pytest.mark.parametrize("path", ["directive", "config-only"])
373def test_a_source_replaced_by_an_older_file_shows_in_the_copy_and_the_page(
374 tmp_path: Path, make_app: _MakeApp, path: str, same_size: bool
375) -> None:
376 """The source replaced by a file whose modification time is OLDER than the copy's
377 (``cp -p``, ``rsync -t``, ``tar x``, a restored cache): the analysis runs again, and
378 the copy and the page show the new content -- the copy carries its source's
379 modification time, so any difference counts, not only a newer one."""
380 _local_project(tmp_path, path)
381 first = _build(tmp_path, make_app)
382 outdir = Path(first.outdir)
383 source = tmp_path / "src" / "refs.cpp"
384 copy = outdir / "src" / "refs.cpp"
385 edit = "return a + 7;" if same_size else "return a + 12345;"
386 source.write_text(
387 source.read_text(encoding="utf-8").replace("return a + 1;", edit),
388 encoding="utf-8",
389 )
390 older = copy.stat().st_mtime - 100
391 os.utime(source, (older, older))
392
393 _build(tmp_path, make_app, freshenv=False)
394
395 assert copy.read_bytes() == source.read_bytes()
396 page = (outdir / "src" / "refs.html").read_text(encoding="utf-8")
397 assert ('<span class="mi">7</span>' if same_size else "12345") in page
398
399
400def _read_only_is_enforced(directory: Path) -> bool:
401 """Whether a read-only file here refuses to be written (not on Windows, not as
402 root)."""
403 probe = directory / "read-only-probe"
404 probe.write_text("", encoding="utf-8")
405 probe.chmod(0o444)
406 try:
407 with probe.open("w", encoding="utf-8"):
408 return False
409 except PermissionError:
410 return True
411 finally:
412 probe.chmod(0o644)
413 probe.unlink()
414
415
416@pytest.mark.parametrize("edit", ["other-size", "one-byte-more"])
417def test_a_read_only_source_that_changes_is_copied_and_paged_again(
418 tmp_path: Path, make_app: _MakeApp, edit: str
419) -> None:
420 """A read-only checkout (Perforce, a Nix or Bazel tree): the copy carries the
421 source's modification time but not its mode, so a changed source is copied over it
422 -- no ``Permission denied``, no stale copy or page."""
423 if os.name == "nt" or not _read_only_is_enforced(tmp_path):
424 pytest.skip("read-only modes are not enforced here")
425 _local_project(tmp_path, "directive")
426 source = tmp_path / "src" / "refs.cpp"
427 source.chmod(0o444)
428 outdir = Path(tmp_path, "docs", "_build", "html")
429 try:
430 _build(tmp_path, make_app)
431 source.chmod(0o644) # p4 edit
432 new = "return a + 12345;" if edit == "other-size" else "return a + 1; "
433 _edit(source, "return a + 1;", new)
434 source.chmod(0o444) # submitted: read-only again
435
436 app = _build(tmp_path, make_app, freshenv=False)
437
438 assert [w for w in build_warnings(app) if "source page" in w] == []
439 assert (outdir / "src" / "refs.cpp").read_bytes() == source.read_bytes()
440 if edit == "other-size":
441 page = (outdir / "src" / "refs.html").read_text(encoding="utf-8")
442 assert "12345" in page
443 finally:
444 source.chmod(0o644)
445 for path in outdir.rglob("*"):
446 if path.is_file():
447 path.chmod(0o644)
448
449
450def test_a_stale_scan_of_a_project_a_directive_now_owns_pages_nothing(
451 tmp_path: Path, make_app: _MakeApp
452) -> None:
453 """A config-only project gains a directive (over ``impl.cpp`` alone): its stored scan
454 stays, unused, and its page (``src/refs.cpp``) is no longer among the pages; the
455 directive removed again, the scan's page is back."""
456 from sphinx_codelinks.config import CodeLinksConfig
457 from sphinx_codelinks.sphinx_extension.rediscovery import (
458 config_only_refs_store,
459 effective_pages,
460 )
461
462 def targets(app: SphinxTestApp) -> list[str]:
463 config = CodeLinksConfig.from_sphinx(app.config)
464 return [page.target for page in effective_pages(app.env, config)]
465
466 _local_project(tmp_path, "config-only")
467 assert targets(_build(tmp_path, make_app)) == ["src/refs.cpp"]
468 index = tmp_path / "docs" / "index.rst"
469 text = index.read_text(encoding="utf-8")
470 _edit(
471 index, text, text + "\n.. src-trace::\n :project: src\n :file: impl.cpp\n"
472 )
473
474 owned = _build(tmp_path, make_app, freshenv=False)
475
476 assert [p.target for p in config_only_refs_store(owned.env)["src"].pages] == [
477 "src/refs.cpp"
478 ]
479 assert targets(owned) == ["src/impl.cpp"]
480 _edit(index, index.read_text(encoding="utf-8"), text)
481 assert targets(_build(tmp_path, make_app, freshenv=False)) == ["src/refs.cpp"]
482
483
484#: two projects whose source directories share their name: both copy to ``src/x.cpp``
485_SAME_BASENAME = """
486[codelinks]
487set_local_url = true
488set_remote_url = false
489
490[codelinks.projects.a.source_discover]
491src_dir = "../a/src"
492comment_type = "cpp"
493
494[codelinks.projects.b.source_discover]
495src_dir = "../b/src"
496comment_type = "cpp"
497"""
498
499
500def test_two_sources_for_one_page_warn(tmp_path: Path, make_app: _MakeApp) -> None:
501 """``a/src/x.cpp`` (from ``index``) and ``b/src/x.cpp`` (from ``later``) both copy to
502 ``src/x.cpp``: one ``codelinks.source_page`` warning names the target and both
503 sources; the first record's source (``index``'s) is the one paged."""
504 _project(
505 tmp_path,
506 files={
507 "docs/ubproject.toml": _SAME_BASENAME,
508 "docs/index.rst": "Index\n=====\n\n.. toctree::\n :glob:\n\n *\n sub/*\n\n"
509 ".. src-trace::\n :project: a\n",
510 "docs/later.rst": "Later\n=====\n\n.. src-trace::\n :project: b\n",
511 "a/src/x.cpp": "// from A\n// @a need, IMPL_A, impl\n",
512 "b/src/x.cpp": "// from B\n// @b need, IMPL_B, impl\n",
513 },
514 )
515 app = _build(tmp_path, make_app)
516
517 warned = [w for w in build_warnings(app) if "source page" in w]
518 assert len(warned) == 1, build_warnings(app)
519 assert "'src/x.cpp'" in warned[0]
520 assert (tmp_path / "a" / "src" / "x.cpp").as_posix() in warned[0]
521 assert (tmp_path / "b" / "src" / "x.cpp").as_posix() in warned[0]
522 if _SHOWS_WARNING_TYPES:
523 assert warned[0].endswith("[codelinks.source_page]")
524 assert "from A" in Path(app.outdir, "src", "x.html").read_text(encoding="utf-8")
525
526
527def test_an_environment_from_before_the_pages_store_is_read_again(
528 tmp_path: Path, make_app: _MakeApp
529) -> None:
530 """An environment pickled by a release without the page records (no
531 ``sphinx_codelinks`` entry in its extension versions, no store): Sphinx starts a
532 fresh one, so a cleaned output gets its copies and pages back on the first build
533 after the upgrade -- every document is read once."""
534 _local_project(tmp_path, "directive")
535 first = _build(tmp_path, make_app)
536 pickled = Path(first.doctreedir, "environment.pickle")
537 env = pickle.loads(pickled.read_bytes())
538 env.version = {k: v for k, v in env.version.items() if k != "sphinx_codelinks"}
539 del env.codelinks_source_pages
540 pickled.write_bytes(pickle.dumps(env, pickle.HIGHEST_PROTOCOL))
541 shutil.rmtree(first.outdir)
542
543 app = _build(tmp_path, make_app, freshenv=False)
544
545 assert "0 added, 0 changed" not in _status(app)
546 assert _paged(app) == COPIES["directive"]
547
548
549_REMOVE_AT_COLLECT = """
550def setup(app):
551 from pathlib import Path
552
553 def remove(app):
554 (Path(app.srcdir).parent / "src" / "impl.cpp").unlink()
555 return []
556
557 app.connect("html-collect-pages", remove)
558"""
559
560
561def test_a_source_removed_before_the_pages_are_written_warns_once(
562 tmp_path: Path, make_app: _MakeApp
563) -> None:
564 """A recorded source that cannot be read when the pages are written (removed since
565 its document was read) warns once, naming it; the build goes on and pages the
566 rest."""
567 _local_project(tmp_path, "directive", append={"docs/conf.py": _REMOVE_AT_COLLECT})
568 app = _build(tmp_path, make_app)
569
570 pages = [w for w in build_warnings(app) if "source page" in w]
571 assert len(pages) == 1, build_warnings(app)
572 assert "src/impl.cpp" in pages[0]
573 if _SHOWS_WARNING_TYPES:
574 assert pages[0].endswith("[codelinks.source_page]")
575 assert [w for w in build_warnings(app) if w not in pages] == [DANGLING]
576 assert _paged(app) == ["src/refs.cpp"]
577 assert not Path(app.outdir, "src", "impl.html").exists()
578
579
580@pytest.mark.skipif(
581 not parallel_available, reason="Sphinx reads sources in parallel on POSIX only"
582)
583def test_parallel_build_pages_a_directives_sources(
584 tmp_path: Path, make_app: _MakeApp
585) -> None:
586 """Under ``-j 2`` the directive is read in a worker; its record reaches the main
587 process, so the page exists beside the copy (#2044: the copy only, before)."""
588 _url_project(tmp_path, patterns={"a": GITHUB}, pages=7)
589 app = make_app(srcdir=tmp_path / "docs", freshenv=True, parallel=2)
590 app.build()
591
592 assert re.search(r"reading sources\.\.\. \[\s*\d+%\] \S+ \.\. \S+", _status(app))
593 assert Path(app.outdir, "srca", "a.cpp").is_file()
594 page = Path(app.outdir, "srca", "a.html").read_text(encoding="utf-8")
595 assert DOCS_LINK.findall(page) == ["../index.html#IMPL_A"]
596
597
598def test_a_config_only_scan_pickled_before_pages_existed_still_loads() -> None:
599 """An environment pickled by an earlier release holds ``ConfigOnlyScan`` objects
600 without the ``pages`` field; they load, and page nothing."""
601 from sphinx_codelinks.sphinx_extension.rediscovery import ConfigOnlyScan
602
603 scan = ConfigOnlyScan(fingerprint=(), records=[])
604 object.__delattr__(scan, "pages") # the state an older class pickled
605 assert "pages" not in vars(scan)
606
607 loaded = pickle.loads(pickle.dumps(scan))
608 assert loaded.pages == ()
609 assert loaded == ConfigOnlyScan(fingerprint=(), records=[])