Skip to content
Merged
2 changes: 1 addition & 1 deletion .github/actions/check-doc-links/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: >-
Expand Down
52 changes: 52 additions & 0 deletions .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 8 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 2 additions & 16 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
34 changes: 34 additions & 0 deletions ci/tools/run_lychee.py
Original file line number Diff line number Diff line change
@@ -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())
73 changes: 73 additions & 0 deletions ci/tools/smoke_test_lychee_precommit.py
Original file line number Diff line number Diff line change
@@ -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())
Loading