diff --git a/.gitignore b/.gitignore index 6938baaaff4..24d3c6cb774 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ __pycache__/ # CUDA Python specific .cache/ +.cython-stdlib +.cython-bindings .lycheecache .pytest_cache/ .benchmarks/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ec6643a8aa4..b135c009783 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -73,7 +73,7 @@ repos: files: '^cuda_core/tests/.*\.py$' - id: check-build-hooks-sync - name: Check shared toolchain block is in sync across build_hooks.py files + name: Check shared build_hooks.py blocks are in sync entry: python ./toolshed/check_build_hooks_sync.py language: python files: '^(cuda_bindings|cuda_core)/build_hooks\.py$' diff --git a/cuda_bindings/build_hooks.py b/cuda_bindings/build_hooks.py index f189fea69cf..9800630447f 100644 --- a/cuda_bindings/build_hooks.py +++ b/cuda_bindings/build_hooks.py @@ -11,11 +11,13 @@ import contextlib import functools import glob +import hashlib import os import shutil import sys import sysconfig import tempfile +import uuid from pathlib import Path from warnings import warn @@ -31,7 +33,8 @@ get_requires_for_build_editable = _build_meta.get_requires_for_build_editable # Note: There is no support guarantee for environment variables like -# CUDA_PYTHON_TOOLCHAIN, etc. They may be removed or changed in the future. +# CUDA_PYTHON_TOOLCHAIN, CUDA_PYTHON_CYTHON_CACHE_DIR, etc. They may be +# removed or changed in the future. # Populated by _build_cuda_bindings(); consumed by setup.py. _extensions = None @@ -86,13 +89,14 @@ def _get_cuda_path() -> str: # ----------------------------------------------------------------------- # Toolchain selection # -# The helpers below (down to the end-of-shared-block marker) are duplicated -# verbatim in cuda_core/build_hooks.py. Keep them in sync. Only the -# per-package _resolve_toolchain() flag assembly that follows is package- -# specific (it differs because the two packages use different C++ standards -# and opt levels). - -# --- begin shared toolchain helpers (keep in sync) --- +# There is one shared helper block below, duplicated verbatim in +# cuda_core/build_hooks.py (keep it in sync; enforced by +# toolshed/check_build_hooks_sync.py). It contains the toolchain helpers and +# the Cython cache helpers. Only the per-package _resolve_toolchain() flag +# assembly that follows the shared block is package-specific (it differs +# because the two packages use different C++ standards and opt levels). + +# --- begin shared build helpers (keep in sync) --- _TOOLCHAINS_LINUX = ("gnu", "llvm") _TOOLCHAINS_WINDOWS = ("msvc",) _TOOLCHAIN_COMPILERS = { @@ -157,7 +161,132 @@ def _check_toolchain_available(name): ) -# --- end shared toolchain helpers --- +# === Cython generated-source cache (opt-in via CUDA_PYTHON_CYTHON_CACHE_DIR) === +# Workaround for Cython issue #7532: Cython's native cache fingerprint omits +# `compiler_directives`, so builds with different directives (e.g. linetrace +# for coverage) could reuse stale generated C/C++ output. This helper +# namespaces the Cython cache by package and a digest of output-affecting +# build configuration so distinct configurations get distinct caches. +# +# Removal: once cython/cython#7532 is resolved in a released Cython version +# and cuda-python's minimum Cython version includes the fix, this helper +# and its workaround-specific tests can be deleted; cythonize() can then be +# called with `cache=` (or `cache=True`) without per-config namespacing. +# See https://github.com/cython/cython/issues/7532 +def _cython_cache_path( + package, + *, + compiler_directives=None, + compile_time_env=None, + language_level=None, + cplus=None, + debug=False, + cuda_major=None, +): + """Return a per-configuration Cython cache directory, or None to disable caching. + + Returns None when CUDA_PYTHON_CYTHON_CACHE_DIR is unset, so cythonize() + is called without ``cache=`` and existing workflows are unchanged. + """ + cache_root = os.environ.get("CUDA_PYTHON_CYTHON_CACHE_DIR") + if not cache_root: + return None + if sys.platform == "win32": + warn( + "CUDA_PYTHON_CYTHON_CACHE_DIR is set but Cython caching via symlinks " + "is not supported on Windows; caching will be disabled.", + stacklevel=2, + ) + return None + + h = hashlib.sha256() + h.update(package.encode("utf-8")) + # The Python version running cythonize affects generated C code + # (e.g. CYTHON_COMPRESS_STRINGS: zstd on 3.14, zlib on 3.12/3.13). + h.update(f"python={sys.version_info.major}.{sys.version_info.minor}".encode()) + + def _update(name, value): + h.update(name.encode("utf-8")) + h.update(repr(value).encode("utf-8")) + + # compiler_directives are not in Cython's native fingerprint (#7532). + if compiler_directives: + for key in sorted(compiler_directives): + _update(f"directive:{key}", compiler_directives[key]) + # compile_time_env, language_level, and cplus are already in Cython's + # fingerprint, but we include them so the namespace stays correct even + # if Cython's fingerprint logic changes. + if compile_time_env: + for key in sorted(compile_time_env): + _update(f"compile_time_env:{key}", compile_time_env[key]) + if language_level is not None: + _update("language_level", language_level) + if cplus is not None: + _update("cplus", cplus) + # debug toggles gdb_debug in cythonize(), which affects generated code. + _update("debug", debug) + if cuda_major is not None: + _update("cuda_major", cuda_major) + + return os.path.join(cache_root, f"{package}-{h.hexdigest()[:16]}") + + +@contextlib.contextmanager +def _stable_cython_alias(target: Path, alias: Path): + """Atomically create a stable directory symlink alias for a Cython include tree. + + Cython's cache fingerprint includes the absolute path of each resolved + .pxd dependency (via ``file_hash()``). PEP 517 build environments install + dependencies under randomized temporary prefixes, making those paths + unstable across runs. This context manager creates a fixed, worktree- + relative symlink so Cython sees a stable lexical path. + + The symlink is created in the *package directory* (the directory containing + this build_hooks.py), not in the cwd, to keep aliases package-local and + avoid cross-package races. + + alias must not already exist as a real file or directory; if it is a + symlink (including a dangling one) it is atomically replaced. + + On exit the alias is removed only if it still points at ``target`` (a + racing replacement will not be deleted). + + POSIX only: directory symlinks require no elevated privileges on Linux. + """ + # Resolve the *parent* directory (must exist), then append the name. + # We deliberately do not follow a symlink that may already sit at alias. + if not alias.is_absolute(): + alias = Path(__file__).parent / alias + alias = alias.parent.resolve() / alias.name + target = target.resolve() + + if alias.exists() and not alias.is_symlink(): + raise RuntimeError( + f"Cannot create Cython include alias at {alias}: a real file or directory already exists there." + ) + + tmp_alias = alias.with_name(f".{alias.name}.{uuid.uuid4().hex[:8]}.tmp") + try: + os.symlink(target, tmp_alias, target_is_directory=True) + try: + os.replace(tmp_alias, alias) + except BaseException: + tmp_alias.unlink(missing_ok=True) + raise + rel = os.path.relpath(alias, start=Path.cwd()) + yield rel + finally: + tmp_alias.unlink(missing_ok=True) + # Only remove the alias we created; leave it alone if something else + # has already replaced it (readlink will differ). + try: + if alias.is_symlink() and Path(os.readlink(alias)).resolve() == target: + alias.unlink() + except OSError: + pass + + +# --- end shared build helpers --- def _resolve_toolchain(debug=False, compile_for_coverage=False): @@ -313,6 +442,7 @@ def _build_cuda_bindings(debug=False): All CUDA-dependent logic (cythonization) is deferred to this function so that metadata queries do not require a CUDA toolkit installation. """ + import Cython from Cython.Build import cythonize from Cython.Compiler import Options as _CythonOptions @@ -403,14 +533,37 @@ def get_static_libraries(f): # build, so a stale .so from a previous toolchain is never packaged. _check_build_toolchain(toolchain) - _extensions = cythonize( - extensions, - nthreads=nthreads, - build_dir="." if compile_for_coverage else "build/cython", + cache_path = _cython_cache_path( + "cuda-bindings", compiler_directives=cython_directives, - **extra_cythonize_kwargs, + language_level=3, + cplus=True, + debug=debug, ) + def _do_cythonize(cython_include_path): + global _extensions + _extensions = cythonize( + extensions, + nthreads=nthreads, + build_dir="." if compile_for_coverage else "build/cython", + compiler_directives=cython_directives, + include_path=cython_include_path, + cache=cache_path, + **extra_cythonize_kwargs, + ) + + if cache_path is not None: + # Alias Cython's bundled .pxd declarations under a stable worktree-relative + # path so Cython's cache fingerprint sees the same path on every run + # despite PEP 517 build environments landing under randomized temp prefixes. + stdlib_target = Path(Cython.__file__).parent / "Includes" + stdlib_alias = Path(__file__).parent / ".cython-stdlib" + with _stable_cython_alias(stdlib_target, stdlib_alias) as rel_stdlib: + _do_cythonize([".", rel_stdlib]) + else: + _do_cythonize(["."]) + # ----------------------------------------------------------------------- # PEP 517 build hooks diff --git a/cuda_bindings/tests/test_build_hooks.py b/cuda_bindings/tests/test_build_hooks.py index 2a1d5656afd..d4e1e1c8d29 100644 --- a/cuda_bindings/tests/test_build_hooks.py +++ b/cuda_bindings/tests/test_build_hooks.py @@ -41,7 +41,7 @@ def _load_build_hooks(): @pytest.fixture(autouse=True) def _isolate_toolchain_env(): - names = ("CUDA_PYTHON_TOOLCHAIN", "CC", "CXX", "LDSHARED") + names = ("CUDA_PYTHON_TOOLCHAIN", "CC", "CXX", "LDSHARED", "CUDA_PYTHON_CYTHON_CACHE_DIR") original = {name: os.environ[name] for name in names if name in os.environ} for name in names: os.environ.pop(name, None) @@ -221,3 +221,77 @@ def test_record_writes_stamp(self, stamp): build_hooks.record_build_toolchain() expected = "msvc" if sys.platform == "win32" else "gnu" assert stamp.read_text().strip() == expected + + +# --------------------------------------------------------------------------- +# Cython cache path helper (workaround for cython/cython#7532) +# +# These tests cover the configuration-digest workaround in build_hooks.py. +# They can be deleted together with the `_cython_cache_path` helper once +# cython/cython#7532 is resolved in a released Cython version and +# cuda-python's minimum Cython version includes the fix. +# See https://github.com/cython/cython/issues/7532 + + +_test_helpers_root = Path(__file__).parents[2] / "cuda_python_test_helpers" +if _test_helpers_root.is_dir() and str(_test_helpers_root) not in sys.path: + sys.path.insert(0, str(_test_helpers_root)) + +from cuda_python_test_helpers.cython_cache import POSIX_ONLY_CACHE, CythonAliasMixin, CythonCachePathMixin + + +class TestCythonCachePath(CythonCachePathMixin): + """`_cython_cache_path` tests specific to cuda.bindings. + + Inherits the common tests from CythonCachePathMixin; the mixin + covers the package-agnostic behavior. cuda.bindings does not pass + ``compile_time_env`` or ``cuda_major``, so the debug-only partition is + tested here. + """ + + build_hooks = build_hooks + package = "cuda-bindings" + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_changed_debug_changes_namespace(self, monkeypatch, tmp_path): + """``debug`` partitions the namespace.""" + self._set_env(monkeypatch, str(tmp_path)) + p1 = build_hooks._cython_cache_path("cuda-bindings", debug=False) + p2 = build_hooks._cython_cache_path("cuda-bindings", debug=True) + assert p1 != p2 + + +class TestCythonCacheSmokeTest: + """Real Cython cache miss/hit through `_cython_cache_path`. + + The actual cythonize exercise lives in + ``cuda_python_test_helpers.cython_cache`` so it is shared with + ``cuda_core/tests/test_build_hooks.py``. + """ + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_cache_miss_then_hit(self, monkeypatch, tmp_path, capsys): + from cuda_python_test_helpers.cython_cache import ( + cython_cache_miss_then_hit, + ) + + cache_root = tmp_path / "cython-cache" + cache_root.mkdir() + monkeypatch.setenv("CUDA_PYTHON_CYTHON_CACHE_DIR", str(cache_root)) + + cache_path = build_hooks._cython_cache_path( + "cuda-bindings", + compiler_directives={"language_level": 3}, + language_level=3, + cplus=True, + ) + assert cache_path is not None + cython_cache_miss_then_hit(cache_path, tmp_path, capsys) + + +class TestCythonAlias(CythonAliasMixin): + """`_stable_cython_alias` tests for cuda.bindings.""" + + build_hooks = build_hooks diff --git a/cuda_core/build_hooks.py b/cuda_core/build_hooks.py index 8eeef6ca4c0..715afdfce4c 100644 --- a/cuda_core/build_hooks.py +++ b/cuda_core/build_hooks.py @@ -7,17 +7,22 @@ # - https://setuptools.pypa.io/en/latest/build_meta.html#dynamic-build-dependencies-and-other-build-meta-tweaks # Specifically, there are 5 APIs required to create a proper build backend, see below. +import contextlib import functools import glob +import hashlib import os import re import shutil import sys import sysconfig import tempfile +import uuid import zipfile from pathlib import Path +from warnings import warn +import Cython as _Cython from Cython.Build import cythonize from Cython.Compiler import Options as _CythonOptions from setuptools import Extension @@ -29,7 +34,8 @@ get_requires_for_build_sdist = _build_meta.get_requires_for_build_sdist # Note: There is no support guarantee for environment variables like CUDA_PYTHON_COVERAGE, -# CUDA_PYTHON_TOOLCHAIN, etc. They may be removed or changed in the future. +# CUDA_PYTHON_TOOLCHAIN, CUDA_PYTHON_CYTHON_CACHE_DIR, etc. They may be removed +# or changed in the future. COMPILE_FOR_COVERAGE = bool(int(os.environ.get("CUDA_PYTHON_COVERAGE", "0"))) @@ -82,13 +88,14 @@ def _get_cuda_path() -> str: # ----------------------------------------------------------------------- # Toolchain selection # -# The helpers below (down to the end-of-shared-block marker) are duplicated -# verbatim in cuda_bindings/build_hooks.py. Keep them in sync. Only the -# per-package _resolve_toolchain() flag assembly that follows is package- -# specific (it differs because the two packages use different C++ standards -# and opt levels). - -# --- begin shared toolchain helpers (keep in sync) --- +# There is one shared helper block below, duplicated verbatim in +# cuda_bindings/build_hooks.py (keep it in sync; enforced by +# toolshed/check_build_hooks_sync.py). It contains the toolchain helpers and +# the Cython cache helpers. Only the per-package _resolve_toolchain() flag +# assembly that follows the shared block is package-specific (it differs +# because the two packages use different C++ standards and opt levels). + +# --- begin shared build helpers (keep in sync) --- _TOOLCHAINS_LINUX = ("gnu", "llvm") _TOOLCHAINS_WINDOWS = ("msvc",) _TOOLCHAIN_COMPILERS = { @@ -153,7 +160,132 @@ def _check_toolchain_available(name): ) -# --- end shared toolchain helpers --- +# === Cython generated-source cache (opt-in via CUDA_PYTHON_CYTHON_CACHE_DIR) === +# Workaround for Cython issue #7532: Cython's native cache fingerprint omits +# `compiler_directives`, so builds with different directives (e.g. linetrace +# for coverage) could reuse stale generated C/C++ output. This helper +# namespaces the Cython cache by package and a digest of output-affecting +# build configuration so distinct configurations get distinct caches. +# +# Removal: once cython/cython#7532 is resolved in a released Cython version +# and cuda-python's minimum Cython version includes the fix, this helper +# and its workaround-specific tests can be deleted; cythonize() can then be +# called with `cache=` (or `cache=True`) without per-config namespacing. +# See https://github.com/cython/cython/issues/7532 +def _cython_cache_path( + package, + *, + compiler_directives=None, + compile_time_env=None, + language_level=None, + cplus=None, + debug=False, + cuda_major=None, +): + """Return a per-configuration Cython cache directory, or None to disable caching. + + Returns None when CUDA_PYTHON_CYTHON_CACHE_DIR is unset, so cythonize() + is called without ``cache=`` and existing workflows are unchanged. + """ + cache_root = os.environ.get("CUDA_PYTHON_CYTHON_CACHE_DIR") + if not cache_root: + return None + if sys.platform == "win32": + warn( + "CUDA_PYTHON_CYTHON_CACHE_DIR is set but Cython caching via symlinks " + "is not supported on Windows; caching will be disabled.", + stacklevel=2, + ) + return None + + h = hashlib.sha256() + h.update(package.encode("utf-8")) + # The Python version running cythonize affects generated C code + # (e.g. CYTHON_COMPRESS_STRINGS: zstd on 3.14, zlib on 3.12/3.13). + h.update(f"python={sys.version_info.major}.{sys.version_info.minor}".encode()) + + def _update(name, value): + h.update(name.encode("utf-8")) + h.update(repr(value).encode("utf-8")) + + # compiler_directives are not in Cython's native fingerprint (#7532). + if compiler_directives: + for key in sorted(compiler_directives): + _update(f"directive:{key}", compiler_directives[key]) + # compile_time_env, language_level, and cplus are already in Cython's + # fingerprint, but we include them so the namespace stays correct even + # if Cython's fingerprint logic changes. + if compile_time_env: + for key in sorted(compile_time_env): + _update(f"compile_time_env:{key}", compile_time_env[key]) + if language_level is not None: + _update("language_level", language_level) + if cplus is not None: + _update("cplus", cplus) + # debug toggles gdb_debug in cythonize(), which affects generated code. + _update("debug", debug) + if cuda_major is not None: + _update("cuda_major", cuda_major) + + return os.path.join(cache_root, f"{package}-{h.hexdigest()[:16]}") + + +@contextlib.contextmanager +def _stable_cython_alias(target: Path, alias: Path): + """Atomically create a stable directory symlink alias for a Cython include tree. + + Cython's cache fingerprint includes the absolute path of each resolved + .pxd dependency (via ``file_hash()``). PEP 517 build environments install + dependencies under randomized temporary prefixes, making those paths + unstable across runs. This context manager creates a fixed, worktree- + relative symlink so Cython sees a stable lexical path. + + The symlink is created in the *package directory* (the directory containing + this build_hooks.py), not in the cwd, to keep aliases package-local and + avoid cross-package races. + + alias must not already exist as a real file or directory; if it is a + symlink (including a dangling one) it is atomically replaced. + + On exit the alias is removed only if it still points at ``target`` (a + racing replacement will not be deleted). + + POSIX only: directory symlinks require no elevated privileges on Linux. + """ + # Resolve the *parent* directory (must exist), then append the name. + # We deliberately do not follow a symlink that may already sit at alias. + if not alias.is_absolute(): + alias = Path(__file__).parent / alias + alias = alias.parent.resolve() / alias.name + target = target.resolve() + + if alias.exists() and not alias.is_symlink(): + raise RuntimeError( + f"Cannot create Cython include alias at {alias}: a real file or directory already exists there." + ) + + tmp_alias = alias.with_name(f".{alias.name}.{uuid.uuid4().hex[:8]}.tmp") + try: + os.symlink(target, tmp_alias, target_is_directory=True) + try: + os.replace(tmp_alias, alias) + except BaseException: + tmp_alias.unlink(missing_ok=True) + raise + rel = os.path.relpath(alias, start=Path.cwd()) + yield rel + finally: + tmp_alias.unlink(missing_ok=True) + # Only remove the alias we created; leave it alone if something else + # has already replaced it (readlink will differ). + try: + if alias.is_symlink() and Path(os.readlink(alias)).resolve() == target: + alias.unlink() + except OSError: + pass + + +# --- end shared build helpers --- def _resolve_toolchain(debug=False, compile_for_coverage=False): @@ -378,6 +510,7 @@ def _build_cuda_core(debug=False): # This is needed for editable installs where meta path finders don't work for Cython # We need to add the directory containing the 'cuda' package so Cython can resolve # "from cuda.bindings cimport cydriver" + cuda_package_dir = None try: import cuda.bindings @@ -449,23 +582,54 @@ def module_names(): _CythonOptions.warning_errors = True if COMPILE_FOR_COVERAGE: compiler_directives["linetrace"] = True - _extensions = cythonize( - ext_modules, - verbose=True, - language_level=3, - # CUDA_PYTHON_COVERAGE deliberately generates in-tree so the sources can - # be packaged; every other build gets its own per-configuration cache, - # anchored alongside the stamp so both resolve the same from any cwd. - # Cython also copies each extension's extern headers and `depends` under - # this directory and compiles against the copies. Copies are refreshed by - # mtime and never deleted, so remove build/ after renaming or deleting a - # header under _cpp/. - build_dir="." if COMPILE_FOR_COVERAGE else str(_BUILD_DIR / "cython" / config_key), - nthreads=nthreads, + cache_path = _cython_cache_path( + "cuda-core", compiler_directives=compiler_directives, compile_time_env=compile_time_env, - **extra_cythonize_kwargs, + language_level=3, + cplus=True, + debug=debug, + cuda_major=cuda_major, ) + + def _do_cythonize(cython_include_path): + global _extensions + _extensions = cythonize( + ext_modules, + verbose=True, + language_level=3, + # CUDA_PYTHON_COVERAGE deliberately generates in-tree so the sources can + # be packaged; every other build gets its own per-configuration cache, + # anchored alongside the stamp so both resolve the same from any cwd. + # Cython also copies each extension's extern headers and `depends` under + # this directory and compiles against the copies. Copies are refreshed by + # mtime and never deleted, so remove build/ after renaming or deleting a + # header under _cpp/. + build_dir="." if COMPILE_FOR_COVERAGE else str(_BUILD_DIR / "cython" / config_key), + nthreads=nthreads, + compiler_directives=compiler_directives, + compile_time_env=compile_time_env, + include_path=cython_include_path, + cache=cache_path, + **extra_cythonize_kwargs, + ) + + if cache_path is not None: + # Alias both Cython's bundled .pxd declarations and cuda.bindings declarations + # under stable worktree-relative paths so Cython's cache fingerprint stays + # stable across PEP 517 builds (which install deps under randomized prefixes). + stdlib_target = Path(_Cython.__file__).parent / "Includes" + stdlib_alias = Path(__file__).parent / ".cython-stdlib" + bindings_alias = Path(__file__).parent / ".cython-bindings" + + with _stable_cython_alias(stdlib_target, stdlib_alias) as rel_stdlib: + if cuda_package_dir is not None: + with _stable_cython_alias(cuda_package_dir, bindings_alias) as rel_bindings: + _do_cythonize([".", rel_bindings, rel_stdlib]) + else: + _do_cythonize([".", rel_stdlib]) + else: + _do_cythonize(["."]) # Cython returns generated sources under the absolute build_dir above. # setuptools mirrors absolute source paths into build/temp, which can push # MSVC linker output paths past MAX_PATH in deeper Windows checkouts. diff --git a/cuda_core/tests/test_build_hooks.py b/cuda_core/tests/test_build_hooks.py index 88f938cf041..5017c0d636e 100644 --- a/cuda_core/tests/test_build_hooks.py +++ b/cuda_core/tests/test_build_hooks.py @@ -22,6 +22,7 @@ import sys import tempfile import threading +import types from distutils.ccompiler import CCompiler from pathlib import Path from unittest import mock @@ -57,7 +58,7 @@ def _load_build_hooks(): @pytest.fixture(autouse=True) def _isolate_toolchain_env(): - names = ("CUDA_PYTHON_TOOLCHAIN", "CC", "CXX", "LDSHARED") + names = ("CUDA_PYTHON_TOOLCHAIN", "CC", "CXX", "LDSHARED", "CUDA_PYTHON_CYTHON_CACHE_DIR") original = {name: os.environ[name] for name in names if name in os.environ} for name in names: os.environ.pop(name, None) @@ -371,11 +372,11 @@ def fail(wheel_path): build_hooks.build_editable("dist", {"debug": True}, "metadata") -def _capture_cythonize_build_dir(monkeypatch, cuda_major): - """Run the cythonize setup for one CUDA major and report its build_dir. +def _capture_cythonize_kwargs(monkeypatch, cuda_major): + """Run the cythonize setup for one CUDA major and report its keyword arguments. cythonize() is replaced, so nothing is generated or compiled: this only - observes which directory the build was about to write into. + observes how the build was configured. """ captured = {} @@ -396,7 +397,11 @@ def fake_cythonize(ext_modules, **kwargs): monkeypatch.setattr(sys, "path", list(sys.path)) build_hooks._build_cuda_core() - return Path(captured["build_dir"]) + return captured + + +def _capture_cythonize_build_dir(monkeypatch, cuda_major): + return Path(_capture_cythonize_kwargs(monkeypatch, cuda_major)["build_dir"]) class TestGeneratedSourceDirIsKeyed: @@ -725,3 +730,135 @@ def fake_which(name): def test_llvm_present_passes(self, monkeypatch): monkeypatch.setattr(build_hooks.shutil, "which", lambda name: "/bin/" + name) build_hooks._check_toolchain_available("llvm") + + +# --------------------------------------------------------------------------- +# Cython cache path helper (workaround for cython/cython#7532) +# +# These tests cover the configuration-digest workaround in build_hooks.py. +# They can be deleted together with the `_cython_cache_path` helper once +# cython/cython#7532 is resolved in a released Cython version and +# cuda-python's minimum Cython version includes the fix. +# See https://github.com/cython/cython/issues/7532 + + +_test_helpers_root = Path(__file__).parents[2] / "cuda_python_test_helpers" +if _test_helpers_root.is_dir() and str(_test_helpers_root) not in sys.path: + sys.path.insert(0, str(_test_helpers_root)) + +from cuda_python_test_helpers.cython_cache import POSIX_ONLY_CACHE, CythonAliasMixin, CythonCachePathMixin + + +class TestCudaCoreCythonIncludePath: + """How `_build_cuda_core` assembles `include_path` for cythonize(). + + `_stable_cython_alias` itself (creation, cleanup, cross-env cache hits) is + covered generically by `TestCythonAlias` below. These tests cover only + `_build_cuda_core`'s own wiring: which aliases it adds, in what order, and + the cache-disabled / bindings-unavailable fallbacks. + """ + + @staticmethod + def _fake_bindings(monkeypatch): + bindings = types.ModuleType("cuda.bindings") + bindings.__file__ = "/random-build-env/cuda/bindings/__init__.py" + bindings.__version__ = "13.0" + monkeypatch.setitem(sys.modules, "cuda.bindings", bindings) + monkeypatch.setattr(sys.modules["cuda"], "bindings", bindings, raising=False) + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_cache_enabled_includes_bindings_and_stdlib_aliases(self, monkeypatch, tmp_path): + self._fake_bindings(monkeypatch) + monkeypatch.setenv("CUDA_PYTHON_CYTHON_CACHE_DIR", str(tmp_path)) + + captured = _capture_cythonize_kwargs(monkeypatch, "13") + + assert captured["include_path"] == [".", ".cython-bindings", ".cython-stdlib"] + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_cache_enabled_without_bindings_omits_bindings_alias(self, monkeypatch, tmp_path): + monkeypatch.setitem(sys.modules, "cuda.bindings", None) # forces ImportError + monkeypatch.setenv("CUDA_PYTHON_CYTHON_CACHE_DIR", str(tmp_path)) + + captured = _capture_cythonize_kwargs(monkeypatch, "13") + + assert captured["include_path"] == [".", ".cython-stdlib"] + + @pytest.mark.agent_authored(model="grok-4.6") + def test_cache_disabled_skips_aliasing(self, monkeypatch): + monkeypatch.setitem(sys.modules, "cuda.bindings", None) # forces ImportError + monkeypatch.delenv("CUDA_PYTHON_CYTHON_CACHE_DIR", raising=False) + + captured = _capture_cythonize_kwargs(monkeypatch, "13") + + assert captured["include_path"] == ["."] + + +class TestCythonCachePath(CythonCachePathMixin): + """`_cython_cache_path` tests specific to cuda.core. + + Inherits the common tests from CythonCachePathMixin; the mixin + covers the package-agnostic behavior. cuda.core passes ``compile_time_env`` + and ``cuda_major``, so those partitions are tested here. + """ + + build_hooks = build_hooks + package = "cuda-core" + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_changed_compile_time_env_changes_namespace(self, monkeypatch, tmp_path): + """Different ``compile_time_env`` values map to different namespaces.""" + self._set_env(monkeypatch, str(tmp_path)) + p1 = build_hooks._cython_cache_path("cuda-core", compile_time_env={"CUDA_CORE_BUILD_MAJOR": 12}) + p2 = build_hooks._cython_cache_path("cuda-core", compile_time_env={"CUDA_CORE_BUILD_MAJOR": 13}) + assert p1 != p2 + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_changed_debug_or_cuda_major_changes_namespace(self, monkeypatch, tmp_path): + """``debug`` and ``cuda_major`` each partition the namespace.""" + self._set_env(monkeypatch, str(tmp_path)) + p1 = build_hooks._cython_cache_path("cuda-core", debug=False, cuda_major="12") + p2 = build_hooks._cython_cache_path("cuda-core", debug=True, cuda_major="12") + p3 = build_hooks._cython_cache_path("cuda-core", debug=False, cuda_major="13") + assert p1 != p2 + assert p1 != p3 + assert p2 != p3 + + +class TestCythonCacheSmokeTest: + """Real Cython cache miss/hit through `_cython_cache_path`. + + The actual cythonize exercise lives in + ``cuda_python_test_helpers.cython_cache`` so it is shared with + ``cuda_bindings/tests/test_build_hooks.py``. + """ + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_cache_miss_then_hit(self, monkeypatch, tmp_path, capsys): + from cuda_python_test_helpers.cython_cache import ( + cython_cache_miss_then_hit, + ) + + cache_root = tmp_path / "cython-cache" + cache_root.mkdir() + monkeypatch.setenv("CUDA_PYTHON_CYTHON_CACHE_DIR", str(cache_root)) + + cache_path = build_hooks._cython_cache_path( + "cuda-core", + compiler_directives={"language_level": 3}, + language_level=3, + cplus=False, + ) + assert cache_path is not None + cython_cache_miss_then_hit(cache_path, tmp_path, capsys) + + +class TestCythonAlias(CythonAliasMixin): + """`_stable_cython_alias` tests for cuda.core.""" + + build_hooks = build_hooks diff --git a/cuda_python_test_helpers/cuda_python_test_helpers/cython_cache.py b/cuda_python_test_helpers/cuda_python_test_helpers/cython_cache.py new file mode 100644 index 00000000000..e22687055d7 --- /dev/null +++ b/cuda_python_test_helpers/cuda_python_test_helpers/cython_cache.py @@ -0,0 +1,393 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared tests for the Cython cache helpers in build_hooks.py. + +Provides: + +- ``cython_cache_miss_then_hit``: a real-cythonize miss/hit smoke test. +- ``CythonCachePathMixin``: the common unit tests for ``_cython_cache_path``. +- ``CythonAliasMixin``: the common unit tests for ``_stable_cython_alias``. +- ``POSIX_ONLY_CACHE``: shared skip marker for tests that need caching/aliasing + actually enabled (both are unconditionally disabled on win32). +- ``WINDOWS_ONLY_CACHE``: shared skip marker for the complementary Windows + disable-path tests (warn + return None when the cache dir is set). + +These are used by ``cuda_bindings/tests/test_build_hooks.py`` +and ``cuda_core/tests/test_build_hooks.py``. Drift between the two vendored +helper copies is enforced by ``toolshed/check_build_hooks_sync.py``, not by a +runtime test. + +Cython is imported inside the functions that need it so this module does not +force a Cython dependency on the ``cuda-python-test-helpers`` package. +""" + +import os +import shutil +import sys +import textwrap +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +import pytest + +from cuda_python_test_helpers.subprocess_runner import run_python_snippet + +# `_cython_cache_path()` unconditionally returns None on win32 (symlink +# aliasing needs elevated privileges/Developer Mode there), so any test that +# sets CUDA_PYTHON_CYTHON_CACHE_DIR and expects a real cache path/alias, or +# that calls `_stable_cython_alias()` directly, would fail deterministically +# on a Windows runner. Share one marker/reason so it's applied consistently. +POSIX_ONLY_CACHE = pytest.mark.skipif( + sys.platform == "win32", + reason="Cython caching and symlink aliasing are POSIX-only (disabled on win32)", +) +WINDOWS_ONLY_CACHE = pytest.mark.skipif( + sys.platform != "win32", + reason="Windows-only: Cython caching is intentionally disabled on win32", +) + + +def cython_cache_miss_then_hit(cache_path, tmp_path, capsys): + """Run a real Cython cache miss/hit through ``cache_path``. + + Uses ``from libc.stdint cimport uint64_t`` to exercise a stdlib + dependency resolved through Cython's include path. The cold run + generates ``mod.c`` and populates the cache; the warm run (same alias, + same contents) restores ``mod.c`` from the cache without regenerating. + + ``cache_path`` is a directory path produced by the build hook's + ``_cython_cache_path`` helper. ``tmp_path`` and ``capsys`` are + standard pytest fixtures. + """ + from Cython.Build import cythonize + from setuptools import Extension + + src_dir = tmp_path / "src" + src_dir.mkdir() + (src_dir / "mod.pyx").write_text("from libc.stdint cimport uint64_t\ndef get_value() -> uint64_t:\n return 42\n") + + ext = Extension("mod", sources=[str(src_dir / "mod.pyx")], language="c") + + # Cold run: no .c exists, cythonize generates mod.c and populates the cache. + capsys.readouterr() + cythonize([ext], cache=cache_path, quiet=False) + out = capsys.readouterr().out + assert "Cythonizing" in out, "cold run should cythonize" + assert "Found compiled" not in out + gen_c = src_dir / "mod.c" + assert gen_c.exists(), "cold run did not generate mod.c" + cold_bytes = gen_c.read_bytes() + + # Warm run: delete the .c so cythonize reconsiders; cache hit restores it. + gen_c.unlink() + capsys.readouterr() + cythonize([ext], cache=cache_path, quiet=False) + out = capsys.readouterr().out + assert "Found compiled" in out, "warm run should hit cache" + assert gen_c.exists(), "warm run did not restore mod.c from cache" + assert gen_c.read_bytes() == cold_bytes, "warm run changed mod.c" + + +class CythonAliasMixin: + """Common unit tests for ``_stable_cython_alias``. + + Subclasses set ``build_hooks`` (the loaded build_hooks module). Tests pass + an *absolute* alias path inside the per-test ``tmp_path`` sandbox, so the + helper's relative-alias anchoring (package directory) is not exercised + here; the production build hooks cover that path. + """ + + build_hooks = None + + def _alias(self, tmp_path, name=".cython-stdlib"): + # An absolute alias lands in the per-test sandbox (tmp_path), not the + # package directory, so a crash can never litter the source tree. + return tmp_path / name + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_alias_created_and_removed_on_success(self, tmp_path): + target = tmp_path / "real" + target.mkdir() + alias = self._alias(tmp_path) + with self.build_hooks._stable_cython_alias(target, alias): + assert alias.is_symlink() + assert alias.resolve() == target.resolve() + assert not alias.exists() + assert not alias.is_symlink() + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_alias_removed_on_exception(self, tmp_path): + target = tmp_path / "real" + target.mkdir() + alias = self._alias(tmp_path) + with pytest.raises(RuntimeError, match="boom"), self.build_hooks._stable_cython_alias(target, alias): + raise RuntimeError("boom") + assert not alias.is_symlink() + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_stale_dangling_symlink_atomically_replaced(self, tmp_path): + target = tmp_path / "real" + target.mkdir() + old_target = tmp_path / "gone" # does not exist => dangling + alias = self._alias(tmp_path) + alias.symlink_to(old_target) + assert not alias.exists() + assert alias.is_symlink() + with self.build_hooks._stable_cython_alias(target, alias) as rel: + assert alias.resolve() == target.resolve() + assert rel # non-empty relative path + assert not alias.is_symlink() + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_real_directory_raises_without_clobbering(self, tmp_path): + target = tmp_path / "real" + target.mkdir() + alias = self._alias(tmp_path) + alias.mkdir(exist_ok=True) # real directory, not a symlink + with ( + pytest.raises(RuntimeError, match="already exists"), + self.build_hooks._stable_cython_alias(target, alias), + ): + pass + assert alias.is_dir() + assert not alias.is_symlink() + + @pytest.mark.agent_authored(model="grok-4.6") + def test_no_filesystem_changes_when_cache_disabled(self, monkeypatch, tmp_path): + monkeypatch.delenv("CUDA_PYTHON_CYTHON_CACHE_DIR", raising=False) + # When cache is disabled _cython_cache_path returns None, so the + # build hook skips alias creation entirely. + cache = self.build_hooks._cython_cache_path("cuda-bindings") + assert cache is None + alias = self._alias(tmp_path) + assert not alias.exists(), f"unexpected alias at {alias}" + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_yielded_value_is_relative_path(self, tmp_path, monkeypatch): + target = tmp_path / "real" + target.mkdir() + alias = self._alias(tmp_path) + monkeypatch.chdir(tmp_path) + with self.build_hooks._stable_cython_alias(target, alias) as rel: + assert not os.path.isabs(rel), f"expected relative path, got {rel!r}" + assert Path(rel).resolve() == target.resolve() + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_tmp_sibling_cleaned_on_replace_failure(self, tmp_path, monkeypatch): + """The temporary atomic-replacement sibling is removed even if os.replace fails.""" + target = tmp_path / "real" + target.mkdir() + alias = self._alias(tmp_path) + + def failing_replace(_src, _dst): + raise OSError("simulated replace failure") + + monkeypatch.setattr(os, "replace", failing_replace) + with pytest.raises(OSError, match="simulated"), self.build_hooks._stable_cython_alias(target, alias): + pass + tmp_files = [p for p in tmp_path.iterdir() if p.suffix == ".tmp"] + assert tmp_files == [], f"leftover tmp siblings: {tmp_files}" + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_stable_alias_gives_cache_hit_across_isolated_envs(self, tmp_path): + """Fingerprint stays stable when the symlink target moves to a new random path. + + Simulates what happens across two consecutive PEP 517 builds: the + Cython stdlib (libc/stdint.pxd) is installed under a different temporary + prefix but the stable alias stays at the same worktree-relative path. + The second subprocess should print "Found compiled" (cache hit) rather + than "Cythonizing". + """ + import Cython + + cache_dir = tmp_path / "cache" + cache_dir.mkdir() + + real_includes = Path(Cython.__file__).parent / "Includes" + env_a = tmp_path / "env_a" / "Includes" + env_b = tmp_path / "env_b" / "Includes" + shutil.copytree(real_includes, env_a) + shutil.copytree(real_includes, env_b) + + src_dir = tmp_path / "src" + src_dir.mkdir() + (src_dir / "mod.pyx").write_text("from libc.stdint cimport uint64_t\ndef f() -> uint64_t:\n return 1\n") + + pkg_dir = tmp_path / "pkg" + pkg_dir.mkdir() + alias = pkg_dir / ".cython-stdlib" + + def _run(target): + script = textwrap.dedent( + f""" + import os + from pathlib import Path + from Cython.Build import cythonize + from setuptools import Extension + target = Path({str(target)!r}) + alias = Path({str(alias)!r}) + if alias.is_symlink(): + alias.unlink() + alias.symlink_to(target, target_is_directory=True) + rel_alias = os.path.relpath(alias) + ext = Extension("mod", sources=[{str(src_dir / "mod.pyx")!r}], language="c") + gen_c = Path({str(src_dir / "mod.c")!r}) + if gen_c.exists(): + gen_c.unlink() + cythonize([ext], cache={str(cache_dir)!r}, include_path=[".", rel_alias], quiet=False) + alias.unlink() + """ + ) + return run_python_snippet(script, cwd=tmp_path) + + r1 = _run(env_a) + assert "Cythonizing" in r1.stdout, "expected cold Cythonizing" + + r2 = _run(env_b) + assert "Found compiled" in r2.stdout, f"expected cache hit but got:\n{r2.stdout}" + + +class CythonCachePathMixin: + """Common unit tests for the ``_cython_cache_path`` build-hook helper. + + Subclasses set the class attributes ``build_hooks`` (the loaded + ``build_hooks`` module) and ``package`` (the cache namespace label, + e.g. ``"cuda-core"``) and inherit the shared tests. Per-package + extras (e.g. ``compile_time_env`` for cuda_core) live on the + subclass. + """ + + build_hooks = None + package = None + + def _set_env(self, monkeypatch, value): + if value is None: + monkeypatch.delenv("CUDA_PYTHON_CYTHON_CACHE_DIR", raising=False) + else: + monkeypatch.setenv("CUDA_PYTHON_CYTHON_CACHE_DIR", value) + + @pytest.mark.agent_authored(model="grok-4.6") + def test_unset_env_returns_none(self, monkeypatch): + """An unset ``CUDA_PYTHON_CYTHON_CACHE_DIR`` disables caching (returns None).""" + self._set_env(monkeypatch, None) + assert self.build_hooks._cython_cache_path(self.package) is None + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_set_env_returns_namespaced_path(self, monkeypatch, tmp_path): + """A set cache root yields a path namespaced by package and config.""" + self._set_env(monkeypatch, str(tmp_path)) + path = self.build_hooks._cython_cache_path(self.package) + assert path is not None + assert path.startswith(str(tmp_path)) + assert f"{self.package}-" in path + + @WINDOWS_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_windows_cache_dir_warns_and_returns_none(self, monkeypatch, tmp_path): + """A set ``CUDA_PYTHON_CYTHON_CACHE_DIR`` warns and returns None on Windows.""" + self._set_env(monkeypatch, str(tmp_path)) + with pytest.warns(UserWarning, match="not supported on Windows"): + assert self.build_hooks._cython_cache_path(self.package) is None + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_windows_disable_path_warns_and_returns_none(self, monkeypatch, tmp_path): + """The win32 branch of ``_cython_cache_path`` warns and returns None. + + Complements ``test_windows_cache_dir_warns_and_returns_none`` so Linux + CI still covers the disable path (that test is Windows-only). + """ + self._set_env(monkeypatch, str(tmp_path)) + with ( + mock.patch.object(self.build_hooks.sys, "platform", "win32"), + pytest.warns(UserWarning, match="not supported on Windows"), + ): + assert self.build_hooks._cython_cache_path(self.package) is None + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_package_namespaces_are_distinct(self, monkeypatch, tmp_path): + """Different package names map to different cache directories.""" + self._set_env(monkeypatch, str(tmp_path)) + a = self.build_hooks._cython_cache_path("cuda-bindings") + b = self.build_hooks._cython_cache_path("cuda-core") + assert a != b + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_identical_settings_produce_same_path(self, monkeypatch, tmp_path): + """Dict insertion order does not affect the namespace (sorting makes it stable).""" + self._set_env(monkeypatch, str(tmp_path)) + directives = {"embedsignature": True, "linetrace": True} + p1 = self.build_hooks._cython_cache_path( + self.package, + compiler_directives=directives, + language_level=3, + cplus=True, + debug=False, + ) + p2 = self.build_hooks._cython_cache_path( + self.package, + compiler_directives=dict(reversed(list(directives.items()))), + language_level=3, + cplus=True, + debug=False, + ) + assert p1 == p2 + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_changed_directives_change_namespace(self, monkeypatch, tmp_path): + """Different ``compiler_directives`` map to different namespaces (#7532 workaround).""" + self._set_env(monkeypatch, str(tmp_path)) + base = dict(embedsignature=True, freethreading_compatible=True) + with_linetrace = dict(base, linetrace=True) + p1 = self.build_hooks._cython_cache_path(self.package, compiler_directives=base) + p2 = self.build_hooks._cython_cache_path(self.package, compiler_directives=with_linetrace) + assert p1 != p2 + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_path_has_no_workspace_component(self, monkeypatch, tmp_path): + """The namespace tail is just ``-``, with no checkout path.""" + self._set_env(monkeypatch, str(tmp_path)) + path = self.build_hooks._cython_cache_path( + self.package, + compiler_directives={"linetrace": True}, + language_level=3, + cplus=True, + ) + tail = path[len(str(tmp_path)) + 1 :] + assert tail.count(os.sep) == 0, f"namespace tail has path separators: {tail!r}" + assert tail.startswith(f"{self.package}-"), f"unexpected namespace tail: {tail!r}" + + @POSIX_ONLY_CACHE + @pytest.mark.agent_authored(model="grok-4.6") + def test_different_python_versions_map_to_distinct_paths(self, monkeypatch, tmp_path): + """Different Python interpreter versions map to different namespaces. + + Cython's generated C code is Python-version-specific (3.14 + switches to zstd-compressed string literals via ``CYTHON_COMPRESS_STRINGS``, + while 3.12/3.13 use zlib), so the interpreter major.minor + must partition the cache namespace. + """ + self._set_env(monkeypatch, str(tmp_path)) + with mock.patch.object(sys, "version_info", SimpleNamespace(major=3, minor=12)): + p312 = self.build_hooks._cython_cache_path(self.package) + with mock.patch.object(sys, "version_info", SimpleNamespace(major=3, minor=13)): + p313 = self.build_hooks._cython_cache_path(self.package) + with mock.patch.object(sys, "version_info", SimpleNamespace(major=3, minor=14)): + p314 = self.build_hooks._cython_cache_path(self.package) + assert p312 != p313 + assert p312 != p314 + assert p313 != p314 diff --git a/toolshed/check_build_hooks_sync.py b/toolshed/check_build_hooks_sync.py index 62765be5407..e1bfcd09183 100644 --- a/toolshed/check_build_hooks_sync.py +++ b/toolshed/check_build_hooks_sync.py @@ -1,13 +1,14 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Check that the shared toolchain helpers are byte-identical in both build_hooks.py files. +"""Check that the shared build-helpers block is byte-identical in both build_hooks.py files. -The block delimited by '# --- begin shared toolchain helpers' and -'# --- end shared toolchain helpers ---' is duplicated verbatim between +The block delimited by '# --- begin shared build helpers' and +'# --- end shared build helpers ---' is duplicated verbatim between cuda_bindings/build_hooks.py and cuda_core/build_hooks.py (PEP 517 build -isolation forbids a shared import). Run as a pre-commit hook so drift is -caught at commit time. +isolation forbids a shared import). It contains the toolchain helpers and +the Cython cache helpers. Run as a pre-commit hook so drift is caught at +commit time. """ from __future__ import annotations @@ -15,8 +16,8 @@ import sys from pathlib import Path -_MARKER_START = "# --- begin shared toolchain helpers" -_MARKER_END = "# --- end shared toolchain helpers ---" +_MARKER_START = "# --- begin shared build helpers" +_MARKER_END = "# --- end shared build helpers ---" ROOT = Path(__file__).resolve().parents[1] _BINDINGS = ROOT / "cuda_bindings" / "build_hooks.py" @@ -38,7 +39,7 @@ def main() -> None: core_block = _shared_block(_CORE) if bindings_block != core_block: sys.exit( - "ERROR: shared toolchain helpers are out of sync between\n" + "ERROR: shared build helpers are out of sync between\n" f" {_BINDINGS}\n" f" {_CORE}\n" "Edit both files to match and commit again."