From f937b0ab5ef9d5ffe9f2f58f6391357783cc4afa Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 3 Mar 2022 18:22:01 -0500 Subject: [PATCH] Backport PR #22005: Further defer backend selection Merge pull request #22005 from tacaswell/further_defer_backend_selection Further defer backend selection --- doc/api/backend_qt_api.rst | 59 ++++- doc/users/explain/backends.rst | 3 +- lib/matplotlib/backends/__init__.py | 1 + lib/matplotlib/backends/backend_qt.py | 22 ++ lib/matplotlib/backends/backend_qt5.py | 14 +- lib/matplotlib/backends/backend_qt5agg.py | 4 +- lib/matplotlib/backends/backend_qt5cairo.py | 8 +- lib/matplotlib/backends/qt_compat.py | 28 ++- lib/matplotlib/pyplot.py | 40 ++-- lib/matplotlib/testing/__init__.py | 47 +++- lib/matplotlib/tests/test_backend_tk.py | 1 + .../tests/test_backends_interactive.py | 208 +++++++++++++----- lib/matplotlib/tests/test_rcparams.py | 3 +- 13 files changed, 348 insertions(+), 90 deletions(-) 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 b02edc6c64df..e1158c49a86c 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -115,6 +115,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 d176fbe82bfb..c81fa6f6ccb3 100644 --- a/lib/matplotlib/backends/backend_qt5agg.py +++ b/lib/matplotlib/backends/backend_qt5agg.py @@ -1,8 +1,10 @@ """ Render to qt from agg """ +from .. import backends -from .backend_qtagg import ( +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 51eae512c654..f7a38fabf07a 100644 --- a/lib/matplotlib/backends/backend_qt5cairo.py +++ b/lib/matplotlib/backends/backend_qt5cairo.py @@ -1,6 +1,8 @@ -from .backend_qtcairo import ( - _BackendQTCairo, 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, RendererCairo ) diff --git a/lib/matplotlib/backends/qt_compat.py b/lib/matplotlib/backends/qt_compat.py index 9e320e341c4c..47888726d766 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 456c44ded097..ced88bbf012d 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 @@ -202,6 +201,28 @@ def _get_required_interactive_framework(backend_mod): return getattr( backend_mod.FigureCanvas, "required_interactive_framework", None) +_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): """ @@ -292,7 +313,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 " @@ -303,7 +324,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. @@ -316,7 +337,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. @@ -365,7 +386,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 f9c547ce00aa..05e20f000deb 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 c8ae9a77c727..ba376b0779bb 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)