From 48db078218fcc08fd2202a4409726a18d610b9cc Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Sat, 18 Dec 2021 20:30:23 -0500 Subject: [PATCH 1/7] MNT: make some environment checking case insensitive --- lib/matplotlib/backends/qt_compat.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/backends/qt_compat.py b/lib/matplotlib/backends/qt_compat.py index fd35b31dd7e1..de0525014419 100644 --- a/lib/matplotlib/backends/qt_compat.py +++ b/lib/matplotlib/backends/qt_compat.py @@ -57,7 +57,12 @@ # 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: From 5336d0e45382878fa185634f8816a113726ebe02 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Wed, 12 Jan 2022 16:19:10 -0500 Subject: [PATCH 2/7] FIX: restore matplotlib.backends.backend_qt5.qApp This has to be a module-level getattr so that we can proxy to the current state of backend_qt. --- lib/matplotlib/backends/backend_qt5.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/matplotlib/backends/backend_qt5.py b/lib/matplotlib/backends/backend_qt5.py index 0774356ff8c5..ec544994a519 100644 --- a/lib/matplotlib/backends/backend_qt5.py +++ b/lib/matplotlib/backends/backend_qt5.py @@ -9,8 +9,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}") From 89159614b349ff5f45bc4d49202362ddcd28345f Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Sat, 18 Dec 2021 20:24:15 -0500 Subject: [PATCH 3/7] FIX: delay resolving automatic backend until actually needed This is to prevent the early importing of GUI bindings in the case where the user wants to use something later in our search list, but also has a toolkit higher in our list installed which we select. Thanks to @anntzer for the implementation suggestion. --- lib/matplotlib/pyplot.py | 40 ++++++++++++------- lib/matplotlib/tests/test_backend_tk.py | 1 + .../tests/test_backends_interactive.py | 24 +++++++++++ lib/matplotlib/tests/test_rcparams.py | 3 +- 4 files changed, 53 insertions(+), 15 deletions(-) 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/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..7d4d617a46b3 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -9,6 +9,7 @@ import sys import time import urllib.request +import textwrap import pytest @@ -255,6 +256,29 @@ def test_interactive_thread_safety(env): assert proc.stdout.count("CloseEvent") == 1 +def test_lazy_auto_backend_selection(): + + def _impl(): + 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) + + proc = subprocess.run( + [sys.executable, "-c", + textwrap.dedent(inspect.getsource(_impl)) + "\n_impl()"], + env={**os.environ, "SOURCE_DATE_EPOCH": "0"}, + timeout=_test_timeout, check=True, + stdout=subprocess.PIPE, universal_newlines=True) + + @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.") 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) From 2bc0c1c4b97d00c0f6e849f799cc9a670a736238 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Sat, 18 Dec 2021 20:59:13 -0500 Subject: [PATCH 4/7] FIX: ensure that qt5agg and qt5cairo backends actually use qt5 Because the code in qt_compat tries qt6 bindings first, backend_qt supports both Qt5 and Qt6, and the qt5 named backends are shims to the generic Qt backend, if you imported matplotlib.backends.backend_qt5agg, matplotlib.backends.backend_qt5cairo, or matplotlib.backends.backend_qt5, and 1. had PyQt6 or pyside6 installed 2. had not previously imported a Qt5 binding Then you will end up with a backend that (by name) claims to be Qt5, but will be using Qt6 bindings. If you then subsequently import a Qt6 binding and try to embed the canvas it will fail (due to being Qt6 objects not Qt5 objects!). Additional changes to qt_compat that only matters if 1. rcparams['backend'] is set to qt5agg or qt5agg 2. QT_API env is not set 3. the user directly import matplotlib.backends.qt_compat This will likely only affect users who are using Matplotlib as an qt-shim implementation. closes #21998 Co-authored-by: Elliott Sales de Andrade --- doc/api/backend_qt_api.rst | 59 ++++++++++++++- doc/users/explain/backends.rst | 3 +- lib/matplotlib/backends/__init__.py | 1 + lib/matplotlib/backends/backend_qt5.py | 7 +- lib/matplotlib/backends/backend_qt5agg.py | 7 +- lib/matplotlib/backends/backend_qt5cairo.py | 9 ++- lib/matplotlib/backends/qt_compat.py | 21 ++++-- .../tests/test_backends_interactive.py | 71 ++++++++++++++++++- 8 files changed, 159 insertions(+), 19 deletions(-) diff --git a/doc/api/backend_qt_api.rst b/doc/api/backend_qt_api.rst index 474e04ef4b4b..4127b534fb4d 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_qt5.py b/lib/matplotlib/backends/backend_qt5.py index ec544994a519..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, 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 de0525014419..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" @@ -66,6 +67,7 @@ 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). @@ -117,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/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 7d4d617a46b3..dc0b6b9af393 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -7,9 +7,9 @@ import signal import subprocess import sys +import textwrap import time import urllib.request -import textwrap import pytest @@ -17,6 +17,12 @@ from matplotlib import _c_internal_utils +def _run_function_in_subprocess(func): + func_source = textwrap.dedent(inspect.getsource(func)) + func_source = func_source[func_source.index('\n')+1:] # Remove decorator + return f"{func_source}\n{func.__name__}()" + + # Minimal smoke-testing of the backends for which the dependencies are # PyPI-installable on CI. They are not available for all tested Python # versions so we don't fail on missing backends. @@ -258,6 +264,7 @@ def test_interactive_thread_safety(env): def test_lazy_auto_backend_selection(): + @_run_function_in_subprocess def _impl(): import matplotlib import matplotlib.pyplot as plt @@ -272,8 +279,66 @@ def _impl(): assert isinstance(bk, str) proc = subprocess.run( - [sys.executable, "-c", - textwrap.dedent(inspect.getsource(_impl)) + "\n_impl()"], + [sys.executable, "-c", _impl], + env={**os.environ, "SOURCE_DATE_EPOCH": "0"}, + timeout=_test_timeout, check=True, + stdout=subprocess.PIPE, universal_newlines=True) + + +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_function_in_subprocess + def _implagg(): + 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 + + @_run_function_in_subprocess + 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 + + @_run_function_in_subprocess + def _implcore(): + import matplotlib.backends.backend_qt5 # 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 + + subprocess.run( + [sys.executable, "-c", _implagg], + env={**os.environ, "SOURCE_DATE_EPOCH": "0"}, + timeout=_test_timeout, check=True, + stdout=subprocess.PIPE, universal_newlines=True) + + subprocess.run( + [sys.executable, "-c", _implcairo], + env={**os.environ, "SOURCE_DATE_EPOCH": "0"}, + timeout=_test_timeout, check=True, + stdout=subprocess.PIPE, universal_newlines=True) + + subprocess.run( + [sys.executable, "-c", _implcore], env={**os.environ, "SOURCE_DATE_EPOCH": "0"}, timeout=_test_timeout, check=True, stdout=subprocess.PIPE, universal_newlines=True) From fdfbf7ce9dfe457a3e6d7f0198e51386397f08da Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Tue, 4 Jan 2022 18:39:16 -0500 Subject: [PATCH 5/7] MNT: add warning if crossing Qt major versions --- lib/matplotlib/backends/backend_qt.py | 22 +++++++++ .../tests/test_backends_interactive.py | 45 +++++++++++++++++++ 2 files changed, 67 insertions(+) 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/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index dc0b6b9af393..c01c8eaa4fb2 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -23,6 +23,7 @@ def _run_function_in_subprocess(func): return f"{func_source}\n{func.__name__}()" + # Minimal smoke-testing of the backends for which the dependencies are # PyPI-installable on CI. They are not available for all tested Python # versions so we don't fail on missing backends. @@ -344,6 +345,50 @@ def _implcore(): stdout=subprocess.PIPE, universal_newlines=True) +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(__name__, _impl_test_cross_Qt_imports, + *pair, + timeout=_test_timeout) + except subprocess.CalledProcessError as ex: + # if segfauldt, 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.") From efc7f81cf0ee0f9f2875bd1dc5eabf48b06ae14e Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Fri, 18 Feb 2022 22:16:56 -0500 Subject: [PATCH 6/7] TST: re-arrange sub-process tests to be able to get coverage on them By putting the implementation in top-level functions and then importing the test module in the sub-process we are able to get accurate coverage on these tests. pytest-cov takes care of all of the coverage related magic implicitly. Also get coverage information out of isolated tk tests. Co-authored-by: Elliott Sales de Andrade --- doc/api/backend_qt_api.rst | 2 +- lib/matplotlib/testing/__init__.py | 47 +++- .../tests/test_backends_interactive.py | 224 +++++++----------- 3 files changed, 137 insertions(+), 136 deletions(-) diff --git a/doc/api/backend_qt_api.rst b/doc/api/backend_qt_api.rst index 4127b534fb4d..74f2274fa882 100644 --- a/doc/api/backend_qt_api.rst +++ b/doc/api/backend_qt_api.rst @@ -37,7 +37,7 @@ 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 +``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 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_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index c01c8eaa4fb2..2818f3d21cca 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -7,7 +7,6 @@ import signal import subprocess import sys -import textwrap import time import urllib.request @@ -15,13 +14,7 @@ import matplotlib as mpl from matplotlib import _c_internal_utils - - -def _run_function_in_subprocess(func): - func_source = textwrap.dedent(inspect.getsource(func)) - func_source = func_source[func_source.index('\n')+1:] # Remove decorator - return f"{func_source}\n{func.__name__}()" - +from matplotlib.testing import subprocess_run_helper as _run_helper # Minimal smoke-testing of the backends for which the dependencies are @@ -95,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 @@ -171,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 @@ -199,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! @@ -254,40 +234,65 @@ 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) - @_run_function_in_subprocess - def _impl(): - 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) - - proc = subprocess.run( - [sys.executable, "-c", _impl], - env={**os.environ, "SOURCE_DATE_EPOCH": "0"}, - timeout=_test_timeout, check=True, - stdout=subprocess.PIPE, universal_newlines=True) +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(): +def test_qt5backends_uses_qt5(): qt5_bindings = [ dep for dep in ['PyQt5', 'pyside2'] if importlib.util.find_spec(dep) is not None @@ -298,51 +303,10 @@ def test_qt5backends_uses_qt5(): ] if len(qt5_bindings) == 0 or len(qt6_bindings) == 0: pytest.skip('need both QT6 and QT5 bindings') - - @_run_function_in_subprocess - def _implagg(): - 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 - - @_run_function_in_subprocess - 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 - - @_run_function_in_subprocess - def _implcore(): - import matplotlib.backends.backend_qt5 # 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 - - subprocess.run( - [sys.executable, "-c", _implagg], - env={**os.environ, "SOURCE_DATE_EPOCH": "0"}, - timeout=_test_timeout, check=True, - stdout=subprocess.PIPE, universal_newlines=True) - - subprocess.run( - [sys.executable, "-c", _implcairo], - env={**os.environ, "SOURCE_DATE_EPOCH": "0"}, - timeout=_test_timeout, check=True, - stdout=subprocess.PIPE, universal_newlines=True) - - subprocess.run( - [sys.executable, "-c", _implcore], - env={**os.environ, "SOURCE_DATE_EPOCH": "0"}, - timeout=_test_timeout, check=True, - stdout=subprocess.PIPE, universal_newlines=True) + _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(): @@ -378,11 +342,11 @@ def test_cross_Qt_imports(): for qt6 in qt6_bindings: for pair in ([qt5, qt6], [qt6, qt5]): try: - _run_helper(__name__, _impl_test_cross_Qt_imports, + _run_helper(_impl_test_cross_Qt_imports, *pair, timeout=_test_timeout) except subprocess.CalledProcessError as ex: - # if segfauldt, carry on. We do try to warn the user they + # 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 @@ -397,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"]) @@ -419,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="") From c68f9d887934209d0fdf45fc8635a1b7d387620a Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Thu, 3 Mar 2022 17:09:45 -0500 Subject: [PATCH 7/7] DOC: fix typos in docs Co-authored-by: Elliott Sales de Andrade --- doc/api/backend_qt_api.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/api/backend_qt_api.rst b/doc/api/backend_qt_api.rst index 74f2274fa882..622889c10e5c 100644 --- a/doc/api/backend_qt_api.rst +++ b/doc/api/backend_qt_api.rst @@ -27,7 +27,7 @@ supported Python bindings per version -- `PyQt5 `_ 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 +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