From 7e74ed64d44574eeecb4b83f40437e906939f326 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 30 Sep 2026 23:43:19 +0200 Subject: [PATCH 1/9] =?UTF-8?q?=F0=9F=A7=AA=20sphinx-codelinks:=20fence=20?= =?UTF-8?q?the=20TOML=20reader=20before=20it=20moves=20to=20ub-project?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red against d0edd0e9 (22): a `-D src_trace_` value beats the TOML for the seven scalar keys and `config_from_toml`, and in the -D / TOML / conf.py three-way; a corrupt default ubproject.toml (syntax, non-UTF-8, a directory, `codelinks` not a table) warns `codelinks.config`; an explicit file without `[codelinks]` says so; the reader's warnings are suppressible with `codelinks.config`; the CLI shows the parser's reason and ub-project's "[codelinks] must be a table" words. Pins, green at d0edd0e9 and proven by mutation later (8): a bare-name `-D` and a refused `-D src_trace_projects=x` leave the TOML alone; conf.py vs TOML is unchanged; an empty or missing `[codelinks]` keeps the CLI's words; a 5000-deep TOML is still rc 2; a symlinked TOML anchors at the link's directory; a TOML-set `config_from_toml` moves the anchor. --- packages/sphinx-codelinks/tests/test_cmd.py | 65 ++++ .../sphinx-codelinks/tests/test_src_trace.py | 300 ++++++++++++++++++ 2 files changed, 365 insertions(+) diff --git a/packages/sphinx-codelinks/tests/test_cmd.py b/packages/sphinx-codelinks/tests/test_cmd.py index 373353543..782cabf67 100644 --- a/packages/sphinx-codelinks/tests/test_cmd.py +++ b/packages/sphinx-codelinks/tests/test_cmd.py @@ -1,6 +1,7 @@ # @Test suite for CLI commands including analyse, discover, and write, TEST_CLI_1, test, [IMPL_CLI_ANALYZE, IMPL_CLI_DISCOVER, IMPL_CLI_WRITE] import json import re +import tomllib from pathlib import Path import pytest @@ -463,3 +464,67 @@ def test_analyse_with_absolute_git_root(tmp_path: Path) -> None: marked_content = json.load(f) # Verify the content was analysed using the correct git_root assert len(marked_content["test_project"]) > 0 + + +# -- the TOML reader of `codelinks analyse` ---------------------------------------------- + + +def _analyse(tmp_path: Path, toml_text: str) -> tuple[int, str]: + config = tmp_path / "cl.toml" + config.write_text(toml_text, encoding="utf-8") + result = runner.invoke(app, ["analyse", str(config), "--outdir", str(tmp_path)]) + return result.exit_code, _normalize_output(result.output) + + +def test_analyse_toml_syntax_error_shows_the_reason(tmp_path: Path) -> None: + """The parser's own message reaches the user, not only "Failed to load".""" + broken = "[codelinks\n" + with pytest.raises(tomllib.TOMLDecodeError) as parse_error: + tomllib.loads(broken) + + exit_code, output = _analyse(tmp_path, broken) + + assert exit_code == 2 + assert _normalize_output(str(parse_error.value)) in output + + +@pytest.mark.parametrize( + ("value", "type_name"), + [('"x"', "str"), ("0", "int"), ("[]", "list"), ("false", "bool")], +) +def test_analyse_codelinks_not_a_table( + tmp_path: Path, value: str, type_name: str +) -> None: + """``codelinks`` that is not a table is named as such, for a falsy value too.""" + exit_code, output = _analyse(tmp_path, f"codelinks = {value}\n") + + assert exit_code == 2 + assert f"[codelinks] must be a table, got {type_name}" in output + assert "No 'codelinks' section" not in output + + +@pytest.mark.parametrize( + "toml_text", + [ + pytest.param("[codelinks]\n", id="empty-table"), + pytest.param("[needs]\nid_required = true\n", id="no-table"), + ], +) +def test_analyse_without_codelinks_configuration( + tmp_path: Path, toml_text: str +) -> None: + """A missing or empty ``[codelinks]`` keeps today's words.""" + exit_code, output = _analyse(tmp_path, toml_text) + + assert exit_code == 2 + assert "No 'codelinks' section found" in output + + +def test_analyse_too_deeply_nested_toml_is_a_bad_parameter(tmp_path: Path) -> None: + """``tomllib`` can fail with an exception ub-project does not wrap (a + ``RecursionError`` on a pathologically nested file): still a usage error, rc 2, + never a traceback.""" + exit_code, _output = _analyse(tmp_path, "x = " + "[" * 5000 + "]" * 5000 + "\n") + + # 1 would be the uncaught exception; typer reports a BadParameter as 2 + assert exit_code == 2 diff --git a/packages/sphinx-codelinks/tests/test_src_trace.py b/packages/sphinx-codelinks/tests/test_src_trace.py index 72ca81895..0434d73ce 100644 --- a/packages/sphinx-codelinks/tests/test_src_trace.py +++ b/packages/sphinx-codelinks/tests/test_src_trace.py @@ -1,10 +1,12 @@ # @Test suite for Sphinx extension source tracing functionality, TEST_EXT_1, test, [IMPL_LNK_1, IMPL_ONE_1, IMPL_MRST_1] +import os import shutil from collections.abc import Callable from dataclasses import fields from pathlib import Path import pytest +import sphinx from sphinx.environment import CONFIG_OK from sphinx.testing.util import SphinxTestApp @@ -370,3 +372,301 @@ def test_explicit_toml_config_missing_warns( warnings = build_warnings(app) assert len(warnings) == 1, warnings assert "does not exist" in warnings[0] + + +# -- the TOML reader: what a -D value, a corrupt file and the typed warnings do ---------- + +#: Sphinx 8 renders a warning's ``[type.subtype]`` itself; 7.4 appends nothing, so on +#: 7.4 the reader's warnings are asserted by their phrase and count only +_SHOWS_WARNING_TYPES = sphinx.version_info >= (8,) + +_LOAD_FAILED = "Failed to load source tracing configuration" + + +def _write_conf(project: Path, extra: str) -> None: + conf_py = project / "conf.py" + conf_py.write_text(conf_py.read_text(encoding="utf-8") + extra, encoding="utf-8") + + +def _assert_one_config_warning(app: SphinxTestApp, phrase: str) -> None: + warnings = build_warnings(app) + assert len(warnings) == 1, warnings + assert phrase in warnings[0] + if _SHOWS_WARNING_TYPES: + assert "[codelinks.config]" in warnings[0] + + +@pytest.mark.parametrize( + ("key", "toml_value", "override"), + [ + ("set_local_url", "true", False), + ("set_remote_url", "true", False), + ("local_url_field", '"toml-url"', "cli-url"), + ("remote_url_field", '"toml-remote"', "cli-remote"), + ("outdir", '"toml-out"', "cli-out"), + ("debug_measurement", "true", False), + ("debug_filters", "true", False), + ], +) +def test_command_line_override_beats_the_toml( + minimal_sphinx_project: Path, + make_app: Callable[..., SphinxTestApp], + key: str, + toml_value: str, + override: object, +) -> None: + """``-D src_trace_`` wins over the same key in ``[codelinks]``. + + ``confoverrides`` is what ``-D`` becomes: Sphinx stores both in ``config.overrides``. + """ + (minimal_sphinx_project / "ubproject.toml").write_text( + f"[codelinks]\n{key} = {toml_value}\n", encoding="utf-8" + ) + app = make_app( + srcdir=minimal_sphinx_project, + freshenv=True, + confoverrides={f"src_trace_{key}": override}, + ) + + assert app.config[f"src_trace_{key}"] == override + + +def test_command_line_override_of_config_from_toml_beats_the_toml( + minimal_sphinx_project: Path, + make_app: Callable[..., SphinxTestApp], +) -> None: + """The eighth key: a ``config_from_toml`` inside the file no longer rewrites the + file name given with ``-D``.""" + (minimal_sphinx_project / "cl.toml").write_text( + '[codelinks]\nconfig_from_toml = "deep/x.toml"\n', encoding="utf-8" + ) + app = make_app( + srcdir=minimal_sphinx_project, + freshenv=True, + confoverrides={"src_trace_config_from_toml": "cl.toml"}, + ) + + assert app.config.src_trace_config_from_toml == "cl.toml" + + +def test_bare_name_override_does_not_suppress_the_toml( + minimal_sphinx_project: Path, + make_app: Callable[..., SphinxTestApp], +) -> None: + """``-D set_local_url=0`` names no confval -- Sphinx warns and ignores it -- so the + TOML value stands; only ``src_trace_`` counts as an override.""" + (minimal_sphinx_project / "ubproject.toml").write_text( + "[codelinks]\nset_local_url = true\n", encoding="utf-8" + ) + app = make_app( + srcdir=minimal_sphinx_project, + freshenv=True, + confoverrides={"set_local_url": False}, + ) + + assert app.config.src_trace_set_local_url is True + + +def test_refused_projects_override_keeps_the_toml_projects( + minimal_sphinx_project: Path, + make_app: Callable[..., SphinxTestApp], +) -> None: + """``-D src_trace_projects=x`` is refused by Sphinx (a dict cannot be overridden + whole) but stays in ``config.overrides``: the TOML's projects must still load.""" + (minimal_sphinx_project / "ubproject.toml").write_text( + '[codelinks.projects.tomlproj.source_discover]\nsrc_dir = "./"\n', + encoding="utf-8", + ) + app = make_app( + srcdir=minimal_sphinx_project, + freshenv=True, + confoverrides={"src_trace_projects": "x"}, + ) + + assert list(app.config.src_trace_projects) == ["tomlproj"] + + +@pytest.mark.parametrize( + ("override", "expected"), + [ + pytest.param({"src_trace_local_url_field": "cli-url"}, "cli-url", id="D-wins"), + pytest.param({}, "toml-url", id="conf.py-vs-toml-unchanged"), + ], +) +def test_command_line_then_toml_then_conf_py( + minimal_sphinx_project: Path, + make_app: Callable[..., SphinxTestApp], + override: dict[str, str], + expected: str, +) -> None: + """One key set in all three places: ``-D`` > TOML > conf.py.""" + _write_conf(minimal_sphinx_project, 'src_trace_local_url_field = "conf-url"\n') + (minimal_sphinx_project / "ubproject.toml").write_text( + '[codelinks]\nlocal_url_field = "toml-url"\n', encoding="utf-8" + ) + app = make_app(srcdir=minimal_sphinx_project, freshenv=True, confoverrides=override) + + assert app.config.src_trace_local_url_field == expected + + +def _syntax_error(path: Path) -> None: + path.write_text("[codelinks\n", encoding="utf-8") + + +def _not_utf8(path: Path) -> None: + path.write_bytes(b'[codelinks]\nlocal_url_field = "caf\xe9"\n') + + +def _a_directory(path: Path) -> None: + path.mkdir() + + +def _not_a_table(path: Path) -> None: + path.write_text('codelinks = "x"\n', encoding="utf-8") + + +_CORRUPT_FILES = [ + pytest.param(_syntax_error, id="syntax-error"), + pytest.param(_not_utf8, id="not-utf8"), + pytest.param(_a_directory, id="a-directory"), + pytest.param(_not_a_table, id="codelinks-not-a-table"), +] + + +@pytest.mark.parametrize("corrupt", _CORRUPT_FILES) +def test_corrupt_default_ubproject_toml_warns( + minimal_sphinx_project: Path, + make_app: Callable[..., SphinxTestApp], + corrupt: Callable[[Path], None], +) -> None: + """A default ubproject.toml that exists but cannot be read or parsed warns, as any + configured file did at 1.4.0 -- only a missing file or table is silent.""" + corrupt(minimal_sphinx_project / "ubproject.toml") + app = make_app(srcdir=minimal_sphinx_project, freshenv=True) + app.build() + + assert app.config.src_trace_projects == {} + _assert_one_config_warning(app, _LOAD_FAILED) + + +def test_explicit_toml_without_codelinks_table_warns( + minimal_sphinx_project: Path, + make_app: Callable[..., SphinxTestApp], +) -> None: + """An explicitly configured file without ``[codelinks]`` warns, and says so.""" + _write_conf(minimal_sphinx_project, 'src_trace_config_from_toml = "cl.toml"\n') + (minimal_sphinx_project / "cl.toml").write_text( + "[needs]\nid_required = true\n", encoding="utf-8" + ) + app = make_app(srcdir=minimal_sphinx_project, freshenv=True) + app.build() + + _assert_one_config_warning(app, "has no [codelinks] table") + + +@pytest.mark.parametrize( + "state", + [ + pytest.param(None, id="missing"), + pytest.param(_syntax_error, id="syntax-error"), + pytest.param( + lambda path: path.write_text("[needs]\n", encoding="utf-8"), + id="no-codelinks-table", + ), + ], +) +def test_reader_warnings_are_suppressible( + minimal_sphinx_project: Path, + make_app: Callable[..., SphinxTestApp], + state: Callable[[Path], object] | None, +) -> None: + """Every warning of the reader carries ``codelinks.config``.""" + _write_conf( + minimal_sphinx_project, + 'src_trace_config_from_toml = "cl.toml"\n' + 'suppress_warnings = ["codelinks.config"]\n', + ) + if state is not None: + state(minimal_sphinx_project / "cl.toml") + app = make_app(srcdir=minimal_sphinx_project, freshenv=True) + app.build() + + assert_no_warnings(app) + + +_MARKER = "# @Found here {tag}, IMPL_{tag}, impl\n" + + +def _traced_project(root: Path, conf_extra: str, files: dict[str, str]) -> None: + """A project whose index traces project ``p``; ``files`` maps a relative path to + its content, and a ``LINK:`` content makes that path a symlink.""" + (root / "conf.py").write_text( + "extensions = ['sphinx_needs', 'sphinx_codelinks']\n" + "exclude_patterns = ['_build']\n" + conf_extra, + encoding="utf-8", + ) + (root / "index.rst").write_text( + "T\n=\n\n.. src-trace::\n :project: p\n", encoding="utf-8" + ) + for relative, content in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + if content.startswith("LINK:"): + try: + os.symlink(content[len("LINK:") :], path) + except (OSError, NotImplementedError) as error: + pytest.skip(f"cannot create a symlink here: {error}") + else: + path.write_text(content, encoding="utf-8") + + +_PROJECT_P = '[codelinks.projects.p.source_discover]\nsrc_dir = "./src"\ncomment_type = "python"\n' + + +def test_symlinked_toml_anchors_at_the_links_directory( + tmp_path: Path, + make_app: Callable[..., SphinxTestApp], +) -> None: + """A relative path in a TOML reached through a symlink is anchored at the LINK's + directory -- ``confdir / Path(config_from_toml).parent`` -- not the target's.""" + _traced_project( + tmp_path, + "src_trace_config_from_toml = 'cl.toml'\n", + { + "other/sub/real.toml": _PROJECT_P, + "cl.toml": "LINK:" + str(Path("other", "sub", "real.toml")), + "src/a.py": _MARKER.format(tag="LINKDIR"), + "other/sub/src/a.py": _MARKER.format(tag="REALDIR"), + }, + ) + app = make_app(srcdir=tmp_path, freshenv=True) + app.build() + + html = Path(app.outdir, "index.html").read_text(encoding="utf-8") + assert "IMPL_LINKDIR" in html + assert "IMPL_REALDIR" not in html + + +def test_config_from_toml_set_in_the_toml_moves_the_anchor( + tmp_path: Path, + make_app: Callable[..., SphinxTestApp], +) -> None: + """A PIN of today's behaviour, not an endorsement: ``config_from_toml`` is a key the + TOML may set, and when it does the use-site anchor moves to that name's directory + (the named file is never read).""" + _traced_project( + tmp_path, + "", + { + "ubproject.toml": _PROJECT_P + + "[codelinks]\nconfig_from_toml = 'deep/x.toml'\n", + "src/a.py": _MARKER.format(tag="CONFDIR"), + "deep/src/a.py": _MARKER.format(tag="DEEPDIR"), + }, + ) + app = make_app(srcdir=tmp_path, freshenv=True) + app.build() + + html = Path(app.outdir, "index.html").read_text(encoding="utf-8") + assert "IMPL_DEEPDIR" in html + assert "IMPL_CONFDIR" not in html From 9ac81c84904080e5a693bf01bd8697484b66603d Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 30 Sep 2026 23:44:29 +0200 Subject: [PATCH 2/9] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20sphinx-codelinks:=20de?= =?UTF-8?q?pend=20on=20ub-project>=3D1.1.0,<2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A runtime dependency, declared directly: PyPI's sphinx-needs 8.5.0 does not bring ub-project, so the workspace's own sphinx-needs making it importable would hide a missing declaration from every local test. The lock is master's plus the two lines of the sphinx-codelinks block -- not a relock, which would rewrite 224 unrelated marker lines. `uv lock --check` passes on uv 0.12.15 and on the hook's 0.12.9. --- packages/sphinx-codelinks/pyproject.toml | 1 + uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/sphinx-codelinks/pyproject.toml b/packages/sphinx-codelinks/pyproject.toml index 3a8b6ce36..9d47df530 100644 --- a/packages/sphinx-codelinks/pyproject.toml +++ b/packages/sphinx-codelinks/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ # so the floor is the only statement of what was actually tested, and the cap is what # stops a future major being co-installed with a wheel written against this one. "sphinx-needs>=8.5.0,<9", + "ub-project>=1.1.0,<2", "jinja2", "pygments", "docutils>=0.21", # the `typing` group's series diff --git a/uv.lock b/uv.lock index 8bc5bfc85..f40a46454 100644 --- a/uv.lock +++ b/uv.lock @@ -2750,6 +2750,7 @@ dependencies = [ { name = "tree-sitter-rust" }, { name = "tree-sitter-yaml" }, { name = "typer" }, + { name = "ub-project" }, ] [package.optional-dependencies] @@ -2794,6 +2795,7 @@ requires-dist = [ { name = "tree-sitter-rust", specifier = ">=0.23.0" }, { name = "tree-sitter-yaml", specifier = ">=0.7.1" }, { name = "typer", specifier = ">=0.16.0" }, + { name = "ub-project", editable = "packages/ub-project" }, ] provides-extras = ["libclang", "docs"] From 7d49aa77cb8e8223935fb61b361dd34042778cb3 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 30 Sep 2026 23:47:51 +0200 Subject: [PATCH 3/9] =?UTF-8?q?=E2=9C=A8=20sphinx-codelinks:=20read=20ubpr?= =?UTF-8?q?oject.toml=20through=20ub-project,=20and=20let=20-D=20override?= =?UTF-8?q?=20the=20TOML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One loader, `config.load_codelinks_table(path)` = ub-project's `load_toml` + `select_table(..., "codelinks", source=path)`, returning the table's raw values; both readers call it and `src/` no longer imports tomllib. The Sphinx reader keeps its discovery as it was. A file that exists but cannot be read or parsed now warns for the default ubproject.toml too, as every configured file did at 1.4.0 (the silence was unreleased); a missing file or a missing `[codelinks]` table stays silent for the default file. Its three warnings are typed `codelinks.config`, the load failure no longer repeats the path ub-project's message already names, and an explicit file without the table says so instead of printing a KeyError repr. `set_config_to_sphinx` skips a TOML key given as `-D src_trace_`: only the full confval name (a bare `-D set_local_url=0` is refused by Sphinx and must not suppress the TOML), and never `projects` (Sphinx refuses a whole-dict override but keeps it in `config.overrides`). `-D src_trace_outdir` is refused by Sphinx in the same way, and the TOML's `outdir` is skipped with it: inert, the extension never reads that value -- pinned by its own test, which replaces the `outdir` row of the "-D wins" parametrization. The CLI re-raises ub-project's error in its own words (a BadParameter, now showing the reason), keeps a catch-all for the RecursionError tomllib can raise, and keeps its falsy "No 'codelinks' section" test. The path joins go through ub-project's `anchor`, at the use sites as before and with every `.resolve()` kept: identical to `/` by construction. --- .../src/sphinx_codelinks/cmd.py | 28 ++++++----- .../src/sphinx_codelinks/config.py | 31 ++++++++++-- .../sphinx_extension/directives/src_trace.py | 9 ++-- .../sphinx_extension/source_tracing.py | 49 ++++++++++++++----- .../sphinx-codelinks/tests/test_src_trace.py | 22 ++++++++- 5 files changed, 106 insertions(+), 33 deletions(-) diff --git a/packages/sphinx-codelinks/src/sphinx_codelinks/cmd.py b/packages/sphinx-codelinks/src/sphinx_codelinks/cmd.py index 761f7f660..4eadbc09b 100644 --- a/packages/sphinx-codelinks/src/sphinx_codelinks/cmd.py +++ b/packages/sphinx-codelinks/src/sphinx_codelinks/cmd.py @@ -1,5 +1,4 @@ import json -import tomllib from collections import deque from os import linesep from pathlib import Path @@ -14,6 +13,7 @@ CodeLinksProjectConfigType, anchor_preproc_paths, generate_project_configs, + load_codelinks_table, ) from sphinx_codelinks.logger import configure_cli, logger from sphinx_codelinks.needextend_write import MarkedObjType, convert_marked_content @@ -23,6 +23,7 @@ SourceDiscoverConfigType, ) from sphinx_codelinks.source_discover.source_discover import SourceDiscover +from ub_project import ProjectConfigError, anchor app = typer.Typer( no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]} @@ -134,8 +135,8 @@ def analyse( # for CLI, so it needs the branches raise typer.BadParameter(f"{linesep.join(errors)}") # src dir shall be relevant to the config file's location - src_discover_config.src_dir = ( - config.parent / src_discover_config.src_dir + src_discover_config.src_dir = anchor( + src_discover_config.src_dir, config.parent ).resolve() src_discover = SourceDiscover(src_discover_config) @@ -147,8 +148,8 @@ def analyse( # for CLI, so it needs the branches # git_root shall be relative to the config file's location (like src_dir) if analyse_config.git_root is not None: - analyse_config.git_root = ( - config.parent / analyse_config.git_root + analyse_config.git_root = anchor( + analyse_config.git_root, config.parent ).resolve() # preprocessor compile_commands / include dirs are relative to the config @@ -329,15 +330,16 @@ def write_rst( # for CLI, so it takes as many as it requires def load_config_from_toml(toml_file: Path) -> CodeLinksConfigType: try: - with toml_file.open("rb") as f: - toml_data = tomllib.load(f) - - except Exception as e: + codelink_dict = load_codelinks_table(toml_file) + except ProjectConfigError as error: + # ub-project's message already names the file and says what is wrong + raise typer.BadParameter(str(error)) from error + except Exception as error: + # the TOML parser can also fail with an exception ``load_toml`` does not wrap (a + # RecursionError on a pathologically nested file): still a usage error raise typer.BadParameter( - f"Failed to load CodeLinks configuration from {toml_file}" - ) from e - - codelink_dict = toml_data.get("codelinks") + f"Failed to load CodeLinks configuration from {toml_file}: {error}" + ) from error if not codelink_dict: raise typer.BadParameter(f"No 'codelinks' section found in {toml_file}") diff --git a/packages/sphinx-codelinks/src/sphinx_codelinks/config.py b/packages/sphinx-codelinks/src/sphinx_codelinks/config.py index 39bc44f75..a556a2ad0 100644 --- a/packages/sphinx-codelinks/src/sphinx_codelinks/config.py +++ b/packages/sphinx-codelinks/src/sphinx_codelinks/config.py @@ -14,6 +14,7 @@ SourceDiscoverSectionConfigType, ) from sphinx_codelinks.source_discover.source_discover import SourceDiscover +from ub_project import anchor, load_toml, select_table UNIX_NEWLINE = "\n" @@ -168,16 +169,17 @@ def anchor_preproc_paths(preproc: PreprocessorConfig, base: Path) -> Preprocesso """Resolve a preprocessor config's ``compile_commands`` and ``includes`` against ``base`` (the config file's directory), so a relative path resolves against the TOML file rather than the process CWD — matching ``src_dir`` / - ``git_root``. Absolute paths are left unchanged. + ``git_root``. An absolute path is not anchored, but it is resolved too + (symlinks followed, ``..`` folded), like every other path here. """ return replace( preproc, compile_commands=( - (base / preproc.compile_commands).resolve() + anchor(preproc.compile_commands, base).resolve() if preproc.compile_commands is not None else None ), - includes=[(base / inc).resolve() for inc in preproc.includes], + includes=[anchor(inc, base).resolve() for inc in preproc.includes], ) @@ -564,6 +566,26 @@ def check_fields_configuration(self) -> list[str]: # ubCode checker, ...) read as well, so all tools see the same projects. DEFAULT_CONFIG_TOML: str = "ubproject.toml" +#: The table of the TOML file that both readers, the Sphinx extension and the CLI, +#: take their configuration from. +CODELINKS_TABLE: str = "codelinks" + + +def load_codelinks_table(path: Path) -> dict[str, object] | None: + """Parse *path* and return its ``[codelinks]`` table, through ub-project. + + The values are returned RAW: relative paths are anchored where they are used + (the Sphinx extension anchors at ``confdir / Path(config_from_toml).parent``, + unresolved), never here. + + :param path: The TOML file. + :return: The table, or ``None`` when the file has no ``codelinks`` key. + :raises ub_project.ProjectConfigError: If the file cannot be read, is not UTF-8 + or not valid TOML, or if ``codelinks`` is not a table. + """ + return select_table(load_toml(path), CODELINKS_TABLE, source=path) + + SRC_TRACE_CACHE: str = "src_trace_cache" @@ -703,7 +725,8 @@ def get_schema(cls, name: str) -> dict[str, Any] | None: Defaults to ``ubproject.toml`` next to :file:`conf.py`. A default file that is missing or has no ``[codelinks]`` table is silently ignored; a missing - explicitly configured file triggers a warning. + explicitly configured file, or any file that exists but cannot be read or + parsed, triggers a ``codelinks.config`` warning. """ set_local_url: bool = field( diff --git a/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py b/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py index f3e6290fc..9dd878150 100644 --- a/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py +++ b/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/directives/src_trace.py @@ -22,6 +22,7 @@ from sphinx_codelinks.sphinx_extension.debug import measure_time from sphinx_needs.api import add_need from sphinx_needs.utils import add_doc +from ub_project import anchor logger = logging.getLogger(__name__) @@ -118,11 +119,11 @@ def run(self) -> list[nodes.Node]: conf_dir = Path(self.env.app.confdir) if src_trace_sphinx_config.config_from_toml: src_trace_toml_path = Path(src_trace_sphinx_config.config_from_toml) - conf_dir = conf_dir / src_trace_toml_path.parent + conf_dir = anchor(src_trace_toml_path.parent, conf_dir) # git_root shall be relative to the config file's location (if provided) git_root = base_analyse_config.git_root if git_root: - git_root = (conf_dir / git_root).resolve() + git_root = anchor(git_root, conf_dir).resolve() # preprocessor compile_commands / include dirs are relative to the config # file's location too (like src_dir / git_root). preprocessor = base_analyse_config.preprocessor @@ -251,9 +252,9 @@ def locate_src_dir( # if config toml file is used, src dir is relative to the config toml if src_trace_sphinx_config.config_from_toml: src_trace_toml_path = Path(src_trace_sphinx_config.config_from_toml) - conf_dir = conf_dir / src_trace_toml_path.parent + conf_dir = anchor(src_trace_toml_path.parent, conf_dir) - src_dir = (conf_dir / src_discover_config.src_dir).resolve() + src_dir = anchor(src_discover_config.src_dir, conf_dir).resolve() return src_dir def render_needs( diff --git a/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/source_tracing.py b/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/source_tracing.py index 9964f2055..c72afca05 100644 --- a/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/source_tracing.py +++ b/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/source_tracing.py @@ -1,5 +1,4 @@ import contextlib -import tomllib from collections.abc import Iterator # only in python 3.11 afterwards from pathlib import Path from timeit import default_timer as timer # Used for timing measurements @@ -21,6 +20,7 @@ check_configuration, file_lineno_href, generate_project_configs, + load_codelinks_table, ) from sphinx_codelinks.logger import configure_sphinx from sphinx_codelinks.sphinx_extension import debug @@ -150,7 +150,12 @@ def load_config_from_toml(app: Sphinx, config: _SphinxConfig) -> None: The default ``ubproject.toml`` is shared with other useblocks tools, which may use the file without any ``[codelinks]`` configuration. It is therefore silently ignored when it does not exist or has no ``[codelinks]`` table, - whereas a missing explicitly configured file emits a warning. + whereas a missing explicitly configured file emits a warning. A file that + exists but cannot be read or parsed warns whether it is the default or not: + it is broken for every tool that reads it. + + Every warning here is ``codelinks.config``, so ``suppress_warnings`` can + silence them. """ src_trc_sphinx_config = CodeLinksConfig.from_sphinx(config) if src_trc_sphinx_config.config_from_toml is None: @@ -165,20 +170,29 @@ def load_config_from_toml(app: Sphinx, config: _SphinxConfig) -> None: if not toml_file.exists(): if not default_file: logger.warning( - f"Source tracing configuration file {toml_file} does not exist. Using configuration from conf.py." + f"Source tracing configuration file {toml_file} does not exist. Using configuration from conf.py.", + type="codelinks", + subtype="config", ) return try: - with toml_file.open("rb") as f: - toml_data = tomllib.load(f) - toml_data = toml_data["codelinks"] - if not isinstance(toml_data, dict): - raise Exception(f"data must be a dict in {toml_file}") - - except Exception as e: + toml_data = load_codelinks_table(toml_file) + except Exception as error: + # Not only ub-project's ProjectConfigError, which names the file itself: + # the TOML parser can also fail with a RecursionError, which ``load_toml`` does not + # wrap. Either way the file only warns -- the default one too. + logger.warning( + f"Failed to load source tracing configuration: {error}", + type="codelinks", + subtype="config", + ) + return + if toml_data is None: if not default_file: logger.warning( - f"Failed to load source tracing configuration from {toml_file}: {e}" + f"Source tracing configuration file {toml_file} has no [codelinks] table. Using configuration from conf.py.", + type="codelinks", + subtype="config", ) return @@ -191,9 +205,22 @@ def set_config_to_sphinx( src_trace_config: CodeLinksConfigType, config: _SphinxConfig ) -> None: allowed_keys = CodeLinksConfig.field_names() + # A value given on the command line (``-D src_trace_=...``, which Sphinx + # keeps in ``config.overrides``) wins over the TOML. Only the full confval + # name counts: a bare ``-D set_local_url=0`` names no confval, Sphinx ignores + # it, and the TOML value has to stand. + overridden: set[str] = set() + config_overrides = getattr(config, "overrides", None) + if isinstance(config_overrides, dict): + overridden = {str(key) for key in config_overrides} for key, value in src_trace_config.items(): if key not in allowed_keys: continue + # ``projects`` is never skipped: Sphinx refuses to override a dict confval + # whole, but keeps the refused ``-D src_trace_projects=...`` in + # ``config.overrides`` -- skipping would drop every TOML project. + if key != "projects" and f"src_trace_{key}" in overridden: + continue if key == "projects": src_trace_projects: dict[str, CodeLinksProjectConfigType] = cast( dict[str, CodeLinksProjectConfigType], value diff --git a/packages/sphinx-codelinks/tests/test_src_trace.py b/packages/sphinx-codelinks/tests/test_src_trace.py index 0434d73ce..080ba047d 100644 --- a/packages/sphinx-codelinks/tests/test_src_trace.py +++ b/packages/sphinx-codelinks/tests/test_src_trace.py @@ -403,7 +403,6 @@ def _assert_one_config_warning(app: SphinxTestApp, phrase: str) -> None: ("set_remote_url", "true", False), ("local_url_field", '"toml-url"', "cli-url"), ("remote_url_field", '"toml-remote"', "cli-remote"), - ("outdir", '"toml-out"', "cli-out"), ("debug_measurement", "true", False), ("debug_filters", "true", False), ], @@ -431,6 +430,27 @@ def test_command_line_override_beats_the_toml( assert app.config[f"src_trace_{key}"] == override +def test_refused_outdir_override_leaves_the_default( + minimal_sphinx_project: Path, + make_app: Callable[..., SphinxTestApp], +) -> None: + """A PIN of the measured cell, not an endorsement: Sphinx refuses + ``-D src_trace_outdir`` (its default is a ``Path``: "unsupported type") but keeps it + in ``config.overrides``, so the TOML's ``outdir`` is skipped as well and the default + stands. Inert for a build: the extension never reads ``src_trace_outdir`` (the CLI, + which does, has no ``-D``).""" + (minimal_sphinx_project / "ubproject.toml").write_text( + '[codelinks]\noutdir = "toml-out"\n', encoding="utf-8" + ) + app = make_app( + srcdir=minimal_sphinx_project, + freshenv=True, + confoverrides={"src_trace_outdir": "cli-out"}, + ) + + assert app.config.src_trace_outdir == Path("output") + + def test_command_line_override_of_config_from_toml_beats_the_toml( minimal_sphinx_project: Path, make_app: Callable[..., SphinxTestApp], From b1ac8d45f2287d263e4603df86987c2b4578f3f7 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 30 Sep 2026 23:50:02 +0200 Subject: [PATCH 4/9] =?UTF-8?q?=F0=9F=93=9A=20sphinx-codelinks:=20document?= =?UTF-8?q?=20-D=20precedence,=20the=20corrupt-default=20warning=20and=20t?= =?UTF-8?q?he=20ub-project=20reader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit configuration.rst: `-D` overrides both conf.py and the TOML, `src_trace_projects` only comes from conf.py or the TOML (no dotted `-D`), Sphinx refuses `-D src_trace_outdir`; a file that exists but cannot be read or parsed warns, the default one included, as `codelinks.config`. changelog `Unreleased`: the default-ubproject.toml bullet is amended in place (it is unreleased text), plus a bullet for the `-D` rule and one for the ub-project reader and its messages. AGENTS.md: config.py holds the loader, `load_codelinks_table`; integration point 2 names the `-D` exception; the stale click/typer caps section and the test counts (393; 337 + 26 skipped without libclang) are corrected. --- packages/sphinx-codelinks/AGENTS.md | 29 +++++++-------- packages/sphinx-codelinks/docs/changelog.rst | 36 +++++++++++++++++-- .../docs/components/configuration.rst | 7 ++-- 3 files changed, 51 insertions(+), 21 deletions(-) diff --git a/packages/sphinx-codelinks/AGENTS.md b/packages/sphinx-codelinks/AGENTS.md index c74f210da..154be5930 100644 --- a/packages/sphinx-codelinks/AGENTS.md +++ b/packages/sphinx-codelinks/AGENTS.md @@ -37,7 +37,7 @@ design/ # import-commit-map.txt: old hash -> new hash for the 20 src/sphinx_codelinks/ # Main source code ├── __init__.py # `__version__` (public, in `__all__`) and the Sphinx `setup()` ├── cmd.py # CLI commands using Typer -├── config.py # Configuration dataclasses + TypedDicts, and the TOML loader +├── config.py # Configuration dataclasses + TypedDicts, and the TOML loader, `load_codelinks_table` ├── logger.py # Logging utilities ├── needextend_write.py # Write RST files with Sphinx-Needs directives ├── analyse/ # Code analysis module @@ -93,9 +93,9 @@ prunes it out again (`Uninstalled 1 package: - libclang==18.1.1`). So the two nu appear either side of that sync, and this is the sequence that shows both: ```bash -uv run poe test-codelinks # 359 passed +uv run poe test-codelinks # 393 passed uv sync --frozen # removes libclang again -uv run --frozen --no-sync pytest packages/sphinx-codelinks/tests # 303 passed, 26 skipped +uv run --frozen --no-sync pytest packages/sphinx-codelinks/tests # 337 passed, 26 skipped ``` **Both runs are green, and only the first tested the engine.** The four modules that need @@ -108,17 +108,12 @@ The summary prints **26 skipped**, not 56: three of the four guards are module-l `pytest.importorskip`, which pytest reports as one skip per module and never collects the tests inside. 56 is how many test cases stop running. -### This package caps `click` and `typer`, and nothing else in the lock does +### It reads `ubproject.toml` through `ub-project` -`click < 8.2` (8.2 produces empty errors when the CLI is given no arguments) and -`typer >=0.16.0,<0.26.8` (0.26.8 removed `rich_utils.STYLE_METAVAR`, which -`sphinxcontrib-typer` still imports for the docs build). Measured across every -`requires-dist` in `uv.lock`: **no other workspace MEMBER names either**, and for `click` -no package in the lock does at all. `typer`, `rich` and `shellingham` are named by third -parties there — `sphinxcontrib-typer` (which is exactly what the `typer` cap exists for), -`typer` itself, `memray` and `textual` — so the `typer` cap is the one that could bind on -someone else. The direction to watch is the reverse one: the day a root, `test` or `dev` -dependency wants `click>=8.2`, `uv lock` will fail and the reason will be here. +`load_codelinks_table` in `config.py` is ub-project's `load_toml` + `select_table`, and both +readers call it; `src/` imports no `tomllib`. It returns raw values: relative paths are +anchored with ub-project's `anchor` where they are used, never in the loader. (`click` is no +longer a dependency and `typer` is no longer capped — the changelog's `Unreleased` says why.) ## Documentation @@ -182,7 +177,7 @@ def form_https_url( **No `--` before the pytest arguments.** poe appends trailing words to the task's command verbatim and forwards a `--` along with them, and pytest then reads `--snapshot-update` as a file path: `poe test-codelinks -- --collect-only -q` collects **0 items**, where -`poe test-codelinks --collect-only -q` collects 359. +`poe test-codelinks --collect-only -q` collects 393. ### Test Structure @@ -328,7 +323,7 @@ The extension connects to these Sphinx events (in execution order): 1. **sphinx-needs Dependency**: The extension requires sphinx-needs and checks for its presence in `setup()`. It adds extra options (`project`, `file`, `directory`, URL fields) and a custom need type (`srctrace`). -2. **TOML Configuration**: Configuration can be loaded from a TOML file specified in `conf.py` via `src_trace_config_from_toml`. The TOML is parsed and values are set on the Sphinx config object. +2. **TOML Configuration**: Configuration can be loaded from a TOML file specified in `conf.py` via `src_trace_config_from_toml`. The TOML is parsed and values are set on the Sphinx config object, except a key given with `-D` (`src_trace_projects` excepted: Sphinx refuses a whole-dict override). 3. **Source Page Generation**: The `generate_code_page()` function yields tuples of `(pagename, context, template)` for each traced source file, allowing Sphinx to generate standalone HTML pages with syntax-highlighted source code and line-number anchors. @@ -351,7 +346,9 @@ the shape a TOML file may carry, and a `@dataclass` holding the loaded, validate - validation is `jsonschema`'s `validate(instance=…, schema=…)` per field, against a schema each config class returns from its own `get_schema`, collected by its `check_schema` and `check_*` methods into a list of error strings — not raised -- the TOML loader is `load_config_from_toml` in `cmd.py` +- the TOML loader is `load_codelinks_table` (ub-project's `load_toml` + `select_table`), + which both `load_config_from_toml`s — the Sphinx hook in `sphinx_extension/source_tracing.py` + and the CLI's in `cmd.py` — call #### Source Discovery (`source_discover/`) diff --git a/packages/sphinx-codelinks/docs/changelog.rst b/packages/sphinx-codelinks/docs/changelog.rst index 21fac1dcf..f808a347f 100644 --- a/packages/sphinx-codelinks/docs/changelog.rst +++ b/packages/sphinx-codelinks/docs/changelog.rst @@ -151,9 +151,39 @@ New and Improved A default file that does not exist or contains no ``[codelinks]`` table is silently ignored, so existing projects without ``ubproject.toml`` keep building without new - warnings. Only a TOML file that was explicitly configured but cannot be loaded - triggers a Sphinx warning, as before. The documentation project itself now stores - its codelinks configuration in ``ubproject.toml``. + warnings. A file that exists but cannot be read or parsed -- invalid TOML, not UTF-8, + a directory, or a ``codelinks`` key that is not a table -- triggers a + ``codelinks.config`` warning, the default file included, as an explicitly configured + file always did. The documentation project itself now stores its codelinks + configuration in ``ubproject.toml``. + +- 🐛 A value given on the command line with ``-D`` now overrides the TOML file. + + ``sphinx-build -D src_trace_set_local_url=0`` was silently overwritten by a + ``set_local_url`` in the ``[codelinks]`` table. The order is now ``-D`` > TOML > + :file:`conf.py` > default, as in Sphinx-Needs. Only the full ``src_trace_`` name + counts: a bare ``-D set_local_url=0``, which Sphinx rejects as an unknown setting, + leaves the TOML value alone. ``src_trace_projects`` always comes from :file:`conf.py` + or the TOML: Sphinx refuses to override a dictionary setting with ``-D``, and the + dotted ``-D src_trace_projects.=...`` form is not supported. Sphinx refuses + ``-D src_trace_outdir`` too, whose default is a path; the extension does not use that + value. + +- 👌 ``ubproject.toml`` is read through `ub-project `__, + the shared reader of the Sphinx-Needs family, which is now a dependency + (``ub-project>=1.1.0,<2``). + + Both the Sphinx extension and ``codelinks analyse`` parse the file and select the + ``[codelinks]`` table through it, and relative paths are anchored through its + ``anchor``, at the same directories as before. What changes is what a broken file + says: every message names the file and the problem (``invalid TOML``, + ``not valid UTF-8``, ``[codelinks] must be a table, got str``), an explicitly + configured file without a ``[codelinks]`` table says so instead of printing + ``'codelinks'``, and ``codelinks analyse`` shows why a file could not be loaded rather + than only that it could not -- a ``codelinks`` key that is ``0``, ``false`` or ``[]`` + is now reported as not a table instead of as a missing section. The extension's + warnings about its configuration file carry the type ``codelinks.config``, so + ``suppress_warnings = ["codelinks.config"]`` silences them. .. _`release:1.4.0`: diff --git a/packages/sphinx-codelinks/docs/components/configuration.rst b/packages/sphinx-codelinks/docs/components/configuration.rst index 65df977ae..d65e88f09 100644 --- a/packages/sphinx-codelinks/docs/components/configuration.rst +++ b/packages/sphinx-codelinks/docs/components/configuration.rst @@ -24,7 +24,7 @@ src_trace_config_from_toml Specifies the path to a `TOML file `__ containing **Sphinx-CodeLinks** configuration options. This allows you to maintain configuration in a separate file for better organization. -**Type:** ``str`` (relative path to the directory where conf.py is located) +**Type:** ``str`` (relative to the directory where conf.py is located; an absolute path also works) **Default:** ``"ubproject.toml"`` .. code-block:: python @@ -37,8 +37,11 @@ When using a TOML configuration file: - Configuration options are placed under a ``[codelinks]`` section - The ``src_trace_`` prefix is omitted in the TOML file - TOML configuration overrides settings in :file:`conf.py` +- A value given on the command line with ``-D`` (``sphinx-build -D src_trace_set_local_url=0``) overrides both. + ``src_trace_projects`` can only come from :file:`conf.py` or the TOML -- the dotted ``-D`` form is not supported -- + and Sphinx refuses ``-D src_trace_outdir`` as well -.. note:: ``ubproject.toml`` is the shared ubCode project file, which other useblocks tools (e.g. Sphinx-Needs via ``needs_from_toml`` or the ubCode checker in VS Code) read as well. Keeping the ``[codelinks]`` configuration in this file makes all tools aware of the configured projects. If the default file does not exist or contains no ``[codelinks]`` section, it is silently ignored and the configuration from :file:`conf.py` is used. Only a TOML file that was explicitly configured but cannot be loaded triggers a Sphinx warning. +.. note:: ``ubproject.toml`` is the shared ubCode project file, which other useblocks tools (e.g. Sphinx-Needs via ``needs_from_toml`` or the ubCode checker in VS Code) read as well. Keeping the ``[codelinks]`` configuration in this file makes all tools aware of the configured projects. If the default file does not exist or contains no ``[codelinks]`` section, it is silently ignored and the configuration from :file:`conf.py` is used. A file that exists but cannot be read or parsed triggers a warning, the default file included, and so does an explicitly configured file that is missing or has no ``[codelinks]`` section. These warnings are of type ``codelinks.config``, so ``suppress_warnings = ["codelinks.config"]`` silences them. .. caution:: Relative paths specified in the TOML file are resolved relative to the directory containing the TOML file, not the Sphinx project root. From b8e75ea30ec16600f43c546e96b801a09a9610c0 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Thu, 1 Oct 2026 00:21:10 +0200 Subject: [PATCH 5/9] =?UTF-8?q?=F0=9F=A7=AA=20sphinx-codelinks:=20fence=20?= =?UTF-8?q?the=20reader's=20catch-all,=20-D=20on=20one=20key,=20and=20the?= =?UTF-8?q?=20outdir=20exemption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red at b1ac8d45 (4): a 5000-deep TOML, default or explicit, warns naming the file (the Sphinx side's non-ProjectConfigError warning dropped the path); `-D src_trace_outdir` leaves the TOML's `outdir` standing (the pin, inverted); the keys exempt from the -D skip are exactly the codelinks confvals Sphinx refuses from -D, detected per confval by Sphinx's own "cannot override" warning. Pins, green at b1ac8d45: `-D` on one key leaves the other TOML keys and the projects; an explicit syntax error names the file once; the default name written in conf.py is still the default (missing file, missing table: silent); the CLI's ProjectConfigError messages are not the catch-all's. --- packages/sphinx-codelinks/tests/test_cmd.py | 3 + .../sphinx-codelinks/tests/test_src_trace.py | 135 +++++++++++++++++- 2 files changed, 131 insertions(+), 7 deletions(-) diff --git a/packages/sphinx-codelinks/tests/test_cmd.py b/packages/sphinx-codelinks/tests/test_cmd.py index 782cabf67..4bde8f6e3 100644 --- a/packages/sphinx-codelinks/tests/test_cmd.py +++ b/packages/sphinx-codelinks/tests/test_cmd.py @@ -486,6 +486,8 @@ def test_analyse_toml_syntax_error_shows_the_reason(tmp_path: Path) -> None: assert exit_code == 2 assert _normalize_output(str(parse_error.value)) in output + # ub-project's own words, not the catch-all's + assert "Failed to load" not in output @pytest.mark.parametrize( @@ -501,6 +503,7 @@ def test_analyse_codelinks_not_a_table( assert exit_code == 2 assert f"[codelinks] must be a table, got {type_name}" in output assert "No 'codelinks' section" not in output + assert "Failed to load" not in output @pytest.mark.parametrize( diff --git a/packages/sphinx-codelinks/tests/test_src_trace.py b/packages/sphinx-codelinks/tests/test_src_trace.py index 080ba047d..d856b197e 100644 --- a/packages/sphinx-codelinks/tests/test_src_trace.py +++ b/packages/sphinx-codelinks/tests/test_src_trace.py @@ -430,15 +430,13 @@ def test_command_line_override_beats_the_toml( assert app.config[f"src_trace_{key}"] == override -def test_refused_outdir_override_leaves_the_default( +def test_refused_outdir_override_keeps_the_toml_value( minimal_sphinx_project: Path, make_app: Callable[..., SphinxTestApp], ) -> None: - """A PIN of the measured cell, not an endorsement: Sphinx refuses - ``-D src_trace_outdir`` (its default is a ``Path``: "unsupported type") but keeps it - in ``config.overrides``, so the TOML's ``outdir`` is skipped as well and the default - stands. Inert for a build: the extension never reads ``src_trace_outdir`` (the CLI, - which does, has no ``-D``).""" + """Sphinx refuses ``-D src_trace_outdir`` (its default is a ``Path``: "unsupported + type") but keeps it in ``config.overrides``: an override that was never applied must + not suppress the TOML value.""" (minimal_sphinx_project / "ubproject.toml").write_text( '[codelinks]\noutdir = "toml-out"\n', encoding="utf-8" ) @@ -448,7 +446,59 @@ def test_refused_outdir_override_leaves_the_default( confoverrides={"src_trace_outdir": "cli-out"}, ) - assert app.config.src_trace_outdir == Path("output") + assert app.config.src_trace_outdir == "toml-out" + + +def test_not_overridable_from_d_is_exactly_what_sphinx_refuses( + minimal_sphinx_project: Path, + make_app: Callable[..., SphinxTestApp], +) -> None: + """The keys exempt from the ``-D`` skip are exactly the codelinks confvals Sphinx + refuses to take from ``-D`` -- detected per confval by Sphinx's own + "cannot override ... 'src_trace_'" warning, with a value ("1") every confval + Sphinx does accept can convert.""" + from sphinx_codelinks.sphinx_extension.source_tracing import ( + NOT_OVERRIDABLE_FROM_D, + ) + + refused = set() + for item in fields(CodeLinksConfig): + app = make_app( + srcdir=minimal_sphinx_project, + freshenv=True, + confoverrides={f"src_trace_{item.name}": "1"}, + ) + if any( + "cannot override" in warning and f"'src_trace_{item.name}'" in warning + for warning in build_warnings(app) + ): + refused.add(item.name) + + assert refused == set(NOT_OVERRIDABLE_FROM_D) + + +def test_override_of_one_key_leaves_the_other_toml_keys( + minimal_sphinx_project: Path, + make_app: Callable[..., SphinxTestApp], +) -> None: + """``-D`` on one key skips that key only: the rest of ``[codelinks]`` still loads.""" + (minimal_sphinx_project / "ubproject.toml").write_text( + "[codelinks]\n" + "set_local_url = true\n" + 'local_url_field = "toml-url"\n' + "[codelinks.projects.tomlproj.source_discover]\n" + 'src_dir = "./"\n', + encoding="utf-8", + ) + app = make_app( + srcdir=minimal_sphinx_project, + freshenv=True, + confoverrides={"src_trace_set_local_url": False}, + ) + + assert app.config.src_trace_set_local_url is False + assert app.config.src_trace_local_url_field == "toml-url" + assert list(app.config.src_trace_projects) == ["tomlproj"] def test_command_line_override_of_config_from_toml_beats_the_toml( @@ -569,6 +619,77 @@ def test_corrupt_default_ubproject_toml_warns( _assert_one_config_warning(app, _LOAD_FAILED) +@pytest.mark.parametrize( + ("name", "conf_extra"), + [ + pytest.param("ubproject.toml", "", id="default"), + pytest.param( + "cl.toml", 'src_trace_config_from_toml = "cl.toml"\n', id="explicit" + ), + ], +) +def test_too_deeply_nested_toml_warns_naming_the_file( + minimal_sphinx_project: Path, + make_app: Callable[..., SphinxTestApp], + name: str, + conf_extra: str, +) -> None: + """The TOML parser can fail with an exception ub-project does not wrap (a + ``RecursionError``): still one ``codelinks.config`` warning, naming the file since + Python's text does not, and the build completes.""" + _write_conf(minimal_sphinx_project, conf_extra) + (minimal_sphinx_project / name).write_text( + "x = " + "[" * 5000 + "]" * 5000 + "\n", encoding="utf-8" + ) + app = make_app(srcdir=minimal_sphinx_project, freshenv=True) + app.build() + + _assert_one_config_warning(app, _LOAD_FAILED) + assert name in build_warnings(app)[0] + + +def test_explicit_toml_syntax_error_names_the_file_once( + minimal_sphinx_project: Path, + make_app: Callable[..., SphinxTestApp], +) -> None: + """ub-project's message already names the file; the warning does not repeat it.""" + _write_conf(minimal_sphinx_project, 'src_trace_config_from_toml = "cl.toml"\n') + _syntax_error(minimal_sphinx_project / "cl.toml") + app = make_app(srcdir=minimal_sphinx_project, freshenv=True) + app.build() + + _assert_one_config_warning(app, _LOAD_FAILED) + assert build_warnings(app)[0].count("cl.toml") == 1 + + +@pytest.mark.parametrize( + "state", + [ + pytest.param(None, id="missing"), + pytest.param( + lambda path: path.write_text("[needs]\n", encoding="utf-8"), + id="no-codelinks-table", + ), + ], +) +def test_default_name_written_in_conf_py_is_still_the_default( + minimal_sphinx_project: Path, + make_app: Callable[..., SphinxTestApp], + state: Callable[[Path], object] | None, +) -> None: + """``src_trace_config_from_toml = "ubproject.toml"`` names the shared default: absent + or without ``[codelinks]`` it is silent, as when the name is left unset.""" + _write_conf( + minimal_sphinx_project, 'src_trace_config_from_toml = "ubproject.toml"\n' + ) + if state is not None: + state(minimal_sphinx_project / "ubproject.toml") + app = make_app(srcdir=minimal_sphinx_project, freshenv=True) + app.build() + + assert_no_warnings(app) + + def test_explicit_toml_without_codelinks_table_warns( minimal_sphinx_project: Path, make_app: Callable[..., SphinxTestApp], From 700480dc7674ecfeb8030191110c21c519138f50 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Thu, 1 Oct 2026 00:21:48 +0200 Subject: [PATCH 6/9] =?UTF-8?q?=F0=9F=90=9B=20sphinx-codelinks:=20-D=20nev?= =?UTF-8?q?er=20suppresses=20the=20TOML's=20outdir,=20and=20a=20parser=20c?= =?UTF-8?q?rash=20names=20the=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `outdir` joins `projects` in one named tuple, `NOT_OVERRIDABLE_FROM_D`: Sphinx refuses a `-D` for both (a dict; a Path-typed default) yet keeps the key in `config.overrides`, so skipping the TOML value would honour an override that was never applied. A TOML `outdir` now stands under `-D src_trace_outdir=...`, as it did before this branch. The Sphinx reader's catch-all keeps ub-project's message for a ProjectConfigError (which names the file) and says `... from : ` for anything else -- a RecursionError's text names no file. --- .../sphinx_extension/source_tracing.py | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/source_tracing.py b/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/source_tracing.py index c72afca05..c64cd26d4 100644 --- a/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/source_tracing.py +++ b/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/source_tracing.py @@ -30,9 +30,16 @@ ) from sphinx_codelinks.sphinx_extension.html_wrapper import html_wrapper from sphinx_needs.api import add_field, add_need_type +from ub_project import ProjectConfigError logger = logging.getLogger(__name__) +#: The ``[codelinks]`` keys a ``-D`` never suppresses. Sphinx refuses a ``-D`` for +#: these two -- ``projects`` is a dict, ``outdir`` has a ``Path`` default ("unsupported +#: type") -- yet keeps the key in ``config.overrides``, so skipping the TOML value would +#: honour an override that was never applied. +NOT_OVERRIDABLE_FROM_D = ("projects", "outdir") + def _register_sn_field(name: str, description: str) -> None: """Register a typed string field with sphinx-needs. @@ -177,16 +184,24 @@ def load_config_from_toml(app: Sphinx, config: _SphinxConfig) -> None: return try: toml_data = load_codelinks_table(toml_file) - except Exception as error: - # Not only ub-project's ProjectConfigError, which names the file itself: - # the TOML parser can also fail with a RecursionError, which ``load_toml`` does not - # wrap. Either way the file only warns -- the default one too. + except ProjectConfigError as error: + # ub-project's message names the file itself logger.warning( f"Failed to load source tracing configuration: {error}", type="codelinks", subtype="config", ) return + except Exception as error: + # the TOML parser can also fail with a RecursionError, which ``load_toml`` does + # not wrap and whose text names no file. Either way the file only warns -- the + # default one too. + logger.warning( + f"Failed to load source tracing configuration from {toml_file}: {error}", + type="codelinks", + subtype="config", + ) + return if toml_data is None: if not default_file: logger.warning( @@ -216,10 +231,7 @@ def set_config_to_sphinx( for key, value in src_trace_config.items(): if key not in allowed_keys: continue - # ``projects`` is never skipped: Sphinx refuses to override a dict confval - # whole, but keeps the refused ``-D src_trace_projects=...`` in - # ``config.overrides`` -- skipping would drop every TOML project. - if key != "projects" and f"src_trace_{key}" in overridden: + if key not in NOT_OVERRIDABLE_FROM_D and f"src_trace_{key}" in overridden: continue if key == "projects": src_trace_projects: dict[str, CodeLinksProjectConfigType] = cast( From c5fe45634ed3edec0451c4ddd4350aa40e843040 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Thu, 1 Oct 2026 00:23:14 +0200 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=93=9A=20sphinx-codelinks:=20state=20?= =?UTF-8?q?the=20default-name=20rule=20truthfully,=20and=20drop=20counts?= =?UTF-8?q?=20that=20go=20stale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A file named ubproject.toml is the shared default whether or not conf.py spells the name: missing, or without `[codelinks]`, it is silent; a file that exists but cannot be read or parsed warns, as any configured file did at 1.4.0; any other name is explicit and warns in all three cases. The earlier text said an "explicitly configured" missing file warns, which is false for the default name written out -- corrected in configuration.rst, both docstrings and the amended changelog bullet, which now also says what a 1.4.0 project that wrote the default name loses. The -D changelog bullet and AGENTS.md name `outdir` beside `projects`, and the bullet says an invalid -D value is now Sphinx's own error rather than being replaced by the TOML. `load_codelinks_table` names the RecursionError it lets through. Counts: the package AGENTS.md says 17 test modules (it was already 17), and it, the root AGENTS.md, the root CLAUDE.md and compat-requirements.txt describe the libclang-gated tests without numbers that go stale with every test added. --- AGENTS.md | 5 ++--- CLAUDE.md | 4 ++-- packages/sphinx-codelinks/AGENTS.md | 10 +++++----- .../sphinx-codelinks/compat-requirements.txt | 15 +++++++------- packages/sphinx-codelinks/docs/changelog.rst | 20 ++++++++++++------- .../docs/components/configuration.rst | 4 ++-- .../src/sphinx_codelinks/config.py | 12 +++++++---- .../sphinx_extension/source_tracing.py | 9 +++++---- 8 files changed, 45 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 088478e8f..f85f91646 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,11 +177,10 @@ directives.) **sphinx-codelinks needs NEITHER renderer**: nothing in that package draws a diagram, its docs build installs no `apt_packages` and its CI cell asks for graphviz only because it shares a cell with sphinx-mounts. What it does need is `git` on `PATH` — its suite builds -real repositories and a real `git worktree` — and, for the 56 tests behind the optional +real repositories and a real `git worktree` — and, for the tests behind the optional preprocessor-aware C/C++ engine, the `libclang` wheel: a root dependency group, `codelinks-libclang`, which every `test-codelinks*` task adds for you. Without it those -tests SKIP rather than fail, so a run that lacked it looks green -(`303 passed, 26 skipped` instead of `359 passed`). +tests SKIP rather than fail, so a run that lacked it looks green. `bazel` (or `bazelisk`) is the other optional binary — without it the `bazel`-marked tests skip, and `test-mounts` deselects them anyway. The browser tests (`-m jstest`, which `test-needs` excludes) additionally need a browser, and it is not a package: `uv run poe diff --git a/CLAUDE.md b/CLAUDE.md index 29cef53e8..6a39f731e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,8 +21,8 @@ What a cloud environment for this repository needs, measured on the Ubuntu 24.04 needflow tests fail rather than skip. Nothing else is needed: `prek` and `poe` come from `uv sync`. `libclang` is not a system package here and does not belong in this script: it is a 23 MiB wheel behind the root `codelinks-libclang` dependency group, which - `poe test-codelinks` adds for you. Without it 56 of sphinx-codelinks' 359 tests skip - rather than fail, so a run that lacked it would look green. + `poe test-codelinks` adds for you. Without it the libclang-gated tests of + sphinx-codelinks skip rather than fail, so a run that lacked it would look green. - **Environment variables**: none to set on the environment. `.claude/settings.json` sets `UV_HTTP_TIMEOUT=180` for every Claude Code session, cloud ones included (committed project settings are read there): uv's 30 s default is not enough for this lock through diff --git a/packages/sphinx-codelinks/AGENTS.md b/packages/sphinx-codelinks/AGENTS.md index 154be5930..91ebc5be1 100644 --- a/packages/sphinx-codelinks/AGENTS.md +++ b/packages/sphinx-codelinks/AGENTS.md @@ -60,7 +60,7 @@ src/sphinx_codelinks/ # Main source code tests/ # Test suite -- `tests/__init__.py` is why this path is NOT in the ├── __init__.py # root `testpaths` (see the root AGENTS.md) ├── conftest.py # Pytest fixtures and configuration -├── test_*.py # 16 test modules +├── test_*.py # 17 test modules ├── __snapshots__/ # Syrupy snapshot test fixtures ├── data/ # Test data and fixtures └── doc_test/ # minimal Sphinx projects for the integration tests @@ -93,9 +93,9 @@ prunes it out again (`Uninstalled 1 package: - libclang==18.1.1`). So the two nu appear either side of that sync, and this is the sequence that shows both: ```bash -uv run poe test-codelinks # 393 passed +uv run poe test-codelinks # every test runs, none skipped uv sync --frozen # removes libclang again -uv run --frozen --no-sync pytest packages/sphinx-codelinks/tests # 337 passed, 26 skipped +uv run --frozen --no-sync pytest packages/sphinx-codelinks/tests # green, the libclang tests skipped ``` **Both runs are green, and only the first tested the engine.** The four modules that need @@ -177,7 +177,7 @@ def form_https_url( **No `--` before the pytest arguments.** poe appends trailing words to the task's command verbatim and forwards a `--` along with them, and pytest then reads `--snapshot-update` as a file path: `poe test-codelinks -- --collect-only -q` collects **0 items**, where -`poe test-codelinks --collect-only -q` collects 393. +`poe test-codelinks --collect-only -q` collects the whole suite. ### Test Structure @@ -323,7 +323,7 @@ The extension connects to these Sphinx events (in execution order): 1. **sphinx-needs Dependency**: The extension requires sphinx-needs and checks for its presence in `setup()`. It adds extra options (`project`, `file`, `directory`, URL fields) and a custom need type (`srctrace`). -2. **TOML Configuration**: Configuration can be loaded from a TOML file specified in `conf.py` via `src_trace_config_from_toml`. The TOML is parsed and values are set on the Sphinx config object, except a key given with `-D` (`src_trace_projects` excepted: Sphinx refuses a whole-dict override). +2. **TOML Configuration**: Configuration can be loaded from a TOML file specified in `conf.py` via `src_trace_config_from_toml`. The TOML is parsed and values are set on the Sphinx config object, except a key given with `-D` — `src_trace_projects` and `src_trace_outdir` excepted (`NOT_OVERRIDABLE_FROM_D`): Sphinx refuses a `-D` for both yet keeps it in `config.overrides`. 3. **Source Page Generation**: The `generate_code_page()` function yields tuples of `(pagename, context, template)` for each traced source file, allowing Sphinx to generate standalone HTML pages with syntax-highlighted source code and line-number anchors. diff --git a/packages/sphinx-codelinks/compat-requirements.txt b/packages/sphinx-codelinks/compat-requirements.txt index c93ef36ed..aeccc103b 100644 --- a/packages/sphinx-codelinks/compat-requirements.txt +++ b/packages/sphinx-codelinks/compat-requirements.txt @@ -14,14 +14,15 @@ # without this line `clang.cindex` is absent -- and that failure is LOUD, not silent: # `release.yaml` runs the `import_check` walk BEFORE pytest, and the walk fails outright # on `sphinx_codelinks.analyse.preproc`, whose `__init__` imports the libclang loader -# eagerly (`FAIL 1 of 21 modules failed to import`, exit 1, under the step's default -# `bash -e`). Measured. So deleting this line does not give a green 301/26 run -- it turns -# the release of sphinx-codelinks red, by name, before the suite starts. +# eagerly (`FAIL 1 of ... modules failed to import`, exit 1, under the step's default +# `bash -e`). Measured. So deleting this line does not give a green run with those +# tests skipped -- it turns the release of sphinx-codelinks red, by name, before the +# suite starts. # -# What the line buys on top of that loud failure is the 56 tests behind -# `pytest.importorskip("clang.cindex")`: with it the compat cell reports 359 passed, and -# without it -- if the walk were ever removed or reordered -- it would report -# `303 passed, 26 skipped`. +# What the line buys on top of that loud failure is the tests behind +# `pytest.importorskip("clang.cindex")`: with it the compat cell runs them, and without +# it -- if the walk were ever removed or reordered -- they would skip rather than fail, +# and the cell would look green. # # The floor is 18 -- what has actually been run here -- rather than the extra's 16, which # states the wider range the published package supports. diff --git a/packages/sphinx-codelinks/docs/changelog.rst b/packages/sphinx-codelinks/docs/changelog.rst index f808a347f..f008768c1 100644 --- a/packages/sphinx-codelinks/docs/changelog.rst +++ b/packages/sphinx-codelinks/docs/changelog.rst @@ -151,11 +151,15 @@ New and Improved A default file that does not exist or contains no ``[codelinks]`` table is silently ignored, so existing projects without ``ubproject.toml`` keep building without new - warnings. A file that exists but cannot be read or parsed -- invalid TOML, not UTF-8, - a directory, or a ``codelinks`` key that is not a table -- triggers a - ``codelinks.config`` warning, the default file included, as an explicitly configured - file always did. The documentation project itself now stores its codelinks - configuration in ``ubproject.toml``. + warnings. A file named ``ubproject.toml`` is the default whether the name is left at + its default or written in :file:`conf.py`, so a 1.4.0 project that wrote + ``src_trace_config_from_toml = "ubproject.toml"`` no longer gets a warning for a + missing file or a missing table. A file that exists but cannot be read or parsed -- + invalid TOML, not UTF-8, a directory, a ``codelinks`` key that is not a table -- warns + (``codelinks.config``) whatever its name, as any configured file did at 1.4.0; any + other file name is explicit and also warns when missing or without the table. The + documentation project itself now stores its codelinks configuration in + ``ubproject.toml``. - 🐛 A value given on the command line with ``-D`` now overrides the TOML file. @@ -166,8 +170,10 @@ New and Improved leaves the TOML value alone. ``src_trace_projects`` always comes from :file:`conf.py` or the TOML: Sphinx refuses to override a dictionary setting with ``-D``, and the dotted ``-D src_trace_projects.=...`` form is not supported. Sphinx refuses - ``-D src_trace_outdir`` too, whose default is a path; the extension does not use that - value. + ``-D src_trace_outdir`` too, whose default is a path, and the TOML value then stands. + A ``-D`` value Sphinx cannot convert (``-D src_trace_set_local_url=yes`` on + Sphinx 8.2 or newer) is now reported by Sphinx instead of being silently replaced by + the TOML, as it already was without a TOML. - 👌 ``ubproject.toml`` is read through `ub-project `__, the shared reader of the Sphinx-Needs family, which is now a dependency diff --git a/packages/sphinx-codelinks/docs/components/configuration.rst b/packages/sphinx-codelinks/docs/components/configuration.rst index d65e88f09..d7382741b 100644 --- a/packages/sphinx-codelinks/docs/components/configuration.rst +++ b/packages/sphinx-codelinks/docs/components/configuration.rst @@ -39,9 +39,9 @@ When using a TOML configuration file: - TOML configuration overrides settings in :file:`conf.py` - A value given on the command line with ``-D`` (``sphinx-build -D src_trace_set_local_url=0``) overrides both. ``src_trace_projects`` can only come from :file:`conf.py` or the TOML -- the dotted ``-D`` form is not supported -- - and Sphinx refuses ``-D src_trace_outdir`` as well + and Sphinx refuses ``-D src_trace_outdir`` as well, so a TOML ``outdir`` stands -.. note:: ``ubproject.toml`` is the shared ubCode project file, which other useblocks tools (e.g. Sphinx-Needs via ``needs_from_toml`` or the ubCode checker in VS Code) read as well. Keeping the ``[codelinks]`` configuration in this file makes all tools aware of the configured projects. If the default file does not exist or contains no ``[codelinks]`` section, it is silently ignored and the configuration from :file:`conf.py` is used. A file that exists but cannot be read or parsed triggers a warning, the default file included, and so does an explicitly configured file that is missing or has no ``[codelinks]`` section. These warnings are of type ``codelinks.config``, so ``suppress_warnings = ["codelinks.config"]`` silences them. +.. note:: ``ubproject.toml`` is the shared ubCode project file, which other useblocks tools (e.g. Sphinx-Needs via ``needs_from_toml`` or the ubCode checker in VS Code) read as well. Keeping the ``[codelinks]`` configuration in this file makes all tools aware of the configured projects. A file named ``ubproject.toml`` next to :file:`conf.py` is the shared default, whether the name is left at its default or written in :file:`conf.py`: it may be absent or have no ``[codelinks]`` section, and both are silent -- the configuration from :file:`conf.py` is then used. A file that exists but cannot be read or parsed warns, as any configured file did at 1.4.0. Any other name is explicit: missing, without the ``[codelinks]`` section, or unreadable, it warns. These warnings are of type ``codelinks.config``, so ``suppress_warnings = ["codelinks.config"]`` silences them. .. caution:: Relative paths specified in the TOML file are resolved relative to the directory containing the TOML file, not the Sphinx project root. diff --git a/packages/sphinx-codelinks/src/sphinx_codelinks/config.py b/packages/sphinx-codelinks/src/sphinx_codelinks/config.py index a556a2ad0..18235f6b4 100644 --- a/packages/sphinx-codelinks/src/sphinx_codelinks/config.py +++ b/packages/sphinx-codelinks/src/sphinx_codelinks/config.py @@ -582,6 +582,8 @@ def load_codelinks_table(path: Path) -> dict[str, object] | None: :return: The table, or ``None`` when the file has no ``codelinks`` key. :raises ub_project.ProjectConfigError: If the file cannot be read, is not UTF-8 or not valid TOML, or if ``codelinks`` is not a table. + :raises RecursionError: For a pathologically nested file, which ``load_toml`` + does not wrap -- why both callers also catch ``Exception``. """ return select_table(load_toml(path), CODELINKS_TABLE, source=path) @@ -723,10 +725,12 @@ def get_schema(cls, name: str) -> dict[str, Any] | None: ) """Path to a TOML file to load configuration from. - Defaults to ``ubproject.toml`` next to :file:`conf.py`. A default file that - is missing or has no ``[codelinks]`` table is silently ignored; a missing - explicitly configured file, or any file that exists but cannot be read or - parsed, triggers a ``codelinks.config`` warning. + Defaults to ``ubproject.toml`` next to :file:`conf.py`. A file of that name + is the shared default whether or not conf.py spells it out: missing, or + without a ``[codelinks]`` table, it is silently ignored. A file that exists + but cannot be read or parsed warns (``codelinks.config``), as any configured + file did at 1.4.0. Any other name is explicit: missing, without the table, + or unreadable, it warns. """ set_local_url: bool = field( diff --git a/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/source_tracing.py b/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/source_tracing.py index c64cd26d4..c2bfc4412 100644 --- a/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/source_tracing.py +++ b/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/source_tracing.py @@ -155,10 +155,11 @@ def load_config_from_toml(app: Sphinx, config: _SphinxConfig) -> None: """Load the configuration from a TOML file, if defined in conf.py. The default ``ubproject.toml`` is shared with other useblocks tools, which - may use the file without any ``[codelinks]`` configuration. It is therefore - silently ignored when it does not exist or has no ``[codelinks]`` table, - whereas a missing explicitly configured file emits a warning. A file that - exists but cannot be read or parsed warns whether it is the default or not: + may use the file without any ``[codelinks]`` configuration. A file of that + name is the default whether or not conf.py spells it out, and is silently + ignored when it does not exist or has no ``[codelinks]`` table. Any other + name is explicit and warns in both cases. A file that exists but cannot be + read or parsed warns whatever its name, as any configured file did at 1.4.0: it is broken for every tool that reads it. Every warning here is ``codelinks.config``, so ``suppress_warnings`` can From 96d4ecb1207f3d0a26b8623d35126999399c4d75 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Thu, 1 Oct 2026 00:41:29 +0200 Subject: [PATCH 8/9] =?UTF-8?q?=F0=9F=A7=AA=20sphinx-codelinks:=20loop-end?= =?UTF-8?q?er=20=E2=80=94=20pin=20the=20default-name=20rule,=20fix=20two?= =?UTF-8?q?=20sentences?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default is the value `ubproject.toml` exactly -- left unset, or written in conf.py as that string -- because the reader compares strings, not files: `./ubproject.toml` (or an absolute path to the same file) is an explicit file and warns when missing or without `[codelinks]`. The sentences in configuration.rst, both docstrings and the changelog bullet said the file's name decided; they now say what the code does, and a test pins it (red if the default were decided by `Path(value).name`). AGENTS.md: "the two numbers" / "check the number" referred to counts the block no longer shows. --- packages/sphinx-codelinks/AGENTS.md | 5 +-- packages/sphinx-codelinks/docs/changelog.rst | 13 ++++---- .../docs/components/configuration.rst | 2 +- .../src/sphinx_codelinks/config.py | 12 +++---- .../sphinx_extension/source_tracing.py | 11 ++++--- .../sphinx-codelinks/tests/test_src_trace.py | 31 +++++++++++++++++++ 6 files changed, 54 insertions(+), 20 deletions(-) diff --git a/packages/sphinx-codelinks/AGENTS.md b/packages/sphinx-codelinks/AGENTS.md index 91ebc5be1..8206a1f96 100644 --- a/packages/sphinx-codelinks/AGENTS.md +++ b/packages/sphinx-codelinks/AGENTS.md @@ -89,7 +89,7 @@ cell. **`test-codelinks` syncs the group into the DEFAULT `.venv`.** It has no `UV_PROJECT_ENVIRONMENT` of its own, unlike its three `-sphinx7/8/9` siblings, so the wheel lands in the environment every other command uses — and the next plain `uv sync --frozen` -prunes it out again (`Uninstalled 1 package: - libclang==18.1.1`). So the two numbers only +prunes it out again (`Uninstalled 1 package: - libclang==18.1.1`). So the two results only appear either side of that sync, and this is the sequence that shows both: ```bash @@ -102,7 +102,8 @@ uv run --frozen --no-sync pytest packages/sphinx-codelinks/tests # green, th it carry `pytest.importorskip("clang.cindex")`, so a run without the group skips politely rather than failing — which means a task or a CI line that quietly lost the group would look like a pass. (CI is fenced: the Extensions cell asserts `import clang.cindex` right -after its sync.) If you are changing anything under `analyse/preproc/`, check the number. +after its sync.) If you are changing anything under `analyse/preproc/`, check that nothing +skipped. The summary prints **26 skipped**, not 56: three of the four guards are module-level `pytest.importorskip`, which pytest reports as one skip per module and never collects the diff --git a/packages/sphinx-codelinks/docs/changelog.rst b/packages/sphinx-codelinks/docs/changelog.rst index f008768c1..d6a42e46d 100644 --- a/packages/sphinx-codelinks/docs/changelog.rst +++ b/packages/sphinx-codelinks/docs/changelog.rst @@ -151,13 +151,14 @@ New and Improved A default file that does not exist or contains no ``[codelinks]`` table is silently ignored, so existing projects without ``ubproject.toml`` keep building without new - warnings. A file named ``ubproject.toml`` is the default whether the name is left at - its default or written in :file:`conf.py`, so a 1.4.0 project that wrote + warnings. The default is the value ``ubproject.toml`` exactly -- left unset, or + written in :file:`conf.py` as that string -- so a 1.4.0 project that wrote ``src_trace_config_from_toml = "ubproject.toml"`` no longer gets a warning for a - missing file or a missing table. A file that exists but cannot be read or parsed -- - invalid TOML, not UTF-8, a directory, a ``codelinks`` key that is not a table -- warns - (``codelinks.config``) whatever its name, as any configured file did at 1.4.0; any - other file name is explicit and also warns when missing or without the table. The + missing file or a missing table. Any other value, ``./ubproject.toml`` included, is an + explicit file and warns when missing or without the table. A file that exists but + cannot be read or parsed -- invalid TOML, not UTF-8, a directory, a ``codelinks`` key + that is not a table -- warns (``codelinks.config``) either way, as any configured file + did at 1.4.0. The documentation project itself now stores its codelinks configuration in ``ubproject.toml``. diff --git a/packages/sphinx-codelinks/docs/components/configuration.rst b/packages/sphinx-codelinks/docs/components/configuration.rst index d7382741b..3a89fd81e 100644 --- a/packages/sphinx-codelinks/docs/components/configuration.rst +++ b/packages/sphinx-codelinks/docs/components/configuration.rst @@ -41,7 +41,7 @@ When using a TOML configuration file: ``src_trace_projects`` can only come from :file:`conf.py` or the TOML -- the dotted ``-D`` form is not supported -- and Sphinx refuses ``-D src_trace_outdir`` as well, so a TOML ``outdir`` stands -.. note:: ``ubproject.toml`` is the shared ubCode project file, which other useblocks tools (e.g. Sphinx-Needs via ``needs_from_toml`` or the ubCode checker in VS Code) read as well. Keeping the ``[codelinks]`` configuration in this file makes all tools aware of the configured projects. A file named ``ubproject.toml`` next to :file:`conf.py` is the shared default, whether the name is left at its default or written in :file:`conf.py`: it may be absent or have no ``[codelinks]`` section, and both are silent -- the configuration from :file:`conf.py` is then used. A file that exists but cannot be read or parsed warns, as any configured file did at 1.4.0. Any other name is explicit: missing, without the ``[codelinks]`` section, or unreadable, it warns. These warnings are of type ``codelinks.config``, so ``suppress_warnings = ["codelinks.config"]`` silences them. +.. note:: ``ubproject.toml`` is the shared ubCode project file, which other useblocks tools (e.g. Sphinx-Needs via ``needs_from_toml`` or the ubCode checker in VS Code) read as well. Keeping the ``[codelinks]`` configuration in this file makes all tools aware of the configured projects. The default is the value ``ubproject.toml`` exactly -- left unset, or written in :file:`conf.py` as that string: that file may be absent or have no ``[codelinks]`` section, and both are silent -- the configuration from :file:`conf.py` is then used. Any other value, ``./ubproject.toml`` or an absolute path to the same file included, is an explicit file: missing or without the ``[codelinks]`` section, it warns. A file that exists but cannot be read or parsed warns either way, as any configured file did at 1.4.0. These warnings are of type ``codelinks.config``, so ``suppress_warnings = ["codelinks.config"]`` silences them. .. caution:: Relative paths specified in the TOML file are resolved relative to the directory containing the TOML file, not the Sphinx project root. diff --git a/packages/sphinx-codelinks/src/sphinx_codelinks/config.py b/packages/sphinx-codelinks/src/sphinx_codelinks/config.py index 18235f6b4..54bc9d3ad 100644 --- a/packages/sphinx-codelinks/src/sphinx_codelinks/config.py +++ b/packages/sphinx-codelinks/src/sphinx_codelinks/config.py @@ -725,12 +725,12 @@ def get_schema(cls, name: str) -> dict[str, Any] | None: ) """Path to a TOML file to load configuration from. - Defaults to ``ubproject.toml`` next to :file:`conf.py`. A file of that name - is the shared default whether or not conf.py spells it out: missing, or - without a ``[codelinks]`` table, it is silently ignored. A file that exists - but cannot be read or parsed warns (``codelinks.config``), as any configured - file did at 1.4.0. Any other name is explicit: missing, without the table, - or unreadable, it warns. + Defaults to ``ubproject.toml`` next to :file:`conf.py`. The default is the + value ``ubproject.toml`` exactly -- left unset, or written in conf.py as that + string: missing, or without a ``[codelinks]`` table, it is silently ignored. + Any other value, ``./ubproject.toml`` included, is an explicit file and warns + in both cases. A file that exists but cannot be read or parsed warns + (``codelinks.config``) either way, as any configured file did at 1.4.0. """ set_local_url: bool = field( diff --git a/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/source_tracing.py b/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/source_tracing.py index c2bfc4412..9183ad0b8 100644 --- a/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/source_tracing.py +++ b/packages/sphinx-codelinks/src/sphinx_codelinks/sphinx_extension/source_tracing.py @@ -155,12 +155,13 @@ def load_config_from_toml(app: Sphinx, config: _SphinxConfig) -> None: """Load the configuration from a TOML file, if defined in conf.py. The default ``ubproject.toml`` is shared with other useblocks tools, which - may use the file without any ``[codelinks]`` configuration. A file of that - name is the default whether or not conf.py spells it out, and is silently + may use the file without any ``[codelinks]`` configuration. The default is + the value ``ubproject.toml`` exactly -- left unset, or written in conf.py as + that string (a string comparison, not a file comparison) -- and is silently ignored when it does not exist or has no ``[codelinks]`` table. Any other - name is explicit and warns in both cases. A file that exists but cannot be - read or parsed warns whatever its name, as any configured file did at 1.4.0: - it is broken for every tool that reads it. + value, ``./ubproject.toml`` included, is an explicit file and warns in both + cases. A file that exists but cannot be read or parsed warns either way, as + any configured file did at 1.4.0: it is broken for every tool that reads it. Every warning here is ``codelinks.config``, so ``suppress_warnings`` can silence them. diff --git a/packages/sphinx-codelinks/tests/test_src_trace.py b/packages/sphinx-codelinks/tests/test_src_trace.py index d856b197e..0b458d73e 100644 --- a/packages/sphinx-codelinks/tests/test_src_trace.py +++ b/packages/sphinx-codelinks/tests/test_src_trace.py @@ -690,6 +690,37 @@ def test_default_name_written_in_conf_py_is_still_the_default( assert_no_warnings(app) +@pytest.mark.parametrize( + ("state", "phrase"), + [ + pytest.param(None, "does not exist", id="missing"), + pytest.param( + lambda path: path.write_text("[needs]\n", encoding="utf-8"), + "has no [codelinks] table", + id="no-codelinks-table", + ), + ], +) +def test_other_spelling_of_the_default_name_is_explicit( + minimal_sphinx_project: Path, + make_app: Callable[..., SphinxTestApp], + state: Callable[[Path], object] | None, + phrase: str, +) -> None: + """The default is the value ``ubproject.toml`` EXACTLY: ``./ubproject.toml`` names + the same file but is an explicit configuration, so missing or without + ``[codelinks]`` it warns.""" + _write_conf( + minimal_sphinx_project, 'src_trace_config_from_toml = "./ubproject.toml"\n' + ) + if state is not None: + state(minimal_sphinx_project / "ubproject.toml") + app = make_app(srcdir=minimal_sphinx_project, freshenv=True) + app.build() + + _assert_one_config_warning(app, phrase) + + def test_explicit_toml_without_codelinks_table_warns( minimal_sphinx_project: Path, make_app: Callable[..., SphinxTestApp], From ee59bc452578a5b35a1429286afa270f43b5d40f Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Thu, 1 Oct 2026 00:56:36 +0200 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=A7=AA=20sphinx-codelinks:=20strip=20?= =?UTF-8?q?ANSI=20escapes=20when=20normalising=20the=20CLI's=20panel=20out?= =?UTF-8?q?put?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typer forces a colour terminal when GITHUB_ACTIONS is set, so on the runners the error panel carries ANSI escape codes, including between the halves of a wrapped line, and `_normalize_output` only collapsed box characters and whitespace: a phrase split by the wrap stayed split. Which test broke depends on the panel width (on CI: test_analyse_toml_syntax_error_shows_the_reason). The escapes are now removed first. Reproduced locally with GITHUB_ACTIONS=1. --- packages/sphinx-codelinks/tests/test_cmd.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/sphinx-codelinks/tests/test_cmd.py b/packages/sphinx-codelinks/tests/test_cmd.py index 4bde8f6e3..1545ee3d5 100644 --- a/packages/sphinx-codelinks/tests/test_cmd.py +++ b/packages/sphinx-codelinks/tests/test_cmd.py @@ -49,11 +49,16 @@ def _normalize_output(text: str) -> str: - """Normalize rich panel output by collapsing box-drawing chars and whitespace. + """Normalize rich panel output: strip ANSI escapes, then collapse box-drawing + chars and whitespace. Typer wraps error messages in rich panels whose line breaks depend on terminal - width, which can cause substring assertions to fail. + width, which can cause substring assertions to fail. And typer forces a colour + terminal when ``GITHUB_ACTIONS`` is set, so on CI the panel also carries ANSI + escape codes -- including between the halves of a wrapped line -- which have to go + first, before the whitespace between the halves can collapse. """ + text = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", text) # Remove box-drawing characters (─│╭╮╯╰) and collapse resulting whitespace text = re.sub(r"[─│╭╮╯╰]", " ", text) return re.sub(r"\s+", " ", text).strip()