Probe QtWidgets, not QtCore: the qt backend needs the full GUI stack

QtCore's libraries (glib, icu) can load while QtWidgets/QtGui still
miss libGL - matplotlib's qt backend imports all three, so the old
probe could promise QtAgg and lose even the error figure at
switch_backend, one library layer deeper than the NixOS fix. The
regression test's mock now mirrors the real failure (bare package and
QtCore import fine, GUI modules raise): both the bare-import and the
QtCore-probe variants now fail it, verified by mutation. Also drop two
redundant f-prefixes on placeholder-less strings in report.py.
This commit is contained in:
2026-07-23 17:25:19 +07:00
parent a4945f419c
commit de7a4f8641
3 changed files with 28 additions and 16 deletions
+10 -6
View File
@@ -26,14 +26,18 @@ def _pick_backend():
("Cannot load backend 'TkAgg' ... as 'qt' is currently running") - ("Cannot load backend 'TkAgg' ... as 'qt' is currently running") -
exactly what happened on macOS, whose bundled Python ships tkinter. exactly what happened on macOS, whose bundled Python ships tkinter.
The probe must import the binding's native core, not just the The probe must import QtWidgets, not just the package or QtCore:
package: on NixOS `import PySide6` succeeds (pure __init__) while on NixOS `import PySide6` succeeds (pure __init__) while QtCore's
QtCore's .so cannot find the system libraries pip wheels expect .so cannot find the system libraries pip wheels expect
("libgthread-2.0.so.0: cannot open shared object file") - promising ("libgthread-2.0.so.0: cannot open shared object file"), and on a
QtAgg then kills even the error figure at switch_backend time.""" partially provisioned system QtCore's deps (glib, icu) can be
present while QtWidgets/QtGui still miss libGL/libEGL. matplotlib's
qt backend imports QtCore, QtGui and QtWidgets, so probe the widest
one - promising QtAgg then kills even the error figure at
switch_backend time."""
for qt in ("PySide6", "PyQt6", "PyQt5", "PySide2"): for qt in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
try: try:
__import__(qt + ".QtCore") __import__(qt + ".QtWidgets")
return "QtAgg" if qt in ("PySide6", "PyQt6") else "Qt5Agg" return "QtAgg" if qt in ("PySide6", "PyQt6") else "Qt5Agg"
except Exception: except Exception:
continue continue
+2 -2
View File
@@ -68,7 +68,7 @@ def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
f"resistivity: {problem.rho_ohm_m:.3e} ohm*m", f"resistivity: {problem.rho_ohm_m:.3e} ohm*m",
f"via plating: {problem.plating_nm / 1000:.0f} um", f"via plating: {problem.plating_nm / 1000:.0f} um",
"", "",
(f"frequency: " ("frequency: "
+ (f"{result.freq_hz:g} Hz (skin depth {result.skin_depth_um:.0f} um)" + (f"{result.freq_hz:g} Hz (skin depth {result.skin_depth_um:.0f} um)"
if result.freq_hz > 0 else "DC")), if result.freq_hz > 0 else "DC")),
f"RESISTANCE: {result.R_ohm * 1000:.6g} mOhm" f"RESISTANCE: {result.R_ohm * 1000:.6g} mOhm"
@@ -127,7 +127,7 @@ def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
f"contact model: {result.contact_model}" f"contact model: {result.contact_model}"
+ (" (uniform orthogonal injection; R is the upper contact bound)" + (" (uniform orthogonal injection; R is the upper contact bound)"
if result.contact_model == "uniform" else " (ideal bonded lug)"), if result.contact_model == "uniform" else " (ideal bonded lug)"),
f"terminals:", "terminals:",
f" V+ ({len(problem.electrodes1)} injection area(s)):", f" V+ ({len(problem.electrodes1)} injection area(s)):",
*(f" {_electrode_line(e)}" for e in problem.electrodes1), *(f" {_electrode_line(e)}" for e in problem.electrodes1),
f" V- ({len(problem.electrodes2)} injection area(s)):", f" V- ({len(problem.electrodes2)} injection area(s)):",
+16 -8
View File
@@ -20,19 +20,27 @@ def test_backend_prefers_qt_over_tk():
assert plots._pick_backend() in ("QtAgg", "Qt5Agg") assert plots._pick_backend() in ("QtAgg", "Qt5Agg")
def test_backend_probe_requires_working_qtcore(monkeypatch): def test_backend_probe_requires_working_qt_gui_stack(monkeypatch):
# NixOS: `import PySide6` succeeds (a pure-Python __init__) while # NixOS: `import PySide6` succeeds (a pure-Python __init__) while
# QtCore's .so cannot load the FHS system libraries pip wheels # the native .so's cannot load the FHS system libraries pip wheels
# expect. The probe must import the native core and fall through - # expect; on a partially provisioned system even QtCore loads
# promising QtAgg kills even the error figure at switch_backend # (glib, icu present) while QtWidgets/QtGui still miss libGL. The
# time, and the failure report with it. # probe must import QtWidgets and fall through - promising QtAgg
# kills even the error figure at switch_backend time, and the
# failure report with it. The mock mirrors that faithfully (bare
# package and QtCore succeed, GUI modules fail) so a probe reverted
# to `__import__(qt)` or `.QtCore` would wrongly return QtAgg here.
import builtins import builtins
import types
real_import = builtins.__import__ real_import = builtins.__import__
def broken_qt(name, *args, **kwargs): def broken_qt(name, *args, **kwargs):
if name.split(".")[0] in ("PySide6", "PyQt6", "PyQt5", "PySide2"): root, _, sub = name.partition(".")
raise ImportError("libgthread-2.0.so.0: cannot open shared " if root in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
"object file: No such file or directory") if sub in ("", "QtCore"):
return types.ModuleType(name)
raise ImportError("libGL.so.1: cannot open shared object "
"file: No such file or directory")
return real_import(name, *args, **kwargs) return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", broken_qt) monkeypatch.setattr(builtins, "__import__", broken_qt)