Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit daaa1ed

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

13 files changed

+350
-92
lines changed

‎doc/api/backend_qt_api.rst

Copy file name to clipboard
+57-2Lines 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

Copy file name to clipboardExpand all lines: doc/users/explain/backends.rst
+2-1Lines 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

Copy file name to clipboard
+1Lines 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

Copy file name to clipboardExpand all lines: lib/matplotlib/backends/backend_qt.py
+22Lines 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

Copy file name to clipboardExpand all lines: lib/matplotlib/backends/backend_qt5.py
+13-1Lines 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

Copy file name to clipboardExpand all lines: lib/matplotlib/backends/backend_qt5agg.py
+4-3Lines 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

Copy file name to clipboardExpand all lines: lib/matplotlib/backends/backend_qt5cairo.py
+6-3Lines 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

Copy file name to clipboardExpand all lines: lib/matplotlib/backends/qt_compat.py
+21-7Lines 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

Copy file name to clipboardExpand all lines: lib/matplotlib/pyplot.py
+26-14Lines 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

Copy file name to clipboardExpand all lines: lib/matplotlib/testing/__init__.py
+44-3Lines 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

Copy file name to clipboardExpand all lines: lib/matplotlib/tests/test_backend_tk.py
+1Lines 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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.