create-mekanism-modular/tools/guiplatform.py

343 lines
14 KiB
Python

#!/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)