From ffbb62c0df946487db37fa330955e9f7499bd948 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Wed, 4 Jul 2018 23:04:19 +0200 Subject: [PATCH 1/5] backend switching. See changes documented in the API changes file. Some followup cleanup (of the now unused old machinery) will come as a separate PR (left some "FIXME: Remove." comments). Changes to the build process (namely, getting rid of trying to detect the default backend in setupext.py) will come as a separate PR. I inlined pylab_setup into switch_backend (and deprecated the old version of pylab_setup) because otherwise the typical call stack would be `use()` -> `set rcParams['backend'] = ...` -> `switch_backend()` -> `pylab_setup()`, which is a bit of a mess; at least we can get rid of one of the layers. --- .../2018-02-15-AL-deprecations.rst | 1 + doc/api/next_api_changes/2018-06-27-AL.rst | 3 + lib/matplotlib/__init__.py | 66 ++++------------ lib/matplotlib/backend_bases.py | 7 +- lib/matplotlib/backends/__init__.py | 3 + lib/matplotlib/pyplot.py | 75 +++++++++++++++---- lib/matplotlib/rcsetup.py | 19 +++-- .../tests/test_backends_interactive.py | 67 +++++++++++++---- matplotlibrc.template | 5 +- setup.py | 4 +- 10 files changed, 155 insertions(+), 95 deletions(-) diff --git a/doc/api/next_api_changes/2018-02-15-AL-deprecations.rst b/doc/api/next_api_changes/2018-02-15-AL-deprecations.rst index a34615e18dee..d13518b81d2d 100644 --- a/doc/api/next_api_changes/2018-02-15-AL-deprecations.rst +++ b/doc/api/next_api_changes/2018-02-15-AL-deprecations.rst @@ -18,6 +18,7 @@ The following classes, methods, functions, and attributes are deprecated: - ``backend_ps.get_bbox``, - ``backend_qt5.error_msg_qt``, ``backend_qt5.exception_handler``, - ``backend_wx.FigureCanvasWx.macros``, +- ``backends.pylab_setup``, - ``cbook.GetRealpathAndStat``, ``cbook.Locked``, - ``cbook.is_numlike`` (use ``isinstance(..., numbers.Number)`` instead), ``cbook.listFiles``, ``cbook.unicode_safe``, diff --git a/doc/api/next_api_changes/2018-06-27-AL.rst b/doc/api/next_api_changes/2018-06-27-AL.rst index 51a8171bf02d..4ed77cb737cb 100644 --- a/doc/api/next_api_changes/2018-06-27-AL.rst +++ b/doc/api/next_api_changes/2018-06-27-AL.rst @@ -1,6 +1,9 @@ Changes to backend loading `````````````````````````` +Assignment to ``rcParams["backend"]`` now sets the backend. Backends can now +be switched until a figure is actually created. + Failure to load backend modules (``macosx`` on non-framework builds and ``gtk3`` when running headless) now raises `ImportError` (instead of `RuntimeError` and `TypeError`, respectively. diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index 492c6af5341c..6760d52a665a 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -1318,6 +1318,7 @@ def __exit__(self, exc_type, exc_value, exc_tb): dict.update(rcParams, self._orig) +# FIXME: Remove. _use_error_msg = """ This call to matplotlib.use() has no effect because the backend has already been chosen; matplotlib.use() must be called *before* pylab, matplotlib.pyplot, @@ -1330,62 +1331,23 @@ def __exit__(self, exc_type, exc_value, exc_tb): def use(arg, warn=True, force=False): """ - Set the matplotlib backend to one of the known backends. + Set the Matplotlib backend. - The argument is case-insensitive. *warn* specifies whether a - warning should be issued if a backend has already been set up. - *force* is an **experimental** flag that tells matplotlib to - attempt to initialize a new backend by reloading the backend - module. + The argument is case-insensitive. Switching to an interactive backend is + only safe if no event loop for another interactive backend has started. + Switching to and from non-interactive backends is safe. - .. note:: - - This function must be called *before* importing pyplot for - the first time; or, if you are not using pyplot, it must be called - before importing matplotlib.backends. If warn is True, a warning - is issued if you try and call this after pylab or pyplot have been - loaded. In certain black magic use cases, e.g. - :func:`pyplot.switch_backend`, we are doing the reloading necessary to - make the backend switch work (in some cases, e.g., pure image - backends) so one can set warn=False to suppress the warnings. - - To find out which backend is currently set, see - :func:`matplotlib.get_backend`. + To find out which backend is currently set, see `matplotlib.get_backend`. + Parameters + ---------- + arg : str + The name of the backend to use. """ - # Lets determine the proper backend name first - if arg.startswith('module://'): - name = arg - else: - # Lowercase only non-module backend names (modules are case-sensitive) - arg = arg.lower() - name = validate_backend(arg) - - # Check if we've already set up a backend - if 'matplotlib.backends' in sys.modules: - # Warn only if called with a different name - if (rcParams['backend'] != name) and warn: - import matplotlib.backends - warnings.warn( - _use_error_msg.format( - backend=rcParams['backend'], - tb=matplotlib.backends._backend_loading_tb), - stacklevel=2) - - # Unless we've been told to force it, just return - if not force: - return - need_reload = True - else: - need_reload = False - - # Store the backend name - rcParams['backend'] = name - - # If needed we reload here because a lot of setup code is triggered on - # module import. See backends/__init__.py for more detail. - if need_reload: - importlib.reload(sys.modules['matplotlib.backends']) + # We want to keep 'use(...); rcdefaults()' working, which means that + # use(...) needs to force the default backend too. + rcParams["backend"] = \ + rcParamsDefault["backend"] = rcParamsOrig["backend"] = arg if os.environ.get('MPLBACKEND'): diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 43f22e3f4f90..1ee3dd5100ca 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -3210,6 +3210,10 @@ class _Backend(object): # class FooBackend(_Backend): # # override the attributes and methods documented below. + # Set to one of {"qt5", "qt4", "gtk3", "wx", "tk", "macosx"} if an + # interactive framework is required, or None otherwise. + required_interactive_framework = None + # `backend_version` may be overridden by the subclass. backend_version = "unknown" @@ -3292,7 +3296,8 @@ def show(cls, block=None): @staticmethod def export(cls): - for name in ["backend_version", + for name in ["required_interactive_framework", + "backend_version", "FigureCanvas", "FigureManager", "new_figure_manager", diff --git a/lib/matplotlib/backends/__init__.py b/lib/matplotlib/backends/__init__.py index 2467a4235373..9be23abe518b 100644 --- a/lib/matplotlib/backends/__init__.py +++ b/lib/matplotlib/backends/__init__.py @@ -5,11 +5,13 @@ import traceback import matplotlib +from matplotlib import cbook from matplotlib.backend_bases import _Backend _log = logging.getLogger(__name__) backend = matplotlib.get_backend() +# FIXME: Remove. _backend_loading_tb = "".join( line for line in traceback.format_stack() # Filter out line noise from importlib line. @@ -64,6 +66,7 @@ def _get_running_interactive_framework(): return None +@cbook.deprecated("3.0") def pylab_setup(name=None): """ Return new_figure_manager, draw_if_interactive and show for pyplot. diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 5f64a7ede616..6ab728a0dcec 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -18,7 +18,9 @@ The object-oriented API is recommended for more complex plots. """ +import importlib import inspect +import logging from numbers import Number import re import sys @@ -29,7 +31,7 @@ import matplotlib import matplotlib.colorbar import matplotlib.image -from matplotlib import style +from matplotlib import rcsetup, style from matplotlib import _pylab_helpers, interactive from matplotlib.cbook import ( dedent, deprecated, silent_list, warn_deprecated, _string_to_bool) @@ -67,10 +69,13 @@ MaxNLocator from matplotlib.backends import pylab_setup +_log = logging.getLogger(__name__) + ## Backend detection ## +# FIXME: Deprecate. def _backend_selection(): """ If rcParams['backend_fallback'] is true, check to see if the @@ -110,8 +115,6 @@ def _backend_selection(): ## Global ## -_backend_mod, new_figure_manager, draw_if_interactive, _show = pylab_setup() - _IP_REGISTERED = None _INSTALL_FIG_OBSERVER = False @@ -213,21 +216,60 @@ def findobj(o=None, match=None, include_self=True): def switch_backend(newbackend): """ - Switch the default backend. This feature is **experimental**, and - is only expected to work switching to an image backend. e.g., if - you have a bunch of PostScript scripts that you want to run from - an interactive ipython session, you may want to switch to the PS - backend before running them to avoid having a bunch of GUI windows - popup. If you try to interactively switch from one GUI backend to - another, you will explode. + Close all open figures and set the Matplotlib backend. - Calling this command will close all open windows. + The argument is case-insensitive. Switching to an interactive backend is + possible only if no event loop for another interactive backend has started. + Switching to and from non-interactive backends is always possible. + + Parameters + ---------- + newbackend : str + The name of the backend to use. """ - close('all') + close("all") + + if newbackend is rcsetup._auto_backend_sentinel: + for candidate in ["macosx", "qt5agg", "qt4agg", "gtk3agg", "gtk3cairo", + "tkagg", "wxagg", "agg", "cairo"]: + try: + switch_backend(candidate) + except ImportError: + continue + else: + return + + backend_name = ( + newbackend[9:] if newbackend.startswith("module://") + else "matplotlib.backends.backend_{}".format(newbackend.lower())) + + backend_mod = importlib.import_module(backend_name) + Backend = type( + "Backend", (matplotlib.backends._Backend,), vars(backend_mod)) + _log.info("Loaded backend %s version %s.", + newbackend, Backend.backend_version) + + required_framework = Backend.required_interactive_framework + current_framework = \ + matplotlib.backends._get_running_interactive_framework() + if (current_framework and required_framework + and current_framework != required_framework): + raise ImportError( + "Cannot load backend {!r} which requires the {!r} interactive " + "framework, as {!r} is currently running".format( + newbackend, required_framework, current_framework)) + + dict.__setitem__(rcParams, "backend", newbackend) # Don't recurse. + global _backend_mod, new_figure_manager, draw_if_interactive, _show - matplotlib.use(newbackend, warn=False, force=True) - from matplotlib.backends import pylab_setup - _backend_mod, new_figure_manager, draw_if_interactive, _show = pylab_setup() + _backend_mod = backend_mod + new_figure_manager = Backend.new_figure_manager + draw_if_interactive = Backend.draw_if_interactive + _show = Backend.show + + # Need to keep a global reference to the backend for compatibility reasons. + # See https://github.com/matplotlib/matplotlib/issues/6092 + matplotlib.backends.backend = newbackend def show(*args, **kw): @@ -2358,6 +2400,9 @@ def _autogen_docstring(base): # to determine if they should trigger a draw. install_repl_displayhook() +# Set up the backend. +switch_backend(rcParams["backend"]) + ################# REMAINING CONTENT GENERATED BY boilerplate.py ############## diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 594154b5e088..76211a4a8e15 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -13,11 +13,13 @@ parameter set listed here should also be visited to the :file:`matplotlibrc.template` in matplotlib's root source directory. """ + from collections import Iterable, Mapping from functools import reduce import operator import os import re +import sys from matplotlib import cbook from matplotlib.cbook import ls_mapper @@ -242,13 +244,17 @@ def validate_fonttype(s): _validate_standard_backends = ValidateInStrings( 'backend', all_backends, ignorecase=True) +_auto_backend_sentinel = object() def validate_backend(s): - if s.startswith('module://'): - return s - else: - return _validate_standard_backends(s) + backend = ( + s if s is _auto_backend_sentinel or s.startswith("module://") + else _validate_standard_backends(s)) + pyplot = sys.modules.get("matplotlib.pyplot") + if pyplot: + pyplot.switch_backend(backend) + return backend def validate_qt4(s): @@ -965,9 +971,8 @@ def _validate_linestyle(ls): # a map from key -> value, converter defaultParams = { - 'backend': ['Agg', validate_backend], # agg is certainly - # present - 'backend_fallback': [True, validate_bool], # agg is certainly present + 'backend': [_auto_backend_sentinel, validate_backend], + 'backend_fallback': [True, validate_bool], 'backend.qt4': [None, validate_qt4], 'backend.qt5': [None, validate_qt5], 'webagg.port': [8988, validate_int], diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 29ee4db3fc0b..1e3925b0d35d 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -18,9 +18,7 @@ def _get_testable_interactive_backends(): backends = [] for deps, backend in [(["cairocffi", "pgi"], "gtk3agg"), - (["cairocffi", "pgi"], "gtk3cairo"), (["PyQt5"], "qt5agg"), - (["cairocffi", "PyQt5"], "qt5cairo"), (["tkinter"], "tkagg"), (["wx"], "wx"), (["wx"], "wxagg")]: @@ -36,27 +34,68 @@ def _get_testable_interactive_backends(): return backends -# 1. Using a timer not only allows testing of timers (on other backends), but -# is also necessary on gtk3 and wx, where a direct call to -# key_press_event("q") from draw_event causes breakage due to the canvas -# widget being deleted too early. -# 2. On gtk3, we cannot even test the timer setup (on Travis, which uses pgi) -# due to https://github.com/pygobject/pgi/issues/45. So we just cleanly -# exit from the draw_event. _test_script = """\ +import importlib import sys +from unittest import TestCase + +import matplotlib as mpl from matplotlib import pyplot as plt, rcParams + rcParams.update({ "webagg.open_in_browser": False, "webagg.port_retries": 1, }) +backend = plt.rcParams["backend"].lower() +assert_equal = TestCase().assertEqual +assert_raises = TestCase().assertRaises + +if backend.endswith("agg") and not backend.startswith(("gtk3", "web")): + # Force interactive framework setup. + plt.figure() + + # Check that we cannot switch to a backend using another interactive + # framework, but can switch to a backend using cairo instead of agg, or a + # non-interactive backend. In the first case, we use tkagg as the "other" + # interactive backend as it is (essentially) guaranteed to be present. + # Moreover, don't test switching away from gtk3 as Gtk.main_level() is + # not set up at this point yet, and webagg, which uses no interactive + # framework. + + if backend != "tkagg": + with assert_raises(ImportError): + mpl.use("tkagg") + + def check_alt_backend(alt_backend): + mpl.use(alt_backend) + fig = plt.figure() + assert_equal( + type(fig.canvas).__module__, + "matplotlib.backends.backend_{}".format(alt_backend)) + + if importlib.util.find_spec("cairocffi"): + check_alt_backend(backend[:-3] + "cairo") + check_alt_backend("svg") + +mpl.use(backend) fig, ax = plt.subplots() +assert_equal( + type(fig.canvas).__module__, + "matplotlib.backends.backend_{}".format(backend)) + ax.plot([0, 1], [2, 3]) -if rcParams["backend"].startswith("GTK3"): +if backend.startswith("gtk3"): + # On gtk3, we cannot even test the timer setup (on Travis, which uses pgi) + # due to https://github.com/pygobject/pgi/issues/45. So we just cleanly + # exit from the draw_event. fig.canvas.mpl_connect("draw_event", lambda event: sys.exit(0)) else: + # Using a timer not only allows testing of timers (on other backends), but + # is also necessary on gtk3 and wx, where a direct call to + # key_press_event("q") from draw_event causes breakage due to the canvas + # widget being deleted too early. timer = fig.canvas.new_timer(1) timer.add_callback(fig.canvas.key_press_event, "q") # Trigger quitting upon draw. @@ -70,10 +109,10 @@ def _get_testable_interactive_backends(): @pytest.mark.parametrize("backend", _get_testable_interactive_backends()) @pytest.mark.flaky(reruns=3) def test_interactive_backend(backend): - subprocess.run([sys.executable, "-c", _test_script], - env={**os.environ, "MPLBACKEND": backend}, - check=True, # Throw on failure. - timeout=_test_timeout) + if subprocess.run([sys.executable, "-c", _test_script], + env={**os.environ, "MPLBACKEND": backend}, + timeout=_test_timeout).returncode: + pytest.fail("The subprocess returned an error.") @pytest.mark.skipif(os.name == "nt", reason="Cannot send SIGINT on Windows.") diff --git a/matplotlibrc.template b/matplotlibrc.template index cf777b901ec2..fb01f5920212 100644 --- a/matplotlibrc.template +++ b/matplotlibrc.template @@ -35,9 +35,8 @@ ## referring to the module name (which must be in the PYTHONPATH) as ## 'module://my_backend'. ## -## If you omit this parameter, it will always default to "Agg", which is a -## non-interactive backend. -backend : $TEMPLATE_BACKEND +## If you omit this parameter, the backend will be determined by fallback. +#backend : Agg ## Note that this can be overridden by the environment variable ## QT_API used by Enthought Tool Suite (ETS); valid values are diff --git a/setup.py b/setup.py index 03ca28762231..08ec77069c21 100644 --- a/setup.py +++ b/setup.py @@ -226,10 +226,8 @@ def run(self): default_backend = setupext.options['backend'] with open('matplotlibrc.template') as fd: template = fd.read() - template = Template(template) with open('lib/matplotlib/mpl-data/matplotlibrc', 'w') as fd: - fd.write( - template.safe_substitute(TEMPLATE_BACKEND=default_backend)) + fd.write(template) # Build in verbose mode if requested if setupext.options['verbose']: From d047f52fc039d23c72e1d3b4b93c923d5961b9db Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Mon, 9 Jul 2018 10:05:11 +0200 Subject: [PATCH 2/5] Lazy-init the OSX event loop. --- lib/matplotlib/backends/__init__.py | 3 -- src/_macosx.m | 66 ++++++++++++++++++----------- 2 files changed, 41 insertions(+), 28 deletions(-) diff --git a/lib/matplotlib/backends/__init__.py b/lib/matplotlib/backends/__init__.py index 9be23abe518b..e4e6082e7d79 100644 --- a/lib/matplotlib/backends/__init__.py +++ b/lib/matplotlib/backends/__init__.py @@ -56,9 +56,6 @@ def _get_running_interactive_framework(): except ImportError: pass else: - # Note that the NSApp event loop is also running when a non-native - # toolkit (e.g. Qt5) is active, but in that case we want to report the - # other toolkit; thus, this check comes after the other toolkits. if _macosx.event_loop_is_running(): return "macosx" if sys.platform.startswith("linux") and not os.environ.get("DISPLAY"): diff --git a/src/_macosx.m b/src/_macosx.m index c3ebac8c35c1..706fb0d14bea 100644 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -267,6 +267,39 @@ - (int)index; /* ---------------------------- Python classes ---------------------------- */ +static bool backend_inited = false; + +static void lazy_init(void) { + if (backend_inited) { + return; + } + backend_inited = true; + + NSApp = [NSApplication sharedApplication]; + + PyOS_InputHook = wait_for_stdin; + + NSAutoreleasePool* pool = [[NSAutoreleasePool alloc] init]; + WindowServerConnectionManager* connectionManager = [WindowServerConnectionManager sharedManager]; + NSWorkspace* workspace = [NSWorkspace sharedWorkspace]; + NSNotificationCenter* notificationCenter = [workspace notificationCenter]; + [notificationCenter addObserver: connectionManager + selector: @selector(launch:) + name: NSWorkspaceDidLaunchApplicationNotification + object: nil]; + [pool release]; +} + +static PyObject* +event_loop_is_running(PyObject* self) +{ + if (backend_inited) { + Py_RETURN_TRUE; + } else { + Py_RETURN_FALSE; + } +} + static CGFloat _get_device_scale(CGContextRef cr) { CGSize pixelSize = CGContextConvertSizeToDeviceSpace(cr, CGSizeMake(1, 1)); @@ -281,6 +314,7 @@ static CGFloat _get_device_scale(CGContextRef cr) static PyObject* FigureCanvas_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { + lazy_init(); FigureCanvas *self = (FigureCanvas*)type->tp_alloc(type, 0); if (!self) return NULL; self->view = [View alloc]; @@ -641,6 +675,7 @@ static CGFloat _get_device_scale(CGContextRef cr) static PyObject* FigureManager_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { + lazy_init(); Window* window = [Window alloc]; if (!window) return NULL; FigureManager *self = (FigureManager*)type->tp_alloc(type, 0); @@ -1016,6 +1051,7 @@ -(void)save_figure:(id)sender static PyObject* NavigationToolbar_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { + lazy_init(); NavigationToolbarHandler* handler = [NavigationToolbarHandler alloc]; if (!handler) return NULL; NavigationToolbar *self = (NavigationToolbar*)type->tp_alloc(type, 0); @@ -1555,6 +1591,7 @@ -(void)save_figure:(id)sender static PyObject* NavigationToolbar2_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { + lazy_init(); NavigationToolbar2Handler* handler = [NavigationToolbar2Handler alloc]; if (!handler) return NULL; NavigationToolbar2 *self = (NavigationToolbar2*)type->tp_alloc(type, 0); @@ -2789,16 +2826,6 @@ - (int)index } @end -static PyObject* -event_loop_is_running(PyObject* self) -{ - if ([NSApp isRunning]) { - Py_RETURN_TRUE; - } else { - Py_RETURN_FALSE; - } -} - static PyObject* show(PyObject* self) { @@ -2825,6 +2852,7 @@ - (int)index static PyObject* Timer_new(PyTypeObject* type, PyObject *args, PyObject *kwds) { + lazy_init(); Timer* self = (Timer*)type->tp_alloc(type, 0); if (!self) return NULL; self->timer = NULL; @@ -3051,7 +3079,7 @@ static bool verify_framework(void) {"event_loop_is_running", (PyCFunction)event_loop_is_running, METH_NOARGS, - "Return whether the NSApp main event loop is currently running." + "Return whether the OSX backend has set up the NSApp main event loop." }, {"show", (PyCFunction)show, @@ -3097,13 +3125,12 @@ static bool verify_framework(void) || PyType_Ready(&TimerType) < 0) return NULL; - NSApp = [NSApplication sharedApplication]; - if (!verify_framework()) return NULL; module = PyModule_Create(&moduledef); - if (module==NULL) return NULL; + if (!module) + return NULL; Py_INCREF(&FigureCanvasType); Py_INCREF(&FigureManagerType); @@ -3116,16 +3143,5 @@ static bool verify_framework(void) PyModule_AddObject(module, "NavigationToolbar2", (PyObject*) &NavigationToolbar2Type); PyModule_AddObject(module, "Timer", (PyObject*) &TimerType); - PyOS_InputHook = wait_for_stdin; - - NSAutoreleasePool* pool = [[NSAutoreleasePool alloc] init]; - WindowServerConnectionManager* connectionManager = [WindowServerConnectionManager sharedManager]; - NSWorkspace* workspace = [NSWorkspace sharedWorkspace]; - NSNotificationCenter* notificationCenter = [workspace notificationCenter]; - [notificationCenter addObserver: connectionManager - selector: @selector(launch:) - name: NSWorkspaceDidLaunchApplicationNotification - object: nil]; - [pool release]; return module; } From 901477c22a7c66321b1a30a05084541dc2513067 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Mon, 9 Jul 2018 11:14:57 +0200 Subject: [PATCH 3/5] Don't fail Qt tests if bindings not installed. --- lib/matplotlib/tests/test_backend_qt4.py | 28 ++++++++++++------------ lib/matplotlib/tests/test_backend_qt5.py | 15 +++++++++---- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/lib/matplotlib/tests/test_backend_qt4.py b/lib/matplotlib/tests/test_backend_qt4.py index 18c94dc2033b..4c39e4882b3c 100644 --- a/lib/matplotlib/tests/test_backend_qt4.py +++ b/lib/matplotlib/tests/test_backend_qt4.py @@ -1,31 +1,31 @@ import copy -from unittest.mock import Mock +from unittest import mock +import matplotlib from matplotlib import pyplot as plt from matplotlib._pylab_helpers import Gcf -import matplotlib import pytest -with matplotlib.rc_context(rc={'backend': 'Qt4Agg'}): - qt_compat = pytest.importorskip('matplotlib.backends.qt_compat') +try: + import PyQt4 +except (ImportError, RuntimeError): # RuntimeError if PyQt5 already imported. + try: + import PySide + except ImportError: + pytestmark = pytest.mark.skip("Failed to import a Qt4 binding.") + +qt_compat = pytest.importorskip('matplotlib.backends.qt_compat') +QtCore = qt_compat.QtCore + from matplotlib.backends.backend_qt4 import ( MODIFIER_KEYS, SUPER, ALT, CTRL, SHIFT) # noqa -QtCore = qt_compat.QtCore _, ControlModifier, ControlKey = MODIFIER_KEYS[CTRL] _, AltModifier, AltKey = MODIFIER_KEYS[ALT] _, SuperModifier, SuperKey = MODIFIER_KEYS[SUPER] _, ShiftModifier, ShiftKey = MODIFIER_KEYS[SHIFT] -try: - py_qt_ver = int(QtCore.PYQT_VERSION_STR.split('.')[0]) -except AttributeError: - py_qt_ver = QtCore.__version_info__[0] - -if py_qt_ver != 4: - pytestmark = pytest.mark.xfail(reason='Qt4 is not available') - @pytest.mark.backend('Qt4Agg') def test_fig_close(): @@ -86,7 +86,7 @@ def test_correct_key(qt_key, qt_mods, answer): """ qt_canvas = plt.figure().canvas - event = Mock() + event = mock.Mock() event.isAutoRepeat.return_value = False event.key.return_value = qt_key event.modifiers.return_value = qt_mods diff --git a/lib/matplotlib/tests/test_backend_qt5.py b/lib/matplotlib/tests/test_backend_qt5.py index df56b69a8791..478e2f2bbdde 100644 --- a/lib/matplotlib/tests/test_backend_qt5.py +++ b/lib/matplotlib/tests/test_backend_qt5.py @@ -7,13 +7,20 @@ import pytest -with matplotlib.rc_context(rc={'backend': 'Qt5Agg'}): - qt_compat = pytest.importorskip('matplotlib.backends.qt_compat', - minversion='5') +try: + import PyQt5 +except (ImportError, RuntimeError): # RuntimeError if PyQt4 already imported. + try: + import PySide2 + except ImportError: + pytestmark = pytest.mark.skip("Failed to import a Qt5 binding.") + +qt_compat = pytest.importorskip('matplotlib.backends.qt_compat') +QtCore = qt_compat.QtCore + from matplotlib.backends.backend_qt5 import ( MODIFIER_KEYS, SUPER, ALT, CTRL, SHIFT) # noqa -QtCore = qt_compat.QtCore _, ControlModifier, ControlKey = MODIFIER_KEYS[CTRL] _, AltModifier, AltKey = MODIFIER_KEYS[ALT] _, SuperModifier, SuperKey = MODIFIER_KEYS[SUPER] From c0bae18d71de74a9180522d8e25d6a6d99117f20 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Tue, 12 Dec 2017 22:55:10 -0800 Subject: [PATCH 4/5] Rewrite and greatly simplify qt_compat.py. The selection logic is now described in the module's docstring. The only changes is that the QT_ENV_MAJOR_VERSION global, which would sometimes be defined (depending on the state of the import cache, the QT_API environment variable, and the requested backend) is never defined anymore. --- INSTALL.rst | 5 +- doc/api/backend_qt4agg_api.rst | 10 +- doc/api/backend_qt4cairo_api.rst | 10 +- doc/api/backend_qt5agg_api.rst | 10 +- doc/api/backend_qt5cairo_api.rst | 10 +- doc/sphinxext/mock_gui_toolkits.py | 104 ------ lib/matplotlib/backends/qt_compat.py | 333 +++++++----------- .../backends/qt_editor/formlayout.py | 2 +- 8 files changed, 164 insertions(+), 320 deletions(-) diff --git a/INSTALL.rst b/INSTALL.rst index 2df97b6f7d7d..3bbcc55688d4 100644 --- a/INSTALL.rst +++ b/INSTALL.rst @@ -150,8 +150,9 @@ interface toolkits. See :ref:`what-is-a-backend` for more details on the optional Matplotlib backends and the capabilities they provide. * :term:`tk` (>= 8.3, != 8.6.0 or 8.6.1): for the TkAgg backend; -* `PyQt4 `_ (>= 4.4) or - `PySide `_: for the Qt4Agg backend; +* `PyQt4 `_ (>= 4.6) or + `PySide `_ (>= 1.0.3): for the Qt4Agg + backend; * `PyQt5 `_: for the Qt5Agg backend; * :term:`wxpython` (>= 4): for the WX or WXAgg backend; * `cairocffi `_ (>=0.8) or diff --git a/doc/api/backend_qt4agg_api.rst b/doc/api/backend_qt4agg_api.rst index 8bf490aa8cb9..8b787512a44c 100644 --- a/doc/api/backend_qt4agg_api.rst +++ b/doc/api/backend_qt4agg_api.rst @@ -2,7 +2,9 @@ :mod:`matplotlib.backends.backend_qt4agg` ========================================= -.. automodule:: matplotlib.backends.backend_qt4agg - :members: - :undoc-members: - :show-inheritance: +**NOTE** Not included, to avoid adding a dependency to building the docs. + +.. .. automodule:: matplotlib.backends.backend_qt4agg +.. :members: +.. :undoc-members: +.. :show-inheritance: diff --git a/doc/api/backend_qt4cairo_api.rst b/doc/api/backend_qt4cairo_api.rst index 590465d7fbc0..1e6cb526de96 100644 --- a/doc/api/backend_qt4cairo_api.rst +++ b/doc/api/backend_qt4cairo_api.rst @@ -2,7 +2,9 @@ :mod:`matplotlib.backends.backend_qt4cairo` =========================================== -.. automodule:: matplotlib.backends.backend_qt4cairo - :members: - :undoc-members: - :show-inheritance: +**NOTE** Not included, to avoid adding a dependency to building the docs. + +.. .. automodule:: matplotlib.backends.backend_qt4cairo +.. :members: +.. :undoc-members: +.. :show-inheritance: diff --git a/doc/api/backend_qt5agg_api.rst b/doc/api/backend_qt5agg_api.rst index 8d1ad2aba0f0..f8400aefa1a2 100644 --- a/doc/api/backend_qt5agg_api.rst +++ b/doc/api/backend_qt5agg_api.rst @@ -2,7 +2,9 @@ :mod:`matplotlib.backends.backend_qt5agg` ========================================= -.. automodule:: matplotlib.backends.backend_qt5agg - :members: - :undoc-members: - :show-inheritance: +**NOTE** Not included, to avoid adding a dependency to building the docs. + +.. .. automodule:: matplotlib.backends.backend_qt5agg +.. :members: +.. :undoc-members: +.. :show-inheritance: diff --git a/doc/api/backend_qt5cairo_api.rst b/doc/api/backend_qt5cairo_api.rst index 73df7ac128a1..7ff3e1233b43 100644 --- a/doc/api/backend_qt5cairo_api.rst +++ b/doc/api/backend_qt5cairo_api.rst @@ -2,7 +2,9 @@ :mod:`matplotlib.backends.backend_qt5cairo` =========================================== -.. automodule:: matplotlib.backends.backend_qt5cairo - :members: - :undoc-members: - :show-inheritance: +**NOTE** Not included, to avoid adding a dependency to building the docs. + +.. .. automodule:: matplotlib.backends.backend_qt5cairo +.. :members: +.. :undoc-members: +.. :show-inheritance: diff --git a/doc/sphinxext/mock_gui_toolkits.py b/doc/sphinxext/mock_gui_toolkits.py index 8e8cc071ff70..a7be59a940c5 100644 --- a/doc/sphinxext/mock_gui_toolkits.py +++ b/doc/sphinxext/mock_gui_toolkits.py @@ -6,108 +6,6 @@ class MyCairoCffi(MagicMock): __name__ = "cairocffi" -class MyPyQt4(MagicMock): - class QtGui(object): - # PyQt4.QtGui public classes. - # Generated with - # textwrap.fill([name for name in dir(PyQt4.QtGui) - # if isinstance(getattr(PyQt4.QtGui, name), type)]) - _QtGui_public_classes = """\ - Display QAbstractButton QAbstractGraphicsShapeItem - QAbstractItemDelegate QAbstractItemView QAbstractPrintDialog - QAbstractProxyModel QAbstractScrollArea QAbstractSlider - QAbstractSpinBox QAbstractTextDocumentLayout QAction QActionEvent - QActionGroup QApplication QBitmap QBoxLayout QBrush QButtonGroup - QCalendarWidget QCheckBox QClipboard QCloseEvent QColor QColorDialog - QColumnView QComboBox QCommandLinkButton QCommonStyle QCompleter - QConicalGradient QContextMenuEvent QCursor QDataWidgetMapper QDateEdit - QDateTimeEdit QDesktopServices QDesktopWidget QDial QDialog - QDialogButtonBox QDirModel QDockWidget QDoubleSpinBox QDoubleValidator - QDrag QDragEnterEvent QDragLeaveEvent QDragMoveEvent QDropEvent - QErrorMessage QFileDialog QFileIconProvider QFileOpenEvent - QFileSystemModel QFocusEvent QFocusFrame QFont QFontComboBox - QFontDatabase QFontDialog QFontInfo QFontMetrics QFontMetricsF - QFormLayout QFrame QGesture QGestureEvent QGestureRecognizer QGlyphRun - QGradient QGraphicsAnchor QGraphicsAnchorLayout QGraphicsBlurEffect - QGraphicsColorizeEffect QGraphicsDropShadowEffect QGraphicsEffect - QGraphicsEllipseItem QGraphicsGridLayout QGraphicsItem - QGraphicsItemAnimation QGraphicsItemGroup QGraphicsLayout - QGraphicsLayoutItem QGraphicsLineItem QGraphicsLinearLayout - QGraphicsObject QGraphicsOpacityEffect QGraphicsPathItem - QGraphicsPixmapItem QGraphicsPolygonItem QGraphicsProxyWidget - QGraphicsRectItem QGraphicsRotation QGraphicsScale QGraphicsScene - QGraphicsSceneContextMenuEvent QGraphicsSceneDragDropEvent - QGraphicsSceneEvent QGraphicsSceneHelpEvent QGraphicsSceneHoverEvent - QGraphicsSceneMouseEvent QGraphicsSceneMoveEvent - QGraphicsSceneResizeEvent QGraphicsSceneWheelEvent - QGraphicsSimpleTextItem QGraphicsTextItem QGraphicsTransform - QGraphicsView QGraphicsWidget QGridLayout QGroupBox QHBoxLayout - QHeaderView QHelpEvent QHideEvent QHoverEvent QIcon QIconDragEvent - QIconEngine QIconEngineV2 QIdentityProxyModel QImage QImageIOHandler - QImageReader QImageWriter QInputContext QInputContextFactory - QInputDialog QInputEvent QInputMethodEvent QIntValidator QItemDelegate - QItemEditorCreatorBase QItemEditorFactory QItemSelection - QItemSelectionModel QItemSelectionRange QKeyEvent QKeyEventTransition - QKeySequence QLCDNumber QLabel QLayout QLayoutItem QLineEdit - QLinearGradient QListView QListWidget QListWidgetItem QMainWindow - QMatrix QMatrix2x2 QMatrix2x3 QMatrix2x4 QMatrix3x2 QMatrix3x3 - QMatrix3x4 QMatrix4x2 QMatrix4x3 QMatrix4x4 QMdiArea QMdiSubWindow - QMenu QMenuBar QMessageBox QMimeSource QMouseEvent - QMouseEventTransition QMoveEvent QMovie QPageSetupDialog QPaintDevice - QPaintEngine QPaintEngineState QPaintEvent QPainter QPainterPath - QPainterPathStroker QPalette QPanGesture QPen QPicture QPictureIO - QPinchGesture QPixmap QPixmapCache QPlainTextDocumentLayout - QPlainTextEdit QPolygon QPolygonF QPrintDialog QPrintEngine - QPrintPreviewDialog QPrintPreviewWidget QPrinter QPrinterInfo - QProgressBar QProgressDialog QProxyModel QPushButton QPyTextObject - QQuaternion QRadialGradient QRadioButton QRawFont QRegExpValidator - QRegion QResizeEvent QRubberBand QScrollArea QScrollBar - QSessionManager QShortcut QShortcutEvent QShowEvent QSizeGrip - QSizePolicy QSlider QSortFilterProxyModel QSound QSpacerItem QSpinBox - QSplashScreen QSplitter QSplitterHandle QStackedLayout QStackedWidget - QStandardItem QStandardItemModel QStaticText QStatusBar - QStatusTipEvent QStringListModel QStyle QStyleFactory QStyleHintReturn - QStyleHintReturnMask QStyleHintReturnVariant QStyleOption - QStyleOptionButton QStyleOptionComboBox QStyleOptionComplex - QStyleOptionDockWidget QStyleOptionDockWidgetV2 QStyleOptionFocusRect - QStyleOptionFrame QStyleOptionFrameV2 QStyleOptionFrameV3 - QStyleOptionGraphicsItem QStyleOptionGroupBox QStyleOptionHeader - QStyleOptionMenuItem QStyleOptionProgressBar QStyleOptionProgressBarV2 - QStyleOptionRubberBand QStyleOptionSizeGrip QStyleOptionSlider - QStyleOptionSpinBox QStyleOptionTab QStyleOptionTabBarBase - QStyleOptionTabBarBaseV2 QStyleOptionTabV2 QStyleOptionTabV3 - QStyleOptionTabWidgetFrame QStyleOptionTabWidgetFrameV2 - QStyleOptionTitleBar QStyleOptionToolBar QStyleOptionToolBox - QStyleOptionToolBoxV2 QStyleOptionToolButton QStyleOptionViewItem - QStyleOptionViewItemV2 QStyleOptionViewItemV3 QStyleOptionViewItemV4 - QStylePainter QStyledItemDelegate QSwipeGesture QSyntaxHighlighter - QSystemTrayIcon QTabBar QTabWidget QTableView QTableWidget - QTableWidgetItem QTableWidgetSelectionRange QTabletEvent - QTapAndHoldGesture QTapGesture QTextBlock QTextBlockFormat - QTextBlockGroup QTextBlockUserData QTextBrowser QTextCharFormat - QTextCursor QTextDocument QTextDocumentFragment QTextDocumentWriter - QTextEdit QTextFormat QTextFragment QTextFrame QTextFrameFormat - QTextImageFormat QTextInlineObject QTextItem QTextLayout QTextLength - QTextLine QTextList QTextListFormat QTextObject QTextObjectInterface - QTextOption QTextTable QTextTableCell QTextTableCellFormat - QTextTableFormat QTimeEdit QToolBar QToolBox QToolButton QToolTip - QTouchEvent QTransform QTreeView QTreeWidget QTreeWidgetItem - QTreeWidgetItemIterator QUndoCommand QUndoGroup QUndoStack QUndoView - QVBoxLayout QValidator QVector2D QVector3D QVector4D QWhatsThis - QWhatsThisClickedEvent QWheelEvent QWidget QWidgetAction QWidgetItem - QWindowStateChangeEvent QWizard QWizardPage QWorkspace - QX11EmbedContainer QX11EmbedWidget QX11Info - """ - for _name in _QtGui_public_classes.split(): - locals()[_name] = type(_name, (), {}) - del _name - - -class MySip(MagicMock): - def getapi(*args): - return 1 - - class MyWX(MagicMock): class Panel(object): pass @@ -125,8 +23,6 @@ class StatusBar(object): def setup(app): sys.modules.update( cairocffi=MyCairoCffi(), - PyQt4=MyPyQt4(), - sip=MySip(), wx=MyWX(), ) return {'parallel_read_safe': True, 'parallel_write_safe': True} diff --git a/lib/matplotlib/backends/qt_compat.py b/lib/matplotlib/backends/qt_compat.py index 759fe24a7a66..6c24a2269dc5 100644 --- a/lib/matplotlib/backends/qt_compat.py +++ b/lib/matplotlib/backends/qt_compat.py @@ -1,220 +1,159 @@ -""" A Qt API selector that can be used to switch between PyQt and PySide. +""" +Qt binding and backend selector. + +The selection logic is as follows: +- if any of PyQt5, PySide2, PyQt4 or PySide have already been imported + (checked in that order), use it; +- otherwise, if the QT_API environment variable (used by Enthought) is + set, use it to determine which binding to use (but do not change the + backend based on it; i.e. if the Qt4Agg backend is requested but QT_API + is set to "pyqt5", then actually use Qt4 with the binding specified by + ``rcParams["backend.qt4"]``; +- otherwise, use whatever the rcParams indicate. """ +from distutils.version import LooseVersion import os -import logging import sys -from matplotlib import rcParams -_log = logging.getLogger(__name__) +from matplotlib import rcParams -# Available APIs. -QT_API_PYQT = 'PyQt4' # API is not set here; Python 2.x default is V 1 -QT_API_PYQTv2 = 'PyQt4v2' # forced to Version 2 API -QT_API_PYSIDE = 'PySide' # only supports Version 2 API -QT_API_PYQT5 = 'PyQt5' # use PyQt5 API; Version 2 with module shim -QT_API_PYSIDE2 = 'PySide2' # Version 2 API with module shim - -ETS = dict(pyqt=(QT_API_PYQTv2, 4), pyside=(QT_API_PYSIDE, 4), - pyqt5=(QT_API_PYQT5, 5), pyside2=(QT_API_PYSIDE2, 5)) -# ETS is a dict of env variable to (QT_API, QT_MAJOR_VERSION) -# If the ETS QT_API environment variable is set, use it, but only -# if the variable is of the same major QT version. Note that -# ETS requires the version 2 of PyQt4, which is not the platform -# default for Python 2.x. +QT_API_PYQT5 = "PyQt5" +QT_API_PYSIDE2 = "PySide2" +QT_API_PYQTv2 = "PyQt4v2" +QT_API_PYSIDE = "PySide" +QT_API_PYQT = "PyQt4" QT_API_ENV = os.environ.get('QT_API') - -if rcParams['backend'] == 'Qt5Agg': - QT_RC_MAJOR_VERSION = 5 -elif rcParams['backend'] == 'Qt4Agg': - QT_RC_MAJOR_VERSION = 4 -else: - # A different backend was specified, but we still got here because a Qt - # related file was imported. This is allowed, so lets try and guess - # what we should be using. - if "PyQt4" in sys.modules or "PySide" in sys.modules: - # PyQt4 or PySide is actually used. - QT_RC_MAJOR_VERSION = 4 - else: - # This is a fallback: PyQt5 - QT_RC_MAJOR_VERSION = 5 - -QT_API = None - -# check if any binding is already imported, if so silently ignore the -# rcparams/ENV settings and use what ever is already imported. -if 'PySide' in sys.modules: - # user has imported PySide before importing mpl - QT_API = QT_API_PYSIDE - -if 'PySide2' in sys.modules: - # user has imported PySide before importing mpl +# First, check if anything is already imported. +if "PyQt5" in sys.modules: + QT_API = QT_API_PYQT5 + dict.__setitem__(rcParams, "backend.qt5", QT_API) +elif "PySide2" in sys.modules: QT_API = QT_API_PYSIDE2 + dict.__setitem__(rcParams, "backend.qt5", QT_API) +elif "PyQt4" in sys.modules: + QT_API = QT_API_PYQTv2 + dict.__setitem__(rcParams, "backend.qt4", QT_API) +elif "PySide" in sys.modules: + QT_API = QT_API_PYSIDE + dict.__setitem__(rcParams, "backend.qt4", QT_API) +# Otherwise, check the QT_API environment variable (from Enthought). This can +# only override the binding, not the backend (in other words, we check that the +# requested backend actually matches). +elif rcParams["backend"] == "Qt5Agg": + if QT_API_ENV == "pyqt5": + dict.__setitem__(rcParams, "backend.qt5", QT_API_PYQT5) + elif QT_API_ENV == "pyside2": + dict.__setitem__(rcParams, "backend.qt5", QT_API_PYSIDE2) + QT_API = dict.__getitem__(rcParams, "backend.qt5") +elif rcParams["backend"] == "Qt4Agg": + if QT_API_ENV == "pyqt4": + dict.__setitem__(rcParams, "backend.qt4", QT_API_PYQTv2) + elif QT_API_ENV == "pyside": + dict.__setitem__(rcParams, "backend.qt4", QT_API_PYSIDE) + QT_API = dict.__getitem__(rcParams, "backend.qt4") +# 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). +else: + QT_API = None -if 'PyQt4' in sys.modules: - # user has imported PyQt4 before importing mpl - # this case also handles the PyQt4v2 case as once sip is imported - # the API versions can not be changed so do not try - QT_API = QT_API_PYQT - -if 'PyQt5' in sys.modules: - # the user has imported PyQt5 before importing mpl - QT_API = QT_API_PYQT5 -if (QT_API_ENV is not None) and QT_API is None: - try: - QT_ENV_MAJOR_VERSION = ETS[QT_API_ENV][1] - except KeyError: - raise RuntimeError( - ('Unrecognized environment variable %r, valid values are:' - ' %r, %r, %r or %r' - % (QT_API_ENV, 'pyqt', 'pyside', 'pyqt5', 'pyside2'))) - if QT_ENV_MAJOR_VERSION == QT_RC_MAJOR_VERSION: - # Only if backend and env qt major version are - # compatible use the env variable. - QT_API = ETS[QT_API_ENV][0] - -_fallback_to_qt4 = False -if QT_API is None: - # No ETS environment or incompatible so use rcParams. - if rcParams['backend'] == 'Qt5Agg': - QT_API = QT_API_PYQT5 - elif rcParams['backend'] == 'Qt4Agg': - QT_API = QT_API_PYQT - else: - # A non-Qt backend was specified, no version of the Qt - # bindings is imported, but we still got here because a Qt - # related file was imported. This is allowed, fall back to Qt5 - # using which ever binding the rparams ask for. - _fallback_to_qt4 = True - QT_API = QT_API_PYQT5 - -# We will define an appropriate wrapper for the differing versions -# of file dialog. -_getSaveFileName = None - -# Flag to check if sip could be imported -_sip_imported = False - -# Now perform the imports. -if QT_API in (QT_API_PYQT, QT_API_PYQTv2): - try: - import sip - _sip_imported = True - except ImportError: - # Try using PySide - if QT_RC_MAJOR_VERSION == 5: - QT_API = QT_API_PYSIDE2 - else: - QT_API = QT_API_PYSIDE - cond = ("Could not import sip; falling back on PySide\n" - "in place of PyQt4 or PyQt5.\n") - _log.info(cond) - -if _sip_imported: - if QT_API == QT_API_PYQTv2: - if QT_API_ENV == 'pyqt': - cond = ("Found 'QT_API=pyqt' environment variable. " - "Setting PyQt4 API accordingly.\n") - else: - cond = "PyQt API v2 specified." - try: - sip.setapi('QString', 2) - except: - res = 'QString API v2 specification failed. Defaulting to v1.' - _log.info(cond + res) - # condition has now been reported, no need to repeat it: - cond = "" - try: - sip.setapi('QVariant', 2) - except: - res = 'QVariant API v2 specification failed. Defaulting to v1.' - _log.info(cond + res) +def _setup_pyqt5(): + global QtCore, QtGui, QtWidgets, __version__, is_pyqt5, _getSaveFileName -if QT_API == QT_API_PYQT5: - try: + if QT_API == QT_API_PYQT5: from PyQt5 import QtCore, QtGui, QtWidgets - _getSaveFileName = QtWidgets.QFileDialog.getSaveFileName - except ImportError: - if _fallback_to_qt4: - # fell through, tried PyQt5, failed fall back to PyQt4 - QT_API = QT_API_PYQT - QT_RC_MAJOR_VERSION = 4 - else: - raise - -if _sip_imported: - # needs to be if so we can re-test the value of QT_API which may - # have been changed in the above if block - if QT_API in [QT_API_PYQT, QT_API_PYQTv2]: # PyQt4 API - from PyQt4 import QtCore, QtGui - - try: - if sip.getapi("QString") > 1: - # Use new getSaveFileNameAndFilter() - _getSaveFileName = QtGui.QFileDialog.getSaveFileNameAndFilter - else: - - # Use old getSaveFileName() - def _getSaveFileName(*args, **kwargs): - return (QtGui.QFileDialog.getSaveFileName(*args, **kwargs), - None) - - except (AttributeError, KeyError): - - # call to getapi() can fail in older versions of sip - def _getSaveFileName(*args, **kwargs): - return QtGui.QFileDialog.getSaveFileName(*args, **kwargs), None - - -if QT_API == QT_API_PYSIDE2: - try: + __version__ = QtCore.PYQT_VERSION_STR + QtCore.Signal = QtCore.pyqtSignal + QtCore.Slot = QtCore.pyqtSlot + QtCore.Property = QtCore.pyqtProperty + elif QT_API == QT_API_PYSIDE2: from PySide2 import QtCore, QtGui, QtWidgets, __version__ - _getSaveFileName = QtWidgets.QFileDialog.getSaveFileName - except ImportError: - # tried PySide2, failed, fall back to PySide - QT_RC_MAJOR_VERSION = 4 - QT_API = QT_API_PYSIDE - -if QT_API == QT_API_PYSIDE: # try importing pyside - try: - from PySide import QtCore, QtGui, __version__, __version_info__ - except ImportError: - raise ImportError( - "Matplotlib qt-based backends require an external PyQt4, PyQt5,\n" - "PySide or PySide2 package to be installed, but it was not found.") + else: + raise ValueError("Unexpected value for the 'backend.qt5' rcparam") + _getSaveFileName = QtWidgets.QFileDialog.getSaveFileName - if __version_info__ < (1, 0, 3): - raise ImportError( - "Matplotlib backend_qt4 and backend_qt4agg require PySide >=1.0.3") + def is_pyqt5(): + return True - _getSaveFileName = QtGui.QFileDialog.getSaveFileName +def _setup_pyqt4(): + global QtCore, QtGui, QtWidgets, __version__, is_pyqt5, _getSaveFileName -if QT_API in (QT_API_PYQT, QT_API_PYQTv2, QT_API_PYQT5): - # Alias PyQt-specific functions for PySide compatibility. - QtCore.Signal = QtCore.pyqtSignal - try: + def _setup_pyqt4_internal(api): + global QtCore, QtGui, QtWidgets, \ + __version__, is_pyqt5, _getSaveFileName + # List of incompatible APIs: + # http://pyqt.sourceforge.net/Docs/PyQt4/incompatible_apis.html + _sip_apis = ["QDate", "QDateTime", "QString", "QTextStream", "QTime", + "QUrl", "QVariant"] + import sip + for _sip_api in _sip_apis: + try: + sip.setapi(_sip_api, api) + except ValueError: + pass + from PyQt4 import QtCore, QtGui + __version__ = QtCore.PYQT_VERSION_STR + # PyQt 4.6 introduced getSaveFileNameAndFilter: + # https://riverbankcomputing.com/news/pyqt-46 + if __version__ < LooseVersion("4.6"): + raise ImportError("PyQt<4.6 is not supported") + QtCore.Signal = QtCore.pyqtSignal QtCore.Slot = QtCore.pyqtSlot - except AttributeError: - # Not a perfect match but works in simple cases - QtCore.Slot = QtCore.pyqtSignature - - QtCore.Property = QtCore.pyqtProperty - __version__ = QtCore.PYQT_VERSION_STR + QtCore.Property = QtCore.pyqtProperty + _getSaveFileName = QtGui.QFileDialog.getSaveFileNameAndFilter -# Apply shim to Qt4 APIs to make them look like Qt5 -if QT_API in (QT_API_PYQT, QT_API_PYQTv2, QT_API_PYSIDE): - '''Import all used QtGui objects into QtWidgets - - Here I've opted to simple copy QtGui into QtWidgets as that - achieves the same result as copying over the objects, and will - continue to work if other objects are used. - - ''' + if QT_API == QT_API_PYQTv2: + _setup_pyqt4_internal(api=2) + elif QT_API == QT_API_PYSIDE: + from PySide import QtCore, QtGui, __version__, __version_info__ + # PySide 1.0.3 fixed the following: + # https://srinikom.github.io/pyside-bz-archive/809.html + if __version_info__ < (1, 0, 3): + raise ImportError("PySide<1.0.3 is not supported") + _getSaveFileName = QtGui.QFileDialog.getSaveFileName + elif QT_API == QT_API_PYQT: + _setup_pyqt4_internal(api=1) + else: + raise ValueError("Unexpected value for the 'backend.qt4' rcparam") QtWidgets = QtGui + def is_pyqt5(): + return False + + +if QT_API in [QT_API_PYQT5, QT_API_PYSIDE2]: + _setup_pyqt5() +elif QT_API in [QT_API_PYQT, QT_API_PYQTv2, QT_API_PYSIDE]: + _setup_pyqt4() +elif QT_API is None: + if rcParams["backend"] == "Qt4Agg": + _candidates = [(_setup_pyqt4, QT_API_PYQTv2), + (_setup_pyqt4, QT_API_PYSIDE), + (_setup_pyqt4, QT_API_PYQT), + (_setup_pyqt5, QT_API_PYQT5), + (_setup_pyqt5, QT_API_PYSIDE2)] + else: + _candidates = [(_setup_pyqt5, QT_API_PYQT5), + (_setup_pyqt5, QT_API_PYSIDE2), + (_setup_pyqt4, QT_API_PYQTv2), + (_setup_pyqt4, QT_API_PYSIDE), + (_setup_pyqt4, QT_API_PYQT)] + for _setup, QT_API in _candidates: + try: + _setup() + except ImportError: + continue + break + else: + raise ImportError("Failed to import any qt binding") +else: + raise AssertionError # We should not get there. + -def is_pyqt5(): - return QT_API == QT_API_PYQT5 +# These globals are only defined for backcompatibilty purposes. +ETS = dict(pyqt=(QT_API_PYQTv2, 4), pyside=(QT_API_PYSIDE, 4), + pyqt5=(QT_API_PYQT5, 5), pyside2=(QT_API_PYSIDE2, 5)) +QT_RC_MAJOR_VERSION = 5 if is_pyqt5() else 4 diff --git a/lib/matplotlib/backends/qt_editor/formlayout.py b/lib/matplotlib/backends/qt_editor/formlayout.py index ee2eb6beae10..6d719c74a1ff 100644 --- a/lib/matplotlib/backends/qt_editor/formlayout.py +++ b/lib/matplotlib/backends/qt_editor/formlayout.py @@ -46,7 +46,7 @@ import warnings from matplotlib import colors as mcolors -from matplotlib.backends.qt_compat import QtGui, QtWidgets, QtCore +from ..qt_compat import QtCore, QtGui, QtWidgets BLACKLIST = {"title", "label"} From eec27b040847b73cc782d5425fc9767ea9e66e03 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Tue, 10 Jul 2018 00:14:15 +0200 Subject: [PATCH 5/5] Cleanup backend switching API --- lib/matplotlib/__init__.py | 23 +++++++++++++++++++---- lib/matplotlib/pyplot.py | 18 ++++++------------ lib/matplotlib/rcsetup.py | 30 +++++++++++++++++++++--------- 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index 6760d52a665a..05784b28b3f3 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -803,7 +803,9 @@ def gen_candidates(): # rcParams deprecated and automatically mapped to another key. # Values are tuples of (version, new_name, f_old2new, f_new2old). -_deprecated_map = {} +_deprecated_map = { + 'backend': (3.0, 'default_backends', lambda x: [x], lambda x: x[0]) +} # rcParams deprecated; some can manually be mapped to another key. # Values are tuples of (version, new_name_or_None). @@ -909,8 +911,17 @@ def __setitem__(self, key, val): '%s is not a valid rc parameter. See rcParams.keys() for a ' 'list of valid parameters.' % (key,)) + def _set_current_backend(self, backend): + dict.__setitem__(self, 'backend', backend) + def __getitem__(self, key): - if key in _deprecated_map: + if key == 'backend': + cbook.warn_deprecated('3.0', + "The rcParam 'backend' is deprecated. Use the rcParam " + "'default_backends' to access the defaults. Use get_backend() " + "to get the current backend.") + return dict.__getitem__(self, backend) + elif key in _deprecated_map: version, alt_key, alt_val, inverse_alt = _deprecated_map[key] cbook.warn_deprecated( version, key, obj_type="rcparam", alternative=alt_key) @@ -1328,8 +1339,7 @@ def __exit__(self, exc_type, exc_value, exc_tb): {tb} """ - -def use(arg, warn=True, force=False): +def set_backend(arg, warn=True, force=False): """ Set the Matplotlib backend. @@ -1350,6 +1360,11 @@ def use(arg, warn=True, force=False): rcParamsDefault["backend"] = rcParamsOrig["backend"] = arg +@cbook.deprecated('3.0', alternative='matplotlib.set_backend') +def use(arg, warn=True, force=False): + set_backend(arg, warn, force) + + if os.environ.get('MPLBACKEND'): use(os.environ['MPLBACKEND']) diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 6ab728a0dcec..c852dd30f800 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -229,16 +229,6 @@ def switch_backend(newbackend): """ close("all") - if newbackend is rcsetup._auto_backend_sentinel: - for candidate in ["macosx", "qt5agg", "qt4agg", "gtk3agg", "gtk3cairo", - "tkagg", "wxagg", "agg", "cairo"]: - try: - switch_backend(candidate) - except ImportError: - continue - else: - return - backend_name = ( newbackend[9:] if newbackend.startswith("module://") else "matplotlib.backends.backend_{}".format(newbackend.lower())) @@ -259,7 +249,7 @@ def switch_backend(newbackend): "framework, as {!r} is currently running".format( newbackend, required_framework, current_framework)) - dict.__setitem__(rcParams, "backend", newbackend) # Don't recurse. + rcParams._set_current_backend(newbackend) global _backend_mod, new_figure_manager, draw_if_interactive, _show _backend_mod = backend_mod @@ -2401,7 +2391,11 @@ def _autogen_docstring(base): install_repl_displayhook() # Set up the backend. -switch_backend(rcParams["backend"]) +for candidate in rcParams['default_backends']: + try: + switch_backend(candidate) + except ImportError: + continue ################# REMAINING CONTENT GENERATED BY boilerplate.py ############## diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 76211a4a8e15..b07c477647a8 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -244,17 +244,26 @@ def validate_fonttype(s): _validate_standard_backends = ValidateInStrings( 'backend', all_backends, ignorecase=True) -_auto_backend_sentinel = object() + + +def validate_default_backends(s): + return [validate_backend(b) for b in s] def validate_backend(s): - backend = ( - s if s is _auto_backend_sentinel or s.startswith("module://") - else _validate_standard_backends(s)) - pyplot = sys.modules.get("matplotlib.pyplot") - if pyplot: - pyplot.switch_backend(backend) - return backend + if s.startswith('module://'): + return s + else: + return _validate_standard_backends(s) + + +def validate_backend_or_None(s): + if s is None or s == 'None': + return None + if s.startswith('module://'): + return s + else: + return _validate_standard_backends(s) def validate_qt4(s): @@ -971,7 +980,10 @@ def _validate_linestyle(ls): # a map from key -> value, converter defaultParams = { - 'backend': [_auto_backend_sentinel, validate_backend], + 'default_backends': [["macosx", "qt5agg", "qt4agg", "gtk3agg", + "gtk3cairo", "tkagg", "wxagg", "agg", "cairo"], + validate_default_backends], + 'backend': [None, validate_backend_or_None], 'backend_fallback': [True, validate_bool], 'backend.qt4': [None, validate_qt4], 'backend.qt5': [None, validate_qt5],