|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 4 | +# SPDX-License-Identifier: LicenseRef-NVIDIA-SOFTWARE-LICENSE |
| 5 | + |
| 6 | +from __future__ import annotations |
| 7 | + |
| 8 | +import argparse |
| 9 | +import re |
| 10 | +import sys |
| 11 | +from pathlib import Path |
| 12 | +from urllib.parse import unquote |
| 13 | + |
| 14 | +CUDA_PYTHON_URL_RE = re.compile( |
| 15 | + r"https://github\.com/NVIDIA/cuda-python/" |
| 16 | + r"(?P<kind>blob|tree)/" |
| 17 | + r"(?P<ref>[^/\s<>`]+)/" |
| 18 | + r"(?P<path>[^\s<>`)]+)" |
| 19 | +) |
| 20 | +SOURCE_SUFFIXES = {".md", ".rst"} |
| 21 | + |
| 22 | + |
| 23 | +def _source_files(source_dir: Path): |
| 24 | + for path in sorted(source_dir.rglob("*")): |
| 25 | + if path.suffix in SOURCE_SUFFIXES and path.is_file(): |
| 26 | + yield path |
| 27 | + |
| 28 | + |
| 29 | +def _normalize_url_path(url_path: str) -> str: |
| 30 | + path = unquote(url_path) |
| 31 | + for separator in ("#", "?"): |
| 32 | + path = path.split(separator, 1)[0] |
| 33 | + return path.rstrip(".") |
| 34 | + |
| 35 | + |
| 36 | +def _is_within(path: str, root: str) -> bool: |
| 37 | + return path == root or path.startswith(f"{root}/") |
| 38 | + |
| 39 | + |
| 40 | +def _display_path(path: Path, repo_root: Path) -> str: |
| 41 | + try: |
| 42 | + return str(path.relative_to(repo_root)) |
| 43 | + except ValueError: |
| 44 | + return str(path) |
| 45 | + |
| 46 | + |
| 47 | +def check_links(args: argparse.Namespace) -> int: |
| 48 | + repo_root = args.repo_root.resolve() |
| 49 | + source_dir = args.source_dir.resolve() |
| 50 | + examples_root = args.examples_root.strip("/") |
| 51 | + placeholder_ref = f"|{args.placeholder}|" if args.placeholder else None |
| 52 | + checked = 0 |
| 53 | + failures: list[str] = [] |
| 54 | + |
| 55 | + for source_path in _source_files(source_dir): |
| 56 | + text = source_path.read_text(encoding="utf-8") |
| 57 | + for match in CUDA_PYTHON_URL_RE.finditer(text): |
| 58 | + url_path = _normalize_url_path(match.group("path")) |
| 59 | + if not _is_within(url_path, examples_root): |
| 60 | + continue |
| 61 | + |
| 62 | + checked += 1 |
| 63 | + ref = match.group("ref") |
| 64 | + kind = match.group("kind") |
| 65 | + location = _display_path(source_path, repo_root) |
| 66 | + target_path = Path(url_path) |
| 67 | + target = repo_root / target_path |
| 68 | + |
| 69 | + if target_path.is_absolute() or ".." in target_path.parts: |
| 70 | + failures.append(f"{location}: invalid repository path in {match.group(0)}") |
| 71 | + continue |
| 72 | + |
| 73 | + if placeholder_ref and ref == placeholder_ref: |
| 74 | + rendered_ref = args.expected_ref |
| 75 | + elif ref == args.expected_ref: |
| 76 | + rendered_ref = ref |
| 77 | + else: |
| 78 | + expected = placeholder_ref or args.expected_ref |
| 79 | + failures.append(f"{location}: {match.group(0)} uses ref {ref!r}; expected {expected!r}") |
| 80 | + rendered_ref = ref |
| 81 | + |
| 82 | + if kind == "blob" and not target.is_file(): |
| 83 | + failures.append( |
| 84 | + f"{location}: {match.group(0)} resolves to missing file " |
| 85 | + f"{target.relative_to(repo_root)} at ref {rendered_ref}" |
| 86 | + ) |
| 87 | + elif kind == "tree" and not target.is_dir(): |
| 88 | + failures.append( |
| 89 | + f"{location}: {match.group(0)} resolves to missing directory " |
| 90 | + f"{target.relative_to(repo_root)} at ref {rendered_ref}" |
| 91 | + ) |
| 92 | + |
| 93 | + if checked == 0 and not args.allow_empty: |
| 94 | + failures.append(f"No example links under {examples_root!r} found in {source_dir}") |
| 95 | + |
| 96 | + if failures: |
| 97 | + sys.stderr.write("Example link check failed:\n") |
| 98 | + for failure in failures: |
| 99 | + sys.stderr.write(f" - {failure}\n") |
| 100 | + return 1 |
| 101 | + |
| 102 | + sys.stdout.write(f"Checked {checked} example link(s) under {examples_root} against ref {args.expected_ref}\n") |
| 103 | + return 0 |
| 104 | + |
| 105 | + |
| 106 | +def parse_args(argv: list[str]) -> argparse.Namespace: |
| 107 | + repo_root = Path(__file__).resolve().parents[2] |
| 108 | + |
| 109 | + parser = argparse.ArgumentParser(description="Validate cuda-python example links without probing GitHub.") |
| 110 | + parser.add_argument("--source-dir", type=Path, required=True) |
| 111 | + parser.add_argument("--examples-root", required=True) |
| 112 | + parser.add_argument("--expected-ref", required=True) |
| 113 | + parser.add_argument("--placeholder") |
| 114 | + parser.add_argument("--repo-root", type=Path, default=repo_root) |
| 115 | + parser.add_argument("--allow-empty", action="store_true") |
| 116 | + return parser.parse_args(argv) |
| 117 | + |
| 118 | + |
| 119 | +def main(argv: list[str] | None = None) -> int: |
| 120 | + args = parse_args(sys.argv[1:] if argv is None else argv) |
| 121 | + return check_links(args) |
| 122 | + |
| 123 | + |
| 124 | +if __name__ == "__main__": |
| 125 | + raise SystemExit(main()) |
0 commit comments