Skip to content

Commit 2bc0c1c

Browse files
tacaswellQuLogic
andcommitted
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 <quantum.analyst@gmail.com>
1 parent 8915961 commit 2bc0c1c

File tree

8 files changed

+159
-19
lines changed

8 files changed

+159
-19
lines changed

doc/api/backend_qt_api.rst

+57-2
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,70 @@
11
:mod:`.backend_qtagg`, :mod:`.backend_qtcairo`
22
==============================================
33

4-
**NOTE** These backends are not documented here, to avoid adding a dependency
5-
to building the docs.
4+
**NOTE** These backends are not (auto) documented here, to avoid adding a
5+
dependency to building the docs.
66

77
.. redirect-from:: /api/backend_qt4agg_api
88
.. redirect-from:: /api/backend_qt4cairo_api
99
.. redirect-from:: /api/backend_qt5agg_api
1010
.. redirect-from:: /api/backend_qt5cairo_api
1111

12+
.. module:: matplotlib.backends.qt_compat
13+
.. module:: matplotlib.backends.backend_qt
1214
.. module:: matplotlib.backends.backend_qtagg
1315
.. module:: matplotlib.backends.backend_qtcairo
1416
.. module:: matplotlib.backends.backend_qt5agg
1517
.. module:: matplotlib.backends.backend_qt5cairo
18+
19+
.. _QT_bindings:
20+
21+
Qt Bindings
22+
-----------
23+
24+
There are currently 2 actively supported Qt versions, Qt5 and Qt6, and two
25+
supported Python bindings per version -- `PyQt5
26+
<https://www.riverbankcomputing.com/static/Docs/PyQt5/>`_ and `PySide2
27+
<https://doc.qt.io/qtforpython-5/contents.html>`_ for Qt5 and `PyQt6
28+
<https://www.riverbankcomputing.com/static/Docs/PyQt6/>`_ and `PySide6
29+
<https://doc.qt.io/qtforpython/contents.html>`_ for Qt6 [#]_. While both PyQt
30+
and Qt for Python (aka Pyside) closely mirror the underlying c++ API they are
31+
wrapping, they are not drop-in replacements for each other [#]_. To account
32+
for this, Matplotlib has an internal API compatibility layer in
33+
`matplotlib.backends.qt_compat` which covers our needs. Despite being a public
34+
module, we do not consider this to be a stable user-facing API and it may
35+
change without warning [#]_.
36+
37+
Previously Matplotlib's Qt backends had the Qt version number in the name, both
38+
in the module and the :rc:`backend` value
39+
(e.g. ``matplotlib.backends.backend_qt4agg`` and
40+
``matplotlib.backends.backend_qt5agg``), however as part of adding support for
41+
Qt6 we were able to support both Qt5 and Qt6 with a single implementation with
42+
all of the Qt version and binding support handled in
43+
`~matplotlib.backends.qt_compat`. A majority of the renderer agnostic Qt code
44+
is now in `matplotlib.backends.backend_qt` with specialization for AGG in
45+
``backend_qtagg`` and cairo in ``backend_qtcairo``.
46+
47+
The binding is selected at run time based on what bindings are already imported
48+
(by checking for the ``QtCore`` sub-package), then by the :envvar:`QT_API`
49+
environment variable, and finally by the :rc:`backend`. In all cases when we
50+
need to search, the order is ``PyQt6``, ``PySide6``, ``PyQt5``, ``PySide2``.
51+
See :ref:`QT_API-usage` for usage instructions.
52+
53+
The ``backend_qt5``, ``backend_qt5agg``, and ``backend_qt5cairo`` are provided
54+
and force the use of a Qt5 binding for backwards compatibility. Their use is
55+
discouraged (but not deprecated) and ``backend_qt``, ``backend_qtagg``, or
56+
``backend_qtcairo`` should be preferred instead. However, these modules will
57+
not be deprecated until we drop support for Qt5.
58+
59+
60+
61+
62+
.. [#] There is also `PyQt4
63+
<https://www.riverbankcomputing.com/static/Docs/PyQt4/>`_ and `PySide
64+
<https://srinikom.github.io/pyside-docs/>`_ for Qt4 but these are no
65+
longer supported by Matplotlib and upstream support for Qt4 ended
66+
in 2015.
67+
.. [#] Despite the slight API differences, the more important distinction
68+
between the PyQt and Qt for Python series of bindings is licensing.
69+
.. [#] If you are looking for a general purpose compatibility library please
70+
see `qtpy <https://github.com/spyder-ide/qtpy>`_.

doc/users/explain/backends.rst

+2-1
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,8 @@ The :envvar:`QT_API` environment variable can be set to override the search
244244
when nothing has already been loaded. It may be set to (case-insensitively)
245245
PyQt6, PySide6, PyQt5, or PySide2 to pick the version and binding to use. If
246246
the chosen implementation is unavailable, the Qt backend will fail to load
247-
without attempting any other Qt implementations.
247+
without attempting any other Qt implementations. See :ref:`QT_bindings` for
248+
more details.
248249

249250
Using non-builtin backends
250251
--------------------------

lib/matplotlib/backends/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
# NOTE: plt.switch_backend() (called at import time) will add a "backend"
22
# attribute here for backcompat.
3+
_QT_FORCE_QT5_BINDING = False

lib/matplotlib/backends/backend_qt5.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
from .backend_qt import (
1+
from .. import backends
2+
3+
backends._QT_FORCE_QT5_BINDING = True
4+
5+
6+
from .backend_qt import ( # noqa
27
backend_version, SPECIAL_KEYS,
38
# Public API
49
cursord, _create_qApp, _BackendQT, TimerQT, MainWindow, FigureCanvasQT,

lib/matplotlib/backends/backend_qt5agg.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""
22
Render to qt from agg
33
"""
4+
from .. import backends
45

5-
from .backend_qtagg import _BackendQTAgg
6-
from .backend_qtagg import ( # noqa: F401 # pylint: disable=W0611
7-
FigureCanvasQTAgg, FigureManagerQT, NavigationToolbar2QT,
6+
backends._QT_FORCE_QT5_BINDING = True
7+
from .backend_qtagg import ( # noqa: F401, E402 # pylint: disable=W0611
8+
_BackendQTAgg, FigureCanvasQTAgg, FigureManagerQT, NavigationToolbar2QT,
89
backend_version, FigureCanvasAgg, FigureCanvasQT
910
)
1011

lib/matplotlib/backends/backend_qt5cairo.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
from .backend_qtcairo import _BackendQTCairo
2-
from .backend_qtcairo import ( # noqa: F401 # pylint: disable=W0611
3-
FigureCanvasQTCairo, FigureCanvasCairo, FigureCanvasQT)
1+
from .. import backends
2+
3+
backends._QT_FORCE_QT5_BINDING = True
4+
from .backend_qtcairo import ( # noqa: F401, E402 # pylint: disable=W0611
5+
_BackendQTCairo, FigureCanvasQTCairo, FigureCanvasCairo, FigureCanvasQT
6+
)
47

58

69
@_BackendQTCairo.export

lib/matplotlib/backends/qt_compat.py

+15-6
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import matplotlib as mpl
2626
from matplotlib import _api
2727

28+
from . import _QT_FORCE_QT5_BINDING
2829

2930
QT_API_PYQT6 = "PyQt6"
3031
QT_API_PYSIDE6 = "PySide6"
@@ -66,6 +67,7 @@
6667
if QT_API_ENV in ["pyqt5", "pyside2"]:
6768
QT_API = _ETS[QT_API_ENV]
6869
else:
70+
_QT_FORCE_QT5_BINDING = True # noqa
6971
QT_API = None
7072
# A non-Qt backend was selected but we still got there (possible, e.g., when
7173
# fully manually embedding Matplotlib in a Qt app without using pyplot).
@@ -117,12 +119,19 @@ def _isdeleted(obj):
117119
if QT_API in [QT_API_PYQT6, QT_API_PYQT5, QT_API_PYSIDE6, QT_API_PYSIDE2]:
118120
_setup_pyqt5plus()
119121
elif QT_API is None: # See above re: dict.__getitem__.
120-
_candidates = [
121-
(_setup_pyqt5plus, QT_API_PYQT6),
122-
(_setup_pyqt5plus, QT_API_PYSIDE6),
123-
(_setup_pyqt5plus, QT_API_PYQT5),
124-
(_setup_pyqt5plus, QT_API_PYSIDE2),
125-
]
122+
if _QT_FORCE_QT5_BINDING:
123+
_candidates = [
124+
(_setup_pyqt5plus, QT_API_PYQT5),
125+
(_setup_pyqt5plus, QT_API_PYSIDE2),
126+
]
127+
else:
128+
_candidates = [
129+
(_setup_pyqt5plus, QT_API_PYQT6),
130+
(_setup_pyqt5plus, QT_API_PYSIDE6),
131+
(_setup_pyqt5plus, QT_API_PYQT5),
132+
(_setup_pyqt5plus, QT_API_PYSIDE2),
133+
]
134+
126135
for _setup, QT_API in _candidates:
127136
try:
128137
_setup()

lib/matplotlib/tests/test_backends_interactive.py

+68-3
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,22 @@
77
import signal
88
import subprocess
99
import sys
10+
import textwrap
1011
import time
1112
import urllib.request
12-
import textwrap
1313

1414
import pytest
1515

1616
import matplotlib as mpl
1717
from matplotlib import _c_internal_utils
1818

1919

20+
def _run_function_in_subprocess(func):
21+
func_source = textwrap.dedent(inspect.getsource(func))
22+
func_source = func_source[func_source.index('\n')+1:] # Remove decorator
23+
return f"{func_source}\n{func.__name__}()"
24+
25+
2026
# Minimal smoke-testing of the backends for which the dependencies are
2127
# PyPI-installable on CI. They are not available for all tested Python
2228
# versions so we don't fail on missing backends.
@@ -258,6 +264,7 @@ def test_interactive_thread_safety(env):
258264

259265
def test_lazy_auto_backend_selection():
260266

267+
@_run_function_in_subprocess
261268
def _impl():
262269
import matplotlib
263270
import matplotlib.pyplot as plt
@@ -272,8 +279,66 @@ def _impl():
272279
assert isinstance(bk, str)
273280

274281
proc = subprocess.run(
275-
[sys.executable, "-c",
276-
textwrap.dedent(inspect.getsource(_impl)) + "\n_impl()"],
282+
[sys.executable, "-c", _impl],
283+
env={**os.environ, "SOURCE_DATE_EPOCH": "0"},
284+
timeout=_test_timeout, check=True,
285+
stdout=subprocess.PIPE, universal_newlines=True)
286+
287+
288+
def test_qt5backends_uses_qt5():
289+
290+
qt5_bindings = [
291+
dep for dep in ['PyQt5', 'pyside2']
292+
if importlib.util.find_spec(dep) is not None
293+
]
294+
qt6_bindings = [
295+
dep for dep in ['PyQt6', 'pyside6']
296+
if importlib.util.find_spec(dep) is not None
297+
]
298+
if len(qt5_bindings) == 0 or len(qt6_bindings) == 0:
299+
pytest.skip('need both QT6 and QT5 bindings')
300+
301+
@_run_function_in_subprocess
302+
def _implagg():
303+
import matplotlib.backends.backend_qt5agg # noqa
304+
import sys
305+
306+
assert 'PyQt6' not in sys.modules
307+
assert 'pyside6' not in sys.modules
308+
assert 'PyQt5' in sys.modules or 'pyside2' in sys.modules
309+
310+
@_run_function_in_subprocess
311+
def _implcairo():
312+
import matplotlib.backends.backend_qt5cairo # noqa
313+
import sys
314+
315+
assert 'PyQt6' not in sys.modules
316+
assert 'pyside6' not in sys.modules
317+
assert 'PyQt5' in sys.modules or 'pyside2' in sys.modules
318+
319+
@_run_function_in_subprocess
320+
def _implcore():
321+
import matplotlib.backends.backend_qt5 # noqa
322+
import sys
323+
324+
assert 'PyQt6' not in sys.modules
325+
assert 'pyside6' not in sys.modules
326+
assert 'PyQt5' in sys.modules or 'pyside2' in sys.modules
327+
328+
subprocess.run(
329+
[sys.executable, "-c", _implagg],
330+
env={**os.environ, "SOURCE_DATE_EPOCH": "0"},
331+
timeout=_test_timeout, check=True,
332+
stdout=subprocess.PIPE, universal_newlines=True)
333+
334+
subprocess.run(
335+
[sys.executable, "-c", _implcairo],
336+
env={**os.environ, "SOURCE_DATE_EPOCH": "0"},
337+
timeout=_test_timeout, check=True,
338+
stdout=subprocess.PIPE, universal_newlines=True)
339+
340+
subprocess.run(
341+
[sys.executable, "-c", _implcore],
277342
env={**os.environ, "SOURCE_DATE_EPOCH": "0"},
278343
timeout=_test_timeout, check=True,
279344
stdout=subprocess.PIPE, universal_newlines=True)

0 commit comments

Comments
 (0)