Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit daaa1ed

Browse files
authored
Merge pull request #22005 from tacaswell/further_defer_backend_selection
Further defer backend selection
2 parents 4a3c5d6 + c68f9d8 commit daaa1ed

13 files changed

+350
-92
lines changed

doc/api/backend_qt_api.rst

Lines changed: 57 additions & 2 deletions
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

Lines changed: 2 additions & 1 deletion
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

Lines changed: 1 addition & 0 deletions
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

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

lib/matplotlib/backends/backend_qt5.py

Lines changed: 13 additions & 1 deletion
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

Lines changed: 4 additions & 3 deletions
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

Lines changed: 6 additions & 3 deletions
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

Lines changed: 21 additions & 7 deletions
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

Lines changed: 26 additions & 14 deletions
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

@@ -207,6 +206,28 @@ def _get_required_interactive_framework(backend_mod):
207206
# Inline this once the deprecation elapses.
208207
return backend_mod.FigureCanvas.required_interactive_framework
209208

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

211232
def switch_backend(newbackend):
212233
"""
@@ -297,7 +318,7 @@ class backend_mod(matplotlib.backend_bases._Backend):
297318

298319

299320
def _warn_if_gui_out_of_main_thread():
300-
if (_get_required_interactive_framework(_backend_mod)
321+
if (_get_required_interactive_framework(_get_backend_mod())
301322
and threading.current_thread() is not threading.main_thread()):
302323
_api.warn_external(
303324
"Starting a Matplotlib GUI outside of the main thread will likely "
@@ -308,7 +329,7 @@ def _warn_if_gui_out_of_main_thread():
308329
def new_figure_manager(*args, **kwargs):
309330
"""Create a new figure manager instance."""
310331
_warn_if_gui_out_of_main_thread()
311-
return _backend_mod.new_figure_manager(*args, **kwargs)
332+
return _get_backend_mod().new_figure_manager(*args, **kwargs)
312333

313334

314335
# This function's signature is rewritten upon backend-load by switch_backend.
@@ -321,7 +342,7 @@ def draw_if_interactive(*args, **kwargs):
321342
End users will typically not have to call this function because the
322343
the interactive mode takes care of this.
323344
"""
324-
return _backend_mod.draw_if_interactive(*args, **kwargs)
345+
return _get_backend_mod().draw_if_interactive(*args, **kwargs)
325346

326347

327348
# This function's signature is rewritten upon backend-load by switch_backend.
@@ -370,7 +391,7 @@ def show(*args, **kwargs):
370391
explicitly there.
371392
"""
372393
_warn_if_gui_out_of_main_thread()
373-
return _backend_mod.show(*args, **kwargs)
394+
return _get_backend_mod().show(*args, **kwargs)
374395

375396

376397
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

Lines changed: 44 additions & 3 deletions
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

Lines changed: 1 addition & 0 deletions
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)