[docs] 1# @Test suite for Sphinx extension source tracing functionality, TEST_EXT_1, test, [IMPL_LNK_1, IMPL_ONE_1, IMPL_MRST_1]
2import shutil
3from collections.abc import Callable
4from dataclasses import fields
5from pathlib import Path
6
7import pytest
8from sphinx.environment import CONFIG_OK
9from sphinx.testing.util import SphinxTestApp
10
11from sphinx_codelinks.analyse.projects import AnalyseProjects
12from sphinx_codelinks.config import (
13 SRC_TRACE_CACHE,
14 CodeLinksConfig,
15 check_configuration,
16)
17from sphinx_codelinks.sphinx_extension.source_tracing import set_config_to_sphinx
18
19
20@pytest.mark.parametrize(
21 ("codelinks_config", "result"),
22 [
23 (
24 {
25 "remote_url_field": 555,
26 "local_url_field": 789,
27 "set_local_url": "fdd",
28 "set_remote_url": "TrueString",
29 "projects": {
30 "dcdc": {
31 "remote_url_pattern": 44332,
32 "source_discover": {
33 "comment_type": "java",
34 "src_dir": ["../dcdc"],
35 "exclude": [123],
36 "include": [345],
37 "gitignore": "_true",
38 },
39 "analyse": {
40 "oneline_comment_style": {
41 "start_sequence": "[[",
42 "end_sequence": "]]",
43 "field_split_char": ",",
44 "needs_fields": [
45 {
46 "name": "title",
47 "type": "list[]",
48 },
49 {
50 "name": "type",
51 "default": "impl",
52 "type": "str",
53 },
54 ],
55 },
56 },
57 }
58 },
59 },
60 [
61 "Project 'dcdc' has the following errors:",
62 "Schema validation error in field 'exclude': 123 is not of type 'string'",
63 "Schema validation error in field 'comment_type': 'java' is not one of ['bash', 'cpp', 'cs', 'go', 'jsonc', 'python', 'rust', 'yaml']",
64 "Schema validation error in field 'gitignore': '_true' is not of type 'boolean'",
65 "Schema validation error in field 'include': 345 is not of type 'string'",
66 "Schema validation error in field 'src_dir': ['../dcdc'] is not of type 'string'",
67 "Schema validation error in filed 'local_url_field': 789 is not of type 'string'",
68 "Schema validation error in filed 'remote_url_field': 555 is not of type 'string'",
69 "Schema validation error in filed 'set_local_url': 'fdd' is not of type 'boolean'",
70 "Schema validation error in filed 'set_remote_url': 'TrueString' is not of type 'boolean'",
71 "OneLineCommentStyle configuration errors:",
72 "Schema validation error in need_fields 'title': 'list[]' is not one of ['str', 'list[str]']",
73 "remote_url_pattern must be a string",
74 ],
75 ),
76 (
77 {
78 "remote_url_field": "remote-url",
79 "local_url_field": "local-url",
80 "set_local_url": True,
81 "set_remote_url": True,
82 "projects": {
83 "dcdc": {
84 # intentionally not given "remote_url_pattern": "https://github.com/useblocks/sphinx-codelinks/blob/{commit}/{path}#L{line}",
85 "source_discover": {
86 "comment_type": "cpp",
87 "src_dir": "../dcdc",
88 "exclude": [],
89 "include": [],
90 "gitignore": True,
91 },
92 "analyse": {
93 "oneline_comment_style": {
94 "start_sequence": "[[",
95 "end_sequence": "]]",
96 "field_split_char": ",",
97 "needs_fields": [
98 {
99 "name": "title",
100 "type": "str",
101 },
102 {
103 "name": "type",
104 "default": "impl",
105 "type": "str",
106 },
107 ],
108 },
109 },
110 }
111 },
112 },
113 [
114 "Project 'dcdc' has the following errors:",
115 "remote_url_pattern must be given, as set_remote_url is enabled",
116 ],
117 ),
118 ],
119)
120def test_src_tracing_config_negative(
121 make_app: Callable[..., SphinxTestApp],
122 codelinks_config,
123 result,
124):
125 this_file_dir = Path(__file__).parent
126 sphinx_project = Path("data") / "sphinx"
127 app = make_app(srcdir=(this_file_dir / sphinx_project))
128 set_config_to_sphinx(codelinks_config, app.env.config)
129 codelinks_sphinx_config = CodeLinksConfig.from_sphinx(app.env.config)
130 errors = check_configuration(codelinks_sphinx_config)
131 assert sorted(errors) == sorted(result)
132
133
134def test_src_tracing_config_positive(make_app: Callable[..., SphinxTestApp], tmp_path):
135 codelinks_config = {
136 "remote_url_field": "remote-url",
137 "local_url_field": "local-url",
138 "set_local_url": True,
139 "set_remote_url": True,
140 "outdir": tmp_path,
141 "projects": {
142 "dcdc": {
143 "source_discover": {
144 "comment_type": "cpp",
145 "src_dir": "../dcdc",
146 "exclude": ["**/*.hpp"],
147 "include": ["**/*.cpp"],
148 "gitignore": True,
149 },
150 "remote_url_pattern": "https://github.com/useblocks/sphinx-codelinks/blob/{commit}/{path}#L{line}",
151 "analyse": {
152 "oneline_comment_style": {
153 "start_sequence": "[[",
154 "end_sequence": "]]",
155 "field_split_char": ",",
156 "needs_fields": [
157 {
158 "name": "title",
159 "type": "str",
160 },
161 {
162 "name": "type",
163 "default": "impl",
164 "type": "str",
165 },
166 ],
167 },
168 },
169 }
170 },
171 }
172 this_file_dir = Path(__file__).parent
173 sphinx_project = Path("data") / "sphinx"
174 app = make_app(srcdir=(this_file_dir / sphinx_project))
175 set_config_to_sphinx(codelinks_config, app.env.config)
176 codelinks_sphinx_config = CodeLinksConfig.from_sphinx(app.env.config)
177 errors = check_configuration(codelinks_sphinx_config)
178 assert not errors
179
180
181@pytest.mark.parametrize(
182 ("sphinx_project", "source_code"),
183 [
184 (Path("data") / "sphinx", Path("data") / "dcdc"),
185 (
186 Path("doc_test") / "recursive_dirs",
187 Path("doc_test") / "recursive_dirs" / "dummy_src_lv1",
188 ),
189 (
190 Path("doc_test") / "minimum_config",
191 Path("doc_test") / "minimum_config",
192 ),
193 (
194 Path("doc_test") / "id_required",
195 Path("doc_test") / "id_required",
196 ),
197 (
198 Path("doc_test") / "cs_basic",
199 Path("doc_test") / "cs_basic",
200 ),
201 (
202 Path("doc_test") / "go_basic",
203 Path("doc_test") / "go_basic",
204 ),
205 ],
206)
207def test_build_html(
208 tmpdir: Path,
209 make_app: Callable[..., SphinxTestApp],
210 sphinx_project,
211 source_code,
212 snapshot_doctree,
213):
214 this_file_dir = Path(__file__).parent
215
216 sphinx_src_dir = tmpdir / sphinx_project
217 shutil.copytree(
218 this_file_dir / sphinx_project,
219 sphinx_src_dir,
220 dirs_exist_ok=True,
221 )
222 shutil.copytree(
223 this_file_dir / source_code,
224 tmpdir / source_code,
225 dirs_exist_ok=True,
226 )
227
228 app: SphinxTestApp = make_app(
229 srcdir=Path(sphinx_src_dir),
230 freshenv=True,
231 )
232 app.build()
233
234 html = Path(app.outdir, "index.html").read_text()
235 assert html
236
237 warnings = AnalyseProjects.load_warnings(Path(app.outdir) / SRC_TRACE_CACHE)
238 assert not warnings
239
240 assert app.env.get_doctree("index") == snapshot_doctree
241
242
243def test_incremental_build_keeps_src_trace_projects_unchanged(
244 tmpdir: Path,
245 make_app: Callable[..., SphinxTestApp],
246) -> None:
247 """An incremental rebuild with no source changes must not invalidate the env.
248
249 Regression test for the ``src-trace`` directive mutating the ``analyse_config``
250 object stored inside the ``rebuild="env"`` ``src_trace_projects`` config value.
251 The mutated object (populated ``src_dir``/``src_files``) was persisted into
252 ``environment.pickle``, so every incremental build compared it against the
253 freshly generated (empty) config and reported
254 ``[config changed ('src_trace_projects')]``, forcing a full re-read.
255 """
256 this_file_dir = Path(__file__).parent
257 sphinx_project = Path("data") / "sphinx"
258 source_code = Path("data") / "dcdc"
259
260 sphinx_src_dir = Path(tmpdir) / sphinx_project
261 shutil.copytree(this_file_dir / sphinx_project, sphinx_src_dir, dirs_exist_ok=True)
262 shutil.copytree(
263 this_file_dir / source_code, Path(tmpdir) / source_code, dirs_exist_ok=True
264 )
265
266 # First build populates environment.pickle in the shared build dir.
267 make_app(srcdir=sphinx_src_dir, freshenv=True).build()
268
269 # Second build reuses the same build dir and loads the pickled environment.
270 app = make_app(srcdir=sphinx_src_dir, freshenv=False)
271
272 captured: dict[str, object] = {}
273
274 def capture_config_status(_app, env, _added, _changed, _removed):
275 # ``env-get-outdated`` fires during read() after the config comparison
276 # but before config_status is reset to CONFIG_OK at the end of read().
277 captured["status"] = env.config_status
278 captured["extra"] = env.config_status_extra
279 return []
280
281 app.connect("env-get-outdated", capture_config_status)
282 app.build()
283
284 assert captured["status"] == CONFIG_OK, (
285 f"incremental build wrongly invalidated the environment: "
286 f"config changed{captured.get('extra')}"
287 )
288
289
290@pytest.fixture
291def minimal_sphinx_project(tmp_path: Path) -> Path:
292 """Minimal Sphinx project with no TOML config file next to conf.py."""
293 (tmp_path / "conf.py").write_text(
294 "extensions = ['sphinx_needs', 'sphinx_codelinks']\n"
295 "exclude_patterns = ['_build']\n"
296 )
297 (tmp_path / "index.rst").write_text("Minimal project\n===============\n")
298 return tmp_path
299
300
301def test_config_from_toml_defaults_to_ubproject_toml() -> None:
302 """The default config file is the shared ubproject.toml (ubcode-pub#75)."""
303 config_field = next(
304 field for field in fields(CodeLinksConfig) if field.name == "config_from_toml"
305 )
306 assert config_field.default == "ubproject.toml"
307
308
309def test_default_ubproject_toml_is_loaded(
310 minimal_sphinx_project: Path,
311 make_app: Callable[..., SphinxTestApp],
312) -> None:
313 """An ubproject.toml next to conf.py is loaded without any conf.py entry."""
314 (minimal_sphinx_project / "ubproject.toml").write_text(
315 "[codelinks.projects.demo]\n"
316 'remote_url_pattern = "https://example.com/{commit}/{path}#L{line}"\n'
317 "\n"
318 "[codelinks.projects.demo.source_discover]\n"
319 'src_dir = "./"\n'
320 )
321 app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
322 app.build()
323
324 assert "demo" in app.config.src_trace_projects
325 assert app.warning.getvalue() == ""
326
327
328def test_default_ubproject_toml_without_codelinks_section_is_silent(
329 minimal_sphinx_project: Path,
330 make_app: Callable[..., SphinxTestApp],
331) -> None:
332 """A default ubproject.toml used by other tools but without [codelinks] is
333 silently ignored instead of warning."""
334 (minimal_sphinx_project / "ubproject.toml").write_text(
335 "[needs]\nid_required = true\n"
336 )
337 app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
338 app.build()
339
340 assert app.config.src_trace_projects == {}
341 assert app.warning.getvalue() == ""
342
343
344def test_missing_default_ubproject_toml_is_silent(
345 minimal_sphinx_project: Path,
346 make_app: Callable[..., SphinxTestApp],
347) -> None:
348 """Without an ubproject.toml next to conf.py, the conf.py configuration is
349 used and no warning is emitted."""
350 app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
351 app.build()
352
353 assert app.config.src_trace_projects == {}
354 assert app.warning.getvalue() == ""
355
356
357def test_explicit_toml_config_missing_warns(
358 minimal_sphinx_project: Path,
359 make_app: Callable[..., SphinxTestApp],
360) -> None:
361 """An explicitly configured TOML file that does not exist still warns."""
362 conf_py = minimal_sphinx_project / "conf.py"
363 conf_py.write_text(
364 conf_py.read_text() + '\nsrc_trace_config_from_toml = "nonexistent.toml"\n'
365 )
366 app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
367 app.build()
368
369 assert "does not exist" in app.warning.getvalue()