#!/usr/bin/env python3 """Pick a Qt platform that VTK can actually draw into, and say which and why. VTK's Python wheels ship no Wayland window backend - the on-screen class is `vtkXOpenGLRenderWindow`, and EGL and OSMesa are both offscreen. Left alone, Qt6 picks Wayland whenever `WAYLAND_DISPLAY` is set while VTK goes on creating an X window, and the two disagree about who owns the surface: the process dies with `BadWindow (X_ConfigureWindow)` before anything is drawn. There is a way out of that in principle - hand VTK a `vtkGenericOpenGLRenderWindow` and let *Qt* own the GL context, which is what VTK's C++ `QVTKOpenGLNativeWidget` does - but not from here. pyvistaqt's interactor is the Python `QVTKRenderWindowInteractor`, and it paints from `paintEvent`: it never implements `paintGL`, never binds Qt's framebuffer and never makes a context current. A generic render window given to it draws into no context at all - a black viewport, and a VTK that will not even read its own buffer back ("render window is not current"). So VTK keeps a window of its own, which is an X window, and a Wayland session is served through XWayland. The order of preference is therefore Wayland, then X11, then offscreen, with Wayland having to prove itself first - and the choice is announced once, because a tool that silently moves you to XWayland is a tool that will be blamed for the missing fractional scaling. The other half of saying why: a Qt that will not import fails the Wayland test frame the same way a bad GL stack does, and blaming the compositor for that is how an afternoon disappears. So the binding is checked first, and a broken one is named as broken rather than dressed up as a display problem. Not all of that stack is packaged everywhere - pyvista and pyvistaqt usually are not - so the tools keep their own virtualenv at `tools/.venv`, built with `--system-site-packages` so the distribution's Qt is still the Qt in use. If the interpreter a tool was started with cannot find the stack and that virtualenv can, the tool is re-run there rather than failed with a recipe to type out. """ from __future__ import annotations import ctypes import ctypes.util import importlib import importlib.util import os import re import subprocess import sys import tempfile import textwrap # The tools' own virtualenv, the modules it exists to provide, and the flag # that keeps a hop to it from happening twice. _VENV = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.venv') _NO_VENV = 'MIAPI_TOOLS_NO_VENV' _STACK = ('PySide6', 'vtkmodules', 'pyvista', 'pyvistaqt') # The platform this process settled on, so a tool that imports another tool # does not re-decide and re-announce it. _chosen = None def configure(prefer=None, quiet=False): """Set the Qt platform and VTK widget base. Call before importing Qt. Returns the platform name chosen. `prefer` forces one; `QT_QPA_PLATFORM` set in the environment wins over both, because someone who set it meant it. """ # Before asking which display server to draw on, ask whether there is # anything to draw with. A stack that will not import fails every probe # too, and a probe that fails is read as "this platform does not work" - # which is how a broken install ends up wearing a Wayland costume. ensure_stack(quiet) global _chosen if _chosen: # A second caller - one tool importing another - is asking a question # that has already been answered, and answering it twice on stderr # reads as indecision. return _chosen chosen = os.environ.get('QT_QPA_PLATFORM') note = '' if not chosen: chosen, note = prefer, '' if not chosen: chosen, note = _detect() os.environ['QT_QPA_PLATFORM'] = chosen origin = 'detected' else: origin = 'from QT_QPA_PLATFORM' if not quiet: print(f'display: {chosen} ({origin}){note}', file=sys.stderr) if chosen == 'offscreen': print('display: no Wayland or X11 session - rendering to files only', file=sys.stderr) _chosen = chosen return chosen def ensure_stack(quiet=False): """Import the drawing stack, or re-run in the interpreter that has it. `configure` calls this; so may a tool that draws without asking for a platform. Exits with something worth reading if neither interpreter can import the stack. """ # qtpy - which pyvistaqt imports - reads QT_API to choose a binding, and # left to its own order it finds PyQt6 first on a machine that has both. # Two bindings in one process is a crash rather than a mismatch, so ours # is named here, before anything goes looking. os.environ.setdefault('QT_API', 'pyside6') missing = [m for m in _STACK if importlib.util.find_spec(m) is None] if missing: _hop_to_venv(missing, quiet) # replaces this process, if it can raise SystemExit(_stack_message(missing=missing)) broken = _import_stack() if broken: raise SystemExit(_stack_message(broken)) def _hop_to_venv(missing, quiet): """Re-run this tool in `tools/.venv`, if that is where the stack lives. Only what is *absent* sends us there. A PySide6 that is installed but will not load is a system problem, and the venv is deliberately built on the system's Qt, so hopping would be asking the same question twice. """ python = os.path.join(_VENV, 'bin', 'python') here = os.path.realpath(sys.prefix) == os.path.realpath(_VENV) if here or os.environ.get(_NO_VENV) or not os.path.exists(python): return if not quiet: print(f'python: no {", ".join(missing)} here - using {_VENV}', file=sys.stderr) os.environ[_NO_VENV] = '1' # one hop: let the venv fail in plain sight try: os.execv(python, [python, *sys.argv]) except OSError as exc: del os.environ[_NO_VENV] print(f'python: {python} would not start ({exc})', file=sys.stderr) def _import_stack(): """Import what has to be imported early, in the order that matters. PySide6 comes first because `vtkmodules.qt` picks its binding from whatever is already imported, and it makes that choice once. The widget base class is left alone: `QVTKRWIBase = 'QOpenGLWidget'` is only sound with a render window Qt drives, and nothing here drives one. Returns the first failure, or None. """ for module in ('PySide6.QtCore', 'PySide6.QtWidgets', 'vtkmodules.qt'): try: importlib.import_module(module) except ImportError as exc: return f'{module}: {exc}' return None def _stack_message(err=None, missing=()): """Say what is actually broken, and what fixes it.""" if missing: return '\n'.join([ 'display: the drawing stack is incomplete, so nothing can be drawn.', f' no module named {", ".join(missing)}', '', *textwrap.wrap( 'pyvista and pyvistaqt are packaged by hardly any distribution, so ' "the tools keep a virtualenv of their own for them - built on the " "system's Qt and VTK rather than over the top of them. The tools " 'use it by themselves once it is there:', 78), f' python -m venv --system-site-packages {_VENV}', f' {os.path.join(_VENV, "bin", "pip")} install pyvista pyvistaqt', ]) lines = ['display: the Qt stack will not import, so no window can be opened.', f' {err}'] # The signature of a half-finished upgrade. PySide6 links Qt's *private* # ABI, which is versioned build-for-build on purpose, so a PySide6 and a # libQt6Core from two different releases will not load together even # though their public API is identical. The symbol names the release # PySide6 was compiled against; libQt6Core will tell us its own. want = re.search(r'QtPrivate_(\d+)_(\d+)_(\d+)', err) if 'undefined symbol' in err and (want or 'Qt_6_PRIVATE_API' in err): built = '.'.join(want.groups()) if want else 'another release' have = _qt_runtime_version() found = f', but the Qt libraries here are {have}' if have else '' lines += ['', *textwrap.wrap( f'PySide6 was built against Qt {built}{found}. The two share a ' 'private ABI and have to be upgraded together, so this is a partial ' 'upgrade rather than a display problem.', 78), ' Arch: sudo pacman -Syu', ' (upgrading qt6-base alone leaves the rest of the system behind)', ] return '\n'.join(lines) def _qt_runtime_version(): """Ask libQt6Core its version, without going through the broken binding.""" try: path = ctypes.util.find_library('Qt6Core') or 'libQt6Core.so.6' lib = ctypes.CDLL(path) lib.qVersion.restype = ctypes.c_char_p return lib.qVersion().decode() except (OSError, AttributeError, UnicodeDecodeError): return None def _detect(): """Choose a platform, and carry back the reason for anything given up.""" wayland = os.environ.get('WAYLAND_DISPLAY') and _socket_exists() x11 = bool(os.environ.get('DISPLAY')) why = '' if wayland: ok, why = _probe('wayland') if ok: return 'wayland', '' if x11: return 'xcb', f' - Wayland {why}' if why else '' if wayland: return 'wayland', (f' - Wayland {why}, and there is no X11 to fall' ' back to') if why else '' return 'offscreen', '' # The failure this guards against is not an exception. A Wayland session whose # GL stack cannot make the context current - WSLg with a software Mesa is the # common one - takes the whole process down with an X `BadAccess` on the first # paint, after the window is already up. Nothing in-process can catch that, so # the question gets asked in a process we can afford to lose. _PROBE = """ import os, sys from PySide6 import QtCore, QtWidgets import pyvistaqt, pyvista as pv # Everything above is toolchain, not display. Past this line - a window, a # context, a frame - a failure really is the display's, and the caller may say # so. The marker goes here rather than after the window is up because opening # the window is itself one of the things that fails. open(sys.argv[1] + '.started', 'w').write('ok') app = QtWidgets.QApplication(['probe']) win = QtWidgets.QMainWindow() view = pyvistaqt.QtInteractor(win) win.setCentralWidget(view) win.resize(64, 64) win.show() def go(): # Exercise what the tools actually ask for. A bare cube survives GL stacks # that fall over on a textured, depth-peeled scene, and a probe that passes # where the app crashes is worse than no probe at all. import numpy as np view.enable_depth_peeling(number_of_peels=8, occlusion_ratio=0.0) mesh = pv.Cube() mesh.active_texture_coordinates = np.random.rand(mesh.n_points, 2).astype(np.float32) tex = pv.Texture(np.random.randint(0, 255, (8, 8, 4), dtype=np.uint8)) tex.SetInterpolate(False) view.add_mesh(mesh, texture=tex) view.add_light(pv.Light(position=(1, 1, 1), light_type='scene light')) view.render() # Read the frame back out of VTK's own window. It is proof of two things # at once: that the paint survived, and that something was drawn into a # buffer VTK can find - the failure that leaves the viewport black does # not raise, it just renders nowhere, and this is where that shows up. view.screenshot(sys.argv[1] + '.png') open(sys.argv[1], 'w').write('ok') app.quit() QtCore.QTimer.singleShot(0, go) app.exec() """ def _probe(platform, timeout=25): """Does a real VTK viewport survive its first frame on this platform? Returns `(ok, why)`, where `why` finishes the sentence "Wayland ...". The reason matters: a probe that died before it ever opened a window says nothing about the display, and reporting it as a failed frame sends the next hour after the wrong bug. """ if os.environ.get('MIAPI_TOOLS_NO_PROBE'): return False, 'was not tried (MIAPI_TOOLS_NO_PROBE)' with tempfile.TemporaryDirectory() as tmp: flag = os.path.join(tmp, 'flag') env = dict(os.environ, QT_QPA_PLATFORM=platform) env.pop('MIAPI_TOOLS_NO_PROBE', None) try: done = subprocess.run([sys.executable, '-c', _PROBE, flag], env=env, timeout=timeout, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) except subprocess.TimeoutExpired: return False, f'did not finish a test frame within {timeout}s' except OSError as exc: return False, f'could not be tested ({exc})' if os.path.exists(flag): return True, '' if os.path.exists(flag + '.started'): return False, ('is present but VTK cannot draw there - ' + _last_error(done.stderr)) return False, f'could not be tested - {_last_error(done.stderr)}' def _last_error(stderr): """The one line worth repeating out of whatever the probe printed.""" lines = [ln.strip() for ln in stderr.decode('utf-8', 'replace').splitlines() if ln.strip()] # An X protocol error is not raised, it is printed - five lines of it, of # which the first and the opcode are the ones that say anything. Xlib then # takes the process down, so this is also the last word on what happened. for i, line in enumerate(lines): if line.startswith('X Error of failed request:'): what = line.split(':', 1)[1].strip() opcode = next((ln.split(':', 1)[1].strip() for ln in lines[i:] if ln.startswith('Major opcode')), '') where = f" on {opcode.split(None, 1)[-1].strip('()')}" if opcode else '' return f'X {what}{where}' for line in reversed(lines): if not line.startswith(('File "', 'Traceback', '^', '~', '|')): return line return 'the probe failed with no output' def _socket_exists(): """A `WAYLAND_DISPLAY` with no socket behind it is a stale export.""" name = os.environ.get('WAYLAND_DISPLAY', '') if os.path.isabs(name): return os.path.exists(name) runtime = os.environ.get('XDG_RUNTIME_DIR') return bool(runtime) and os.path.exists(os.path.join(runtime, name)) def viewport(parent): """The 3D view the tools draw into. Import Qt first. Deliberately plain: pyvistaqt is left to make its own render window, since the only one it knows how to drive is the one it makes. """ import pyvistaqt return pyvistaqt.QtInteractor(parent)