diff --git a/setup.cfg b/setup.cfg index 99265661..0dedfa0e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -99,7 +99,7 @@ readthedocs = # this is just to trigger pip to check for pre-releases as well pre = - spatialdata>=0.7.0dev0 + spatialdata>=0.7.3a1 all = napari[pyqt5] diff --git a/tests/conftest.py b/tests/conftest.py index 7d9daa80..5089971a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ from __future__ import annotations +# ruff: noqa: E402 # MUST set environment variables BEFORE any Qt/napari/vispy imports # to enable headless mode in CI environments (Ubuntu/Linux without display) import os @@ -10,6 +11,90 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("NAPARI_HEADLESS", "1") + + +def _patch_napari_gl_for_headless() -> None: + """Patch napari's OpenGL utility functions to work without a real display. + + The patch implements two workaround that are no-ops in environments that + have a real display (CI with Xvfb, macOS, local dev). Once the upstreams + bugs are addressed this patch should be removed. + + In the Qt offscreen platform ``glGetString(GL_EXTENSIONS)`` returns ``None`` + (raising AttributeError on ``.decode()``) and ``glGetIntegerv`` returns an + empty tuple instead of an integer. napari then stores ``None`` as the max + texture size, which later crashes ``TiledImageNode``. + + Upstream bugs: + * **vispy** – ``vispy/gloo/gl/_pyopengl2.py`` does not guard against + ``GL.glGetString()`` returning ``None`` (no valid OpenGL context). + + * **napari** – ``get_gl_extensions()`` and ``get_max_texture_sizes()`` in + ``napari/_vispy/utils/gl.py`` do not handle the failure/empty-result + case from the underlying GL calls, crashing when run in offscreen mode. + + Fixes applied here: + * ``vispy.gloo.gl._pyopengl2.glGetParameter`` – return ``""`` for string + queries when the result is ``None``, and ``0`` for empty-tuple results. + * ``napari._vispy.utils.gl.get_max_texture_sizes`` (and every module that + imported it) – fall back to ``(2048, 2048)`` when the GL query returns 0. + """ + try: + import vispy.gloo.gl as _vgl + import vispy.gloo.gl._pyopengl2 as _pyopengl2_mod + + _orig_get_param = _pyopengl2_mod.glGetParameter + + def _safe_get_param(pname): # type: ignore[no-untyped-def] + try: + result = _orig_get_param(pname) + except AttributeError: + # glGetString returned None – no valid OpenGL context yet + return "" + if result is None: + return "" + if isinstance(result, tuple) and len(result) == 0: + return 0 + return result + + _pyopengl2_mod.glGetParameter = _safe_get_param + _vgl.glGetParameter = _safe_get_param + + # get_max_texture_sizes caches (None, None) when GL returns 0/empty; + # replace it everywhere it was imported so image layers get valid sizes. + from functools import lru_cache + + @lru_cache(maxsize=1) + def _safe_get_max_texture_sizes(): # type: ignore[no-untyped-def] + try: + from napari._vispy.utils.gl import _opengl_context + + with _opengl_context(): + max_2d = _vgl.glGetParameter(_vgl.GL_MAX_TEXTURE_SIZE) + max_3d = _vgl.glGetParameter(32883) # GL_MAX_3D_TEXTURE_SIZE + return (int(max_2d) if max_2d else 2048, int(max_3d) if max_3d else 2048) + except Exception: # noqa: BLE001 + return 2048, 2048 + + import napari._vispy.canvas as _canvas_mod + import napari._vispy.layers.base as _base_mod + import napari._vispy.layers.image as _img_mod + import napari._vispy.layers.labels as _lbl_mod + import napari._vispy.utils.gl as _gl_mod + + for _mod in (_gl_mod, _img_mod, _lbl_mod, _base_mod, _canvas_mod): + if hasattr(_mod, "get_max_texture_sizes"): + _mod.get_max_texture_sizes = _safe_get_max_texture_sizes + + except Exception as exc: # noqa: BLE001 # pragma: no cover + import warnings + + warnings.warn(f"Could not patch napari GL functions for headless mode: {exc}", stacklevel=2) + + +if os.environ.get("QT_QPA_PLATFORM") == "offscreen": + _patch_napari_gl_for_headless() + import random import string from abc import ABC, ABCMeta