diff --git a/doc/api/backend_qt_api.rst b/doc/api/backend_qt_api.rst
index 474e04ef4b4b..622889c10e5c 100644
--- a/doc/api/backend_qt_api.rst
+++ b/doc/api/backend_qt_api.rst
@@ -1,15 +1,70 @@
:mod:`.backend_qtagg`, :mod:`.backend_qtcairo`
==============================================
-**NOTE** These backends are not documented here, to avoid adding a dependency
-to building the docs.
+**NOTE** These backends are not (auto) documented here, to avoid adding a
+dependency to building the docs.
.. redirect-from:: /api/backend_qt4agg_api
.. redirect-from:: /api/backend_qt4cairo_api
.. redirect-from:: /api/backend_qt5agg_api
.. redirect-from:: /api/backend_qt5cairo_api
+.. module:: matplotlib.backends.qt_compat
+.. module:: matplotlib.backends.backend_qt
.. module:: matplotlib.backends.backend_qtagg
.. module:: matplotlib.backends.backend_qtcairo
.. module:: matplotlib.backends.backend_qt5agg
.. module:: matplotlib.backends.backend_qt5cairo
+
+.. _QT_bindings:
+
+Qt Bindings
+-----------
+
+There are currently 2 actively supported Qt versions, Qt5 and Qt6, and two
+supported Python bindings per version -- `PyQt5
+`_ and `PySide2
+`_ for Qt5 and `PyQt6
+`_ and `PySide6
+`_ for Qt6 [#]_. While both PyQt
+and Qt for Python (aka PySide) closely mirror the underlying C++ API they are
+wrapping, they are not drop-in replacements for each other [#]_. To account
+for this, Matplotlib has an internal API compatibility layer in
+`matplotlib.backends.qt_compat` which covers our needs. Despite being a public
+module, we do not consider this to be a stable user-facing API and it may
+change without warning [#]_.
+
+Previously Matplotlib's Qt backends had the Qt version number in the name, both
+in the module and the :rc:`backend` value
+(e.g. ``matplotlib.backends.backend_qt4agg`` and
+``matplotlib.backends.backend_qt5agg``). However as part of adding support for
+Qt6 we were able to support both Qt5 and Qt6 with a single implementation with
+all of the Qt version and binding support handled in
+`~matplotlib.backends.qt_compat`. A majority of the renderer agnostic Qt code
+is now in `matplotlib.backends.backend_qt` with specialization for AGG in
+``backend_qtagg`` and cairo in ``backend_qtcairo``.
+
+The binding is selected at run time based on what bindings are already imported
+(by checking for the ``QtCore`` sub-package), then by the :envvar:`QT_API`
+environment variable, and finally by the :rc:`backend`. In all cases when we
+need to search, the order is ``PyQt6``, ``PySide6``, ``PyQt5``, ``PySide2``.
+See :ref:`QT_API-usage` for usage instructions.
+
+The ``backend_qt5``, ``backend_qt5agg``, and ``backend_qt5cairo`` are provided
+and force the use of a Qt5 binding for backwards compatibility. Their use is
+discouraged (but not deprecated) and ``backend_qt``, ``backend_qtagg``, or
+``backend_qtcairo`` should be preferred instead. However, these modules will
+not be deprecated until we drop support for Qt5.
+
+
+
+
+.. [#] There is also `PyQt4
+ `_ and `PySide
+ `_ for Qt4 but these are no
+ longer supported by Matplotlib and upstream support for Qt4 ended
+ in 2015.
+.. [#] Despite the slight API differences, the more important distinction
+ between the PyQt and Qt for Python series of bindings is licensing.
+.. [#] If you are looking for a general purpose compatibility library please
+ see `qtpy `_.
diff --git a/doc/users/explain/backends.rst b/doc/users/explain/backends.rst
index e42a489c707b..ca670b82f9ba 100644
--- a/doc/users/explain/backends.rst
+++ b/doc/users/explain/backends.rst
@@ -244,7 +244,8 @@ The :envvar:`QT_API` environment variable can be set to override the search
when nothing has already been loaded. It may be set to (case-insensitively)
PyQt6, PySide6, PyQt5, or PySide2 to pick the version and binding to use. If
the chosen implementation is unavailable, the Qt backend will fail to load
-without attempting any other Qt implementations.
+without attempting any other Qt implementations. See :ref:`QT_bindings` for
+more details.
Using non-builtin backends
--------------------------
diff --git a/lib/matplotlib/backends/__init__.py b/lib/matplotlib/backends/__init__.py
index 6f4015d6ea8e..3e687f85b0be 100644
--- a/lib/matplotlib/backends/__init__.py
+++ b/lib/matplotlib/backends/__init__.py
@@ -1,2 +1,3 @@
# NOTE: plt.switch_backend() (called at import time) will add a "backend"
# attribute here for backcompat.
+_QT_FORCE_QT5_BINDING = False
diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py
index 5234b424d974..76b4d74ef640 100644
--- a/lib/matplotlib/backends/backend_qt.py
+++ b/lib/matplotlib/backends/backend_qt.py
@@ -114,6 +114,28 @@ def _create_qApp():
QtCore.Qt.AA_EnableHighDpiScaling)
except AttributeError: # Only for Qt>=5.6, <6.
pass
+
+ # Check to make sure a QApplication from a different major version
+ # of Qt is not instantiated in the process
+ if QT_API in {'PyQt6', 'PySide6'}:
+ other_bindings = ('PyQt5', 'PySide2')
+ elif QT_API in {'PyQt5', 'PySide2'}:
+ other_bindings = ('PyQt6', 'PySide6')
+ else:
+ raise RuntimeError("Should never be here")
+
+ for binding in other_bindings:
+ mod = sys.modules.get(f'{binding}.QtWidgets')
+ if mod is not None and mod.QApplication.instance() is not None:
+ other_core = sys.modules.get(f'{binding}.QtCore')
+ _api.warn_external(
+ f'Matplotlib is using {QT_API} which wraps '
+ f'{QtCore.qVersion()} however an instantiated '
+ f'QApplication from {binding} which wraps '
+ f'{other_core.qVersion()} exists. Mixing Qt major '
+ 'versions may not work as expected.'
+ )
+ break
try:
QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy(
QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
diff --git a/lib/matplotlib/backends/backend_qt5.py b/lib/matplotlib/backends/backend_qt5.py
index 0774356ff8c5..3c6b2c66a845 100644
--- a/lib/matplotlib/backends/backend_qt5.py
+++ b/lib/matplotlib/backends/backend_qt5.py
@@ -1,4 +1,9 @@
-from .backend_qt import (
+from .. import backends
+
+backends._QT_FORCE_QT5_BINDING = True
+
+
+from .backend_qt import ( # noqa
backend_version, SPECIAL_KEYS,
# Public API
cursord, _create_qApp, _BackendQT, TimerQT, MainWindow, FigureCanvasQT,
@@ -9,8 +14,15 @@
FigureCanvasBase, FigureManagerBase, MouseButton, NavigationToolbar2,
TimerBase, ToolContainerBase, figureoptions, Gcf
)
+from . import backend_qt as _backend_qt # noqa
@_BackendQT.export
class _BackendQT5(_BackendQT):
pass
+
+
+def __getattr__(name):
+ if name == 'qApp':
+ return _backend_qt.qApp
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/lib/matplotlib/backends/backend_qt5agg.py b/lib/matplotlib/backends/backend_qt5agg.py
index d4f618df8ea7..c81fa6f6ccb3 100644
--- a/lib/matplotlib/backends/backend_qt5agg.py
+++ b/lib/matplotlib/backends/backend_qt5agg.py
@@ -1,10 +1,11 @@
"""
Render to qt from agg
"""
+from .. import backends
-from .backend_qtagg import _BackendQTAgg
-from .backend_qtagg import ( # noqa: F401 # pylint: disable=W0611
- FigureCanvasQTAgg, FigureManagerQT, NavigationToolbar2QT,
+backends._QT_FORCE_QT5_BINDING = True
+from .backend_qtagg import ( # noqa: F401, E402 # pylint: disable=W0611
+ _BackendQTAgg, FigureCanvasQTAgg, FigureManagerQT, NavigationToolbar2QT,
backend_version, FigureCanvasAgg, FigureCanvasQT
)
diff --git a/lib/matplotlib/backends/backend_qt5cairo.py b/lib/matplotlib/backends/backend_qt5cairo.py
index 02cf9920ce61..a4263f597119 100644
--- a/lib/matplotlib/backends/backend_qt5cairo.py
+++ b/lib/matplotlib/backends/backend_qt5cairo.py
@@ -1,6 +1,9 @@
-from .backend_qtcairo import _BackendQTCairo
-from .backend_qtcairo import ( # noqa: F401 # pylint: disable=W0611
- FigureCanvasQTCairo, FigureCanvasCairo, FigureCanvasQT)
+from .. import backends
+
+backends._QT_FORCE_QT5_BINDING = True
+from .backend_qtcairo import ( # noqa: F401, E402 # pylint: disable=W0611
+ _BackendQTCairo, FigureCanvasQTCairo, FigureCanvasCairo, FigureCanvasQT
+)
@_BackendQTCairo.export
diff --git a/lib/matplotlib/backends/qt_compat.py b/lib/matplotlib/backends/qt_compat.py
index fd35b31dd7e1..47c1cedff741 100644
--- a/lib/matplotlib/backends/qt_compat.py
+++ b/lib/matplotlib/backends/qt_compat.py
@@ -25,6 +25,7 @@
import matplotlib as mpl
from matplotlib import _api
+from . import _QT_FORCE_QT5_BINDING
QT_API_PYQT6 = "PyQt6"
QT_API_PYSIDE6 = "PySide6"
@@ -57,10 +58,16 @@
# requested backend actually matches). Use dict.__getitem__ to avoid
# triggering backend resolution (which can result in a partially but
# incompletely imported backend_qt5).
-elif dict.__getitem__(mpl.rcParams, "backend") in ["Qt5Agg", "Qt5Cairo"]:
+elif (
+ isinstance(dict.__getitem__(mpl.rcParams, "backend"), str) and
+ dict.__getitem__(mpl.rcParams, "backend").lower() in [
+ "qt5agg", "qt5cairo"
+ ]
+):
if QT_API_ENV in ["pyqt5", "pyside2"]:
QT_API = _ETS[QT_API_ENV]
else:
+ _QT_FORCE_QT5_BINDING = True # noqa
QT_API = None
# A non-Qt backend was selected but we still got there (possible, e.g., when
# fully manually embedding Matplotlib in a Qt app without using pyplot).
@@ -112,12 +119,19 @@ def _isdeleted(obj):
if QT_API in [QT_API_PYQT6, QT_API_PYQT5, QT_API_PYSIDE6, QT_API_PYSIDE2]:
_setup_pyqt5plus()
elif QT_API is None: # See above re: dict.__getitem__.
- _candidates = [
- (_setup_pyqt5plus, QT_API_PYQT6),
- (_setup_pyqt5plus, QT_API_PYSIDE6),
- (_setup_pyqt5plus, QT_API_PYQT5),
- (_setup_pyqt5plus, QT_API_PYSIDE2),
- ]
+ if _QT_FORCE_QT5_BINDING:
+ _candidates = [
+ (_setup_pyqt5plus, QT_API_PYQT5),
+ (_setup_pyqt5plus, QT_API_PYSIDE2),
+ ]
+ else:
+ _candidates = [
+ (_setup_pyqt5plus, QT_API_PYQT6),
+ (_setup_pyqt5plus, QT_API_PYSIDE6),
+ (_setup_pyqt5plus, QT_API_PYQT5),
+ (_setup_pyqt5plus, QT_API_PYSIDE2),
+ ]
+
for _setup, QT_API in _candidates:
try:
_setup()
diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py
index 60887b574645..48131a5b1950 100644
--- a/lib/matplotlib/pyplot.py
+++ b/lib/matplotlib/pyplot.py
@@ -104,7 +104,6 @@ def _copy_docstring_and_deprecators(method, func=None):
## Global ##
-
_IP_REGISTERED = None
_INSTALL_FIG_OBSERVER = False
@@ -207,6 +206,28 @@ def _get_required_interactive_framework(backend_mod):
# Inline this once the deprecation elapses.
return backend_mod.FigureCanvas.required_interactive_framework
+_backend_mod = None
+
+
+def _get_backend_mod():
+ """
+ Ensure that a backend is selected and return it.
+
+ This is currently private, but may be made public in the future.
+ """
+ if _backend_mod is None:
+ # Use __getitem__ here to avoid going through the fallback logic (which
+ # will (re)import pyplot and then call switch_backend if we need to
+ # resolve the auto sentinel)
+ switch_backend(dict.__getitem__(rcParams, "backend"))
+ # Just to be safe. Interactive mode can be turned on without calling
+ # `plt.ion()` so register it again here. This is safe because multiple
+ # calls to `install_repl_displayhook` are no-ops and the registered
+ # function respects `mpl.is_interactive()` to determine if it should
+ # trigger a draw.
+ install_repl_displayhook()
+ return _backend_mod
+
def switch_backend(newbackend):
"""
@@ -297,7 +318,7 @@ class backend_mod(matplotlib.backend_bases._Backend):
def _warn_if_gui_out_of_main_thread():
- if (_get_required_interactive_framework(_backend_mod)
+ if (_get_required_interactive_framework(_get_backend_mod())
and threading.current_thread() is not threading.main_thread()):
_api.warn_external(
"Starting a Matplotlib GUI outside of the main thread will likely "
@@ -308,7 +329,7 @@ def _warn_if_gui_out_of_main_thread():
def new_figure_manager(*args, **kwargs):
"""Create a new figure manager instance."""
_warn_if_gui_out_of_main_thread()
- return _backend_mod.new_figure_manager(*args, **kwargs)
+ return _get_backend_mod().new_figure_manager(*args, **kwargs)
# This function's signature is rewritten upon backend-load by switch_backend.
@@ -321,7 +342,7 @@ def draw_if_interactive(*args, **kwargs):
End users will typically not have to call this function because the
the interactive mode takes care of this.
"""
- return _backend_mod.draw_if_interactive(*args, **kwargs)
+ return _get_backend_mod().draw_if_interactive(*args, **kwargs)
# This function's signature is rewritten upon backend-load by switch_backend.
@@ -370,7 +391,7 @@ def show(*args, **kwargs):
explicitly there.
"""
_warn_if_gui_out_of_main_thread()
- return _backend_mod.show(*args, **kwargs)
+ return _get_backend_mod().show(*args, **kwargs)
def isinteractive():
@@ -2226,15 +2247,6 @@ def polar(*args, **kwargs):
set(_interactive_bk) - {'WebAgg', 'nbAgg'})
and cbook._get_running_interactive_framework()):
dict.__setitem__(rcParams, "backend", rcsetup._auto_backend_sentinel)
-# Set up the backend.
-switch_backend(rcParams["backend"])
-
-# Just to be safe. Interactive mode can be turned on without
-# calling `plt.ion()` so register it again here.
-# This is safe because multiple calls to `install_repl_displayhook`
-# are no-ops and the registered function respect `mpl.is_interactive()`
-# to determine if they should trigger a draw.
-install_repl_displayhook()
################# REMAINING CONTENT GENERATED BY boilerplate.py ##############
diff --git a/lib/matplotlib/testing/__init__.py b/lib/matplotlib/testing/__init__.py
index 754277c41f43..eba878e0a4a3 100644
--- a/lib/matplotlib/testing/__init__.py
+++ b/lib/matplotlib/testing/__init__.py
@@ -1,12 +1,13 @@
"""
Helper functions for testing.
"""
-
+from pathlib import Path
+from tempfile import TemporaryDirectory
import locale
import logging
+import os
import subprocess
-from pathlib import Path
-from tempfile import TemporaryDirectory
+import sys
import matplotlib as mpl
from matplotlib import _api
@@ -49,6 +50,46 @@ def setup():
set_reproducibility_for_testing()
+def subprocess_run_helper(func, *args, timeout, **extra_env):
+ """
+ Run a function in a sub-process
+
+ Parameters
+ ----------
+ func : function
+ The function to be run. It must be in a module that is importable.
+
+ *args : str
+ Any additional command line arguments to be passed in
+ the first argument to subprocess.run
+
+ **extra_env : Dict[str, str]
+ Any additional envromental variables to be set for
+ the subprocess.
+
+ """
+ target = func.__name__
+ module = func.__module__
+ proc = subprocess.run(
+ [sys.executable,
+ "-c",
+ f"""
+from {module} import {target}
+{target}()
+""",
+ *args],
+ env={
+ **os.environ,
+ "SOURCE_DATE_EPOCH": "0",
+ **extra_env
+ },
+ timeout=timeout, check=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ universal_newlines=True)
+ return proc
+
+
def _check_for_pgf(texsystem):
"""
Check if a given TeX system + pgf is available
diff --git a/lib/matplotlib/tests/test_backend_tk.py b/lib/matplotlib/tests/test_backend_tk.py
index c3dac0556087..f7bb141d2541 100644
--- a/lib/matplotlib/tests/test_backend_tk.py
+++ b/lib/matplotlib/tests/test_backend_tk.py
@@ -64,6 +64,7 @@ def test_func():
def test_blit(): # pragma: no cover
import matplotlib.pyplot as plt
import numpy as np
+ import matplotlib.backends.backend_tkagg # noqa
from matplotlib.backends import _tkagg
fig, ax = plt.subplots()
diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py
index 346e0e2b967e..2818f3d21cca 100644
--- a/lib/matplotlib/tests/test_backends_interactive.py
+++ b/lib/matplotlib/tests/test_backends_interactive.py
@@ -14,6 +14,7 @@
import matplotlib as mpl
from matplotlib import _c_internal_utils
+from matplotlib.testing import subprocess_run_helper as _run_helper
# Minimal smoke-testing of the backends for which the dependencies are
@@ -87,8 +88,8 @@ def _test_interactive_impl():
"webagg.open_in_browser": False,
"webagg.port_retries": 1,
})
- if len(sys.argv) >= 2: # Second argument is json-encoded rcParams.
- rcParams.update(json.loads(sys.argv[1]))
+
+ rcParams.update(json.loads(sys.argv[1]))
backend = plt.rcParams["backend"].lower()
assert_equal = TestCase().assertEqual
assert_raises = TestCase().assertRaises
@@ -163,27 +164,16 @@ def test_interactive_backend(env, toolbar):
if env["MPLBACKEND"] == "macosx":
if toolbar == "toolmanager":
pytest.skip("toolmanager is not implemented for macosx.")
+ proc = _run_helper(_test_interactive_impl,
+ json.dumps({"toolbar": toolbar}),
+ timeout=_test_timeout,
+ **env)
- proc = subprocess.run(
- [sys.executable, "-c",
- inspect.getsource(_test_interactive_impl)
- + "\n_test_interactive_impl()",
- json.dumps({"toolbar": toolbar})],
- env={**os.environ, "SOURCE_DATE_EPOCH": "0", **env},
- timeout=_test_timeout,
- stdout=subprocess.PIPE, universal_newlines=True)
- if proc.returncode:
- pytest.fail("The subprocess returned with non-zero exit status "
- f"{proc.returncode}.")
assert proc.stdout.count("CloseEvent") == 1
-# The source of this function gets extracted and run in another process, so it
-# must be fully self-contained.
def _test_thread_impl():
from concurrent.futures import ThreadPoolExecutor
- import json
- import sys
from matplotlib import pyplot as plt, rcParams
@@ -191,8 +181,6 @@ def _test_thread_impl():
"webagg.open_in_browser": False,
"webagg.port_retries": 1,
})
- if len(sys.argv) >= 2: # Second argument is json-encoded rcParams.
- rcParams.update(json.loads(sys.argv[1]))
# Test artist creation and drawing does not crash from thread
# No other guarantees!
@@ -246,15 +234,125 @@ def _test_thread_impl():
@pytest.mark.parametrize("env", _thread_safe_backends)
@pytest.mark.flaky(reruns=3)
def test_interactive_thread_safety(env):
- proc = subprocess.run(
- [sys.executable, "-c",
- inspect.getsource(_test_thread_impl) + "\n_test_thread_impl()"],
- env={**os.environ, "SOURCE_DATE_EPOCH": "0", **env},
- timeout=_test_timeout, check=True,
- stdout=subprocess.PIPE, universal_newlines=True)
+ proc = _run_helper(_test_thread_impl,
+ timeout=_test_timeout, **env)
assert proc.stdout.count("CloseEvent") == 1
+def _impl_test_lazy_auto_backend_selection():
+ import matplotlib
+ import matplotlib.pyplot as plt
+ # just importing pyplot should not be enough to trigger resolution
+ bk = dict.__getitem__(matplotlib.rcParams, 'backend')
+ assert not isinstance(bk, str)
+ assert plt._backend_mod is None
+ # but actually plotting should
+ plt.plot(5)
+ assert plt._backend_mod is not None
+ bk = dict.__getitem__(matplotlib.rcParams, 'backend')
+ assert isinstance(bk, str)
+
+
+def test_lazy_auto_backend_selection():
+ _run_helper(_impl_test_lazy_auto_backend_selection,
+ timeout=_test_timeout)
+
+
+def _implqt5agg():
+ import matplotlib.backends.backend_qt5agg # noqa
+ import sys
+
+ assert 'PyQt6' not in sys.modules
+ assert 'pyside6' not in sys.modules
+ assert 'PyQt5' in sys.modules or 'pyside2' in sys.modules
+
+ import matplotlib.backends.backend_qt5
+ matplotlib.backends.backend_qt5.qApp
+
+
+def _implcairo():
+ import matplotlib.backends.backend_qt5cairo # noqa
+ import sys
+
+ assert 'PyQt6' not in sys.modules
+ assert 'pyside6' not in sys.modules
+ assert 'PyQt5' in sys.modules or 'pyside2' in sys.modules
+
+ import matplotlib.backends.backend_qt5
+ matplotlib.backends.backend_qt5.qApp
+
+
+def _implcore():
+ import matplotlib.backends.backend_qt5
+ import sys
+
+ assert 'PyQt6' not in sys.modules
+ assert 'pyside6' not in sys.modules
+ assert 'PyQt5' in sys.modules or 'pyside2' in sys.modules
+ matplotlib.backends.backend_qt5.qApp
+
+
+def test_qt5backends_uses_qt5():
+ qt5_bindings = [
+ dep for dep in ['PyQt5', 'pyside2']
+ if importlib.util.find_spec(dep) is not None
+ ]
+ qt6_bindings = [
+ dep for dep in ['PyQt6', 'pyside6']
+ if importlib.util.find_spec(dep) is not None
+ ]
+ if len(qt5_bindings) == 0 or len(qt6_bindings) == 0:
+ pytest.skip('need both QT6 and QT5 bindings')
+ _run_helper(_implqt5agg, timeout=_test_timeout)
+ if importlib.util.find_spec('pycairo') is not None:
+ _run_helper(_implcairo, timeout=_test_timeout)
+ _run_helper(_implcore, timeout=_test_timeout)
+
+
+def _impl_test_cross_Qt_imports():
+ import sys
+ import importlib
+ import pytest
+
+ _, host_binding, mpl_binding = sys.argv
+ # import the mpl binding. This will force us to use that binding
+ importlib.import_module(f'{mpl_binding}.QtCore')
+ mpl_binding_qwidgets = importlib.import_module(f'{mpl_binding}.QtWidgets')
+ import matplotlib.backends.backend_qt
+ host_qwidgets = importlib.import_module(f'{host_binding}.QtWidgets')
+
+ host_app = host_qwidgets.QApplication(["mpl testing"])
+ with pytest.warns(UserWarning, match="Mixing Qt major"):
+ matplotlib.backends.backend_qt._create_qApp()
+
+
+def test_cross_Qt_imports():
+ qt5_bindings = [
+ dep for dep in ['PyQt5', 'PySide2']
+ if importlib.util.find_spec(dep) is not None
+ ]
+ qt6_bindings = [
+ dep for dep in ['PyQt6', 'PySide6']
+ if importlib.util.find_spec(dep) is not None
+ ]
+ if len(qt5_bindings) == 0 or len(qt6_bindings) == 0:
+ pytest.skip('need both QT6 and QT5 bindings')
+
+ for qt5 in qt5_bindings:
+ for qt6 in qt6_bindings:
+ for pair in ([qt5, qt6], [qt6, qt5]):
+ try:
+ _run_helper(_impl_test_cross_Qt_imports,
+ *pair,
+ timeout=_test_timeout)
+ except subprocess.CalledProcessError as ex:
+ # if segfault, carry on. We do try to warn the user they
+ # are doing something that we do not expect to work
+ if ex.returncode == -11:
+ continue
+ raise
+
+
@pytest.mark.skipif('TF_BUILD' in os.environ,
reason="this test fails an azure for unknown reasons")
@pytest.mark.skipif(os.name == "nt", reason="Cannot send SIGINT on Windows.")
@@ -263,7 +361,7 @@ def test_webagg():
proc = subprocess.Popen(
[sys.executable, "-c",
inspect.getsource(_test_interactive_impl)
- + "\n_test_interactive_impl()"],
+ + "\n_test_interactive_impl()", "{}"],
env={**os.environ, "MPLBACKEND": "webagg", "SOURCE_DATE_EPOCH": "0"})
url = "http://{}:{}".format(
mpl.rcParams["webagg.address"], mpl.rcParams["webagg.port"])
@@ -285,37 +383,33 @@ def test_webagg():
assert proc.wait(timeout=_test_timeout) == 0
+def _lazy_headless():
+ import os
+ import sys
+
+ # make it look headless
+ os.environ.pop('DISPLAY', None)
+ os.environ.pop('WAYLAND_DISPLAY', None)
+
+ # we should fast-track to Agg
+ import matplotlib.pyplot as plt
+ plt.get_backend() == 'agg'
+ assert 'PyQt5' not in sys.modules
+
+ # make sure we really have pyqt installed
+ import PyQt5 # noqa
+ assert 'PyQt5' in sys.modules
+
+ # try to switch and make sure we fail with ImportError
+ try:
+ plt.switch_backend('qt5agg')
+ except ImportError:
+ ...
+ else:
+ sys.exit(1)
+
+
@pytest.mark.skipif(sys.platform != "linux", reason="this a linux-only test")
@pytest.mark.backend('QtAgg', skip_on_importerror=True)
def test_lazy_linux_headless():
- test_script = """
-import os
-import sys
-
-# make it look headless
-os.environ.pop('DISPLAY', None)
-os.environ.pop('WAYLAND_DISPLAY', None)
-
-# we should fast-track to Agg
-import matplotlib.pyplot as plt
-plt.get_backend() == 'agg'
-assert 'PyQt5' not in sys.modules
-
-# make sure we really have pyqt installed
-import PyQt5
-assert 'PyQt5' in sys.modules
-
-# try to switch and make sure we fail with ImportError
-try:
- plt.switch_backend('qt5agg')
-except ImportError:
- ...
-else:
- sys.exit(1)
-
-"""
- proc = subprocess.run([sys.executable, "-c", test_script],
- env={**os.environ, "MPLBACKEND": ""})
- if proc.returncode:
- pytest.fail("The subprocess returned with non-zero exit status "
- f"{proc.returncode}.")
+ proc = _run_helper(_lazy_headless, timeout=_test_timeout, MPLBACKEND="")
diff --git a/lib/matplotlib/tests/test_rcparams.py b/lib/matplotlib/tests/test_rcparams.py
index 2b2e36e2a516..75b6f727f799 100644
--- a/lib/matplotlib/tests/test_rcparams.py
+++ b/lib/matplotlib/tests/test_rcparams.py
@@ -497,7 +497,8 @@ def test_backend_fallback_headless(tmpdir):
[sys.executable, "-c",
"import matplotlib;"
"matplotlib.use('tkagg');"
- "import matplotlib.pyplot"
+ "import matplotlib.pyplot;"
+ "matplotlib.pyplot.plot(42);"
],
env=env, check=True, stderr=subprocess.DEVNULL)