Skip to content

Commit 634808b

Browse files
authored
Merge pull request #22595 from tacaswell/auto-backport-of-pr-22005-on-v3.5.x
Backport PR #22005: Further defer backend selection
2 parents f7e3833 + f937b0a commit 634808b

13 files changed

+348
-90
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_qt.py

+22
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,28 @@ def _create_qApp():
115115
QtCore.Qt.AA_EnableHighDpiScaling)
116116
except AttributeError: # Only for Qt>=5.6, <6.
117117
pass
118+
119+
# Check to make sure a QApplication from a different major version
120+
# of Qt is not instantiated in the process
121+
if QT_API in {'PyQt6', 'PySide6'}:
122+
other_bindings = ('PyQt5', 'PySide2')
123+
elif QT_API in {'PyQt5', 'PySide2'}:
124+
other_bindings = ('PyQt6', 'PySide6')
125+
else:
126+
raise RuntimeError("Should never be here")
127+
128+
for binding in other_bindings:
129+
mod = sys.modules.get(f'{binding}.QtWidgets')
130+
if mod is not None and mod.QApplication.instance() is not None:
131+
other_core = sys.modules.get(f'{binding}.QtCore')
132+
_api.warn_external(
133+
f'Matplotlib is using {QT_API} which wraps '
134+
f'{QtCore.qVersion()} however an instantiated '
135+
f'QApplication from {binding} which wraps '
136+
f'{other_core.qVersion()} exists. Mixing Qt major '
137+
'versions may not work as expected.'
138+
)
139+
break
118140
try:
119141
QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy(
120142
QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)

lib/matplotlib/backends/backend_qt5.py

+13-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,
@@ -9,8 +14,15 @@
914
FigureCanvasBase, FigureManagerBase, MouseButton, NavigationToolbar2,
1015
TimerBase, ToolContainerBase, figureoptions, Gcf
1116
)
17+
from . import backend_qt as _backend_qt # noqa
1218

1319

1420
@_BackendQT.export
1521
class _BackendQT5(_BackendQT):
1622
pass
23+
24+
25+
def __getattr__(name):
26+
if name == 'qApp':
27+
return _backend_qt.qApp
28+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

lib/matplotlib/backends/backend_qt5agg.py

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

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

lib/matplotlib/backends/backend_qt5cairo.py

+5-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
from .backend_qtcairo import (
2-
_BackendQTCairo, FigureCanvasQTCairo,
3-
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,
46
RendererCairo
57
)
68

lib/matplotlib/backends/qt_compat.py

+21-7
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"
@@ -57,10 +58,16 @@
5758
# requested backend actually matches). Use dict.__getitem__ to avoid
5859
# triggering backend resolution (which can result in a partially but
5960
# incompletely imported backend_qt5).
60-
elif dict.__getitem__(mpl.rcParams, "backend") in ["Qt5Agg", "Qt5Cairo"]:
61+
elif (
62+
isinstance(dict.__getitem__(mpl.rcParams, "backend"), str) and
63+
dict.__getitem__(mpl.rcParams, "backend").lower() in [
64+
"qt5agg", "qt5cairo"
65+
]
66+
):
6167
if QT_API_ENV in ["pyqt5", "pyside2"]:
6268
QT_API = _ETS[QT_API_ENV]
6369
else:
70+
_QT_FORCE_QT5_BINDING = True # noqa
6471
QT_API = None
6572
# A non-Qt backend was selected but we still got there (possible, e.g., when
6673
# fully manually embedding Matplotlib in a Qt app without using pyplot).
@@ -112,12 +119,19 @@ def _isdeleted(obj):
112119
if QT_API in [QT_API_PYQT6, QT_API_PYQT5, QT_API_PYSIDE6, QT_API_PYSIDE2]:
113120
_setup_pyqt5plus()
114121
elif QT_API is None: # See above re: dict.__getitem__.
115-
_candidates = [
116-
(_setup_pyqt5plus, QT_API_PYQT6),
117-
(_setup_pyqt5plus, QT_API_PYSIDE6),
118-
(_setup_pyqt5plus, QT_API_PYQT5),
119-
(_setup_pyqt5plus, QT_API_PYSIDE2),
120-
]
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+
121135
for _setup, QT_API in _candidates:
122136
try:
123137
_setup()

lib/matplotlib/pyplot.py

+26-14
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,6 @@ def _copy_docstring_and_deprecators(method, func=None):
104104

105105
## Global ##
106106

107-
108107
_IP_REGISTERED = None
109108
_INSTALL_FIG_OBSERVER = False
110109

@@ -202,6 +201,28 @@ def _get_required_interactive_framework(backend_mod):
202201
return getattr(
203202
backend_mod.FigureCanvas, "required_interactive_framework", None)
204203

