diff --git a/cuda_bindings/tests/test_graphics_apis.py b/cuda_bindings/tests/test_graphics_apis.py index 5e4ae636d69..ddf35109f19 100644 --- a/cuda_bindings/tests/test_graphics_apis.py +++ b/cuda_bindings/tests/test_graphics_apis.py @@ -9,11 +9,12 @@ import pyglet import pytest +from cuda_python_test_helpers.graphics import is_gl_context_unavailable from cuda.bindings import runtime as cudart -def _configure_pyglet_headless(pyglet): +def _configure_pyglet_headless(): """On headless Linux: enable EGL mode or skip if EGL is absent.""" if sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")): if ctypes.util.find_library("EGL") is None: @@ -21,61 +22,82 @@ def _configure_pyglet_headless(pyglet): pyglet.options["headless"] = True -def _setup_gl_texture(pyglet): - """Open a GL context and allocate a 2-D RGBA8 texture. Returns (win, tex_id, target).""" +def _open_gl_window(): + """Open a hidden window (or configure EGL headless). Returns the window or None. + + Closes the window if switch_to() fails so a partially-constructed window does not leak. + """ if not pyglet.options.get("headless"): # Hidden window path (WGL on Windows, GLX/WLS on Linux) from pyglet import gl config = gl.Config(double_buffer=False) win = pyglet.window.Window(visible=False, config=config) - win.switch_to() + try: + win.switch_to() + except Exception: + with contextlib.suppress(Exception): + win.close() + raise + return win else: # Headless EGL path; pyglet will arrange a pbuffer-like headless context from pyglet.gl import headless # noqa: F401 - win = None + return None + + +def _allocate_gl_texture(win): + """Allocate a 2-D RGBA8 texture. Caller must have a current GL context. - # Make a tiny texture so we have a real GL object to register + Deletes the generated texture if a later GL call fails, so a partial + resource does not leak. + """ from pyglet.gl import gl as _gl tex_id = _gl.GLuint(0) - _gl.glGenTextures(1, ctypes.byref(tex_id)) - target = _gl.GL_TEXTURE_2D - _gl.glBindTexture(target, tex_id.value) - _gl.glTexParameteri(target, _gl.GL_TEXTURE_MIN_FILTER, _gl.GL_NEAREST) - _gl.glTexParameteri(target, _gl.GL_TEXTURE_MAG_FILTER, _gl.GL_NEAREST) - width, height = 16, 16 - _gl.glTexImage2D(target, 0, _gl.GL_RGBA8, width, height, 0, _gl.GL_RGBA, _gl.GL_UNSIGNED_BYTE, None) - return win, tex_id, target + try: + _gl.glGenTextures(1, ctypes.byref(tex_id)) + target = _gl.GL_TEXTURE_2D + _gl.glBindTexture(target, tex_id.value) + _gl.glTexParameteri(target, _gl.GL_TEXTURE_MIN_FILTER, _gl.GL_NEAREST) + _gl.glTexParameteri(target, _gl.GL_TEXTURE_MAG_FILTER, _gl.GL_NEAREST) + width, height = 16, 16 + _gl.glTexImage2D(target, 0, _gl.GL_RGBA8, width, height, 0, _gl.GL_RGBA, _gl.GL_UNSIGNED_BYTE, None) + return tex_id, target + except Exception: + if tex_id.value: + with contextlib.suppress(Exception): + _gl.glDeleteTextures(1, ctypes.byref(tex_id)) + raise @contextlib.contextmanager def _gl_context(): """Yield ``(tex_id, tex_target)`` with a current GL context, or skip if GL is unavailable.""" - _configure_pyglet_headless(pyglet) + _configure_pyglet_headless() try: - win, tex_id, target = _setup_gl_texture(pyglet) + win = _open_gl_window() except Exception as e: - pytest.skip(f"Could not create GL context/texture: {type(e).__name__}: {e}") + if is_gl_context_unavailable(e): + pytest.skip(f"Could not create GL context: {type(e).__name__}: {e}") + raise + tex_id = None try: + tex_id, target = _allocate_gl_texture(win) yield int(tex_id.value), int(target) finally: - # Best-effort cleanup - try: - from pyglet.gl import gl as _gl + if tex_id is not None: + with contextlib.suppress(Exception): + from pyglet.gl import gl as _gl - if tex_id.value: - _gl.glDeleteTextures(1, ctypes.byref(tex_id)) - except Exception: # noqa: S110 - pass - try: + if tex_id.value: + _gl.glDeleteTextures(1, ctypes.byref(tex_id)) + with contextlib.suppress(Exception): if win is not None: win.close() - except Exception: # noqa: S110 - pass @pytest.mark.parametrize( diff --git a/cuda_core/tests/AGENTS.md b/cuda_core/tests/AGENTS.md index fe6f100b923..a52ddb4032d 100644 --- a/cuda_core/tests/AGENTS.md +++ b/cuda_core/tests/AGENTS.md @@ -83,6 +83,11 @@ Follow these rules when adding or moving shared test code: `tests/helpers/` instead. - Import helpers explicitly from the test root, for example: `from helpers.memory import create_managed_memory_resource_or_skip`. +- Search `tests/helpers/` and `cuda_python_test_helpers/` for prior art + before adding a new helper; consolidate duplicates across + + packages into `cuda_python_test_helpers` (both `cuda_core` and + `cuda_bindings` test environments already depend on it). - Fixtures in a nested `conftest.py` are available to tests in its directory and descendants; fixtures from applicable parent `conftest.py` files remain available. @@ -90,3 +95,154 @@ Follow these rules when adding or moving shared test code: `conftest.py`. - In directories without `__init__.py`, keep test-module basenames unique within this test suite. + +## Skip only real setup failures + +`pytest.skip(reason)` records the test as SKIPPED with `reason` in the +report. A helper that wraps `yield` in `except Exception: pytest.skip(...)` +therefore records every test-body failure as a skip — a real regression, a +`TypeError`, an `AttributeError` all become "SKIPPED: " instead of +"FAILED", and the suite goes green regardless of whether the code under +test works. + +Catch only the specific exception that legitimately means "not available", +and only around the setup call — never around `yield`: + +```python +@contextlib.contextmanager +def _gl_context(): + try: + win, tex_id = _setup_gl_texture() # setup only + except (pyglet.NoSuchConfigException, GLContextError) as e: + pytest.skip(f"GL unavailable: {e}") + try: + yield tex_id # body exceptions propagate + finally: + _cleanup(win, tex_id) +``` + +The exception names in the example are illustrative — `GLContextError` +is not a real pyglet class. Match real pyglet exception names by type, or +use the shared `is_gl_context_unavailable` helper in +`cuda_python_test_helpers.graphics`. + +`GLException` is pyglet's generic GL-error class, raised after any GL call that reports an error +(`GL_INVALID_ENUM`, etc.). Do **not** include it in the "GL unavailable" set — it hides real bugs in GL allocation code as skips. + +Platform-specific "library not loadable" manifestations (genuine "GL unavailable"): + +- Linux without libGL/libEGL: `ImportError('Library "GL" not found.')` / `ImportError('Library "EGL" not found.')` from `pyglet/lib.py`. +- Windows without opengl32.dll: `FileNotFoundError` from `ctypes.windll.opengl32`; on Python 3.12+ `ctypes.LibraryLoader` re-raises `AttributeError("opengl32")`. + +When a CUDA call's error means "feature refused by this driver" (e.g. +`CUDA_ERROR_OPERATING_SYSTEM` for CUDA-GL interop on WSL), skip at the call +site with a narrow catch on the specific error, not inside the GL helper — +see `_register_gl_buffer` / `_register_gl_image` in `tests/test_graphics.py`. + +## `importorskip` is for optional dependencies only + +`pytest.importorskip("X")` is correct when `X` is genuinely optional +(platform-gated binding, parametrized "test each available module"). It is +dead code when `X` is a declared test or runtime dependency: the skip then +fires only when the environment is broken, which is the case you want to fail +loudly, not hide. Use a bare top-level `import` for declared deps. + +Before adding `importorskip`, check `cuda_core/pyproject.toml`'s `test` +and `test-cu*` groups and `cuda_core`'s `dependencies`. If the target is +listed, import it directly. + +## Capability probes must not swallow real bugs + +A probe function that answers "is feature X available?" by catching +`Exception` and returning `False` will report "not available" even when the +probed API failed for a real, unexpected reason — silently enabling a skip +that hides the bug. Catch only the exception that genuinely means "not +available", and split the checks so each catch is narrow: + +```python +def _is_nvfatbin_available(): + from cuda.bindings._internal.utils import FunctionNotFoundError + from cuda.pathfinder import DynamicLibNotFoundError + + try: + from cuda.bindings import nvfatbin + except ImportError: + return False + try: + nvfatbin.version() + except (DynamicLibNotFoundError, FunctionNotFoundError): + # libnvfatbin not loadable, or nvFatbinVersion symbol missing. + return False + return True +``` + +Catch only the exceptions that mean "not installed / not loadable". +A genuine API-status failure (e.g. `nvfatbin.nvFatbinError` from a +successfully loaded library) must propagate so a real bug is not hidden +as "unavailable". + +For a probe that calls a CUDA API returning a `CUresult`, let `handle_return` +raise on any non-success result and classify only a successful bitmask +lacking the documented bit as `False`; other failures propagate so a real driver bug is not hidden as "unsupported": + +```python +@functools.cache +def supports_ipc_mempool(device_id): + from cuda.bindings import driver + + handle_return(driver.cuInit(0)) + dev_id = int(getattr(device_id, "device_id", device_id)) + attr = driver.CUdevice_attribute.CU_DEVICE_ATTRIBUTE_MEMPOOL_SUPPORTED_HANDLE_TYPES + mask = handle_return(driver.cuDeviceGetAttribute(attr, dev_id)) + posix_fd = driver.CUmemAllocationHandleType.CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR + return (int(mask) & int(posix_fd)) != 0 +``` + +Do not catch `ImportError` for a hard runtime dependency (e.g. +`cuda.bindings` for `cuda.core`) — that is a broken environment and should +surface at collection time. + +## Clean up partial setup on failure + +If a setup step allocates a resource (GL object, window, file handle) and a later +step fails, clean up the partial resource before re-raising so it does not +leak. Wrap the allocation in `try/except` and delete the generated object in +the `except` before re-raising: + +```python +def _allocate_gl_buffer(win, nbytes): + from pyglet.gl import gl as _gl + + buf_id = _gl.GLuint(0) + try: + _gl.glGenBuffers(1, ctypes.byref(buf_id)) + _gl.glBindBuffer(_gl.GL_ARRAY_BUFFER, buf_id.value) + _gl.glBufferData(_gl.GL_ARRAY_BUFFER, nbytes, None, _gl.GL_DYNAMIC_DRAW) + return buf_id + except Exception: + if buf_id.value: + with contextlib.suppress(Exception): + _gl.glDeleteBuffers(1, ctypes.byref(buf_id)) + raise +``` + +Initialize handles to `None` before the protected region so the `finally` +cleanup does not `NameError` when allocation raises before returning a handle. + +## Tests that touch CUDA must establish their own context + +The `init_cuda` fixture pops the CUDA context on teardown, so a test +that calls a CUDA API without `init_cuda` (or an explicit +`Device.set_current()`) inherits whatever context the previous test happened +to leave current on the thread — possibly none. With `pytest-randomly` that +makes the pass/fail outcome depend on test order, so it moves seed to seed and +looks like flakiness. Request `init_cuda` for any test that calls into the +driver, or set up and tear down a context yourself. + +## Assert on behavior, not implementation + +Pin on observable behavior the contract guarantees — return values, raised +exception types, public state transitions. Avoid asserting on internal +call counts, private helper invocation order, or error message substrings +that are not part of the contract. A refactor that preserves behavior but +changes internals should not break the test. diff --git a/cuda_core/tests/graph/test_device_launch.py b/cuda_core/tests/graph/test_device_launch.py index d77ceeec37f..5056b01fd43 100644 --- a/cuda_core/tests/graph/test_device_launch.py +++ b/cuda_core/tests/graph/test_device_launch.py @@ -7,6 +7,7 @@ import pytest from cuda_python_test_helpers.marks import requires_module +import cuda.pathfinder as pathfinder from cuda.core import ( Device, LaunchConfig, @@ -48,7 +49,6 @@ def _compile_device_launcher_kernel(): Raises pytest.skip if libcudadevrt.a cannot be found. """ - pathfinder = pytest.importorskip("cuda.pathfinder") try: cudadevrt_path = pathfinder.find_static_lib("cudadevrt") except pathfinder.StaticLibNotFoundError as e: diff --git a/cuda_core/tests/graph/test_graph_definition_integration.py b/cuda_core/tests/graph/test_graph_definition_integration.py index 629f302bd75..1adce901d16 100644 --- a/cuda_core/tests/graph/test_graph_definition_integration.py +++ b/cuda_core/tests/graph/test_graph_definition_integration.py @@ -7,10 +7,11 @@ import numpy as np import pytest +from helpers.graph_kernels import skip_if_nvrtc_lacks_conditional_handle from helpers.memory import xfail_on_graph_mempool_oom from cuda.core import Device, EventOptions, LaunchConfig, Program, ProgramOptions -from cuda.core._utils.cuda_utils import driver, handle_return +from cuda.core._utils.cuda_utils import CUDAError, driver, handle_return from cuda.core.graph import GraphDefinition SIZEOF_FLOAT = 4 @@ -128,8 +129,9 @@ def _compile_heat_kernels(): "cubin", name_expressions=("heat_step", "countdown"), ) - except Exception: - pytest.skip("NVRTC does not support cudaGraphConditionalHandle") + except CUDAError as exc: + skip_if_nvrtc_lacks_conditional_handle(exc) + raise return mod.get_kernel("heat_step"), mod.get_kernel("countdown") @@ -145,8 +147,9 @@ def _compile_bisect_kernels(): prog = Program(_BISECT_KERNEL_SOURCE, code_type="c++", options=_nvrtc_opts()) try: mod = prog.compile("cubin", name_expressions=names) - except Exception: - pytest.skip("NVRTC does not support cudaGraphConditionalHandle") + except CUDAError as exc: + skip_if_nvrtc_lacks_conditional_handle(exc) + raise return tuple(mod.get_kernel(n) for n in names) diff --git a/cuda_core/tests/helpers/__init__.py b/cuda_core/tests/helpers/__init__.py index 2305cfaa1e5..95b6bce9341 100644 --- a/cuda_core/tests/helpers/__init__.py +++ b/cuda_core/tests/helpers/__init__.py @@ -28,26 +28,29 @@ def supports_ipc_mempool(device_id: int | object) -> bool: Uses cuDeviceGetAttribute(CU_DEVICE_ATTRIBUTE_MEMPOOL_SUPPORTED_HANDLE_TYPES) to check for CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR support. Does not require an active CUDA context. + + Unsupported handle types are represented by a successful query whose bitmask + lacks the POSIX-FD bit, so the check below naturally returns False. + Other driver errors (invalid device, deinitialized driver) propagate via + handle_return so a real bug is not hidden as "unsupported". """ if IS_WSL: return False - try: - # Lazy import to avoid hard dependency when not running GPU tests - from cuda.bindings import driver # type: ignore + # Lazy import to avoid hard dependency when not running GPU tests + from cuda.bindings import driver # type: ignore - # Initialize CUDA - handle_return(driver.cuInit(0)) + # Initialize CUDA + handle_return(driver.cuInit(0)) - # Resolve device id from int or Device-like object - dev_id = int(getattr(device_id, "device_id", device_id)) + # Resolve device id from int or Device-like object + dev_id = int(getattr(device_id, "device_id", device_id)) - # Query supported mempool handle types bitmask - attr = driver.CUdevice_attribute.CU_DEVICE_ATTRIBUTE_MEMPOOL_SUPPORTED_HANDLE_TYPES - mask = handle_return(driver.cuDeviceGetAttribute(attr, dev_id)) + # Query supported mempool handle types bitmask. Unsupported handle types are + # represented by a successful query whose bitmask lacks the POSIX-FD bit. + attr = driver.CUdevice_attribute.CU_DEVICE_ATTRIBUTE_MEMPOOL_SUPPORTED_HANDLE_TYPES + mask = handle_return(driver.cuDeviceGetAttribute(attr, dev_id)) - # Check POSIX FD handle type support via bitmask - posix_fd = driver.CUmemAllocationHandleType.CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR - return (int(mask) & int(posix_fd)) != 0 - except Exception: - return False + # Check POSIX FD handle type support via bitmask + posix_fd = driver.CUmemAllocationHandleType.CU_MEM_HANDLE_TYPE_POSIX_FILE_DESCRIPTOR + return (int(mask) & int(posix_fd)) != 0 diff --git a/cuda_core/tests/helpers/graph_kernels.py b/cuda_core/tests/helpers/graph_kernels.py index d08837585fe..fec6c7b2d82 100644 --- a/cuda_core/tests/helpers/graph_kernels.py +++ b/cuda_core/tests/helpers/graph_kernels.py @@ -12,6 +12,30 @@ from cuda.core import Device, Program, ProgramOptions from cuda.core._utils.cuda_utils import NVRTCError, handle_return +# NVRTC diagnostic phrases that indicate cudaGraphConditionalHandle itself is unknown +# to the compiler (older NVRTC builds predate the type). Matched narrowly so a +# genuine compile error (syntax error, etc.) is not hidden as a skip. +# Phrase #1 is the exact diagnostic observed on this machine's NVRTC; phrase #2 +# is a common clang/NVRTC wording for an unknown type, not verified against the +# cudaGraphConditionalHandle case on an old NVRTC build. +_COND_HANDLE_UNKNOWN = ( + 'identifier "cudaGraphConditionalHandle" is undefined', + 'unknown type name "cudaGraphConditionalHandle"', +) + + +def skip_if_nvrtc_lacks_conditional_handle(exc): + """Skip when *exc* means NVRTC predates cudaGraphConditionalHandle. + + Catches only the documented "type unknown" cases; a genuine compile + error (syntax error, etc.) re-raises so a real bug is not hidden as a skip. + """ + msg = str(exc) + if any(phrase in msg for phrase in _COND_HANDLE_UNKNOWN): + nvrtc_version = handle_return(nvrtc.nvrtcVersion()) + pytest.skip(f"NVRTC version {nvrtc_version} does not support conditionals") + raise + def compile_common_kernels(): """Compile basic kernels for graph tests. @@ -78,11 +102,9 @@ def compile_conditional_kernels(cond_type): prog = Program(code, code_type="c++", options=program_options) try: mod = prog.compile("cubin", name_expressions=("empty_kernel", "add_one", "set_handle", "loop_kernel")) - except NVRTCError as e: - with pytest.raises(NVRTCError, match='error: identifier "cudaGraphConditionalHandle" is undefined'): - raise e - nvrtcVersion = handle_return(nvrtc.nvrtcVersion()) - pytest.skip(f"NVRTC version {nvrtcVersion} does not support conditionals") + except NVRTCError as exc: + skip_if_nvrtc_lacks_conditional_handle(exc) + raise return mod diff --git a/cuda_core/tests/test_build_hooks.py b/cuda_core/tests/test_build_hooks.py index da410257e24..b9359164faf 100644 --- a/cuda_core/tests/test_build_hooks.py +++ b/cuda_core/tests/test_build_hooks.py @@ -24,14 +24,15 @@ from pathlib import Path from unittest import mock +# build_hooks.py imports Cython and setuptools at the top level; both are +# declared test dependencies, so a missing install must surface as an +# ImportError at collection time rather than being hidden by importorskip. +import Cython # noqa: F401 import pytest +import setuptools # noqa: F401 from cuda.pathfinder import get_cuda_path_or_home -# build_hooks.py imports Cython and setuptools at the top level, so skip if not available -pytest.importorskip("Cython") -pytest.importorskip("setuptools") - def _load_build_hooks(): """Load build_hooks module from source without permanently modifying sys.path. diff --git a/cuda_core/tests/test_graphics.py b/cuda_core/tests/test_graphics.py index f31f2d14a8b..8a63aa4254d 100644 --- a/cuda_core/tests/test_graphics.py +++ b/cuda_core/tests/test_graphics.py @@ -8,11 +8,11 @@ import gc import os import sys -from unittest.mock import patch import numpy as np import pyglet import pytest +from cuda_python_test_helpers.graphics import is_gl_context_unavailable from cuda.core import ( Buffer, @@ -50,7 +50,7 @@ def _register_gl_image(tex_id, target): raise -def _configure_pyglet_headless(pyglet): +def _configure_pyglet_headless(): """On headless Linux: enable EGL mode or skip if EGL is absent.""" if sys.platform.startswith("linux") and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")): if ctypes.util.find_library("EGL") is None: @@ -58,14 +58,22 @@ def _configure_pyglet_headless(pyglet): pyglet.options["headless"] = True -def _open_gl_window(pyglet): - """Open a hidden window (or configure EGL headless). Returns the window or None.""" +def _open_gl_window(): + """Open a hidden window (or configure EGL headless). Returns the window or None. + + Closes the window if switch_to() fails so a partially-constructed window does not leak. + """ if not pyglet.options.get("headless"): from pyglet import gl config = gl.Config(double_buffer=False) win = pyglet.window.Window(visible=False, config=config) - win.switch_to() + try: + win.switch_to() + except Exception: + with contextlib.suppress(Exception): + win.close() + raise return win else: from pyglet.gl import headless # noqa: F401 @@ -73,85 +81,105 @@ def _open_gl_window(pyglet): return None -def _setup_gl_buffer(pyglet, nbytes): - """Open a GL context and allocate a buffer. Returns (win, buf_id).""" - win = _open_gl_window(pyglet) +def _allocate_gl_buffer(win, nbytes): + """Allocate a GL buffer. Caller must have a current GL context. + + Deletes the generated buffer if a later GL call fails, so a partial + resource does not leak. + """ from pyglet.gl import gl as _gl buf_id = _gl.GLuint(0) - _gl.glGenBuffers(1, ctypes.byref(buf_id)) - _gl.glBindBuffer(_gl.GL_ARRAY_BUFFER, buf_id.value) - _gl.glBufferData(_gl.GL_ARRAY_BUFFER, nbytes, None, _gl.GL_DYNAMIC_DRAW) - return win, buf_id + try: + _gl.glGenBuffers(1, ctypes.byref(buf_id)) + _gl.glBindBuffer(_gl.GL_ARRAY_BUFFER, buf_id.value) + _gl.glBufferData(_gl.GL_ARRAY_BUFFER, nbytes, None, _gl.GL_DYNAMIC_DRAW) + return buf_id + except Exception: + if buf_id.value: + with contextlib.suppress(Exception): + _gl.glDeleteBuffers(1, ctypes.byref(buf_id)) + raise + +def _allocate_gl_texture(win, width, height): + """Allocate a 2-D RGBA8 texture. Caller must have a current GL context. -def _setup_gl_texture(pyglet, width, height): - """Open a GL context and allocate a 2-D RGBA8 texture. Returns (win, tex_id, target).""" - win = _open_gl_window(pyglet) + Deletes the generated texture if a later GL call fails, so a partial + resource does not leak. + """ from pyglet.gl import gl as _gl tex_id = _gl.GLuint(0) - _gl.glGenTextures(1, ctypes.byref(tex_id)) - target = _gl.GL_TEXTURE_2D - _gl.glBindTexture(target, tex_id.value) - _gl.glTexParameteri(target, _gl.GL_TEXTURE_MIN_FILTER, _gl.GL_NEAREST) - _gl.glTexParameteri(target, _gl.GL_TEXTURE_MAG_FILTER, _gl.GL_NEAREST) - _gl.glTexImage2D(target, 0, _gl.GL_RGBA8, width, height, 0, _gl.GL_RGBA, _gl.GL_UNSIGNED_BYTE, None) - return win, tex_id, target + try: + _gl.glGenTextures(1, ctypes.byref(tex_id)) + target = _gl.GL_TEXTURE_2D + _gl.glBindTexture(target, tex_id.value) + _gl.glTexParameteri(target, _gl.GL_TEXTURE_MIN_FILTER, _gl.GL_NEAREST) + _gl.glTexParameteri(target, _gl.GL_TEXTURE_MAG_FILTER, _gl.GL_NEAREST) + _gl.glTexImage2D(target, 0, _gl.GL_RGBA8, width, height, 0, _gl.GL_RGBA, _gl.GL_UNSIGNED_BYTE, None) + return tex_id, target + except Exception: + if tex_id.value: + with contextlib.suppress(Exception): + _gl.glDeleteTextures(1, ctypes.byref(tex_id)) + raise @contextlib.contextmanager def _gl_context_and_buffer(nbytes=1024): """Yield ``(gl_buffer_name, nbytes)`` with a current GL context, or skip if GL is unavailable.""" - _configure_pyglet_headless(pyglet) + _configure_pyglet_headless() try: - win, buf_id = _setup_gl_buffer(pyglet, nbytes) + win = _open_gl_window() except Exception as e: - pytest.skip(f"Could not create GL context/buffer: {type(e).__name__}: {e}") + if is_gl_context_unavailable(e): + pytest.skip(f"Could not create GL context: {type(e).__name__}: {e}") + raise + buf_id = None try: + buf_id = _allocate_gl_buffer(win, nbytes) yield int(buf_id.value), nbytes finally: - try: - from pyglet.gl import gl as _gl + if buf_id is not None: + with contextlib.suppress(Exception): + from pyglet.gl import gl as _gl - if buf_id.value: - _gl.glDeleteBuffers(1, ctypes.byref(buf_id)) - except Exception: # noqa: S110 - pass - try: + if buf_id.value: + _gl.glDeleteBuffers(1, ctypes.byref(buf_id)) + with contextlib.suppress(Exception): if win is not None: win.close() - except Exception: # noqa: S110 - pass @contextlib.contextmanager def _gl_context_and_texture(width=16, height=16): """Yield ``(tex_id, tex_target)`` with a current GL context, or skip if GL is unavailable.""" - _configure_pyglet_headless(pyglet) + _configure_pyglet_headless() try: - win, tex_id, target = _setup_gl_texture(pyglet, width, height) + win = _open_gl_window() except Exception as e: - pytest.skip(f"Could not create GL context/texture: {type(e).__name__}: {e}") + if is_gl_context_unavailable(e): + pytest.skip(f"Could not create GL context: {type(e).__name__}: {e}") + raise + tex_id = None try: + tex_id, target = _allocate_gl_texture(win, width, height) yield int(tex_id.value), int(target) finally: - try: - from pyglet.gl import gl as _gl + if tex_id is not None: + with contextlib.suppress(Exception): + from pyglet.gl import gl as _gl - if tex_id.value: - _gl.glDeleteTextures(1, ctypes.byref(tex_id)) - except Exception: # noqa: S110 - pass - try: + if tex_id.value: + _gl.glDeleteTextures(1, ctypes.byref(tex_id)) + with contextlib.suppress(Exception): if win is not None: win.close() - except Exception: # noqa: S110 - pass # --------------------------------------------------------------------------- @@ -419,33 +447,6 @@ def test_close_while_mapped(init_cuda): assert buf.handle == 0 -@pytest.mark.xfail( - reason="Buffer is an immutable Cython type; patch.object and __class__ assignment both fail", - raises=TypeError, - strict=True, -) -def test_close_while_mapped_passes_stream_override(init_cuda): - with _gl_context_and_buffer() as (gl_buf, _): - map_stream = init_cuda.create_stream() - close_stream = init_cuda.create_stream() - resource = _register_gl_buffer(gl_buf, flags="write_discard") - resource.map(stream=map_stream) - - original_close = Buffer.close - - def tracking_close(self, stream=None): - tracking_close.calls.append(stream) - return original_close(self, stream=stream) - - tracking_close.calls = [] - - with patch.object(Buffer, "close", new=tracking_close): - resource.close(stream=close_stream) - - assert tracking_close.calls == [close_stream] - assert not resource.is_mapped - - def test_buffer_close_updates_resource_state(init_cuda): with _gl_context_and_buffer() as (gl_buf, _): stream = init_cuda.create_stream() diff --git a/cuda_core/tests/test_helpers.py b/cuda_core/tests/test_helpers.py index 77f8f416c47..8fc7b8bb75a 100644 --- a/cuda_core/tests/test_helpers.py +++ b/cuda_core/tests/test_helpers.py @@ -351,3 +351,62 @@ def test_oom_diagnostics_probe_basics_is_live_and_cheap(init_cuda): assert snapshot.pool_va_ok is None assert snapshot.get_mem_pool_ok is None assert snapshot.capped_pool_create_ok is None + + +# --------------------------------------------------------------------------- +# GL context availability predicate tests +# --------------------------------------------------------------------------- + +import pytest +from cuda_python_test_helpers.graphics import is_gl_context_unavailable + + +class _PygletError(Exception): + pass + + +# Simulate a pyglet-namespaced exception by name. +def _make_pyglet_exc(name, module="pyglet.window"): + cls = type(name, (_PygletError,), {}) + cls.__module__ = module + return cls + + +@pytest.mark.human_reviewed +@pytest.mark.parametrize( + "exc", + [ + _make_pyglet_exc("NoSuchDisplayException")("x"), + _make_pyglet_exc("NoSuchConfigException")("x"), + _make_pyglet_exc("NoSuchScreenModeException")("x"), + _make_pyglet_exc("WindowException")("x"), + _make_pyglet_exc("ContextException")("x"), + _make_pyglet_exc("MissingFunctionException", module="pyglet.gl.lib")("x"), + FileNotFoundError("Could not find module 'opengl32' (or one of its dependencies)."), + AttributeError("opengl32"), + ImportError('Library "GL" not found.'), + ImportError('Library "EGL" not found.'), + ], +) +def test_is_gl_context_unavailable_accepts_genuine(exc): + assert is_gl_context_unavailable(exc) is True + + +@pytest.mark.human_reviewed +@pytest.mark.parametrize( + "exc", + [ + # pyglet exception names that are not context-creation failures + _make_pyglet_exc("GLException")("GL_INVALID_ENUM"), + _make_pyglet_exc("ImageException")("x"), + # Built-in exceptions that do not mention opengl32 / GL library + TypeError("bug"), + AttributeError("'NoneType' object has no attribute 'Config'"), + FileNotFoundError("No such file: /tmp/missing"), + ImportError("No module named 'foo'"), + OSError("disk full"), + RuntimeError("bug"), + ], +) +def test_is_gl_context_unavailable_rejects_unrelated(exc): + assert is_gl_context_unavailable(exc) is False diff --git a/cuda_core/tests/test_module.py b/cuda_core/tests/test_module.py index 7a0ba965b5d..3e85101bd85 100644 --- a/cuda_core/tests/test_module.py +++ b/cuda_core/tests/test_module.py @@ -39,14 +39,25 @@ def _is_nvfatbin_available(): - """Check if nvfatbin bindings are available.""" + """Check if nvfatbin bindings are available. + + Catches only the exceptions that mean "not installed / not loadable" + (ImportError, DynamicLibNotFoundError, FunctionNotFoundError). A + genuine nvfatbin API-status failure (nvFatbinError) propagates so a + real bug is not hidden as "unavailable". + """ + from cuda.bindings._internal.utils import FunctionNotFoundError + from cuda.pathfinder import DynamicLibNotFoundError + try: from cuda.bindings import nvfatbin - + except ImportError: + return False + try: nvfatbin.version() - return True - except Exception: + except (DynamicLibNotFoundError, FunctionNotFoundError): return False + return True nvfatbin_available = pytest.mark.skipif(not _is_nvfatbin_available(), reason="nvfatbin bindings not available") diff --git a/cuda_core/tests/test_program.py b/cuda_core/tests/test_program.py index 3b280cc48cf..8c6dfae8467 100644 --- a/cuda_core/tests/test_program.py +++ b/cuda_core/tests/test_program.py @@ -2,7 +2,6 @@ # # SPDX-License-Identifier: Apache-2.0 -import contextlib import re import shutil import subprocess @@ -11,12 +10,14 @@ import pytest +from cuda.bindings._internal.utils import FunctionNotFoundError from cuda.core import _linker from cuda.core._device import Device from cuda.core._module import Kernel, ObjectCode from cuda.core._program import Program, ProgramOptions -from cuda.core._utils.cuda_utils import CUDAError, handle_return +from cuda.core._utils.cuda_utils import CUDAError, handle_return, nvrtc from cuda.core.typing import CompilerBackendType, PCHStatusType +from cuda.pathfinder import DynamicLibNotFoundError pytest_plugins = ("cuda_python_test_helpers.nvvm_bitcode",) @@ -38,9 +39,6 @@ def _is_nvvm_available(): not _is_nvvm_available(), reason="NVVM not available (libNVVM not found or cuda-bindings < 12.9.0)" ) -with contextlib.suppress(Exception): - from cuda.core._utils.cuda_utils import nvrtc - def _get_nvrtc_version_for_tests(): """ @@ -52,10 +50,11 @@ def _get_nvrtc_version_for_tests(): """ try: nvrtc_major, nvrtc_minor = handle_return(nvrtc.nvrtcVersion()) - version = nvrtc_major * 1000 + nvrtc_minor * 100 - return version - except Exception: + return nvrtc_major * 1000 + nvrtc_minor * 100 + except (DynamicLibNotFoundError, FunctionNotFoundError): + # libnvrtc not loadable, or nvrtcVersion symbol missing. return None + # CUDAError from a successfully loaded library propagates (real bug). def _has_nvrtc_pch_apis_for_tests(): diff --git a/cuda_python_test_helpers/cuda_python_test_helpers/graphics.py b/cuda_python_test_helpers/cuda_python_test_helpers/graphics.py new file mode 100644 index 00000000000..0f25554a111 --- /dev/null +++ b/cuda_python_test_helpers/cuda_python_test_helpers/graphics.py @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""GL availability classification for graphics interop tests. + +Both ``cuda_core`` and ``cuda_bindings`` graphics tests need to skip +when the GL backend cannot be made current, and that decision must not +hide real bugs in the tests' own GL allocation code. This module owns the +shared predicate so the two test suites stay in sync. + +The helper intentionally does **not** import ``pyglet``: importing +``pyglet.gl`` / ``pyglet.window`` triggers pyglet's shadow-window +creation, which fails on headless machines before the test has had a +chance to set ``pyglet.options["headless"]``. Classification is by +exception module/name and tightly matched built-in loader errors instead. +""" + +_GL_CONTEXT_UNAVAILABLE_EXC_NAMES = frozenset( + { + "NoSuchDisplayException", + "NoSuchConfigException", + "NoSuchScreenModeException", + "WindowException", + "ContextException", + # Pyglet's headless display raises MissingFunctionException when libEGL + # exists but eglQueryDevicesEXT / eglGetPlatformDisplayEXT entry points do not. + "MissingFunctionException", + } +) + +# pyglet raises these from pyglet/lib.py when libGL/libEGL cannot be loaded. +_PYGLET_GL_LIBRARY_IMPORT_ERRORS = frozenset( + { + 'Library "GL" not found.', + 'Library "EGL" not found.', + } +) + + +def is_gl_context_unavailable(exc: BaseException) -> bool: + """Return True if *exc* means "no GL context could be created". + + Returns False for any other exception, so a real bug in the caller's + own GL allocation code (e.g. a ``GLException`` from an invalid-enum GL + call, a ``TypeError`` from wrong argument types) propagates and + fails the test rather than being hidden as a skip. + """ + exc_type = type(exc) + if exc_type.__module__.startswith("pyglet") and exc_type.__name__ in _GL_CONTEXT_UNAVAILABLE_EXC_NAMES: + return True + + # Windows CI runners may lack opengl32.dll; pyglet's WGL backend raises + # FileNotFoundError from ctypes.windll.opengl32. On newer Python + # (3.12+) ctypes.LibraryLoader catches that and re-raises + # AttributeError(dll_name). Match narrowly on the dll name so a + # different FileNotFoundError or AttributeError from our own code + # does not match. + if isinstance(exc, (FileNotFoundError, AttributeError)) and "opengl32" in str(exc): + return True + + # Linux without libGL/libEGL: pyglet raises ImportError with the + # exact messages above from pyglet/lib.py. A different ImportError + # from our own code does not match. + return isinstance(exc, ImportError) and str(exc) in _PYGLET_GL_LIBRARY_IMPORT_ERRORS