[docs] 1# @Test suite for Sphinx extension source tracing functionality, TEST_EXT_1, test, [IMPL_LNK_1, IMPL_ONE_1]
2import os
3import pickle
4import re
5import shutil
6from collections.abc import Callable
7from dataclasses import fields
8from pathlib import Path
9
10import pytest
11import sphinx
12from sphinx.environment import CONFIG_OK
13from sphinx.testing.util import SphinxTestApp
14from sphinx.util.console import strip_colors
15
16import sphinx_needs.api
17from sphinx_codelinks.analyse.oneline_parser import WarningSubTypeEnum
18from sphinx_codelinks.config import (
19 CodeLinksConfig,
20 check_configuration,
21)
22from sphinx_codelinks.sphinx_extension.source_tracing import set_config_to_sphinx
23from sphinx_needs.api import get_need_types
24from sphinx_needs_testkit import assert_no_warnings, build_warnings
25
26#: a one-line parser warning, by its kind: Sphinx 7 shows no ``[codelinks.oneline]``
27_ONELINE_KIND = re.compile(
28 rf"WARNING: ({'|'.join(kind.value for kind in WarningSubTypeEnum)}): "
29)
30
31
32def _sphinx_project_copy(tmp_path: Path) -> Path:
33 """Copy ``data/sphinx`` and ``data/dcdc``, which its ``src_trace.toml`` names as
34 ``src_dir = "../dcdc"``, side by side into ``tmp_path``; return the copy's srcdir.
35
36 A ``SphinxTestApp`` writes its ``_build`` under the srcdir, so an app made on the
37 in-tree project would write into the checkout, at one path for every test.
38 """
39 data = Path(__file__).parent / "data"
40 for name in ("sphinx", "dcdc"):
41 shutil.copytree(data / name, tmp_path / name)
42 return tmp_path / "sphinx"
43
44
45@pytest.mark.parametrize(
46 ("codelinks_config", "result"),
47 [
48 (
49 {
50 "remote_url_field": 555,
51 "local_url_field": 789,
52 "set_local_url": "fdd",
53 "set_remote_url": "TrueString",
54 "projects": {
55 "dcdc": {
56 "remote_url_pattern": 44332,
57 "source_discover": {
58 "comment_type": "java",
59 "src_dir": ["../dcdc"],
60 "exclude": [123],
61 "include": [345],
62 "gitignore": "_true",
63 },
64 "analyse": {
65 "oneline_comment_style": {
66 "start_sequence": "[[",
67 "end_sequence": "]]",
68 "field_split_char": ",",
69 "needs_fields": [
70 {
71 "name": "title",
72 "type": "list[]",
73 },
74 {
75 "name": "type",
76 "default": "impl",
77 "type": "str",
78 },
79 ],
80 },
81 },
82 }
83 },
84 },
85 [
86 "Project 'dcdc' has the following errors:",
87 "Schema validation error in field 'exclude': 123 is not of type 'string'",
88 "Schema validation error in field 'comment_type': 'java' is not one of ['bash', 'cpp', 'cs', 'go', 'jsonc', 'python', 'rust', 'ts', 'vhdl', 'yaml']",
89 "Schema validation error in field 'gitignore': '_true' is not of type 'boolean'",
90 "Schema validation error in field 'include': 345 is not of type 'string'",
91 "Schema validation error in field 'src_dir': ['../dcdc'] is not of type 'string'",
92 "Schema validation error in filed 'local_url_field': 789 is not of type 'string'",
93 "Schema validation error in filed 'remote_url_field': 555 is not of type 'string'",
94 "Schema validation error in filed 'set_local_url': 'fdd' is not of type 'boolean'",
95 "Schema validation error in filed 'set_remote_url': 'TrueString' is not of type 'boolean'",
96 "OneLineCommentStyle configuration errors:",
97 "Schema validation error in need_fields 'title': 'list[]' is not one of ['str', 'list[str]']",
98 "remote_url_pattern must be a string",
99 ],
100 ),
101 (
102 {
103 "remote_url_field": "remote-url",
104 "local_url_field": "local-url",
105 "set_local_url": True,
106 "set_remote_url": True,
107 "projects": {
108 "dcdc": {
109 # intentionally not given "remote_url_pattern": "https://github.com/useblocks/sphinx-codelinks/blob/{commit}/{path}#L{line}",
110 "source_discover": {
111 "comment_type": "cpp",
112 "src_dir": "../dcdc",
113 "exclude": [],
114 "include": [],
115 "gitignore": True,
116 },
117 "analyse": {
118 "oneline_comment_style": {
119 "start_sequence": "[[",
120 "end_sequence": "]]",
121 "field_split_char": ",",
122 "needs_fields": [
123 {
124 "name": "title",
125 "type": "str",
126 },
127 {
128 "name": "type",
129 "default": "impl",
130 "type": "str",
131 },
132 ],
133 },
134 },
135 }
136 },
137 },
138 [
139 "Project 'dcdc' has the following errors:",
140 "remote_url_pattern must be given, as set_remote_url is enabled",
141 ],
142 ),
143 ],
144)
145def test_src_tracing_config_negative(
146 make_app: Callable[..., SphinxTestApp],
147 codelinks_config,
148 result,
149 tmp_path: Path,
150):
151 app = make_app(srcdir=_sphinx_project_copy(tmp_path))
152 set_config_to_sphinx(codelinks_config, app.env.config)
153 codelinks_sphinx_config = CodeLinksConfig.from_sphinx(app.env.config)
154 errors = check_configuration(codelinks_sphinx_config)
155 assert sorted(errors) == sorted(result)
156
157
158def test_src_tracing_config_positive(make_app: Callable[..., SphinxTestApp], tmp_path):
159 codelinks_config = {
160 "remote_url_field": "remote-url",
161 "local_url_field": "local-url",
162 "set_local_url": True,
163 "set_remote_url": True,
164 "outdir": tmp_path,
165 "projects": {
166 "dcdc": {
167 "source_discover": {
168 "comment_type": "cpp",
169 "src_dir": "../dcdc",
170 "exclude": ["**/*.hpp"],
171 "include": ["**/*.cpp"],
172 "gitignore": True,
173 },
174 "remote_url_pattern": "https://github.com/useblocks/sphinx-codelinks/blob/{commit}/{path}#L{line}",
175 "analyse": {
176 "oneline_comment_style": {
177 "start_sequence": "[[",
178 "end_sequence": "]]",
179 "field_split_char": ",",
180 "needs_fields": [
181 {
182 "name": "title",
183 "type": "str",
184 },
185 {
186 "name": "type",
187 "default": "impl",
188 "type": "str",
189 },
190 ],
191 },
192 },
193 }
194 },
195 }
196 app = make_app(srcdir=_sphinx_project_copy(tmp_path))
197 set_config_to_sphinx(codelinks_config, app.env.config)
198 codelinks_sphinx_config = CodeLinksConfig.from_sphinx(app.env.config)
199 errors = check_configuration(codelinks_sphinx_config)
200 assert not errors
201
202
203@pytest.mark.parametrize(
204 ("sphinx_project", "source_code"),
205 [
206 (Path("data") / "sphinx", Path("data") / "dcdc"),
207 (
208 Path("doc_test") / "recursive_dirs",
209 Path("doc_test") / "recursive_dirs" / "dummy_src_lv1",
210 ),
211 (
212 Path("doc_test") / "minimum_config",
213 Path("doc_test") / "minimum_config",
214 ),
215 (
216 Path("doc_test") / "id_required",
217 Path("doc_test") / "id_required",
218 ),
219 (
220 Path("doc_test") / "cs_basic",
221 Path("doc_test") / "cs_basic",
222 ),
223 (
224 Path("doc_test") / "go_basic",
225 Path("doc_test") / "go_basic",
226 ),
227 ],
228)
229def test_build_html(
230 tmpdir: Path,
231 make_app: Callable[..., SphinxTestApp],
232 sphinx_project,
233 source_code,
234 snapshot_doctree,
235):
236 this_file_dir = Path(__file__).parent
237
238 sphinx_src_dir = tmpdir / sphinx_project
239 shutil.copytree(
240 this_file_dir / sphinx_project,
241 sphinx_src_dir,
242 dirs_exist_ok=True,
243 )
244 shutil.copytree(
245 this_file_dir / source_code,
246 tmpdir / source_code,
247 dirs_exist_ok=True,
248 )
249
250 app: SphinxTestApp = make_app(
251 srcdir=Path(sphinx_src_dir),
252 freshenv=True,
253 )
254 app.build()
255
256 html = Path(app.outdir, "index.html").read_text()
257 assert html
258
259 # the fixtures warn about other things (no git root, unknown links), never about
260 # a one-line marker
261 assert [w for w in build_warnings(app) if _ONELINE_KIND.search(w)] == []
262
263 assert app.env.get_doctree("index") == snapshot_doctree
264
265
266def test_incremental_build_keeps_src_trace_projects_unchanged(
267 tmpdir: Path,
268 make_app: Callable[..., SphinxTestApp],
269) -> None:
270 """An incremental rebuild with no source changes must not invalidate the env.
271
272 Regression test for the ``src-trace`` directive mutating the ``analyse_config``
273 object stored inside the ``rebuild="env"`` ``src_trace_projects`` config value.
274 The mutated object (populated ``src_dir``/``src_files``) was persisted into
275 ``environment.pickle``, so every incremental build compared it against the
276 freshly generated (empty) config and reported
277 ``[config changed ('src_trace_projects')]``, forcing a full re-read.
278 """
279 this_file_dir = Path(__file__).parent
280 sphinx_project = Path("data") / "sphinx"
281 source_code = Path("data") / "dcdc"
282
283 sphinx_src_dir = Path(tmpdir) / sphinx_project
284 shutil.copytree(this_file_dir / sphinx_project, sphinx_src_dir, dirs_exist_ok=True)
285 shutil.copytree(
286 this_file_dir / source_code, Path(tmpdir) / source_code, dirs_exist_ok=True
287 )
288
289 # First build populates environment.pickle in the shared build dir.
290 make_app(srcdir=sphinx_src_dir, freshenv=True).build()
291
292 # Second build reuses the same build dir and loads the pickled environment.
293 app = make_app(srcdir=sphinx_src_dir, freshenv=False)
294
295 captured: dict[str, object] = {}
296
297 def capture_config_status(_app, env, _added, _changed, _removed):
298 # ``env-get-outdated`` fires during read() after the config comparison
299 # but before config_status is reset to CONFIG_OK at the end of read().
300 captured["status"] = env.config_status
301 captured["extra"] = env.config_status_extra
302 return []
303
304 app.connect("env-get-outdated", capture_config_status)
305 app.build()
306
307 assert captured["status"] == CONFIG_OK, (
308 f"incremental build wrongly invalidated the environment: "
309 f"config changed{captured.get('extra')}"
310 )
311
312
313@pytest.mark.skipif(
314 not hasattr(sphinx_needs.api, "add_need_layout"),
315 reason="the installed sphinx-needs predates its fix of #2265 (which added "
316 "add_need_layout); the fix ships in the next sphinx-needs release",
317)
318def test_an_incremental_build_changes_no_configuration_option(
319 minimal_sphinx_project: Path,
320 make_app: Callable[..., SphinxTestApp],
321) -> None:
322 """The need type this extension adds is not written into ``needs_types``.
323
324 ``add_need_type`` keeps ``srctrace`` in sphinx-needs' own registry, as it keeps the
325 GitHub services' need types and layout (sphinx-needs #2265), so the configuration
326 an incremental build compares with the pickled one is the same, and Sphinx names
327 no changed option. They used to be appended to ``needs_types`` and
328 ``needs_layouts``, after Sphinx had compared the configurations.
329 """
330 first = make_app(srcdir=minimal_sphinx_project, freshenv=True)
331 first.build()
332 old_env = pickle.loads(Path(first.doctreedir, "environment.pickle").read_bytes())
333 assert "srctrace" in get_need_types(first)
334 assert "srctrace" not in [t["directive"] for t in first.config.needs_types]
335
336 second = make_app(srcdir=minimal_sphinx_project)
337 old_values = {item.name: item.value for item in old_env.config}
338 new_values = {item.name: item.value for item in second.config}
339 assert {
340 name
341 for name in old_values.keys() | new_values.keys()
342 if old_values.get(name) != new_values.get(name)
343 } == set()
344 second.build()
345 # Sphinx 8.1 and later name the changed options in a status line; 7.4 and 8.0
346 # compare only the options that rebuild the environment, and print no such line.
347 # ``html_theme`` is among them: ``SphinxTestApp`` sets it to ``basic`` only after
348 # Sphinx has compared the configurations, and the first build pickled ``basic``
349 reported = re.search(
350 r"The configuration has changed \(\d+ options?: (.*)\)",
351 strip_colors(second._status.getvalue()),
352 )
353 assert reported is None or re.findall(r"'(needs_[^']+)'", reported.group(1)) == []
354
355
356@pytest.fixture
357def minimal_sphinx_project(tmp_path: Path) -> Path:
358 """Minimal Sphinx project with no TOML config file next to conf.py."""
359 (tmp_path / "conf.py").write_text(
360 "extensions = ['sphinx_needs', 'sphinx_codelinks']\n"
361 "exclude_patterns = ['_build']\n"
362 )
363 (tmp_path / "index.rst").write_text("Minimal project\n===============\n")
364 return tmp_path
365
366
367def test_config_from_toml_defaults_to_ubproject_toml() -> None:
368 """The default config file is the shared ubproject.toml (ubcode-pub#75)."""
369 config_field = next(
370 field for field in fields(CodeLinksConfig) if field.name == "config_from_toml"
371 )
372 assert config_field.default == "ubproject.toml"
373
374
375def test_default_ubproject_toml_is_loaded(
376 minimal_sphinx_project: Path,
377 make_app: Callable[..., SphinxTestApp],
378) -> None:
379 """An ubproject.toml next to conf.py is loaded without any conf.py entry."""
380 (minimal_sphinx_project / "ubproject.toml").write_text(
381 "[codelinks.projects.demo]\n"
382 'remote_url_pattern = "https://example.com/{commit}/{path}#L{line}"\n'
383 "\n"
384 "[codelinks.projects.demo.source_discover]\n"
385 'src_dir = "./"\n'
386 )
387 app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
388 app.build()
389
390 assert "demo" in app.config.src_trace_projects
391 assert_no_warnings(app)
392
393
394def test_default_ubproject_toml_without_codelinks_section_is_silent(
395 minimal_sphinx_project: Path,
396 make_app: Callable[..., SphinxTestApp],
397) -> None:
398 """A default ubproject.toml used by other tools but without [codelinks] is
399 silently ignored instead of warning."""
400 (minimal_sphinx_project / "ubproject.toml").write_text(
401 "[needs]\nid_required = true\n"
402 )
403 app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
404 app.build()
405
406 assert app.config.src_trace_projects == {}
407 assert_no_warnings(app)
408
409
410def test_missing_default_ubproject_toml_is_silent(
411 minimal_sphinx_project: Path,
412 make_app: Callable[..., SphinxTestApp],
413) -> None:
414 """Without an ubproject.toml next to conf.py, the conf.py configuration is
415 used and no warning is emitted."""
416 app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
417 app.build()
418
419 assert app.config.src_trace_projects == {}
420 assert_no_warnings(app)
421
422
423def test_explicit_toml_config_missing_warns(
424 minimal_sphinx_project: Path,
425 make_app: Callable[..., SphinxTestApp],
426) -> None:
427 """An explicitly configured TOML file that does not exist still warns."""
428 conf_py = minimal_sphinx_project / "conf.py"
429 conf_py.write_text(
430 conf_py.read_text() + '\nsrc_trace_config_from_toml = "nonexistent.toml"\n'
431 )
432 app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
433 app.build()
434
435 warnings = build_warnings(app)
436 assert len(warnings) == 1, warnings
437 assert "does not exist" in warnings[0]
438
439
440# -- the TOML reader: what a -D value, a corrupt file and the typed warnings do ----------
441
442#: Sphinx 8 renders a warning's ``[type.subtype]`` itself; 7.4 appends nothing, so on
443#: 7.4 the reader's warnings are asserted by their phrase and count only
444_SHOWS_WARNING_TYPES = sphinx.version_info >= (8,)
445
446_LOAD_FAILED = "Failed to load source tracing configuration"
447
448
449def _write_conf(project: Path, extra: str) -> None:
450 conf_py = project / "conf.py"
451 conf_py.write_text(conf_py.read_text(encoding="utf-8") + extra, encoding="utf-8")
452
453
454def _assert_one_config_warning(app: SphinxTestApp, phrase: str) -> None:
455 warnings = build_warnings(app)
456 assert len(warnings) == 1, warnings
457 assert phrase in warnings[0]
458 if _SHOWS_WARNING_TYPES:
459 assert "[codelinks.config]" in warnings[0]
460
461
462@pytest.mark.parametrize(
463 ("key", "toml_value", "override"),
464 [
465 ("set_local_url", "true", False),
466 ("set_remote_url", "true", False),
467 ("local_url_field", '"toml-url"', "cli-url"),
468 ("remote_url_field", '"toml-remote"', "cli-remote"),
469 ("debug_measurement", "true", False),
470 ("debug_filters", "true", False),
471 ],
472)
473def test_command_line_override_beats_the_toml(
474 minimal_sphinx_project: Path,
475 make_app: Callable[..., SphinxTestApp],
476 key: str,
477 toml_value: str,
478 override: object,
479) -> None:
480 """``-D src_trace_<key>`` wins over the same key in ``[codelinks]``.
481
482 ``confoverrides`` is what ``-D`` becomes: Sphinx stores both in ``config.overrides``.
483 """
484 (minimal_sphinx_project / "ubproject.toml").write_text(
485 f"[codelinks]\n{key} = {toml_value}\n", encoding="utf-8"
486 )
487 app = make_app(
488 srcdir=minimal_sphinx_project,
489 freshenv=True,
490 confoverrides={f"src_trace_{key}": override},
491 )
492
493 assert app.config[f"src_trace_{key}"] == override
494
495
496def test_refused_outdir_override_keeps_the_toml_value(
497 minimal_sphinx_project: Path,
498 make_app: Callable[..., SphinxTestApp],
499) -> None:
500 """Sphinx refuses ``-D src_trace_outdir`` (its default is a ``Path``: "unsupported
501 type") but keeps it in ``config.overrides``: an override that was never applied must
502 not suppress the TOML value."""
503 (minimal_sphinx_project / "ubproject.toml").write_text(
504 '[codelinks]\noutdir = "toml-out"\n', encoding="utf-8"
505 )
506 app = make_app(
507 srcdir=minimal_sphinx_project,
508 freshenv=True,
509 confoverrides={"src_trace_outdir": "cli-out"},
510 )
511
512 assert app.config.src_trace_outdir == "toml-out"
513
514
515def test_not_overridable_from_d_is_exactly_what_sphinx_refuses(
516 minimal_sphinx_project: Path,
517 make_app: Callable[..., SphinxTestApp],
518) -> None:
519 """The keys exempt from the ``-D`` skip are exactly the codelinks confvals Sphinx
520 refuses to take from ``-D`` -- detected per confval by Sphinx's own
521 "cannot override ... 'src_trace_<key>'" warning, with a value ("1") every confval
522 Sphinx does accept can convert."""
523 from sphinx_codelinks.sphinx_extension.source_tracing import (
524 NOT_OVERRIDABLE_FROM_D,
525 )
526
527 refused = set()
528 for item in fields(CodeLinksConfig):
529 app = make_app(
530 srcdir=minimal_sphinx_project,
531 freshenv=True,
532 confoverrides={f"src_trace_{item.name}": "1"},
533 )
534 if any(
535 "cannot override" in warning and f"'src_trace_{item.name}'" in warning
536 for warning in build_warnings(app)
537 ):
538 refused.add(item.name)
539
540 assert refused == set(NOT_OVERRIDABLE_FROM_D)
541
542
543def test_override_of_one_key_leaves_the_other_toml_keys(
544 minimal_sphinx_project: Path,
545 make_app: Callable[..., SphinxTestApp],
546) -> None:
547 """``-D`` on one key skips that key only: the rest of ``[codelinks]`` still loads."""
548 (minimal_sphinx_project / "ubproject.toml").write_text(
549 "[codelinks]\n"
550 "set_local_url = true\n"
551 'local_url_field = "toml-url"\n'
552 "[codelinks.projects.tomlproj.source_discover]\n"
553 'src_dir = "./"\n',
554 encoding="utf-8",
555 )
556 app = make_app(
557 srcdir=minimal_sphinx_project,
558 freshenv=True,
559 confoverrides={"src_trace_set_local_url": False},
560 )
561
562 assert app.config.src_trace_set_local_url is False
563 assert app.config.src_trace_local_url_field == "toml-url"
564 assert list(app.config.src_trace_projects) == ["tomlproj"]
565
566
567def test_command_line_override_of_config_from_toml_beats_the_toml(
568 minimal_sphinx_project: Path,
569 make_app: Callable[..., SphinxTestApp],
570) -> None:
571 """The eighth key: a ``config_from_toml`` inside the file no longer rewrites the
572 file name given with ``-D``."""
573 (minimal_sphinx_project / "cl.toml").write_text(
574 '[codelinks]\nconfig_from_toml = "deep/x.toml"\n', encoding="utf-8"
575 )
576 app = make_app(
577 srcdir=minimal_sphinx_project,
578 freshenv=True,
579 confoverrides={"src_trace_config_from_toml": "cl.toml"},
580 )
581
582 assert app.config.src_trace_config_from_toml == "cl.toml"
583
584
585def test_bare_name_override_does_not_suppress_the_toml(
586 minimal_sphinx_project: Path,
587 make_app: Callable[..., SphinxTestApp],
588) -> None:
589 """``-D set_local_url=0`` names no confval -- Sphinx warns and ignores it -- so the
590 TOML value stands; only ``src_trace_<key>`` counts as an override."""
591 (minimal_sphinx_project / "ubproject.toml").write_text(
592 "[codelinks]\nset_local_url = true\n", encoding="utf-8"
593 )
594 app = make_app(
595 srcdir=minimal_sphinx_project,
596 freshenv=True,
597 confoverrides={"set_local_url": False},
598 )
599
600 assert app.config.src_trace_set_local_url is True
601
602
603def test_refused_projects_override_keeps_the_toml_projects(
604 minimal_sphinx_project: Path,
605 make_app: Callable[..., SphinxTestApp],
606) -> None:
607 """``-D src_trace_projects=x`` is refused by Sphinx (a dict cannot be overridden
608 whole) but stays in ``config.overrides``: the TOML's projects must still load."""
609 (minimal_sphinx_project / "ubproject.toml").write_text(
610 '[codelinks.projects.tomlproj.source_discover]\nsrc_dir = "./"\n',
611 encoding="utf-8",
612 )
613 app = make_app(
614 srcdir=minimal_sphinx_project,
615 freshenv=True,
616 confoverrides={"src_trace_projects": "x"},
617 )
618
619 assert list(app.config.src_trace_projects) == ["tomlproj"]
620
621
622@pytest.mark.parametrize(
623 ("override", "expected"),
624 [
625 pytest.param({"src_trace_local_url_field": "cli-url"}, "cli-url", id="D-wins"),
626 pytest.param({}, "toml-url", id="conf.py-vs-toml-unchanged"),
627 ],
628)
629def test_command_line_then_toml_then_conf_py(
630 minimal_sphinx_project: Path,
631 make_app: Callable[..., SphinxTestApp],
632 override: dict[str, str],
633 expected: str,
634) -> None:
635 """One key set in all three places: ``-D`` > TOML > conf.py."""
636 _write_conf(minimal_sphinx_project, 'src_trace_local_url_field = "conf-url"\n')
637 (minimal_sphinx_project / "ubproject.toml").write_text(
638 '[codelinks]\nlocal_url_field = "toml-url"\n', encoding="utf-8"
639 )
640 app = make_app(srcdir=minimal_sphinx_project, freshenv=True, confoverrides=override)
641
642 assert app.config.src_trace_local_url_field == expected
643
644
645def _syntax_error(path: Path) -> None:
646 path.write_text("[codelinks\n", encoding="utf-8")
647
648
649def _not_utf8(path: Path) -> None:
650 path.write_bytes(b'[codelinks]\nlocal_url_field = "caf\xe9"\n')
651
652
653def _a_directory(path: Path) -> None:
654 path.mkdir()
655
656
657def _not_a_table(path: Path) -> None:
658 path.write_text('codelinks = "x"\n', encoding="utf-8")
659
660
661_CORRUPT_FILES = [
662 pytest.param(_syntax_error, id="syntax-error"),
663 pytest.param(_not_utf8, id="not-utf8"),
664 pytest.param(_a_directory, id="a-directory"),
665 pytest.param(_not_a_table, id="codelinks-not-a-table"),
666]
667
668
669@pytest.mark.parametrize("corrupt", _CORRUPT_FILES)
670def test_corrupt_default_ubproject_toml_warns(
671 minimal_sphinx_project: Path,
672 make_app: Callable[..., SphinxTestApp],
673 corrupt: Callable[[Path], None],
674) -> None:
675 """A default ubproject.toml that exists but cannot be read or parsed warns, as any
676 configured file did at 1.4.0 -- only a missing file or table is silent."""
677 corrupt(minimal_sphinx_project / "ubproject.toml")
678 app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
679 app.build()
680
681 assert app.config.src_trace_projects == {}
682 _assert_one_config_warning(app, _LOAD_FAILED)
683
684
685@pytest.mark.parametrize(
686 ("name", "conf_extra"),
687 [
688 pytest.param("ubproject.toml", "", id="default"),
689 pytest.param(
690 "cl.toml", 'src_trace_config_from_toml = "cl.toml"\n', id="explicit"
691 ),
692 ],
693)
694def test_too_deeply_nested_toml_warns_naming_the_file(
695 minimal_sphinx_project: Path,
696 make_app: Callable[..., SphinxTestApp],
697 name: str,
698 conf_extra: str,
699) -> None:
700 """The TOML parser can fail with an exception ub-project does not wrap (a
701 ``RecursionError``): still one ``codelinks.config`` warning, naming the file since
702 Python's text does not, and the build completes."""
703 _write_conf(minimal_sphinx_project, conf_extra)
704 (minimal_sphinx_project / name).write_text(
705 "x = " + "[" * 5000 + "]" * 5000 + "\n", encoding="utf-8"
706 )
707 app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
708 app.build()
709
710 _assert_one_config_warning(app, _LOAD_FAILED)
711 assert name in build_warnings(app)[0]
712
713
714def test_explicit_toml_syntax_error_names_the_file_once(
715 minimal_sphinx_project: Path,
716 make_app: Callable[..., SphinxTestApp],
717) -> None:
718 """ub-project's message already names the file; the warning does not repeat it."""
719 _write_conf(minimal_sphinx_project, 'src_trace_config_from_toml = "cl.toml"\n')
720 _syntax_error(minimal_sphinx_project / "cl.toml")
721 app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
722 app.build()
723
724 _assert_one_config_warning(app, _LOAD_FAILED)
725 assert build_warnings(app)[0].count("cl.toml") == 1
726
727
728@pytest.mark.parametrize(
729 "state",
730 [
731 pytest.param(None, id="missing"),
732 pytest.param(
733 lambda path: path.write_text("[needs]\n", encoding="utf-8"),
734 id="no-codelinks-table",
735 ),
736 ],
737)
738def test_default_name_written_in_conf_py_is_still_the_default(
739 minimal_sphinx_project: Path,
740 make_app: Callable[..., SphinxTestApp],
741 state: Callable[[Path], object] | None,
742) -> None:
743 """``src_trace_config_from_toml = "ubproject.toml"`` names the shared default: absent
744 or without ``[codelinks]`` it is silent, as when the name is left unset."""
745 _write_conf(
746 minimal_sphinx_project, 'src_trace_config_from_toml = "ubproject.toml"\n'
747 )
748 if state is not None:
749 state(minimal_sphinx_project / "ubproject.toml")
750 app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
751 app.build()
752
753 assert_no_warnings(app)
754
755
756@pytest.mark.parametrize(
757 ("state", "phrase"),
758 [
759 pytest.param(None, "does not exist", id="missing"),
760 pytest.param(
761 lambda path: path.write_text("[needs]\n", encoding="utf-8"),
762 "has no [codelinks] table",
763 id="no-codelinks-table",
764 ),
765 ],
766)
767def test_other_spelling_of_the_default_name_is_explicit(
768 minimal_sphinx_project: Path,
769 make_app: Callable[..., SphinxTestApp],
770 state: Callable[[Path], object] | None,
771 phrase: str,
772) -> None:
773 """The default is the value ``ubproject.toml`` EXACTLY: ``./ubproject.toml`` names
774 the same file but is an explicit configuration, so missing or without
775 ``[codelinks]`` it warns."""
776 _write_conf(
777 minimal_sphinx_project, 'src_trace_config_from_toml = "./ubproject.toml"\n'
778 )
779 if state is not None:
780 state(minimal_sphinx_project / "ubproject.toml")
781 app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
782 app.build()
783
784 _assert_one_config_warning(app, phrase)
785
786
787def test_explicit_toml_without_codelinks_table_warns(
788 minimal_sphinx_project: Path,
789 make_app: Callable[..., SphinxTestApp],
790) -> None:
791 """An explicitly configured file without ``[codelinks]`` warns, and says so."""
792 _write_conf(minimal_sphinx_project, 'src_trace_config_from_toml = "cl.toml"\n')
793 (minimal_sphinx_project / "cl.toml").write_text(
794 "[needs]\nid_required = true\n", encoding="utf-8"
795 )
796 app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
797 app.build()
798
799 _assert_one_config_warning(app, "has no [codelinks] table")
800
801
802@pytest.mark.parametrize(
803 "state",
804 [
805 pytest.param(None, id="missing"),
806 pytest.param(_syntax_error, id="syntax-error"),
807 pytest.param(
808 lambda path: path.write_text("[needs]\n", encoding="utf-8"),
809 id="no-codelinks-table",
810 ),
811 ],
812)
813def test_reader_warnings_are_suppressible(
814 minimal_sphinx_project: Path,
815 make_app: Callable[..., SphinxTestApp],
816 state: Callable[[Path], object] | None,
817) -> None:
818 """Every warning of the reader carries ``codelinks.config``."""
819 _write_conf(
820 minimal_sphinx_project,
821 'src_trace_config_from_toml = "cl.toml"\n'
822 'suppress_warnings = ["codelinks.config"]\n',
823 )
824 if state is not None:
825 state(minimal_sphinx_project / "cl.toml")
826 app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
827 app.build()
828
829 assert_no_warnings(app)
830
831
832_MARKER = "# @Found here {tag}, IMPL_{tag}, impl\n"
833
834
835def _traced_project(root: Path, conf_extra: str, files: dict[str, str]) -> None:
836 """A project whose index traces project ``p``; ``files`` maps a relative path to
837 its content, and a ``LINK:<target>`` content makes that path a symlink."""
838 (root / "conf.py").write_text(
839 "extensions = ['sphinx_needs', 'sphinx_codelinks']\n"
840 "exclude_patterns = ['_build']\n" + conf_extra,
841 encoding="utf-8",
842 )
843 (root / "index.rst").write_text(
844 "T\n=\n\n.. src-trace::\n :project: p\n", encoding="utf-8"
845 )
846 for relative, content in files.items():
847 path = root / relative
848 path.parent.mkdir(parents=True, exist_ok=True)
849 if content.startswith("LINK:"):
850 try:
851 os.symlink(content[len("LINK:") :], path)
852 except (OSError, NotImplementedError) as error:
853 pytest.skip(f"cannot create a symlink here: {error}")
854 else:
855 path.write_text(content, encoding="utf-8")
856
857
858_PROJECT_P = '[codelinks.projects.p.source_discover]\nsrc_dir = "./src"\ncomment_type = "python"\n'
859
860
861def test_conf_py_projects_are_normalized_for_src_trace(
862 tmp_path: Path,
863 make_app: Callable[..., SphinxTestApp],
864) -> None:
865 """Projects declared in ``conf.py`` get the same typed config as TOML projects."""
866 _traced_project(
867 tmp_path,
868 "src_trace_config_from_toml = None\n"
869 "src_trace_projects = {\n"
870 " 'p': {\n"
871 " 'source_discover': {\n"
872 " 'src_dir': './src',\n"
873 " 'comment_type': 'python',\n"
874 " },\n"
875 " 'analyse': {},\n"
876 " },\n"
877 "}\n",
878 {"src/a.py": _MARKER.format(tag="CONFPY")},
879 )
880 app = make_app(srcdir=tmp_path, freshenv=True)
881 app.build()
882
883 html = Path(app.outdir, "index.html").read_text(encoding="utf-8")
884 assert "IMPL_CONFPY" in html
885
886
887def test_symlinked_toml_anchors_at_the_links_directory(
888 tmp_path: Path,
889 make_app: Callable[..., SphinxTestApp],
890) -> None:
891 """A relative path in a TOML reached through a symlink is anchored at the LINK's
892 directory -- ``confdir / Path(config_from_toml).parent`` -- not the target's."""
893 _traced_project(
894 tmp_path,
895 "src_trace_config_from_toml = 'cl.toml'\n",
896 {
897 "other/sub/real.toml": _PROJECT_P,
898 "cl.toml": "LINK:" + str(Path("other", "sub", "real.toml")),
899 "src/a.py": _MARKER.format(tag="LINKDIR"),
900 "other/sub/src/a.py": _MARKER.format(tag="REALDIR"),
901 },
902 )
903 app = make_app(srcdir=tmp_path, freshenv=True)
904 app.build()
905
906 html = Path(app.outdir, "index.html").read_text(encoding="utf-8")
907 assert "IMPL_LINKDIR" in html
908 assert "IMPL_REALDIR" not in html
909
910
911def test_config_from_toml_set_in_the_toml_moves_the_anchor(
912 tmp_path: Path,
913 make_app: Callable[..., SphinxTestApp],
914) -> None:
915 """A PIN of today's behaviour, not an endorsement: ``config_from_toml`` is a key the
916 TOML may set, and when it does the use-site anchor moves to that name's directory
917 (the named file is never read)."""
918 _traced_project(
919 tmp_path,
920 "",
921 {
922 "ubproject.toml": _PROJECT_P
923 + "[codelinks]\nconfig_from_toml = 'deep/x.toml'\n",
924 "src/a.py": _MARKER.format(tag="CONFDIR"),
925 "deep/src/a.py": _MARKER.format(tag="DEEPDIR"),
926 },
927 )
928 app = make_app(srcdir=tmp_path, freshenv=True)
929 app.build()
930
931 html = Path(app.outdir, "index.html").read_text(encoding="utf-8")
932 assert "IMPL_DEEPDIR" in html
933 assert "IMPL_CONFDIR" not in html