From 9e1ad89cd169e398e1a91b87edeaa19c8f0f2221 Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Thu, 27 Aug 2026 14:15:30 -0700 Subject: [PATCH 1/7] cuda.core: fix a few test issues From cccbbfe8966a18e0e117a673a0e3661e62a655f5 Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Thu, 27 Aug 2026 14:16:15 -0700 Subject: [PATCH 2/7] address PR #2701 feedback --- cuda_bindings/tests/test_graphics_apis.py | 45 ++++++---- cuda_core/tests/test_graphics.py | 104 ++++++++++------------ 2 files changed, 77 insertions(+), 72 deletions(-) diff --git a/cuda_bindings/tests/test_graphics_apis.py b/cuda_bindings/tests/test_graphics_apis.py index 5e4ae636d69..f3a87e686cd 100644 --- a/cuda_bindings/tests/test_graphics_apis.py +++ b/cuda_bindings/tests/test_graphics_apis.py @@ -13,7 +13,7 @@ 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,8 +21,11 @@ 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 _setup_gl_texture(): + """Open a GL context and allocate a 2-D RGBA8 texture. Returns (win, tex_id, target). + + Cleans up the window if texture allocation raises, so partial resources do not leak. + """ if not pyglet.options.get("headless"): # Hidden window path (WGL on Windows, GLX/WLS on Linux) from pyglet import gl @@ -36,27 +39,35 @@ def _setup_gl_texture(pyglet): win = None - # Make a tiny texture so we have a real GL object to register - 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: + # Make a tiny texture so we have a real GL object to register + 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 + except Exception: + try: + if win is not None: + win.close() + except Exception: # noqa: S110 + pass + 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, tex_id, target = _setup_gl_texture() except Exception as e: pytest.skip(f"Could not create GL context/texture: {type(e).__name__}: {e}") diff --git a/cuda_core/tests/test_graphics.py b/cuda_core/tests/test_graphics.py index f31f2d14a8b..eb96ae056af 100644 --- a/cuda_core/tests/test_graphics.py +++ b/cuda_core/tests/test_graphics.py @@ -8,7 +8,6 @@ import gc import os import sys -from unittest.mock import patch import numpy as np import pyglet @@ -50,7 +49,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,7 +57,7 @@ def _configure_pyglet_headless(pyglet): pyglet.options["headless"] = True -def _open_gl_window(pyglet): +def _open_gl_window(): """Open a hidden window (or configure EGL headless). Returns the window or None.""" if not pyglet.options.get("headless"): from pyglet import gl @@ -73,40 +72,62 @@ 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) - from pyglet.gl import gl as _gl +def _setup_gl_buffer(nbytes): + """Open a GL context and allocate a buffer. Returns (win, buf_id). - 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 + Cleans up the window if buffer allocation raises, so partial resources do not leak. + """ + win = _open_gl_window() + try: + 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 + except Exception: + try: + if win is not None: + win.close() + except Exception: # noqa: S110 + pass + raise -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) - from pyglet.gl import gl as _gl +def _setup_gl_texture(width, height): + """Open a GL context and allocate a 2-D RGBA8 texture. Returns (win, tex_id, target). - 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 + Cleans up the window if texture allocation raises, so partial resources do not leak. + """ + win = _open_gl_window() + try: + 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 + except Exception: + try: + if win is not None: + win.close() + except Exception: # noqa: S110 + pass + 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, buf_id = _setup_gl_buffer(nbytes) except Exception as e: pytest.skip(f"Could not create GL context/buffer: {type(e).__name__}: {e}") @@ -130,10 +151,10 @@ def _gl_context_and_buffer(nbytes=1024): @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, tex_id, target = _setup_gl_texture(width, height) except Exception as e: pytest.skip(f"Could not create GL context/texture: {type(e).__name__}: {e}") @@ -419,33 +440,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() From 171130a7099657b5df1f80fd9103d75db5c437df Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Thu, 27 Aug 2026 14:25:31 -0700 Subject: [PATCH 3/7] drop problematic importorskip --- cuda_core/tests/graph/test_device_launch.py | 2 +- cuda_core/tests/test_build_hooks.py | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) 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/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. From 373b6bb36ef655a4afab86e7432a3bf9dbae604e Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Thu, 27 Aug 2026 14:35:39 -0700 Subject: [PATCH 4/7] sharpen skip condition in graph def tests --- .../test_graph_definition_integration.py | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/cuda_core/tests/graph/test_graph_definition_integration.py b/cuda_core/tests/graph/test_graph_definition_integration.py index 629f302bd75..df6eee57f4b 100644 --- a/cuda_core/tests/graph/test_graph_definition_integration.py +++ b/cuda_core/tests/graph/test_graph_definition_integration.py @@ -10,7 +10,7 @@ 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 @@ -121,6 +121,25 @@ def _nvrtc_opts(): return ProgramOptions(std="c++17", arch=f"sm_{arch}") +# 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): + msg = str(exc) + if any(phrase in msg for phrase in _COND_HANDLE_UNKNOWN): + pytest.skip("NVRTC does not support cudaGraphConditionalHandle") + raise + + def _compile_heat_kernels(): prog = Program(_HEAT_KERNEL_SOURCE, code_type="c++", options=_nvrtc_opts()) try: @@ -128,8 +147,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 +165,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) From b7e3015b4ee9d740f47c8a73df8fb7edee4df8bb Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Thu, 27 Aug 2026 14:51:32 -0700 Subject: [PATCH 5/7] sharpen a few more catches --- cuda_core/tests/helpers/__init__.py | 5 +++-- cuda_core/tests/test_module.py | 8 +++++--- cuda_core/tests/test_program.py | 4 +++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/cuda_core/tests/helpers/__init__.py b/cuda_core/tests/helpers/__init__.py index 2305cfaa1e5..ef6c06654ac 100644 --- a/cuda_core/tests/helpers/__init__.py +++ b/cuda_core/tests/helpers/__init__.py @@ -4,7 +4,7 @@ import functools import os -from cuda.core._utils.cuda_utils import handle_return +from cuda.core._utils.cuda_utils import CUDAError, handle_return from cuda.pathfinder import get_cuda_path_or_home from cuda_python_test_helpers import * @@ -49,5 +49,6 @@ def supports_ipc_mempool(device_id: int | object) -> bool: # 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: + except CUDAError: + # cuInit or cuDeviceGetAttribute failed: IPC mempool not usable here. return False diff --git a/cuda_core/tests/test_module.py b/cuda_core/tests/test_module.py index 7a0ba965b5d..7da7905c063 100644 --- a/cuda_core/tests/test_module.py +++ b/cuda_core/tests/test_module.py @@ -42,11 +42,13 @@ def _is_nvfatbin_available(): """Check if nvfatbin bindings are available.""" try: from cuda.bindings import nvfatbin - + except ImportError: + return False + try: nvfatbin.version() - return True - except Exception: + except nvfatbin.nvFatbinError: 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..040eb7e8e96 100644 --- a/cuda_core/tests/test_program.py +++ b/cuda_core/tests/test_program.py @@ -54,7 +54,9 @@ def _get_nvrtc_version_for_tests(): nvrtc_major, nvrtc_minor = handle_return(nvrtc.nvrtcVersion()) version = nvrtc_major * 1000 + nvrtc_minor * 100 return version - except Exception: + except (AttributeError, CUDAError): + # AttributeError: nvrtc not imported (suppressed above) or missing nvrtcVersion. + # CUDAError: driver not loaded or nvrtc call failed. return None From 92eb2ee909bf430ad726de4a453c5bb57b073611 Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Thu, 27 Aug 2026 14:52:13 -0700 Subject: [PATCH 6/7] add guidance to AGENTS.md --- cuda_core/tests/AGENTS.md | 85 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/cuda_core/tests/AGENTS.md b/cuda_core/tests/AGENTS.md index fe6f100b923..ee5b3438099 100644 --- a/cuda_core/tests/AGENTS.md +++ b/cuda_core/tests/AGENTS.md @@ -90,3 +90,88 @@ 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) +``` + +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(): + try: + from cuda.bindings import nvfatbin + except ImportError: + return False + try: + nvfatbin.version() + except nvfatbin.nvFatbinError: + return False + return True +``` + +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. + +## 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. From f05c176ef4ac5c2f4078152744a20c784616690d Mon Sep 17 00:00:00 2001 From: Ralf Juengling Date: Thu, 27 Aug 2026 15:07:47 -0700 Subject: [PATCH 7/7] sharpen gl setup to align with guidance --- cuda_bindings/tests/test_graphics_apis.py | 34 +++++++++++++++++++- cuda_core/tests/test_graphics.py | 38 +++++++++++++++++++++-- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/cuda_bindings/tests/test_graphics_apis.py b/cuda_bindings/tests/test_graphics_apis.py index f3a87e686cd..53a8741f6f5 100644 --- a/cuda_bindings/tests/test_graphics_apis.py +++ b/cuda_bindings/tests/test_graphics_apis.py @@ -12,6 +12,36 @@ from cuda.bindings import runtime as cudart +# pyglet raises these when GL context/window creation fails. Matched by type +# name (not by class) because importing pyglet.gl / pyglet.window at +# module top triggers pyglet's shadow-window creation, which fails on +# headless machines before _configure_pyglet_headless() has set the +# headless option. A bug in our own setup code (e.g. a TypeError) comes +# from builtins, not pyglet, so it re-raises and fails the test rather than +# being hidden as a skip. +_GL_UNAVAILABLE_EXC_NAMES = frozenset( + { + "NoSuchDisplayException", + "NoSuchConfigException", + "NoSuchScreenModeException", + "WindowException", + "ContextException", + "GLException", + } +) + + +def _is_gl_unavailable(exc): + if type(exc).__module__.startswith("pyglet") and type(exc).__name__ in _GL_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. + return isinstance(exc, (FileNotFoundError, AttributeError)) and "opengl32" in str(exc) + def _configure_pyglet_headless(): """On headless Linux: enable EGL mode or skip if EGL is absent.""" @@ -69,7 +99,9 @@ def _gl_context(): try: win, tex_id, target = _setup_gl_texture() except Exception as e: - pytest.skip(f"Could not create GL context/texture: {type(e).__name__}: {e}") + if _is_gl_unavailable(e): + pytest.skip(f"Could not create GL context/texture: {type(e).__name__}: {e}") + raise try: yield int(tex_id.value), int(target) diff --git a/cuda_core/tests/test_graphics.py b/cuda_core/tests/test_graphics.py index eb96ae056af..91cf5e51f70 100644 --- a/cuda_core/tests/test_graphics.py +++ b/cuda_core/tests/test_graphics.py @@ -49,6 +49,36 @@ def _register_gl_image(tex_id, target): raise +# pyglet raises these when GL context/window creation fails. Matched by type +# name (not by class) because importing pyglet.gl / pyglet.window at module +# top triggers pyglet's shadow-window creation, which fails on headless +# machines before _configure_pyglet_headless() has set the headless option. +# A bug in our own setup code (e.g. a TypeError) comes from builtins, not +# pyglet, so it re-raises and fails the test rather than being hidden as a skip. +_GL_UNAVAILABLE_EXC_NAMES = frozenset( + { + "NoSuchDisplayException", + "NoSuchConfigException", + "NoSuchScreenModeException", + "WindowException", + "ContextException", + "GLException", + } +) + + +def _is_gl_unavailable(exc): + if type(exc).__module__.startswith("pyglet") and type(exc).__name__ in _GL_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. + return isinstance(exc, (FileNotFoundError, AttributeError)) and "opengl32" in str(exc) + + 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")): @@ -129,7 +159,9 @@ def _gl_context_and_buffer(nbytes=1024): try: win, buf_id = _setup_gl_buffer(nbytes) except Exception as e: - pytest.skip(f"Could not create GL context/buffer: {type(e).__name__}: {e}") + if _is_gl_unavailable(e): + pytest.skip(f"Could not create GL context/buffer: {type(e).__name__}: {e}") + raise try: yield int(buf_id.value), nbytes @@ -156,7 +188,9 @@ def _gl_context_and_texture(width=16, height=16): try: win, tex_id, target = _setup_gl_texture(width, height) except Exception as e: - pytest.skip(f"Could not create GL context/texture: {type(e).__name__}: {e}") + if _is_gl_unavailable(e): + pytest.skip(f"Could not create GL context/texture: {type(e).__name__}: {e}") + raise try: yield int(tex_id.value), int(target)