From c92925262b2a6b98d97791979979c18e51b0c289 Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Tue, 22 Sep 2026 14:02:26 -0700 Subject: [PATCH] build: add opt-in Cython generated-source cache Add an opt-in Cython cache behind CUDA_PYTHON_CYTHON_CACHE_DIR for cuda.bindings and cuda.core builds. When unset, cythonize() is called without cache= and builds are unchanged. POSIX only; Windows returns None with a warning. _cython_cache_path namespaces the cache by package, Python version, and a SHA-256 of output-affecting config (compiler_directives, compile_time_env, language_level, cplus, debug, cuda_major). This works around cython/cython#7532 (Cython omits compiler_directives from its native fingerprint); the helper and its tests can be removed once that issue is fixed in a released Cython version covered by cuda-python's minimum. _stable_cython_alias creates package-local directory symlinks (.cython-stdlib, and .cython-bindings for cuda.core) before cythonize() and removes them in finally, giving Cython stable relative include paths across PEP 517 builds that install deps under randomized temp prefixes. The two helpers are vendored in both build_hooks.py files (PEP 517 isolation forbids a shared import) and kept in sync by the existing pre-commit hook toolshed/check_build_hooks_sync.py, now covering one merged "shared build helpers" block instead of the toolchain-only block. No runtime drift test. Tests live in cuda_python_test_helpers/cython_cache.py (shared mixins + miss/hit smoke test + cross-isolated-env cache-hit regression) and are exercised by both packages' tests/test_build_hooks.py. POSIX-only tests skip on Windows; a Windows-only test asserts the set-env warn+None path. --- .gitignore | 2 + .pre-commit-config.yaml | 2 +- cuda_bindings/build_hooks.py | 181 +++++++- cuda_bindings/tests/test_build_hooks.py | 76 +++- cuda_core/build_hooks.py | 210 +++++++++- cuda_core/tests/test_build_hooks.py | 147 ++++++- .../cuda_python_test_helpers/cython_cache.py | 393 ++++++++++++++++++ toolshed/check_build_hooks_sync.py | 17 +- 8 files changed, 976 insertions(+), 52 deletions(-) create mode 100644 cuda_python_test_helpers/cuda_python_test_helpers/cython_cache.py 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."