diff --git a/.github/actions/lychee-links/action.yml b/.github/actions/lychee-links/action.yml index 670c7ad074d..45ba88fc772 100644 --- a/.github/actions/lychee-links/action.yml +++ b/.github/actions/lychee-links/action.yml @@ -69,7 +69,7 @@ runs: KIND: ${{ inputs.kind }} # Anything that can change a check result or the cache's meaning. The # hook configuration carries the native wheel pin and every hook option. - POLICY_HASH: ${{ hashFiles('.pre-commit-config.yaml', 'lychee.toml', 'ci/tools/run_lychee.py', 'ci/tools/run_lychee_precommit.py', 'ci/tools/retry_lychee.py') }} + POLICY_HASH: ${{ hashFiles('.pre-commit-config.yaml', 'lychee.toml', 'ci/tools/run_lychee.py', 'ci/tools/run_lychee_precommit.py', 'ci/tools/retry_lychee.py', 'ci/tools/checkout_source_links.py') }} run: | # Authored and rendered results stay apart: only rendered checks validate # fragments, and Lychee's cache does not record which validation ran. diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 6e52bc71b15..46ddbcf8d84 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -118,7 +118,7 @@ jobs: fi else FILE_HASH="${{ github.sha }}" - DOCS_GITHUB_REF="${{ github.sha }}" + DOCS_GITHUB_REF=$(python ci/tools/checkout_source_links.py --github-ref) fi # make outputs from the previous job as env vars diff --git a/ci/README.md b/ci/README.md index dcd5047b05a..47448144ee0 100644 --- a/ci/README.md +++ b/ci/README.md @@ -30,7 +30,9 @@ Two pre-commit hooks check links with one pinned native wheel (`lychee-bin`, see `.pre-commit-config.yaml`) through the wrapper `ci/tools/run_lychee.py`: `lychee` checks authored Markdown and reST (fragments unchecked), and the manual-stage `lychee-rendered` checks the generated HTML in `artifacts/docs`, -including fragments. Developers run the same hooks locally. +including fragments. Developers run the same hooks locally. Public repository +`main` links are checked against the proposed checkout, as described under +[Repository Links](#repository-links). CI runs a hook through `ci/tools/run_lychee_precommit.py `, wrapped with the shared cache and report handling in @@ -95,13 +97,71 @@ can stay unnoticed for up to 72 hours. The action keys each baseline by checker kind and a hash of `.pre-commit-config.yaml` (hook options and wheel pin), `lychee.toml`, and -`ci/tools/run_lychee.py`, `run_lychee_precommit.py`, and `retry_lychee.py`, so -editing any of them starts a cold cache. +`ci/tools/run_lychee.py`, `run_lychee_precommit.py`, `retry_lychee.py`, and +`checkout_source_links.py`, so editing any of them starts a cold cache. Authored and rendered caches stay apart because only rendered checks validate fragments and Lychee's cache does not record which validation ran. URL results are shared across operating systems; pre-commit's installed environments keep their OS- and Python-specific caches. +### Repository Links + +Documentation can link to a path added or moved in the same PR using its +intended public URL, for example +`https://github.com/NVIDIA/cuda-python/tree/main/new-directory`. Lychee +validates the proposed target before it exists on public `main`. Only the +validation target changes; source files and built documentation retain their URLs. + +`ci/tools/checkout_source_links.py` matches the complete public repository +prefix followed by `blob/main/` or `tree/main/`: + +- Authored Markdown and reST map to file URIs in the working tree, including + uncommitted and unpushed paths. Missing files and directories fail even after + an earlier success. These checks need no repository metadata and retain the + existing policy of omitting fragments. +- Rendered HTML from the public repository maps source links to GitHub's file + or directory view at the actual checked-out commit, retaining fragment checks. + That commit must be available on GitHub. +- Rendered HTML from other repositories maps the same public source links to + local files and directories. Only those source links omit fragments, since + local files do not reproduce every GitHub source-view anchor. Generated + documentation pages and their anchors still receive full validation. + +Tags, explicit commits, other branches, unrelated repositories, and URLs embedded +inside other URLs retain their original targets. For rendered checks and checking +builds, CI supplies the checkout identity through `GITHUB_REPOSITORY`. Local runs +use `git config lychee.repository`, or otherwise the `upstream` remote followed by +`origin`. A checkout whose remotes only name a fork can select public-repository +validation explicitly: + +```bash +git config lychee.repository NVIDIA/cuda-python +``` + +For rendered checks of an unpublished local commit, select your fork's identity +to validate source links against local files: + +```bash +git config lychee.repository YOUR_GITHUB_USER/cuda-python +``` + +Use your actual fork's owner and repository. Set this before both the checking +documentation build and the rendered hook; rebuild existing checking output if +its example links contain an unpublished SHA. Clear any explicit +`CUDA_PYTHON_DOCS_GITHUB_REF` override to use this selection. Only GitHub +source-view fragments are omitted; generated HTML anchors remain checked. +CI uses `GITHUB_REPOSITORY` and retains its normal validation policy. + +The checking build uses the public checkout's actual commit for generated +example links. In other repositories it uses `main`, allowing those public URLs +to receive the same local target checks without inserting another repository's +commit into public links. An explicit `CUDA_PYTHON_DOCS_GITHUB_REF` override is +retained; release builds continue to use their release tags. + +These rules validate the proposed source tree. Before adopting them on a +maintenance branch, choose an explicit policy for links that intentionally point +to a different tree from the maintenance checkout. + ## Temporary Pixi Lockfile Maintenance Pause Pixi lockfile freshness enforcement and automated refreshes are temporarily diff --git a/ci/tools/build_docs_for_link_check.sh b/ci/tools/build_docs_for_link_check.sh index 32c781bf1ad..d13cec4a36f 100644 --- a/ci/tools/build_docs_for_link_check.sh +++ b/ci/tools/build_docs_for_link_check.sh @@ -15,7 +15,10 @@ if [[ "${PIXI_ENVIRONMENT_NAME:-}" != "docs" ]]; then exit 1 fi -export CUDA_PYTHON_DOCS_GITHUB_REF="${CUDA_PYTHON_DOCS_GITHUB_REF:-$(git rev-parse HEAD)}" +if [[ -z "${CUDA_PYTHON_DOCS_GITHUB_REF:-}" ]]; then + CUDA_PYTHON_DOCS_GITHUB_REF=$(python ci/tools/checkout_source_links.py --github-ref) +fi +export CUDA_PYTHON_DOCS_GITHUB_REF export BUILD_LATEST=1 export BUILD_PREVIEW=0 diff --git a/ci/tools/checkout_source_links.py b/ci/tools/checkout_source_links.py new file mode 100644 index 00000000000..8375418d9ca --- /dev/null +++ b/ci/tools/checkout_source_links.py @@ -0,0 +1,104 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Check public main source links against the proposed checkout. + +Authored checks use local files without consulting Git metadata. Rendered +checks use the tested public commit for GitHub's fragment handling; other +checkouts validate local source paths and leave GitHub-specific fragments +for their eventual public review. +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +from pathlib import Path +from urllib.parse import urlsplit + +PUBLIC_REPOSITORY = "NVIDIA/cuda-python" +SOURCE_URL = r"^https://github[.]com/NVIDIA/cuda-python/(blob|tree)/main/" + + +def _git(root: Path, *arguments: str, optional: bool = False) -> str | None: + result = subprocess.run( # noqa: S603 + ["git", "-C", str(root), *arguments], # noqa: S607 + check=False, + capture_output=True, + text=True, + ) + if optional and result.returncode == 1: + return None + result.check_returncode() + return result.stdout.strip() + + +def _repository(value: str) -> str: + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", value): + raise ValueError("Expected a GitHub OWNER/REPOSITORY identity") + return PUBLIC_REPOSITORY if value.casefold() == PUBLIC_REPOSITORY.casefold() else value + + +def _remote_repository(url: str) -> str | None: + if url.startswith("git@github.com:"): + path = url.removeprefix("git@github.com:") + else: + parsed = urlsplit(url) + if parsed.scheme not in {"https", "ssh"} or parsed.hostname != "github.com": + return None + path = parsed.path.lstrip("/") + return _repository(path.rstrip("/").removesuffix(".git")) + + +def checkout_repository(root: Path) -> str: + """Identify a rendered checkout without querying remote default branches.""" + if "GITHUB_REPOSITORY" in os.environ: + return _repository(os.environ["GITHUB_REPOSITORY"]) + explicit = _git(root, "config", "--get", "lychee.repository", optional=True) + if explicit is not None: + return _repository(explicit) + for name in ("upstream", "origin"): + url = _git(root, "config", "--get", f"remote.{name}.url", optional=True) + if url is not None and (repository := _remote_repository(url)) is not None: + return repository + raise ValueError( + "Cannot identify this checkout's GitHub repository. Configure an upstream/origin remote, " + "or run git config lychee.repository OWNER/REPOSITORY." + ) + + +def github_source_ref(root: Path) -> str: + """Use the tested public commit, or main for links checked against local files.""" + if checkout_repository(root) != PUBLIC_REPOSITORY: + return "main" + commit = _git(root, "rev-parse", "HEAD") + if commit is None or not re.fullmatch(r"[0-9a-fA-F]{40,64}", commit): + raise ValueError("Git did not return a full checkout commit ID") + return commit + + +def repo_link_remaps(root: Path, *, rendered: bool = False) -> list[str]: + """Remap only canonical public main source URLs; keep other targets literal.""" + local = root.resolve().as_uri() + "/" + if not rendered: + return ["--remap", f"{SOURCE_URL} {local}"] + ref = github_source_ref(root) + if ref != "main": + return ["--remap", f"{SOURCE_URL} https://github.com/{PUBLIC_REPOSITORY}/$1/{ref}/"] + # GitHub source fragments do not describe local files. Omit them only on + # these source links; generated HTML keeps its ordinary full anchor checks. + pattern = SOURCE_URL.replace("(blob|tree)", "(?:blob|tree)") + r"([^#]*)(?:#.*)?$" + return ["--remap", f"{pattern} {local}$1"] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--github-ref", action="store_true", required=True) + parser.parse_args() + print(github_source_ref(Path(__file__).resolve().parents[2])) + + +if __name__ == "__main__": + main() diff --git a/ci/tools/run_lychee.py b/ci/tools/run_lychee.py index 84fb8684c60..2c0991eb34e 100644 --- a/ci/tools/run_lychee.py +++ b/ci/tools/run_lychee.py @@ -21,6 +21,8 @@ import uuid from pathlib import Path +from checkout_source_links import repo_link_remaps + ROOT = Path(__file__).resolve().parents[2] @@ -82,7 +84,9 @@ def main(arguments: list[str] | None = None) -> int: report = directory / f"{uuid.uuid4().hex}.json" arguments = ["--format", "json", "--output", str(report), *arguments] - code = subprocess.run([str(executable), *arguments], check=False, **options).returncode # noqa: S603 + code = subprocess.run( # noqa: S603 + [str(executable), *repo_link_remaps(ROOT, rendered=rendered), *arguments], check=False, **options + ).returncode if report is not None: report.with_suffix(".status.json").write_text(json.dumps({"exit_code": code}), encoding="utf-8") return code diff --git a/ci/tools/tests/test_build_docs_for_link_check.py b/ci/tools/tests/test_build_docs_for_link_check.py index a0a514b3ab9..06b5eeaa903 100644 --- a/ci/tools/tests/test_build_docs_for_link_check.py +++ b/ci/tools/tests/test_build_docs_for_link_check.py @@ -12,16 +12,49 @@ import pytest -BUILD_HELPER = Path(__file__).resolve().parents[1] / "build_docs_for_link_check.sh" +TOOLS = Path(__file__).resolve().parents[1] +BUILD_HELPER = TOOLS / "build_docs_for_link_check.sh" @pytest.mark.parametrize("checkout_name", ["checkout", "checkout with spaces"]) +@pytest.mark.parametrize("repository", ["NVIDIA/cuda-python", "example/project"]) +@pytest.mark.parametrize("explicit_ref", [None, "test-ref"]) @pytest.mark.agent_authored(model="gpt-6") -def test_checking_build_uses_final_local_output_as_docs_domain(tmp_path, monkeypatch, checkout_name): +def test_checking_build_uses_local_output_and_source_ref( + tmp_path, monkeypatch, checkout_name, repository, explicit_ref +): checkout = tmp_path / checkout_name helper = checkout / "ci/tools/build_docs_for_link_check.sh" helper.parent.mkdir(parents=True) shutil.copyfile(BUILD_HELPER, helper) + shutil.copyfile(TOOLS / "checkout_source_links.py", helper.with_name("checkout_source_links.py")) + subprocess.run(["git", "init", str(checkout)], check=True, capture_output=True) # noqa: S603, S607 + subprocess.run( # noqa: S603 + ["git", "-C", str(checkout), "add", "ci"], # noqa: S607 + check=True, + ) + subprocess.run( # noqa: S603 + [ # noqa: S607 + "git", + "-C", + str(checkout), + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "Initial checking-build fixture", + ], + check=True, + capture_output=True, + ) + head = subprocess.check_output( # noqa: S603 + ["git", "-C", str(checkout), "rev-parse", "HEAD"], # noqa: S607 + text=True, + ).strip() # Skip package installation/import checks while running the helper's actual # environment setup and artifact copy. Sphinx is not a CI-tools dependency. @@ -56,7 +89,7 @@ def test_checking_build_uses_final_local_output_as_docs_domain(tmp_path, monkeyp output = Path("build/html") output.mkdir(parents=True) settings = {key: os.environ[key] for key in ( - "CUDA_PYTHON_DOCS_DOMAIN", "BUILD_LATEST", "BUILD_PREVIEW", + "CUDA_PYTHON_DOCS_DOMAIN", "CUDA_PYTHON_DOCS_GITHUB_REF", "BUILD_LATEST", "BUILD_PREVIEW", )} (output / "build-settings.json").write_text(json.dumps(settings), encoding="utf-8") PY @@ -67,7 +100,11 @@ def test_checking_build_uses_final_local_output_as_docs_domain(tmp_path, monkeyp monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}") monkeypatch.setenv("TEST_PYTHON", sys.executable) monkeypatch.setenv("PIXI_ENVIRONMENT_NAME", "docs") - monkeypatch.setenv("CUDA_PYTHON_DOCS_GITHUB_REF", "test-ref") + monkeypatch.setenv("GITHUB_REPOSITORY", repository) + if explicit_ref is None: + monkeypatch.delenv("CUDA_PYTHON_DOCS_GITHUB_REF", raising=False) + else: + monkeypatch.setenv("CUDA_PYTHON_DOCS_GITHUB_REF", explicit_ref) monkeypatch.setenv("CUDA_PYTHON_DOCS_DOMAIN", "https://nvidia.github.io/cuda-python") monkeypatch.setenv("BUILD_PREVIEW", "1") @@ -77,6 +114,7 @@ def test_checking_build_uses_final_local_output_as_docs_domain(tmp_path, monkeyp settings = json.loads((output / "build-settings.json").read_text(encoding="utf-8")) assert settings == { "CUDA_PYTHON_DOCS_DOMAIN": output.as_uri(), + "CUDA_PYTHON_DOCS_GITHUB_REF": explicit_ref or (head if repository == "NVIDIA/cuda-python" else "main"), "BUILD_LATEST": "1", "BUILD_PREVIEW": "0", } diff --git a/ci/tools/tests/test_checkout_source_links.py b/ci/tools/tests/test_checkout_source_links.py new file mode 100644 index 00000000000..170d94e78cb --- /dev/null +++ b/ci/tools/tests/test_checkout_source_links.py @@ -0,0 +1,164 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) +import checkout_source_links as links + + +def git(root, *arguments): + return subprocess.run( # noqa: S603 + ["git", "-C", str(root), *arguments], # noqa: S607 + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def checkout(tmp_path): + git(tmp_path, "init") + git( + tmp_path, + "-c", + "user.name=Link Test", + "-c", + "user.email=link-test@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "Initial test revision", + ) + return tmp_path + + +def remap(arguments, url): + for argument in arguments[1::2]: + pattern, replacement = argument.split() + url = re.sub(pattern, replacement.replace("$1", r"\g<1>"), url) + return url + + +@pytest.mark.parametrize("view", ["blob", "tree"]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_authored_mapping_needs_no_git_or_repository_metadata(tmp_path, monkeypatch, view): + root = tmp_path / "checkout space$#" + monkeypatch.setenv("GITHUB_REPOSITORY", "invalid metadata") + with patch("checkout_source_links.subprocess.run") as run: + arguments = links.repo_link_remaps(root) + run.assert_not_called() + url = f"https://github.com/NVIDIA/cuda-python/{view}/main/docs/a%20guide.md?plain=1#heading" + assert remap(arguments, url) == f"{root.as_uri()}/docs/a%20guide.md?plain=1#heading" + + +@pytest.mark.parametrize("view", ["blob", "tree"]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_public_rendered_mapping_uses_checkout_commit_and_keeps_suffix(tmp_path, monkeypatch, view): + root = checkout(tmp_path) + commit = git(root, "rev-parse", "HEAD") + monkeypatch.setenv("GITHUB_REPOSITORY", "nvidia/CUDA-PYTHON") + monkeypatch.setenv("GITHUB_BASE_REF", "12.9.x") + monkeypatch.setenv("GITHUB_REF_NAME", "123/merge") + arguments = links.repo_link_remaps(root, rendered=True) + url = f"https://github.com/NVIDIA/cuda-python/{view}/main/README.md?plain=1#heading" + assert remap(arguments, url) == f"https://github.com/NVIDIA/cuda-python/{view}/{commit}/README.md?plain=1#heading" + assert links.github_source_ref(root) == commit + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_other_rendered_checkout_omits_only_remapped_source_fragments(tmp_path, monkeypatch): + monkeypatch.setenv("GITHUB_REPOSITORY", "Example/project") + with patch("checkout_source_links.subprocess.run") as run: + arguments = links.repo_link_remaps(tmp_path, rendered=True) + assert links.github_source_ref(tmp_path) == "main" + run.assert_not_called() + for view in ("blob", "tree"): + url = f"https://github.com/NVIDIA/cuda-python/{view}/main/docs/guide.md?plain=1#heading" + assert remap(arguments, url) == f"{tmp_path.as_uri()}/docs/guide.md?plain=1" + for url in ("guide.html#heading", "file:///docs/guide.html#heading", "https://example.org/guide#heading"): + assert remap(arguments, url) == url + + +@pytest.mark.parametrize("rendered", [False, True]) +@pytest.mark.parametrize( + "url", + [ + "https://github.com/NVIDIA/cuda-python/blob/12.9.x/README.md#heading", + "https://github.com/NVIDIA/cuda-python/tree/v13.0.0/cuda_bindings", + "https://github.com/NVIDIA/cuda-python/blob/0123456789abcdef/README.md", + "https://github.com/NVIDIA/cuda-python/blob/mainish/README.md", + "https://github.com/NVIDIA/cuda-python/tree/main", + "https://github.com/Example/project/blob/main/README.md#heading", + "https://github.com/NVIDIA/other-repository/tree/main/docs", + "http://github.com/NVIDIA/cuda-python/blob/main/README.md", + "https://example.com/redirect?url=https://github.com/NVIDIA/cuda-python/blob/main/README.md", + ], +) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_unmatched_urls_remain_literal(tmp_path, monkeypatch, rendered, url): + monkeypatch.setenv("GITHUB_REPOSITORY", "Example/project") + assert remap(links.repo_link_remaps(tmp_path, rendered=rendered), url) == url + + +@pytest.mark.parametrize( + "url", + [ + "https://github.com/NVIDIA/cuda-python.git", + "git@github.com:NVIDIA/cuda-python.git", + "ssh://git@github.com/NVIDIA/cuda-python.git", + ], +) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_local_rendered_checkout_needs_no_cached_remote_head(tmp_path, monkeypatch, url): + monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) + root = checkout(tmp_path) + git(root, "remote", "add", "upstream", url) + git(root, "remote", "add", "origin", "https://github.com/Example/project.git") + assert links.github_source_ref(root) == git(root, "rev-parse", "HEAD") + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_explicit_local_repository_takes_precedence_over_remotes(tmp_path, monkeypatch): + monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) + root = checkout(tmp_path) + git(root, "config", "lychee.repository", links.PUBLIC_REPOSITORY) + git(root, "remote", "add", "upstream", "https://github.com/Example/project.git") + assert links.github_source_ref(root) == git(root, "rev-parse", "HEAD") + monkeypatch.setenv("GITHUB_REPOSITORY", "Example/project") + assert links.github_source_ref(root) == "main" + + +@pytest.mark.parametrize("identity", ["", "not-a-repository", "OWNER/", "https://github.com/Example/project"]) +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_invalid_environment_identity_fails_explicitly(tmp_path, monkeypatch, identity): + monkeypatch.setenv("GITHUB_REPOSITORY", identity) + with pytest.raises(ValueError, match="OWNER/REPOSITORY"): + links.github_source_ref(tmp_path) + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_invalid_explicit_local_identity_fails_instead_of_using_remotes(tmp_path, monkeypatch): + monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) + root = checkout(tmp_path) + git(root, "config", "lychee.repository", "invalid") + git(root, "remote", "add", "origin", "https://github.com/NVIDIA/cuda-python.git") + with pytest.raises(ValueError, match="OWNER/REPOSITORY"): + links.github_source_ref(root) + + +@pytest.mark.agent_authored(model="gpt-6.1-sol") +def test_unidentified_rendered_checkout_explains_explicit_configuration(tmp_path, monkeypatch): + monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) + root = checkout(tmp_path) + with pytest.raises(ValueError, match="git config lychee.repository OWNER/REPOSITORY"): + links.github_source_ref(root) diff --git a/ci/tools/tests/test_run_lychee.py b/ci/tools/tests/test_run_lychee.py index 81af33cd11d..2671b95a835 100644 --- a/ci/tools/tests/test_run_lychee.py +++ b/ci/tools/tests/test_run_lychee.py @@ -7,7 +7,7 @@ import subprocess import sys from pathlib import Path -from unittest.mock import patch +from unittest.mock import call, patch import pytest @@ -20,10 +20,12 @@ def test_native_reports_preserve_each_precommit_chunk_exit_code(tmp_path, monkeypatch): executable = tmp_path / "lychee" executable.touch() + remap_arguments = ["--remap", "source target"] monkeypatch.setenv("LYCHEE_REPORT_DIR", str(tmp_path / "reports")) with ( patch("run_lychee.sysconfig.get_path", return_value=str(tmp_path)), patch("run_lychee.sys.platform", "linux"), + patch("run_lychee.repo_link_remaps", return_value=remap_arguments) as remaps, patch( "run_lychee.subprocess.run", side_effect=[subprocess.CompletedProcess([], 2), subprocess.CompletedProcess([], 0)], @@ -31,11 +33,13 @@ def test_native_reports_preserve_each_precommit_chunk_exit_code(tmp_path, monkey ): assert main(["one.md"]) == 2 assert main(["two.rst"]) == 0 + assert remaps.call_args_list == [call(run_lychee.ROOT, rendered=False)] * 2 reports = [] - for call, code in zip(run.call_args_list, (2, 0), strict=True): - command = call.args[0] - assert command[1:4] == ["--format", "json", "--output"] - report = Path(command[4]) + for invocation, code in zip(run.call_args_list, (2, 0), strict=True): + command = invocation.args[0] + assert command[1:3] == remap_arguments + assert command[3:6] == ["--format", "json", "--output"] + report = Path(command[6]) assert report.parent == (tmp_path / "reports").resolve() assert json.loads(report.with_suffix(".status.json").read_text()) == {"exit_code": code} reports.append(report) @@ -52,18 +56,22 @@ def test_rendered_hook_checks_untracked_html_in_its_own_cache(tmp_path, monkeypa (docs / "_static").mkdir() (docs / "_static/theme.html").write_text("ignored theme") cache = tmp_path / ".lychee-rendered" + remap_arguments = ["--remap", "source target"] monkeypatch.setattr(run_lychee, "ROOT", tmp_path) monkeypatch.delenv("LYCHEE_REPORT_DIR", raising=False) with ( patch("run_lychee.sysconfig.get_path", return_value=str(tmp_path)), patch("run_lychee.sys.platform", "linux"), + patch("run_lychee.repo_link_remaps", return_value=remap_arguments) as remaps, patch("run_lychee.subprocess.run", return_value=subprocess.CompletedProcess([], 0)) as run, ): assert main(["--rendered", "--cache"]) == 0 + remaps.assert_called_once_with(tmp_path, rendered=True) assert (cache / "inputs.txt").read_text().splitlines() == [str(docs / "guide.html"), str(docs / "index.html")] run.assert_called_once_with( [ str(tmp_path / "lychee"), + *remap_arguments, "--files-from", str(cache / "inputs.txt"), "--include-fragments=full", @@ -83,10 +91,12 @@ def test_rendered_hook_rejects_missing_build_before_invoking_native_checker(tmp_ with ( patch("run_lychee.sysconfig.get_path", return_value=str(tmp_path)), patch("run_lychee.sys.platform", "linux"), + patch("run_lychee.repo_link_remaps") as remaps, patch("run_lychee.subprocess.run") as run, pytest.raises(ValueError, match="No documentation inputs"), ): main(["--rendered", "--cache"]) + remaps.assert_not_called() run.assert_not_called()