Skip to content

Commit a5b423f

Browse files
LucaMarconatoUbuntupre-commit-ci[bot]
authored
Patch headless tests due to QT_QPA_PLATFORM=offscreen edgecase (#390)
* patch headless tests due to vispy * improve docstring * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix precommit; fix spatialdata pre-release dep --------- Co-authored-by: Ubuntu <ubuntu@machine.denbi.prod> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 07ff794 commit a5b423f

File tree

2 files changed

+86
-1
lines changed

2 files changed

+86
-1
lines changed

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ readthedocs =
9999

100100
# this is just to trigger pip to check for pre-releases as well
101101
pre =
102-
spatialdata>=0.7.0dev0
102+
spatialdata>=0.7.3a1
103103

104104
all =
105105
napari[pyqt5]

tests/conftest.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
# ruff: noqa: E402
34
# MUST set environment variables BEFORE any Qt/napari/vispy imports
45
# to enable headless mode in CI environments (Ubuntu/Linux without display)
56
import os
@@ -10,6 +11,90 @@
1011
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
1112

1213
os.environ.setdefault("NAPARI_HEADLESS", "1")
14+
15+
16+
def _patch_napari_gl_for_headless() -> None:
17+
"""Patch napari's OpenGL utility functions to work without a real display.
18+
19+
The patch implements two workaround that are no-ops in environments that
20+
have a real display (CI with Xvfb, macOS, local dev). Once the upstreams
21+
bugs are addressed this patch should be removed.
22+
23+
In the Qt offscreen platform ``glGetString(GL_EXTENSIONS)`` returns ``None``
24+
(raising AttributeError on ``.decode()``) and ``glGetIntegerv`` returns an
25+
empty tuple instead of an integer. napari then stores ``None`` as the max
26+
texture size, which later crashes ``TiledImageNode``.
27+
28+
Upstream bugs:
29+
* **vispy** – ``vispy/gloo/gl/_pyopengl2.py`` does not guard against
30+
``GL.glGetString()`` returning ``None`` (no valid OpenGL context).
31+
32+
* **napari** – ``get_gl_extensions()`` and ``get_max_texture_sizes()`` in
33+
``napari/_vispy/utils/gl.py`` do not handle the failure/empty-result
34+
case from the underlying GL calls, crashing when run in offscreen mode.
35+
36+
Fixes applied here:
37+
* ``vispy.gloo.gl._pyopengl2.glGetParameter`` – return ``""`` for string
38+
queries when the result is ``None``, and ``0`` for empty-tuple results.
39+
* ``napari._vispy.utils.gl.get_max_texture_sizes`` (and every module that
40+
imported it) – fall back to ``(2048, 2048)`` when the GL query returns 0.
41+
"""
42+
try:
43+
import vispy.gloo.gl as _vgl
44+
import vispy.gloo.gl._pyopengl2 as _pyopengl2_mod
45+
46+
_orig_get_param = _pyopengl2_mod.glGetParameter
47+
48+
def _safe_get_param(pname): # type: ignore[no-untyped-def]
49+
try:
50+
result = _orig_get_param(pname)
51+
except AttributeError:
52+
# glGetString returned None – no valid OpenGL context yet
53+
return ""
54+
if result is None:
55+
return ""
56+
if isinstance(result, tuple) and len(result) == 0:
57+
return 0
58+
return result
59+
60+
_pyopengl2_mod.glGetParameter = _safe_get_param
61+
_vgl.glGetParameter = _safe_get_param
62+
63+
# get_max_texture_sizes caches (None, None) when GL returns 0/empty;
64+
# replace it everywhere it was imported so image layers get valid sizes.
65+
from functools import lru_cache
66+
67+
@lru_cache(maxsize=1)
68+
def _safe_get_max_texture_sizes(): # type: ignore[no-untyped-def]
69+
try:
70+
from napari._vispy.utils.gl import _opengl_context
71+
72+
with _opengl_context():
73+
max_2d = _vgl.glGetParameter(_vgl.GL_MAX_TEXTURE_SIZE)
74+
max_3d = _vgl.glGetParameter(32883) # GL_MAX_3D_TEXTURE_SIZE
75+
return (int(max_2d) if max_2d else 2048, int(max_3d) if max_3d else 2048)
76+
except Exception: # noqa: BLE001
77+
return 2048, 2048
78+
79+
import napari._vispy.canvas as _canvas_mod
80+
import napari._vispy.layers.base as _base_mod
81+
import napari._vispy.layers.image as _img_mod
82+
import napari._vispy.layers.labels as _lbl_mod
83+
import napari._vispy.utils.gl as _gl_mod
84+
85+
for _mod in (_gl_mod, _img_mod, _lbl_mod, _base_mod, _canvas_mod):
86+
if hasattr(_mod, "get_max_texture_sizes"):
87+
_mod.get_max_texture_sizes = _safe_get_max_texture_sizes
88+
89+
except Exception as exc: # noqa: BLE001 # pragma: no cover
90+
import warnings
91+
92+
warnings.warn(f"Could not patch napari GL functions for headless mode: {exc}", stacklevel=2)
93+
94+
95+
if os.environ.get("QT_QPA_PLATFORM") == "offscreen":
96+
_patch_napari_gl_for_headless()
97+
1398
import random
1499
import string
15100
from abc import ABC, ABCMeta

0 commit comments

Comments
 (0)