diff --git a/cuda_bindings/build_hooks.py b/cuda_bindings/build_hooks.py index 99ad5c66268..4ce987321ae 100644 --- a/cuda_bindings/build_hooks.py +++ b/cuda_bindings/build_hooks.py @@ -34,6 +34,48 @@ _extensions = None +def env_int(name: str, default: int) -> int: + """Read an integer build knob from the environment. + + Unset and empty mean the same thing. ``CUDA_PYTHON_COVERAGE= pip install .`` + (and the equivalent empty value in a Dockerfile ``ENV`` or a CI job spec) is + how a variable gets neutralised, and it must not abort the build -- a bare + ``int("")`` raises ``ValueError: invalid literal for int() with base 10: ''`` + from the PEP 517 backend, so the failure arrives with no indication of which + variable caused it. + + A value that is set to something non-integer is a typo worth stopping for: + silently ignoring ``CUDA_PYTHON_COVERAGE=yes`` would hand back a build with + no coverage instrumentation. It is reported with the variable's name rather + than as an anonymous ``invalid literal for int()``. + """ + raw = os.environ.get(name, "").strip() + if not raw: + return default + try: + return int(raw) + except ValueError: + raise ValueError(f"environment variable {name}={os.environ[name]!r} must be an integer") from None + + +def parallel_level() -> int: + """Number of parallel jobs requested for the build. + + ``PARALLEL_LEVEL`` is the deprecated spelling of + ``CUDA_PYTHON_PARALLEL_LEVEL``. An empty value counts as "not set" for both, + so ``PARALLEL_LEVEL=`` neither warns about a variable the caller is not + using nor shadows ``CUDA_PYTHON_PARALLEL_LEVEL``. + """ + if os.environ.get("PARALLEL_LEVEL", "").strip(): + warn( + "Environment variable PARALLEL_LEVEL is deprecated. Use CUDA_PYTHON_PARALLEL_LEVEL instead", + DeprecationWarning, + stacklevel=2, + ) + return env_int("PARALLEL_LEVEL", 0) + return env_int("CUDA_PYTHON_PARALLEL_LEVEL", 0) + + # Please keep in sync with the copy in cuda_core/build_hooks.py. def _import_get_cuda_path_or_home(): """Import get_cuda_path_or_home, working around PEP 517 namespace shadowing. @@ -142,17 +184,9 @@ def _build_cuda_bindings(debug=False): cuda_path = _get_cuda_path() - if os.environ.get("PARALLEL_LEVEL") is not None: - warn( - "Environment variable PARALLEL_LEVEL is deprecated. Use CUDA_PYTHON_PARALLEL_LEVEL instead", - DeprecationWarning, - stacklevel=2, - ) - nthreads = int(os.environ.get("PARALLEL_LEVEL", "0")) - else: - nthreads = int(os.environ.get("CUDA_PYTHON_PARALLEL_LEVEL", "0") or "0") + nthreads = parallel_level() - compile_for_coverage = bool(int(os.environ.get("CUDA_PYTHON_COVERAGE", "0"))) + compile_for_coverage = bool(env_int("CUDA_PYTHON_COVERAGE", 0)) # Prepare compile/link arguments include_path_list = [os.path.join(cuda_path, "include")] diff --git a/cuda_bindings/setup.py b/cuda_bindings/setup.py index 4cfffcf6e1a..21fd23744c0 100644 --- a/cuda_bindings/setup.py +++ b/cuda_bindings/setup.py @@ -2,26 +2,18 @@ # SPDX-License-Identifier: Apache-2.0 import functools -import os import subprocess -from warnings import warn import build_hooks from setuptools import setup from setuptools.command.build_ext import build_ext as _build_ext from setuptools.command.build_py import build_py as _build_py -if os.environ.get("PARALLEL_LEVEL") is not None: - warn( - "Environment variable PARALLEL_LEVEL is deprecated. Use CUDA_PYTHON_PARALLEL_LEVEL instead", - DeprecationWarning, - stacklevel=1, - ) - nthreads = int(os.environ.get("PARALLEL_LEVEL", "0")) -else: - nthreads = int(os.environ.get("CUDA_PYTHON_PARALLEL_LEVEL", "0") or "0") +# Shared with build_hooks so the two build entry points parse these knobs +# identically (see build_hooks.env_int for why a bare int() is not enough). +nthreads = build_hooks.parallel_level() -coverage_mode = bool(int(os.environ.get("CUDA_PYTHON_COVERAGE", "0"))) +coverage_mode = bool(build_hooks.env_int("CUDA_PYTHON_COVERAGE", 0)) def _is_clang(compiler): diff --git a/cuda_bindings/tests/test_build_hooks.py b/cuda_bindings/tests/test_build_hooks.py new file mode 100644 index 00000000000..aa01bfffd34 --- /dev/null +++ b/cuda_bindings/tests/test_build_hooks.py @@ -0,0 +1,136 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the integer build knobs read by build_hooks.py. + +These tests do NOT require cuda.bindings to be built/installed since they test +build-time infrastructure. Run with --noconftest to avoid loading conftest.py, +which imports cuda.bindings.driver: + + pytest tests/test_build_hooks.py -v --noconftest + +Mirrors cuda_core/tests/test_build_hooks.py. +""" + +import importlib.util +import warnings +from pathlib import Path + +import pytest + +# build_hooks.py imports setuptools at the top level, so skip if not available. +pytest.importorskip("setuptools") + + +def _load_build_hooks(): + """Load build_hooks module from source without permanently modifying sys.path. + + build_hooks.py is a PEP 517 build backend, not an installed module. Load it + directly from source so sys.path never gains the cuda_bindings/ directory + (which contains cuda/bindings/ sources that could shadow the installed + package). + """ + build_hooks_path = Path(__file__).parent.parent / "build_hooks.py" + spec = importlib.util.spec_from_file_location("build_hooks", build_hooks_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +build_hooks = _load_build_hooks() + + +class TestEnvInt: + """Integer build knobs read from the environment.""" + + @pytest.mark.agent_authored(model="claude-opus-5") + @pytest.mark.parametrize( + "raw", + [ + pytest.param("", id="empty"), + pytest.param(" ", id="space"), + pytest.param("\t", id="tab"), + ], + ) + def test_empty_value_means_unset(self, monkeypatch, raw): + """``CUDA_PYTHON_COVERAGE= pip install .`` neutralises the variable. + + A bare ``int("")`` raises while the PEP 517 backend is still importing, + so the build dies before it starts with an anonymous + ``invalid literal for int() with base 10: ''``. + """ + monkeypatch.setenv("CUDA_PYTHON_COVERAGE", raw) + assert build_hooks.env_int("CUDA_PYTHON_COVERAGE", 7) == 7 + + @pytest.mark.agent_authored(model="claude-opus-5") + def test_unset_value_uses_the_default(self, monkeypatch): + monkeypatch.delenv("CUDA_PYTHON_COVERAGE", raising=False) + assert build_hooks.env_int("CUDA_PYTHON_COVERAGE", 3) == 3 + + @pytest.mark.agent_authored(model="claude-opus-5") + @pytest.mark.parametrize( + ("raw", "expected"), + [("0", 0), ("1", 1), ("12", 12), (" 4 ", 4), ("-1", -1)], + ) + def test_integer_value_is_parsed(self, monkeypatch, raw, expected): + monkeypatch.setenv("CUDA_PYTHON_PARALLEL_LEVEL", raw) + assert build_hooks.env_int("CUDA_PYTHON_PARALLEL_LEVEL", 99) == expected + + @pytest.mark.agent_authored(model="claude-opus-5") + def test_non_integer_value_names_the_variable(self, monkeypatch): + monkeypatch.setenv("CUDA_PYTHON_COVERAGE", "yes") + with pytest.raises(ValueError, match="CUDA_PYTHON_COVERAGE='yes' must be an integer"): + build_hooks.env_int("CUDA_PYTHON_COVERAGE", 0) + + +class TestParallelLevel: + """PARALLEL_LEVEL is the deprecated spelling of CUDA_PYTHON_PARALLEL_LEVEL.""" + + @pytest.mark.agent_authored(model="claude-opus-5") + def test_neither_set(self, monkeypatch): + monkeypatch.delenv("PARALLEL_LEVEL", raising=False) + monkeypatch.delenv("CUDA_PYTHON_PARALLEL_LEVEL", raising=False) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + assert build_hooks.parallel_level() == 0 + assert caught == [] + + @pytest.mark.agent_authored(model="claude-opus-5") + def test_current_variable_is_used(self, monkeypatch): + monkeypatch.delenv("PARALLEL_LEVEL", raising=False) + monkeypatch.setenv("CUDA_PYTHON_PARALLEL_LEVEL", "6") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + assert build_hooks.parallel_level() == 6 + assert caught == [] + + @pytest.mark.agent_authored(model="claude-opus-5") + def test_deprecated_variable_still_works_and_warns(self, monkeypatch): + monkeypatch.setenv("PARALLEL_LEVEL", "5") + monkeypatch.delenv("CUDA_PYTHON_PARALLEL_LEVEL", raising=False) + with pytest.warns(DeprecationWarning, match="PARALLEL_LEVEL is deprecated"): + assert build_hooks.parallel_level() == 5 + + @pytest.mark.agent_authored(model="claude-opus-5") + def test_empty_deprecated_variable_does_not_abort_the_build(self, monkeypatch): + """``PARALLEL_LEVEL=`` used to take the deprecated branch on ``is not + None`` and then raise from ``int("")``, so neutralising the old + variable aborted the build instead of falling through. + """ + monkeypatch.setenv("PARALLEL_LEVEL", "") + monkeypatch.setenv("CUDA_PYTHON_PARALLEL_LEVEL", "4") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + assert build_hooks.parallel_level() == 4 + # An empty value is how a variable gets neutralised: warning about a + # variable the caller is not using is noise. + assert [w for w in caught if issubclass(w.category, DeprecationWarning)] == [] + + @pytest.mark.agent_authored(model="claude-opus-5") + def test_non_integer_deprecated_variable_names_the_variable(self, monkeypatch): + monkeypatch.setenv("PARALLEL_LEVEL", "auto") + with ( + pytest.warns(DeprecationWarning, match="PARALLEL_LEVEL is deprecated"), + pytest.raises(ValueError, match="PARALLEL_LEVEL='auto' must be an integer"), + ): + build_hooks.parallel_level()