204+
_backend_mod = None
205+
206+
207+
def _get_backend_mod():
208+
"""
209+
Ensure that a backend is selected and return it.
210+
211+
This is currently private, but may be made public in the future.
212+
"""
213+
if _backend_mod is None:
214+
# Use __getitem__ here to avoid going through the fallback logic (which
215+
# will (re)import pyplot and then call switch_backend if we need to
216+
# resolve the auto sentinel)
217+
switch_backend(dict.__getitem__(rcParams, "backend"))
218+
# Just to be safe. Interactive mode can be turned on without calling
219+
# `plt.ion()` so register it again here. This is safe because multiple
220+
# calls to `install_repl_displayhook` are no-ops and the registered
221+
# function respects `mpl.is_interactive()` to determine if it should
222+
# trigger a draw.
223+
install_repl_displayhook()
224+
return _backend_mod
225+
205226

206227
def switch_backend(newbackend):
207228
"""
@@ -292,7 +313,7 @@ class backend_mod(matplotlib.backend_bases._Backend):
292313

293314

294315
def _warn_if_gui_out_of_main_thread():
295-
if (_get_required_interactive_framework(_backend_mod)
316+
if (_get_required_interactive_framework(_get_backend_mod())
296317
and threading.current_thread() is not threading.main_thread()):
297318
_api.warn_external(
298319
"Starting a Matplotlib GUI outside of the main thread will likely "
@@ -303,7 +324,7 @@ def _warn_if_gui_out_of_main_thread():
303324
def new_figure_manager(*args, **kwargs):
304325
"""Create a new figure manager instance."""
305326
_warn_if_gui_out_of_main_thread()
306-
return _backend_mod.new_figure_manager(*args, **kwargs)
327+
return _get_backend_mod().new_figure_manager(*args, **kwargs)
307328

308329

309330
# This function's signature is rewritten upon backend-load by switch_backend.
@@ -316,7 +337,7 @@ def draw_if_interactive(*args, **kwargs):
316337
End users will typically not have to call this function because the
317338
the interactive mode takes care of this.
318339
"""
319-
return _backend_mod.draw_if_interactive(*args, **kwargs)
340+
return _get_backend_mod().draw_if_interactive(*args, **kwargs)
320341

321342

322343
# This function's signature is rewritten upon backend-load by switch_backend.
@@ -365,7 +386,7 @@ def show(*args, **kwargs):
365386
explicitly there.
366387
"""
367388
_warn_if_gui_out_of_main_thread()
368-
return _backend_mod.show(*args, **kwargs)
389+
return _get_backend_mod().show(*args, **kwargs)
369390

370391

371392
def isinteractive():
@@ -2226,15 +2247,6 @@ def polar(*args, **kwargs):
22262247
set(_interactive_bk) - {'WebAgg', 'nbAgg'})
22272248
and cbook._get_running_interactive_framework()):
22282249
dict.__setitem__(rcParams, "backend", rcsetup._auto_backend_sentinel)
2229-
# Set up the backend.
2230-
switch_backend(rcParams["backend"])
2231-
2232-
# Just to be safe. Interactive mode can be turned on without
2233-
# calling `plt.ion()` so register it again here.
2234-
# This is safe because multiple calls to `install_repl_displayhook`
2235-
# are no-ops and the registered function respect `mpl.is_interactive()`
2236-
# to determine if they should trigger a draw.
2237-
install_repl_displayhook()
22382250

22392251

22402252
################# REMAINING CONTENT GENERATED BY boilerplate.py ##############

lib/matplotlib/testing/__init__.py

+44-3
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""
22
Helper functions for testing.
33
"""
4-
4+
from pathlib import Path
5+
from tempfile import TemporaryDirectory
56
import locale
67
import logging
8+
import os
79
import subprocess
8-
from pathlib import Path
9-
from tempfile import TemporaryDirectory
10+
import sys
1011

1112
import matplotlib as mpl
1213
from matplotlib import _api
@@ -49,6 +50,46 @@ def setup():
4950
set_reproducibility_for_testing()
5051

5152

53+
def subprocess_run_helper(func, *args, timeout, **extra_env):
54+
"""
55+
Run a function in a sub-process
56+
57+
Parameters
58+
----------
59+
func : function
60+
The function to be run. It must be in a module that is importable.
61+
62+
*args : str
63+
Any additional command line arguments to be passed in
64+
the first argument to subprocess.run
65+
66+
**extra_env : Dict[str, str]
67+
Any additional envromental variables to be set for
68+
the subprocess.
69+
70+
"""
71+
target = func.__name__
72+
module = func.__module__
73+
proc = subprocess.run(
74+
[sys.executable,
75+
"-c",
76+
f"""
77+
from {module} import {target}
78+
{target}()
79+
""",
80+
*args],
81+
env={
82+
**os.environ,
83+
"SOURCE_DATE_EPOCH": "0",
84+
**extra_env
85+
},
86+
timeout=timeout, check=True,
87+
stdout=subprocess.PIPE,
88+
stderr=subprocess.PIPE,
89+
universal_newlines=True)
90+
return proc
91+
92+
5293
def _check_for_pgf(texsystem):
5394
"""
5495
Check if a given TeX system + pgf is available

lib/matplotlib/tests/test_backend_tk.py

+1
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def test_func():
6464
def test_blit(): # pragma: no cover
6565
import matplotlib.pyplot as plt
6666
import numpy as np
67+
import matplotlib.backends.backend_tkagg # noqa
6768
from matplotlib.backends import _tkagg
6869

6970
fig, ax = plt.subplots()

0 commit comments

Comments
 (0)