[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, get_needs_view
 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', '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    # No need field carries the code entity a marker is attached to: the analysis
264    # records it (``scope``), and no ``code_entity*`` field is registered.
265    needs = get_needs_view(app)
266    assert needs, "the project traces needs"
267    assert [
268        name
269        for need in needs.values()
270        for name in need
271        if name.startswith("code_entity")
272    ] == []
273
274    assert app.env.get_doctree("index") == snapshot_doctree
275
276
277def test_incremental_build_keeps_src_trace_projects_unchanged(
278    tmpdir: Path,
279    make_app: Callable[..., SphinxTestApp],
280) -> None:
281    """An incremental rebuild with no source changes must not invalidate the env.
282
283    Regression test for the ``src-trace`` directive mutating the ``analyse_config``
284    object stored inside the ``rebuild="env"`` ``src_trace_projects`` config value.
285    The mutated object (populated ``src_dir``/``src_files``) was persisted into
286    ``environment.pickle``, so every incremental build compared it against the
287    freshly generated (empty) config and reported
288    ``[config changed ('src_trace_projects')]``, forcing a full re-read.
289    """
290    this_file_dir = Path(__file__).parent
291    sphinx_project = Path("data") / "sphinx"
292    source_code = Path("data") / "dcdc"
293
294    sphinx_src_dir = Path(tmpdir) / sphinx_project
295    shutil.copytree(this_file_dir / sphinx_project, sphinx_src_dir, dirs_exist_ok=True)
296    shutil.copytree(
297        this_file_dir / source_code, Path(tmpdir) / source_code, dirs_exist_ok=True
298    )
299
300    # First build populates environment.pickle in the shared build dir.
301    make_app(srcdir=sphinx_src_dir, freshenv=True).build()
302
303    # Second build reuses the same build dir and loads the pickled environment.
304    app = make_app(srcdir=sphinx_src_dir, freshenv=False)
305
306    captured: dict[str, object] = {}
307
308    def capture_config_status(_app, env, _added, _changed, _removed):
309        # ``env-get-outdated`` fires during read() after the config comparison
310        # but before config_status is reset to CONFIG_OK at the end of read().
311        captured["status"] = env.config_status
312        captured["extra"] = env.config_status_extra
313        return []
314
315    app.connect("env-get-outdated", capture_config_status)
316    app.build()
317
318    assert captured["status"] == CONFIG_OK, (
319        f"incremental build wrongly invalidated the environment: "
320        f"config changed{captured.get('extra')}"
321    )
322
323
324@pytest.mark.skipif(
325    not hasattr(sphinx_needs.api, "add_need_layout"),
326    reason="the installed sphinx-needs predates its fix of #2265 (which added "
327    "add_need_layout); the fix ships in the next sphinx-needs release",
328)
329def test_an_incremental_build_changes_no_configuration_option(
330    minimal_sphinx_project: Path,
331    make_app: Callable[..., SphinxTestApp],
332) -> None:
333    """The need type this extension adds is not written into ``needs_types``.
334
335    ``add_need_type`` keeps ``srctrace`` in sphinx-needs' own registry, as it keeps the
336    GitHub services' need types and layout (sphinx-needs #2265), so the configuration
337    an incremental build compares with the pickled one is the same, and Sphinx names
338    no changed option. They used to be appended to ``needs_types`` and
339    ``needs_layouts``, after Sphinx had compared the configurations.
340    """
341    first = make_app(srcdir=minimal_sphinx_project, freshenv=True)
342    first.build()
343    old_env = pickle.loads(Path(first.doctreedir, "environment.pickle").read_bytes())
344    assert "srctrace" in get_need_types(first)
345    assert "srctrace" not in [t["directive"] for t in first.config.needs_types]
346
347    second = make_app(srcdir=minimal_sphinx_project)
348    old_values = {item.name: item.value for item in old_env.config}
349    new_values = {item.name: item.value for item in second.config}
350    assert {
351        name
352        for name in old_values.keys() | new_values.keys()
353        if old_values.get(name) != new_values.get(name)
354    } == set()
355    second.build()
356    # Sphinx 8.1 and later name the changed options in a status line; 7.4 and 8.0
357    # compare only the options that rebuild the environment, and print no such line.
358    # ``html_theme`` is among them: ``SphinxTestApp`` sets it to ``basic`` only after
359    # Sphinx has compared the configurations, and the first build pickled ``basic``
360    reported = re.search(
361        r"The configuration has changed \(\d+ options?: (.*)\)",
362        strip_colors(second._status.getvalue()),
363    )
364    assert reported is None or re.findall(r"'(needs_[^']+)'", reported.group(1)) == []
365
366
367@pytest.fixture
368def minimal_sphinx_project(tmp_path: Path) -> Path:
369    """Minimal Sphinx project with no TOML config file next to conf.py."""
370    (tmp_path / "conf.py").write_text(
371        "extensions = ['sphinx_needs', 'sphinx_codelinks']\n"
372        "exclude_patterns = ['_build']\n"
373    )
374    (tmp_path / "index.rst").write_text("Minimal project\n===============\n")
375    return tmp_path
376
377
378def test_config_from_toml_defaults_to_ubproject_toml() -> None:
379    """The default config file is the shared ubproject.toml (ubcode-pub#75)."""
380    config_field = next(
381        field for field in fields(CodeLinksConfig) if field.name == "config_from_toml"
382    )
383    assert config_field.default == "ubproject.toml"
384
385
386def test_default_ubproject_toml_is_loaded(
387    minimal_sphinx_project: Path,
388    make_app: Callable[..., SphinxTestApp],
389) -> None:
390    """An ubproject.toml next to conf.py is loaded without any conf.py entry."""
391    (minimal_sphinx_project / "ubproject.toml").write_text(
392        "[codelinks.projects.demo]\n"
393        'remote_url_pattern = "https://example.com/{commit}/{path}#L{line}"\n'
394        "\n"
395        "[codelinks.projects.demo.source_discover]\n"
396        'src_dir = "./"\n'
397    )
398    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
399    app.build()
400
401    assert "demo" in app.config.src_trace_projects
402    assert_no_warnings(app)
403
404
405def test_default_ubproject_toml_without_codelinks_section_is_silent(
406    minimal_sphinx_project: Path,
407    make_app: Callable[..., SphinxTestApp],
408) -> None:
409    """A default ubproject.toml used by other tools but without [codelinks] is
410    silently ignored instead of warning."""
411    (minimal_sphinx_project / "ubproject.toml").write_text(
412        "[needs]\nid_required = true\n"
413    )
414    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
415    app.build()
416
417    assert app.config.src_trace_projects == {}
418    assert_no_warnings(app)
419
420
421def test_missing_default_ubproject_toml_is_silent(
422    minimal_sphinx_project: Path,
423    make_app: Callable[..., SphinxTestApp],
424) -> None:
425    """Without an ubproject.toml next to conf.py, the conf.py configuration is
426    used and no warning is emitted."""
427    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
428    app.build()
429
430    assert app.config.src_trace_projects == {}
431    assert_no_warnings(app)
432
433
434def test_explicit_toml_config_missing_warns(
435    minimal_sphinx_project: Path,
436    make_app: Callable[..., SphinxTestApp],
437) -> None:
438    """An explicitly configured TOML file that does not exist still warns."""
439    conf_py = minimal_sphinx_project / "conf.py"
440    conf_py.write_text(
441        conf_py.read_text() + '\nsrc_trace_config_from_toml = "nonexistent.toml"\n'
442    )
443    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
444    app.build()
445
446    warnings = build_warnings(app)
447    assert len(warnings) == 1, warnings
448    assert "does not exist" in warnings[0]
449
450
451# -- the TOML reader: what a -D value, a corrupt file and the typed warnings do ----------
452
453#: Sphinx 8 renders a warning's ``[type.subtype]`` itself; 7.4 appends nothing, so on
454#: 7.4 the reader's warnings are asserted by their phrase and count only
455_SHOWS_WARNING_TYPES = sphinx.version_info >= (8,)
456
457_LOAD_FAILED = "Failed to load source tracing configuration"
458
459
460def _write_conf(project: Path, extra: str) -> None:
461    conf_py = project / "conf.py"
462    conf_py.write_text(conf_py.read_text(encoding="utf-8") + extra, encoding="utf-8")
463
464
465def _assert_one_config_warning(app: SphinxTestApp, phrase: str) -> None:
466    warnings = build_warnings(app)
467    assert len(warnings) == 1, warnings
468    assert phrase in warnings[0]
469    if _SHOWS_WARNING_TYPES:
470        assert "[codelinks.config]" in warnings[0]
471
472
473@pytest.mark.parametrize(
474    ("key", "toml_value", "override"),
475    [
476        ("set_local_url", "true", False),
477        ("set_remote_url", "true", False),
478        ("local_url_field", '"toml-url"', "cli-url"),
479        ("remote_url_field", '"toml-remote"', "cli-remote"),
480        ("debug_measurement", "true", False),
481        ("debug_filters", "true", False),
482    ],
483)
484def test_command_line_override_beats_the_toml(
485    minimal_sphinx_project: Path,
486    make_app: Callable[..., SphinxTestApp],
487    key: str,
488    toml_value: str,
489    override: object,
490) -> None:
491    """``-D src_trace_<key>`` wins over the same key in ``[codelinks]``.
492
493    ``confoverrides`` is what ``-D`` becomes: Sphinx stores both in ``config.overrides``.
494    """
495    (minimal_sphinx_project / "ubproject.toml").write_text(
496        f"[codelinks]\n{key} = {toml_value}\n", encoding="utf-8"
497    )
498    app = make_app(
499        srcdir=minimal_sphinx_project,
500        freshenv=True,
501        confoverrides={f"src_trace_{key}": override},
502    )
503
504    assert app.config[f"src_trace_{key}"] == override
505
506
507def test_refused_outdir_override_keeps_the_toml_value(
508    minimal_sphinx_project: Path,
509    make_app: Callable[..., SphinxTestApp],
510) -> None:
511    """Sphinx refuses ``-D src_trace_outdir`` (its default is a ``Path``: "unsupported
512    type") but keeps it in ``config.overrides``: an override that was never applied must
513    not suppress the TOML value."""
514    (minimal_sphinx_project / "ubproject.toml").write_text(
515        '[codelinks]\noutdir = "toml-out"\n', encoding="utf-8"
516    )
517    app = make_app(
518        srcdir=minimal_sphinx_project,
519        freshenv=True,
520        confoverrides={"src_trace_outdir": "cli-out"},
521    )
522
523    assert app.config.src_trace_outdir == "toml-out"
524
525
526def test_not_overridable_from_d_is_exactly_what_sphinx_refuses(
527    minimal_sphinx_project: Path,
528    make_app: Callable[..., SphinxTestApp],
529) -> None:
530    """The keys exempt from the ``-D`` skip are exactly the codelinks confvals Sphinx
531    refuses to take from ``-D`` -- detected per confval by Sphinx's own
532    "cannot override ... 'src_trace_<key>'" warning, with a value ("1") every confval
533    Sphinx does accept can convert."""
534    from sphinx_codelinks.sphinx_extension.source_tracing import (
535        NOT_OVERRIDABLE_FROM_D,
536    )
537
538    refused = set()
539    for item in fields(CodeLinksConfig):
540        app = make_app(
541            srcdir=minimal_sphinx_project,
542            freshenv=True,
543            confoverrides={f"src_trace_{item.name}": "1"},
544        )
545        if any(
546            "cannot override" in warning and f"'src_trace_{item.name}'" in warning
547            for warning in build_warnings(app)
548        ):
549            refused.add(item.name)
550
551    assert refused == set(NOT_OVERRIDABLE_FROM_D)
552
553
554def test_override_of_one_key_leaves_the_other_toml_keys(
555    minimal_sphinx_project: Path,
556    make_app: Callable[..., SphinxTestApp],
557) -> None:
558    """``-D`` on one key skips that key only: the rest of ``[codelinks]`` still loads."""
559    (minimal_sphinx_project / "ubproject.toml").write_text(
560        "[codelinks]\n"
561        "set_local_url = true\n"
562        'local_url_field = "toml-url"\n'
563        "[codelinks.projects.tomlproj.source_discover]\n"
564        'src_dir = "./"\n',
565        encoding="utf-8",
566    )
567    app = make_app(
568        srcdir=minimal_sphinx_project,
569        freshenv=True,
570        confoverrides={"src_trace_set_local_url": False},
571    )
572
573    assert app.config.src_trace_set_local_url is False
574    assert app.config.src_trace_local_url_field == "toml-url"
575    assert list(app.config.src_trace_projects) == ["tomlproj"]
576
577
578def test_command_line_override_of_config_from_toml_beats_the_toml(
579    minimal_sphinx_project: Path,
580    make_app: Callable[..., SphinxTestApp],
581) -> None:
582    """The eighth key: a ``config_from_toml`` inside the file no longer rewrites the
583    file name given with ``-D``."""
584    (minimal_sphinx_project / "cl.toml").write_text(
585        '[codelinks]\nconfig_from_toml = "deep/x.toml"\n', encoding="utf-8"
586    )
587    app = make_app(
588        srcdir=minimal_sphinx_project,
589        freshenv=True,
590        confoverrides={"src_trace_config_from_toml": "cl.toml"},
591    )
592
593    assert app.config.src_trace_config_from_toml == "cl.toml"
594
595
596def test_bare_name_override_does_not_suppress_the_toml(
597    minimal_sphinx_project: Path,
598    make_app: Callable[..., SphinxTestApp],
599) -> None:
600    """``-D set_local_url=0`` names no confval -- Sphinx warns and ignores it -- so the
601    TOML value stands; only ``src_trace_<key>`` counts as an override."""
602    (minimal_sphinx_project / "ubproject.toml").write_text(
603        "[codelinks]\nset_local_url = true\n", encoding="utf-8"
604    )
605    app = make_app(
606        srcdir=minimal_sphinx_project,
607        freshenv=True,
608        confoverrides={"set_local_url": False},
609    )
610
611    assert app.config.src_trace_set_local_url is True
612
613
614def test_refused_projects_override_keeps_the_toml_projects(
615    minimal_sphinx_project: Path,
616    make_app: Callable[..., SphinxTestApp],
617) -> None:
618    """``-D src_trace_projects=x`` is refused by Sphinx (a dict cannot be overridden
619    whole) but stays in ``config.overrides``: the TOML's projects must still load."""
620    (minimal_sphinx_project / "ubproject.toml").write_text(
621        '[codelinks.projects.tomlproj.source_discover]\nsrc_dir = "./"\n',
622        encoding="utf-8",
623    )
624    app = make_app(
625        srcdir=minimal_sphinx_project,
626        freshenv=True,
627        confoverrides={"src_trace_projects": "x"},
628    )
629
630    assert list(app.config.src_trace_projects) == ["tomlproj"]
631
632
633@pytest.mark.parametrize(
634    ("override", "expected"),
635    [
636        pytest.param({"src_trace_local_url_field": "cli-url"}, "cli-url", id="D-wins"),
637        pytest.param({}, "toml-url", id="conf.py-vs-toml-unchanged"),
638    ],
639)
640def test_command_line_then_toml_then_conf_py(
641    minimal_sphinx_project: Path,
642    make_app: Callable[..., SphinxTestApp],
643    override: dict[str, str],
644    expected: str,
645) -> None:
646    """One key set in all three places: ``-D`` > TOML > conf.py."""
647    _write_conf(minimal_sphinx_project, 'src_trace_local_url_field = "conf-url"\n')
648    (minimal_sphinx_project / "ubproject.toml").write_text(
649        '[codelinks]\nlocal_url_field = "toml-url"\n', encoding="utf-8"
650    )
651    app = make_app(srcdir=minimal_sphinx_project, freshenv=True, confoverrides=override)
652
653    assert app.config.src_trace_local_url_field == expected
654
655
656def _syntax_error(path: Path) -> None:
657    path.write_text("[codelinks\n", encoding="utf-8")
658
659
660def _not_utf8(path: Path) -> None:
661    path.write_bytes(b'[codelinks]\nlocal_url_field = "caf\xe9"\n')
662
663
664def _a_directory(path: Path) -> None:
665    path.mkdir()
666
667
668def _not_a_table(path: Path) -> None:
669    path.write_text('codelinks = "x"\n', encoding="utf-8")
670
671
672_CORRUPT_FILES = [
673    pytest.param(_syntax_error, id="syntax-error"),
674    pytest.param(_not_utf8, id="not-utf8"),
675    pytest.param(_a_directory, id="a-directory"),
676    pytest.param(_not_a_table, id="codelinks-not-a-table"),
677]
678
679
680@pytest.mark.parametrize("corrupt", _CORRUPT_FILES)
681def test_corrupt_default_ubproject_toml_warns(
682    minimal_sphinx_project: Path,
683    make_app: Callable[..., SphinxTestApp],
684    corrupt: Callable[[Path], None],
685) -> None:
686    """A default ubproject.toml that exists but cannot be read or parsed warns, as any
687    configured file did at 1.4.0 -- only a missing file or table is silent."""
688    corrupt(minimal_sphinx_project / "ubproject.toml")
689    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
690    app.build()
691
692    assert app.config.src_trace_projects == {}
693    _assert_one_config_warning(app, _LOAD_FAILED)
694
695
696@pytest.mark.parametrize(
697    ("name", "conf_extra"),
698    [
699        pytest.param("ubproject.toml", "", id="default"),
700        pytest.param(
701            "cl.toml", 'src_trace_config_from_toml = "cl.toml"\n', id="explicit"
702        ),
703    ],
704)
705def test_too_deeply_nested_toml_warns_naming_the_file(
706    minimal_sphinx_project: Path,
707    make_app: Callable[..., SphinxTestApp],
708    name: str,
709    conf_extra: str,
710) -> None:
711    """The TOML parser can fail with an exception ub-project does not wrap (a
712    ``RecursionError``): still one ``codelinks.config`` warning, naming the file since
713    Python's text does not, and the build completes."""
714    _write_conf(minimal_sphinx_project, conf_extra)
715    (minimal_sphinx_project / name).write_text(
716        "x = " + "[" * 5000 + "]" * 5000 + "\n", encoding="utf-8"
717    )
718    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
719    app.build()
720
721    _assert_one_config_warning(app, _LOAD_FAILED)
722    assert name in build_warnings(app)[0]
723
724
725def test_explicit_toml_syntax_error_names_the_file_once(
726    minimal_sphinx_project: Path,
727    make_app: Callable[..., SphinxTestApp],
728) -> None:
729    """ub-project's message already names the file; the warning does not repeat it."""
730    _write_conf(minimal_sphinx_project, 'src_trace_config_from_toml = "cl.toml"\n')
731    _syntax_error(minimal_sphinx_project / "cl.toml")
732    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
733    app.build()
734
735    _assert_one_config_warning(app, _LOAD_FAILED)
736    assert build_warnings(app)[0].count("cl.toml") == 1
737
738
739@pytest.mark.parametrize(
740    "state",
741    [
742        pytest.param(None, id="missing"),
743        pytest.param(
744            lambda path: path.write_text("[needs]\n", encoding="utf-8"),
745            id="no-codelinks-table",
746        ),
747    ],
748)
749def test_default_name_written_in_conf_py_is_still_the_default(
750    minimal_sphinx_project: Path,
751    make_app: Callable[..., SphinxTestApp],
752    state: Callable[[Path], object] | None,
753) -> None:
754    """``src_trace_config_from_toml = "ubproject.toml"`` names the shared default: absent
755    or without ``[codelinks]`` it is silent, as when the name is left unset."""
756    _write_conf(
757        minimal_sphinx_project, 'src_trace_config_from_toml = "ubproject.toml"\n'
758    )
759    if state is not None:
760        state(minimal_sphinx_project / "ubproject.toml")
761    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
762    app.build()
763
764    assert_no_warnings(app)
765
766
767@pytest.mark.parametrize(
768    ("state", "phrase"),
769    [
770        pytest.param(None, "does not exist", id="missing"),
771        pytest.param(
772            lambda path: path.write_text("[needs]\n", encoding="utf-8"),
773            "has no [codelinks] table",
774            id="no-codelinks-table",
775        ),
776    ],
777)
778def test_other_spelling_of_the_default_name_is_explicit(
779    minimal_sphinx_project: Path,
780    make_app: Callable[..., SphinxTestApp],
781    state: Callable[[Path], object] | None,
782    phrase: str,
783) -> None:
784    """The default is the value ``ubproject.toml`` EXACTLY: ``./ubproject.toml`` names
785    the same file but is an explicit configuration, so missing or without
786    ``[codelinks]`` it warns."""
787    _write_conf(
788        minimal_sphinx_project, 'src_trace_config_from_toml = "./ubproject.toml"\n'
789    )
790    if state is not None:
791        state(minimal_sphinx_project / "ubproject.toml")
792    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
793    app.build()
794
795    _assert_one_config_warning(app, phrase)
796
797
798def test_explicit_toml_without_codelinks_table_warns(
799    minimal_sphinx_project: Path,
800    make_app: Callable[..., SphinxTestApp],
801) -> None:
802    """An explicitly configured file without ``[codelinks]`` warns, and says so."""
803    _write_conf(minimal_sphinx_project, 'src_trace_config_from_toml = "cl.toml"\n')
804    (minimal_sphinx_project / "cl.toml").write_text(
805        "[needs]\nid_required = true\n", encoding="utf-8"
806    )
807    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
808    app.build()
809
810    _assert_one_config_warning(app, "has no [codelinks] table")
811
812
813@pytest.mark.parametrize(
814    "state",
815    [
816        pytest.param(None, id="missing"),
817        pytest.param(_syntax_error, id="syntax-error"),
818        pytest.param(
819            lambda path: path.write_text("[needs]\n", encoding="utf-8"),
820            id="no-codelinks-table",
821        ),
822    ],
823)
824def test_reader_warnings_are_suppressible(
825    minimal_sphinx_project: Path,
826    make_app: Callable[..., SphinxTestApp],
827    state: Callable[[Path], object] | None,
828) -> None:
829    """Every warning of the reader carries ``codelinks.config``."""
830    _write_conf(
831        minimal_sphinx_project,
832        'src_trace_config_from_toml = "cl.toml"\n'
833        'suppress_warnings = ["codelinks.config"]\n',
834    )
835    if state is not None:
836        state(minimal_sphinx_project / "cl.toml")
837    app = make_app(srcdir=minimal_sphinx_project, freshenv=True)
838    app.build()
839
840    assert_no_warnings(app)
841
842
843_MARKER = "# @Found here {tag}, IMPL_{tag}, impl\n"
844
845
846def _traced_project(root: Path, conf_extra: str, files: dict[str, str]) -> None:
847    """A project whose index traces project ``p``; ``files`` maps a relative path to
848    its content, and a ``LINK:<target>`` content makes that path a symlink."""
849    (root / "conf.py").write_text(
850        "extensions = ['sphinx_needs', 'sphinx_codelinks']\n"
851        "exclude_patterns = ['_build']\n" + conf_extra,
852        encoding="utf-8",
853    )
854    (root / "index.rst").write_text(
855        "T\n=\n\n.. src-trace::\n   :project: p\n", encoding="utf-8"
856    )
857    for relative, content in files.items():
858        path = root / relative
859        path.parent.mkdir(parents=True, exist_ok=True)
860        if content.startswith("LINK:"):
861            try:
862                os.symlink(content[len("LINK:") :], path)
863            except (OSError, NotImplementedError) as error:
864                pytest.skip(f"cannot create a symlink here: {error}")
865        else:
866            path.write_text(content, encoding="utf-8")
867
868
869_PROJECT_P = '[codelinks.projects.p.source_discover]\nsrc_dir = "./src"\ncomment_type = "python"\n'
870
871
872def test_conf_py_projects_are_normalized_for_src_trace(
873    tmp_path: Path,
874    make_app: Callable[..., SphinxTestApp],
875) -> None:
876    """Projects declared in ``conf.py`` get the same typed config as TOML projects."""
877    _traced_project(
878        tmp_path,
879        "src_trace_config_from_toml = None\n"
880        "src_trace_projects = {\n"
881        "    'p': {\n"
882        "        'source_discover': {\n"
883        "            'src_dir': './src',\n"
884        "            'comment_type': 'python',\n"
885        "        },\n"
886        "        'analyse': {},\n"
887        "    },\n"
888        "}\n",
889        {"src/a.py": _MARKER.format(tag="CONFPY")},
890    )
891    app = make_app(srcdir=tmp_path, freshenv=True)
892    app.build()
893
894    html = Path(app.outdir, "index.html").read_text(encoding="utf-8")
895    assert "IMPL_CONFPY" in html
896
897
898def test_symlinked_toml_anchors_at_the_links_directory(
899    tmp_path: Path,
900    make_app: Callable[..., SphinxTestApp],
901) -> None:
902    """A relative path in a TOML reached through a symlink is anchored at the LINK's
903    directory -- ``confdir / Path(config_from_toml).parent`` -- not the target's."""
904    _traced_project(
905        tmp_path,
906        "src_trace_config_from_toml = 'cl.toml'\n",
907        {
908            "other/sub/real.toml": _PROJECT_P,
909            "cl.toml": "LINK:" + str(Path("other", "sub", "real.toml")),
910            "src/a.py": _MARKER.format(tag="LINKDIR"),
911            "other/sub/src/a.py": _MARKER.format(tag="REALDIR"),
912        },
913    )
914    app = make_app(srcdir=tmp_path, freshenv=True)
915    app.build()
916
917    html = Path(app.outdir, "index.html").read_text(encoding="utf-8")
918    assert "IMPL_LINKDIR" in html
919    assert "IMPL_REALDIR" not in html
920
921
922def test_config_from_toml_set_in_the_toml_moves_the_anchor(
923    tmp_path: Path,
924    make_app: Callable[..., SphinxTestApp],
925) -> None:
926    """A PIN of today's behaviour, not an endorsement: ``config_from_toml`` is a key the
927    TOML may set, and when it does the use-site anchor moves to that name's directory
928    (the named file is never read)."""
929    _traced_project(
930        tmp_path,
931        "",
932        {
933            "ubproject.toml": _PROJECT_P
934            + "[codelinks]\nconfig_from_toml = 'deep/x.toml'\n",
935            "src/a.py": _MARKER.format(tag="CONFDIR"),
936            "deep/src/a.py": _MARKER.format(tag="DEEPDIR"),
937        },
938    )
939    app = make_app(srcdir=tmp_path, freshenv=True)
940    app.build()
941
942    html = Path(app.outdir, "index.html").read_text(encoding="utf-8")
943    assert "IMPL_DEEPDIR" in html
944    assert "IMPL_CONFDIR" not in html