diff --git a/.github/actions/check-doc-links/action.yml b/.github/actions/check-doc-links/action.yml index 7bde3fbdd48..b835c744423 100644 --- a/.github/actions/check-doc-links/action.yml +++ b/.github/actions/check-doc-links/action.yml @@ -25,7 +25,7 @@ runs: shell: bash --noprofile --norc -euo pipefail {0} env: KIND: ${{ inputs.kind }} - LYCHEE_VERSION: v0.24.2 # Must match the pinned rev in .pre-commit-config.yaml + LYCHEE_VERSION: v0.24.2 # Must match lychee-bin in .pre-commit-config.yaml POLICY_HASH: ${{ hashFiles('lychee.toml', '.github/actions/check-doc-links/action.yml', 'ci/tools/prepare_lychee_inputs.py', 'ci/tools/retry_lychee.py') }} # The first pass and all retries use the same inputs and cache policy. LYCHEE_ARGS: >- diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index fe4e07fbd11..322cc8e127d 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -85,6 +85,58 @@ jobs: - name: Install pre-commit run: python -m pip install --upgrade pip pre-commit + # Keep this offline hook smoke check before network-dependent Lychee steps, + # so hook setup and invocation failures surface before link-check retries. + # Invoke pre-commit directly to cover each shell's command lookup and quoting. + # Each runner's shells reuse one offline configuration and hook environment. + - name: Prepare offline Lychee configuration + id: lychee-smoke + # pre-commit needs the config on the checkout's drive; spaces exercise quoting. + run: | + directory="$(mktemp -d 'lychee offline smoke XXXXXX')" + echo "directory=$directory" >> "${GITHUB_OUTPUT}" + python ci/tools/smoke_test_lychee_precommit.py --prepare "$directory" + + - name: Smoke-test Lychee in ${{ runner.os == 'Windows' && 'Git Bash' || 'Bash' }} + if: ${{ !cancelled() && steps.lychee-smoke.outcome == 'success' }} + shell: bash + env: + LYCHEE_SMOKE_DIR: ${{ steps.lychee-smoke.outputs.directory }} + SKIP: "" + PYTHONUTF8: "1" + run: | + status=0 + pre-commit run lychee --config "$LYCHEE_SMOKE_DIR/pre-commit.yaml" --files README.md --verbose > "$LYCHEE_SMOKE_DIR/bash.log" 2>&1 || status=$? + python ci/tools/smoke_test_lychee_precommit.py --verify "$LYCHEE_SMOKE_DIR/bash.log" "$status" + + - name: Smoke-test Lychee in PowerShell (Windows) + if: ${{ !cancelled() && runner.os == 'Windows' && steps.lychee-smoke.outcome == 'success' }} + shell: pwsh + env: + LYCHEE_SMOKE_DIR: ${{ steps.lychee-smoke.outputs.directory }} + SKIP: "" + PYTHONUTF8: "1" + run: | + pre-commit run lychee --config "$env:LYCHEE_SMOKE_DIR/pre-commit.yaml" --files README.md --verbose > "$env:LYCHEE_SMOKE_DIR/pwsh.log" 2>&1 + python ci/tools/smoke_test_lychee_precommit.py --verify "$env:LYCHEE_SMOKE_DIR/pwsh.log" $LASTEXITCODE + + - name: Smoke-test Lychee in cmd (Windows) + if: ${{ !cancelled() && runner.os == 'Windows' && steps.lychee-smoke.outcome == 'success' }} + shell: cmd + env: + LYCHEE_SMOKE_DIR: ${{ steps.lychee-smoke.outputs.directory }} + SKIP: "" + PYTHONUTF8: "1" + run: | + pre-commit run lychee --config "%LYCHEE_SMOKE_DIR%\pre-commit.yaml" --files README.md --verbose > "%LYCHEE_SMOKE_DIR%\cmd.log" 2>&1 + python ci/tools/smoke_test_lychee_precommit.py --verify "%LYCHEE_SMOKE_DIR%\cmd.log" %ERRORLEVEL% + + - name: Remove offline Lychee configuration + if: ${{ always() && steps.lychee-smoke.outputs.directory != '' }} + env: + LYCHEE_SMOKE_DIR: ${{ steps.lychee-smoke.outputs.directory }} + run: python ci/tools/smoke_test_lychee_precommit.py --cleanup "$LYCHEE_SMOKE_DIR" + - name: Run pre-commit run: pre-commit run --all-files --show-diff-on-failure diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2cc4bb91d26..b3d4f99ae24 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -105,12 +105,17 @@ repos: - Cython==3.2.9 # Link checking for authored documentation files - - repo: https://github.com/lycheeverse/lychee - rev: 2bba271688c1abb1503097a064e6c3bc1d1b6a9b # frozen: lychee-v0.24.2 + - repo: local hooks: - id: lychee + name: lychee + entry: python ./ci/tools/run_lychee.py + language: python + additional_dependencies: + - lychee-bin==0.24.2 # Must match check-doc-links/action.yml + types: [text] + require_serial: true args: - - LYCHEE_VERSION=0.24.2 # Must match the pinned rev above - --cache - --max-concurrency=4 - --max-retries=3 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 68e25ff0951..c4808f2cb02 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,6 @@ Thank you for your interest in contributing to CUDA Python! Based on the type of - [Symptoms of a bad clone](#symptoms-of-a-bad-clone) - [Development on Windows](#development-on-windows) - [Enabling git symlinks](#enabling-git-symlinks) - - [Pre-commit lychee workaround](#pre-commit-lychee-workaround) - [Type stubs for cuda.core](#type-stubs-for-cudacore) - [Pre-commit](#pre-commit) - [Pixi lockfiles](#pixi-lockfiles) @@ -192,19 +191,6 @@ $ reg.exe add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /v Then return to [Recommended clone](#recommended-clone). -### Pre-commit lychee workaround - -For development on Windows (not WSL), the `lychee` pre-commit task will not -work when running `pre-commit run --all-files`. This problem does not occur -if you install the pre-commit hook and run it automatically as part of your -`git commit` workflow. To resolve this, you can either: - -1. Run `pre-commit` in Git Bash, rather than directly in PowerShell or cmd - -2. Skip it by setting the environment variable `SKIP` to `lychee`. This would - be `$env:SKIP = "lychee"` in PowerShell or `set SKIP=lychee` in cmd. - - ## Type stubs for cuda.core `cuda.core` is a PEP 561-compliant package: it ships a `py.typed` marker and @@ -237,6 +223,8 @@ To set yourself up for running pre-commit checks locally and to catch issues bef * Run this once per checkout: `pre-commit install` * You can manually check all files at any time by running: `pre-commit run --all-files` +On Windows, these commands work from PowerShell, cmd, or Git Bash. + This command runs all configured hooks (such as linters and formatters) across your repository, letting you review and address issues before committing. Installing the hook is required, not optional. Some of the automated checks @@ -247,8 +235,6 @@ between commits, leaving stale headers or out-of-date stubs in the history. If the hook isn't installed, `pre-commit run` will print a visible warning reminding you to run `pre-commit install`. -Windows contributors: see [Pre-commit lychee workaround](#pre-commit-lychee-workaround) under Development on Windows. - ## Pixi lockfiles The repository checks in a `pixi.lock` next to each `pixi.toml`. Those lockfiles diff --git a/ci/tools/run_lychee.py b/ci/tools/run_lychee.py new file mode 100644 index 00000000000..0d8be24d06d --- /dev/null +++ b/ci/tools/run_lychee.py @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Run pre-commit's pinned native Lychee executable. + +The upstream Lychee hook uses a shell launcher, which requires a compatible +shell on Windows. We configure a Python hook so pre-commit installs the pinned +lychee-bin wheel in an isolated environment. This wrapper runs that +environment's native executable, passing through arguments and returning +its exit status. The same hook works on Linux and in Windows shells such as +PowerShell, cmd, and Git Bash. +""" + +from __future__ import annotations + +import subprocess +import sys +import sysconfig +from pathlib import Path + + +def main(arguments: list[str] | None = None) -> int: + # Resolve the executable from this hook's environment to honor the pin. + executable = Path(sysconfig.get_path("scripts")) / ("lychee.exe" if sys.platform == "win32" else "lychee") + if not executable.is_file(): + raise SystemExit( + f"lychee was not found at {executable}; run this script through the configured pre-commit hook" + ) + arguments = sys.argv[1:] if arguments is None else arguments + return subprocess.run([str(executable), *arguments], check=False).returncode # noqa: S603 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ci/tools/smoke_test_lychee_precommit.py b/ci/tools/smoke_test_lychee_precommit.py new file mode 100644 index 00000000000..db6db4113dc --- /dev/null +++ b/ci/tools/smoke_test_lychee_precommit.py @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Offline smoke check for the configured native Lychee hook. + +Exercise the hook's environment, Python launcher, and native executable against +the README's local links without making external link requests. As an accepted +tradeoff for using a real document, a broken local README link also fails this +check even when the hook itself works. +""" + +from __future__ import annotations + +import argparse +import shutil +from pathlib import Path + +import yaml +from retry_lychee import read_report + +ROOT = Path(__file__).resolve().parents[2] +REPORT_NAME = "lychee.json" + + +def write_config(directory: Path) -> None: + # Use the checked-in hook, including its entry point and pinned dependency. + # PyYAML is already installed as a dependency of pre-commit. + config = yaml.safe_load((ROOT / ".pre-commit-config.yaml").read_text(encoding="utf-8")) + [hook] = [hook for repo in config["repos"] for hook in repo["hooks"] if hook["id"] == "lychee"] + report = directory / REPORT_NAME + hook.setdefault("args", []).extend(["--offline", "--format", "json", "--output", str(report)]) + config_path = directory / "pre-commit.yaml" + config_path.write_text(yaml.safe_dump(config), encoding="utf-8") + + +def verify(log: Path, exit_code: int) -> int: + report = log.with_name(REPORT_NAME) + try: + print(log.read_text(encoding="utf-8"), end="") + if exit_code != 0: + return exit_code + if not report.is_file(): + raise SystemExit(f"The configured Lychee hook produced no report ({report}); was it skipped?") + read_report(report, exit_code) + return 0 + finally: + # Consume each shell's report so a later skipped hook cannot reuse it. + report.unlink(missing_ok=True) + + +def main() -> int: + # CI uses separate phases so each shell invokes pre-commit directly. + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--prepare", type=Path, metavar="DIRECTORY") + mode.add_argument("--verify", nargs=2, metavar=("LOG", "EXIT_CODE")) + mode.add_argument("--cleanup", type=Path, metavar="DIRECTORY") + args = parser.parse_args() + if args.prepare is not None: + write_config(ROOT / args.prepare) + return 0 + if args.verify is not None: + log, exit_code = args.verify + return verify(ROOT / log, int(exit_code)) + directory = (ROOT / args.cleanup).resolve() + if directory.parent != ROOT or not directory.name.startswith("lychee offline smoke "): + raise SystemExit("Refusing to remove a directory outside the smoke check's temporary files") + shutil.rmtree(directory) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())