[docs] 1# @Test suite for incremental builds that see new source files, TEST_REDISCOVERY_1, test, [IMPL_LNK_1]
2"""Incremental builds see the files a ``src-trace`` directive's scope gains or loses.
3
4Every case builds a copy of ``doc_test/need_id_refs`` (see ``test_need_id_refs``), with
5the ``src-trace`` directive of ``index`` given the scope the case needs, then builds
6again without ``-E``: a file added, removed or edited under the scope re-reads the
7hosting document; a file outside the scope does not.
8"""
9
10import os
11import re
12from pathlib import Path, PureWindowsPath
13from typing import Any
14
15import pytest
16from sphinx.testing.util import SphinxTestApp
17from sphinx.util.console import strip_colors
18from sphinx.util.parallel import parallel_available
19
20from .test_need_id_refs import (
21 FIXTURE,
22 _build,
23 _card_links,
24 _json,
25 _MakeApp,
26 _project,
27 _refs,
28 _url,
29)
30
31INDEX = (FIXTURE / "docs" / "index.rst").read_text(encoding="utf-8")
32DIRECTIVE = ".. src-trace::\n :project: src\n"
33assert DIRECTIVE in INDEX
34
35#: a one-line need and a reference, in a file the first build did not see
36NEW_SOURCE = "// @new need, IMPL_NEW, impl, [REQ_002]\n// @need-ids: REQ_002\n"
37
38
39def _scoped(option: str | None) -> dict[str, str]:
40 """``files=`` for :func:`_project`: ``index``'s directive with ``option`` added."""
41 directive = DIRECTIVE if option is None else f"{DIRECTIVE} {option}\n"
42 return {"docs/index.rst": INDEX.replace(DIRECTIVE, directive)}
43
44
45def _status(app: SphinxTestApp) -> str:
46 return strip_colors(app._status.getvalue())
47
48
49def _touch_later(path: Path) -> None:
50 later = path.stat().st_mtime + 10
51 os.utime(path, (later, later))
52
53
54def test_unchanged_rebuild_reads_nothing(tmp_path: Path, make_app: _MakeApp) -> None:
55 """The fingerprint is stable: a rebuild with nothing touched re-reads nothing."""
56 _project(tmp_path, files=_scoped(":directory: ."))
57 _build(tmp_path, make_app)
58 app = _build(tmp_path, make_app, freshenv=False)
59
60 assert "0 added, 0 changed, 0 removed" in _status(app)
61
62
63def test_new_file_in_a_directory_scope_rereads_the_host(
64 tmp_path: Path, make_app: _MakeApp
65) -> None:
66 """A file added under a ``:directory:`` scope is seen without ``-E``: its one-line
67 need is created and its reference attached (#2040)."""
68 commit = _project(tmp_path, files=_scoped(":directory: ."))
69 first = _build(tmp_path, make_app)
70 assert "IMPL_NEW" not in _json(first)["needs"]
71
72 (tmp_path / "src" / "new.cpp").write_text(NEW_SOURCE, encoding="utf-8")
73 app = _build(tmp_path, make_app, freshenv=False)
74
75 assert "0 added, 1 changed, 0 removed" in _status(app)
76 assert "IMPL_NEW" in _json(app)["needs"]
77 assert _refs(app)["REQ_002"] == [
78 _url(commit, 2, "src/new.cpp"),
79 _url(commit, 3),
80 ]
81
82
83def test_new_file_in_a_subdirectory_of_the_scope_rereads_the_host(
84 tmp_path: Path, make_app: _MakeApp
85) -> None:
86 """The walk is recursive: a nested addition counts (a directory's own mtime would
87 not show it)."""
88 _project(tmp_path, files={**_scoped(":directory: ."), "src/lib/a.cpp": "// a\n"})
89 _build(tmp_path, make_app)
90
91 (tmp_path / "src" / "lib" / "deeper").mkdir()
92 (tmp_path / "src" / "lib" / "deeper" / "new.cpp").write_text(
93 NEW_SOURCE, encoding="utf-8"
94 )
95 app = _build(tmp_path, make_app, freshenv=False)
96
97 assert "0 added, 1 changed, 0 removed" in _status(app)
98 assert "IMPL_NEW" in _json(app)["needs"]
99
100
101def test_new_sibling_of_a_file_scope_rereads_nothing(
102 tmp_path: Path, make_app: _MakeApp
103) -> None:
104 """A ``:file:`` scope is that one file: a new file beside it costs nothing."""
105 _project(tmp_path, files=_scoped(":file: refs.cpp"))
106 _build(tmp_path, make_app)
107
108 (tmp_path / "src" / "sibling.cpp").write_text(NEW_SOURCE, encoding="utf-8")
109 app = _build(tmp_path, make_app, freshenv=False)
110
111 assert "0 added, 0 changed, 0 removed" in _status(app)
112 assert "IMPL_NEW" not in _json(app)["needs"]
113
114
115def test_removed_file_rereads_and_drops_its_records(
116 tmp_path: Path, make_app: _MakeApp
117) -> None:
118 commit = _project(
119 tmp_path,
120 files={
121 **_scoped(":directory: ."),
122 "src/new.cpp": NEW_SOURCE,
123 },
124 )
125 first = _build(tmp_path, make_app)
126 assert _refs(first)["REQ_002"] == [_url(commit, 2, "src/new.cpp"), _url(commit, 3)]
127
128 (tmp_path / "src" / "new.cpp").unlink()
129 app = _build(tmp_path, make_app, freshenv=False)
130
131 assert "0 added, 1 changed, 0 removed" in _status(app)
132 assert "IMPL_NEW" not in _json(app)["needs"]
133 assert _refs(app)["REQ_002"] == [_url(commit, 3)]
134
135
136@pytest.mark.parametrize("rule", ["exclude", "gitignore"])
137def test_new_file_the_discovery_rules_skip_rereads_nothing(
138 tmp_path: Path, make_app: _MakeApp, rule: str
139) -> None:
140 """The fingerprint walks exactly what the directive discovers."""
141 if rule == "exclude":
142 _project(
143 tmp_path,
144 files=_scoped(":directory: ."),
145 toml_replace=(
146 'src_dir = "../src"\n',
147 'src_dir = "../src"\nexclude = ["skipped/**"]\n',
148 ),
149 )
150 else:
151 _project(
152 tmp_path,
153 files={**_scoped(":directory: ."), "src/.gitignore": "skipped/\n"},
154 )
155 _build(tmp_path, make_app)
156
157 (tmp_path / "src" / "skipped").mkdir()
158 (tmp_path / "src" / "skipped" / "new.cpp").write_text(NEW_SOURCE, encoding="utf-8")
159 app = _build(tmp_path, make_app, freshenv=False)
160
161 assert "0 added, 0 changed, 0 removed" in _status(app)
162 assert "IMPL_NEW" not in _json(app)["needs"]
163
164
165def test_two_directives_over_one_scope_walk_it_once(
166 tmp_path: Path, make_app: _MakeApp, monkeypatch: pytest.MonkeyPatch
167) -> None:
168 """The check is memoised per scope within a build."""
169 from sphinx_codelinks.sphinx_extension import rediscovery
170
171 _project(
172 tmp_path,
173 append={"docs/later.rst": f"\n{DIRECTIVE}"},
174 )
175 _build(tmp_path, make_app)
176 walks: list[Any] = []
177 original = rediscovery.discover_scope
178
179 def counting(*args: Any, **kwargs: Any) -> Any:
180 walks.append(args)
181 return original(*args, **kwargs)
182
183 monkeypatch.setattr(rediscovery, "discover_scope", counting)
184 app = _build(tmp_path, make_app, freshenv=False)
185
186 assert "0 added, 0 changed, 0 removed" in _status(app)
187 assert len(walks) == 1
188
189
190@pytest.mark.skipif(
191 not parallel_available, reason="Sphinx reads sources in parallel on POSIX only"
192)
193def test_scopes_read_in_parallel_reach_the_main_process(
194 tmp_path: Path, make_app: _MakeApp
195) -> None:
196 """``-j 2`` records the scopes in a worker; ``env-merge-info`` brings them back, so the
197 next (serial) build still sees the new file."""
198 _project(tmp_path, files=_scoped(":directory: ."))
199 first = _build(tmp_path, make_app, parallel=2)
200 assert re.search(r"reading sources\.\.\. \[\s*\d+%\] \S+ \.\. \S+", _status(first))
201
202 (tmp_path / "src" / "new.cpp").write_text(NEW_SOURCE, encoding="utf-8")
203 app = _build(tmp_path, make_app, freshenv=False)
204
205 assert "0 added, 1 changed, 0 removed" in _status(app)
206 assert "IMPL_NEW" in _json(app)["needs"]
207
208
209def test_a_card_in_a_document_not_read_again_is_rewritten(
210 tmp_path: Path, make_app: _MakeApp
211) -> None:
212 """A source-only edit re-reads only the hosting document; ``REQ_002`` lives in
213 ``later``, which is not read again -- its card is still rewritten, and so it no
214 longer shows the reference the edit removed. ``later`` is written, not read."""
215 commit = _project(tmp_path)
216 first = _build(tmp_path, make_app)
217 assert _card_links(first, "later.html") == [[(_url(commit, 3), "src/refs.cpp#L3")]]
218
219 source = tmp_path / "src" / "refs.cpp"
220 source.write_text(
221 source.read_text(encoding="utf-8").replace(
222 "@need-ids: REQ_002, REQ_001", "@need-ids: REQ_001"
223 ),
224 encoding="utf-8",
225 )
226 _touch_later(source)
227 app = _build(tmp_path, make_app, freshenv=False)
228
229 status = _status(app)
230 assert "0 added, 1 changed, 0 removed" in status
231 assert _refs(app)["REQ_002"] is None
232 assert _card_links(app, "later.html") == []
233 assert not re.search(r"reading sources\.\.\. \[[^\]]*\] later\b", status)
234 assert re.search(r"writing output\.\.\. \[[^\]]*\] later\b", status)
235
236
237def test_fingerprint_is_the_discovered_files_with_mtime_and_size(
238 tmp_path: Path,
239) -> None:
240 from sphinx_codelinks.source_discover.config import SourceDiscoverConfig
241 from sphinx_codelinks.sphinx_extension.rediscovery import fingerprint
242
243 (tmp_path / "lib").mkdir()
244 (tmp_path / "b.cpp").write_bytes(b"// b\n")
245 (tmp_path / "lib" / "a.cpp").write_bytes(b"// aa\n")
246 (tmp_path / "notes.txt").write_text("not a source\n", encoding="utf-8")
247 config = SourceDiscoverConfig(tmp_path, comment_type="cpp")
248
249 found = fingerprint(config, exclude=())
250 assert [(path, size) for path, _mtime, size in found] == [
251 ("b.cpp", 5),
252 ("lib/a.cpp", 6),
253 ]
254 assert fingerprint(config, exclude=()) == found
255 _touch_later(tmp_path / "b.cpp")
256 assert fingerprint(config, exclude=()) != found
257 assert fingerprint(SourceDiscoverConfig(tmp_path / "missing"), exclude=()) == ()
258
259
260def test_a_file_scope_fingerprints_that_one_file(tmp_path: Path) -> None:
261 from sphinx_codelinks.sphinx_extension.rediscovery import file_fingerprint
262
263 (tmp_path / "a.cpp").write_bytes(b"// a\n")
264 found = file_fingerprint(tmp_path, "a.cpp")
265 assert [(path, size) for path, _mtime, size in found] == [("a.cpp", 5)]
266 (tmp_path / "b.cpp").write_bytes(b"// b\n")
267 assert file_fingerprint(tmp_path, "a.cpp") == found
268 assert file_fingerprint(tmp_path, "missing.cpp") == ()
269
270
271def test_fingerprint_paths_are_posix_on_windows() -> None:
272 """The stored path is POSIX whatever the platform's separator."""
273 from sphinx_codelinks.sphinx_extension.rediscovery import scope_relative_path
274
275 path = PureWindowsPath("C:/work/src/lib/a.cpp")
276 assert scope_relative_path(path, PureWindowsPath("C:/work/src")) == "lib/a.cpp"
277
278
279def test_exclusion_stops_at_the_directory_boundary(tmp_path: Path) -> None:
280 """``_build2/`` is not under ``_build/``: the excluded prefix ends at a separator."""
281 from sphinx_codelinks.sphinx_extension.rediscovery import _outside
282
283 root = tmp_path.resolve()
284 files = [root / "_build" / "a.cpp", root / "_build2" / "a.cpp"]
285 assert _outside(files, [root / "_build"]) == [root / "_build2" / "a.cpp"]