Skip to content

Further defer backend selection #22005

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Mar 3, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions doc/api/backend_qt_api.rst
Original file line number Diff line number Diff line change
@@ -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
<https://www.riverbankcomputing.com/static/Docs/PyQt5/>`_ and `PySide2
<https://doc.qt.io/qtforpython-5/contents.html>`_ for Qt5 and `PyQt6
<https://www.riverbankcomputing.com/static/Docs/PyQt6/>`_ and `PySide6
<https://doc.qt.io/qtforpython/contents.html>`_ 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
<https://www.riverbankcomputing.com/static/Docs/PyQt4/>`_ and `PySide
<https://srinikom.github.io/pyside-docs/>`_ 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 <https://github.com/spyder-ide/qtpy>`_.
3 changes: 2 additions & 1 deletion doc/users/explain/backends.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------------
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/backends/__init__.py
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions lib/matplotlib/backends/backend_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 13 additions & 1 deletion lib/matplotlib/backends/backend_qt5.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,8 +14,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}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why expose a new API here? Isn't this just qt_compat.QtWidgets.QApplication.instance()? (which is standard qt, doesn't need to be documented, yada yada)
(I guess I would rather deprecate backend_qt.qApp instead.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we used to expose it as public and I know for a fact (because it bit us at NSLS-II!) that it is being used in the wild.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want to block #22503 then? (In particular, note the subtly different semantics.) (It's OK if you want to block it, just let me know and I'll close that :))

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am 👍🏻 on deprecating it, but need to put it back to do so 😞

7 changes: 4 additions & 3 deletions lib/matplotlib/backends/backend_qt5agg.py
Original file line number Diff line number Diff line change
@@ -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
)

Expand Down
9 changes: 6 additions & 3 deletions lib/matplotlib/backends/backend_qt5cairo.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
28 changes: 21 additions & 7 deletions lib/matplotlib/backends/qt_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -57,10 +58,16 @@
# 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:
_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).
Expand Down Expand Up @@ -112,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()
Expand Down
40 changes: 26 additions & 14 deletions lib/matplotlib/pyplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@ def _copy_docstring_and_deprecators(method, func=None):

## Global ##


_IP_REGISTERED = None
_INSTALL_FIG_OBSERVER = False

Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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 "
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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 ##############
Expand Down
47 changes: 44 additions & 3 deletions lib/matplotlib/testing/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/tests/test_backend_tk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading