From 86f26a0227ca640210c670286f0c65dbd5a1de8d Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Sun, 22 May 2022 18:58:41 +0200 Subject: [PATCH] Move show() to somewhere naturally inheritable. It's actually not clear whether to move it to FigureCanvas or FigureManager. FigureCanvas already has start_event_loop (cf. start_main_loop), but pyplot_show/start_main_loop is more a global/pyplot-only concept, so perhaps it belongs to FigureManager instead? OTOH, being on canvas_class makes it easier for switch_backend to access it (it doesn't need to go through `canvas_class.manager_class.pyplot_show`, which is relevant considering that some designs of inheritable backends didn't even have a manager_class attribute). --- lib/matplotlib/backend_bases.py | 58 ++++++++++++++++++- lib/matplotlib/backends/_backend_gtk.py | 46 ++++++++------- lib/matplotlib/backends/_backend_tk.py | 30 +++++----- lib/matplotlib/backends/backend_gtk3.py | 11 ++-- lib/matplotlib/backends/backend_gtk4.py | 10 ++-- lib/matplotlib/backends/backend_macosx.py | 9 +-- lib/matplotlib/backends/backend_qt.py | 14 +++-- lib/matplotlib/backends/backend_webagg.py | 36 ++++++------ lib/matplotlib/backends/backend_wx.py | 15 ++--- lib/matplotlib/pyplot.py | 23 ++++++-- lib/matplotlib/tests/test_backend_template.py | 12 ++++ 11 files changed, 174 insertions(+), 90 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 7e62d1a5be1a..c49a301b6a24 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2840,6 +2840,53 @@ def create_with_canvas(cls, canvas_class, figure, num): """ return cls(canvas_class(figure), num) + @classmethod + def start_main_loop(cls): + """ + Start the main event loop. + + This method is called by `.FigureManagerBase.pyplot_show`, which is the + implementation of `.pyplot.show`. To customize the behavior of + `.pyplot.show`, interactive backends should usually override + `~.FigureManagerBase.start_main_loop`; if more customized logic is + necessary, `~.FigureManagerBase.pyplot_show` can also be overridden. + """ + + @classmethod + def pyplot_show(cls, *, block=None): + """ + Show all figures. This method is the implementation of `.pyplot.show`. + + To customize the behavior of `.pyplot.show`, interactive backends + should usually override `~.FigureManagerBase.start_main_loop`; if more + customized logic is necessary, `~.FigureManagerBase.pyplot_show` can + also be overridden. + + Parameters + ---------- + block : bool, optional + Whether to block by calling ``start_main_loop``. The default, + None, means to block if we are neither in IPython's ``%pylab`` mode + nor in ``interactive`` mode. + """ + managers = Gcf.get_all_fig_managers() + if not managers: + return + for manager in managers: + try: + manager.show() # Emits a warning for non-interactive backend. + except NonGuiException as exc: + _api.warn_external(str(exc)) + if block is None: + # Hack: Are we in IPython's %pylab mode? In pylab mode, IPython + # (>= 0.10) tacks a _needmain attribute onto pyplot.show (always + # set to False). + ipython_pylab = hasattr( + getattr(sys.modules.get("pyplot"), "show", None), "_needmain") + block = not ipython_pylab and not is_interactive() + if block: + cls.start_main_loop() + def show(self): """ For GUI backends, show the figure window and redraw. @@ -3518,7 +3565,12 @@ def new_figure_manager_given_figure(cls, num, figure): @classmethod def draw_if_interactive(cls): - if cls.mainloop is not None and is_interactive(): + manager_class = cls.FigureCanvas.manager_class + # Interactive backends reimplement start_main_loop or pyplot_show. + backend_is_interactive = ( + manager_class.start_main_loop != FigureManagerBase.start_main_loop + or manager_class.pyplot_show != FigureManagerBase.pyplot_show) + if backend_is_interactive and is_interactive(): manager = Gcf.get_active() if manager: manager.canvas.draw_idle() @@ -3546,8 +3598,8 @@ def show(cls, *, block=None): # Hack: Are we in IPython's %pylab mode? In pylab mode, IPython # (>= 0.10) tacks a _needmain attribute onto pyplot.show (always # set to False). - from matplotlib import pyplot - ipython_pylab = hasattr(pyplot.show, "_needmain") + ipython_pylab = hasattr( + getattr(sys.modules.get("pyplot"), "show", None), "_needmain") block = not ipython_pylab and not is_interactive() if block: cls.mainloop() diff --git a/lib/matplotlib/backends/_backend_gtk.py b/lib/matplotlib/backends/_backend_gtk.py index 7eda5b924268..1fadc49a0d37 100644 --- a/lib/matplotlib/backends/_backend_gtk.py +++ b/lib/matplotlib/backends/_backend_gtk.py @@ -9,7 +9,8 @@ from matplotlib import _api, backend_tools, cbook from matplotlib._pylab_helpers import Gcf from matplotlib.backend_bases import ( - _Backend, FigureManagerBase, NavigationToolbar2, TimerBase) + _Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2, + TimerBase) from matplotlib.backend_tools import Cursors import gi @@ -113,6 +114,10 @@ def _on_timer(self): return False +class _FigureCanvasGTK(FigureCanvasBase): + _timer_cls = TimerGTK + + class _FigureManagerGTK(FigureManagerBase): """ Attributes @@ -192,6 +197,25 @@ def destroy(self, *args): self.window.destroy() self.canvas.destroy() + @classmethod + def start_main_loop(cls): + global _application + if _application is None: + return + + try: + _application.run() # Quits when all added windows close. + except KeyboardInterrupt: + # Ensure all windows can process their close event from + # _shutdown_application. + context = GLib.MainContext.default() + while context.pending(): + context.iteration(True) + raise + finally: + # Running after quit is undefined, so create a new one next time. + _application = None + def show(self): # show the figure window self.window.show() @@ -305,22 +329,4 @@ class _BackendGTK(_Backend): Gtk.get_minor_version(), Gtk.get_micro_version(), ) - - @staticmethod - def mainloop(): - global _application - if _application is None: - return - - try: - _application.run() # Quits when all added windows close. - except KeyboardInterrupt: - # Ensure all windows can process their close event from - # _shutdown_application. - context = GLib.MainContext.default() - while context.pending(): - context.iteration(True) - raise - finally: - # Running after quit is undefined, so create a new one next time. - _application = None + mainloop = _FigureManagerGTK.start_main_loop diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index 07aacd74eb5b..2a691f55b974 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -484,6 +484,20 @@ def create_with_canvas(cls, canvas_class, figure, num): canvas.draw_idle() return manager + @classmethod + def start_main_loop(cls): + managers = Gcf.get_all_fig_managers() + if managers: + first_manager = managers[0] + manager_class = type(first_manager) + if manager_class._owns_mainloop: + return + manager_class._owns_mainloop = True + try: + first_manager.window.mainloop() + finally: + manager_class._owns_mainloop = False + def _update_window_dpi(self, *args): newdpi = self._window_dpi.get() self.window.call('tk', 'scaling', newdpi / 72) @@ -1018,18 +1032,6 @@ def trigger(self, *args): @_Backend.export class _BackendTk(_Backend): backend_version = tk.TkVersion + FigureCanvas = FigureCanvasTk FigureManager = FigureManagerTk - - @staticmethod - def mainloop(): - managers = Gcf.get_all_fig_managers() - if managers: - first_manager = managers[0] - manager_class = type(first_manager) - if manager_class._owns_mainloop: - return - manager_class._owns_mainloop = True - try: - first_manager.window.mainloop() - finally: - manager_class._owns_mainloop = False + mainloop = FigureManagerTk.start_main_loop diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 2169a58de4de..4994e434987d 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -7,8 +7,8 @@ import matplotlib as mpl from matplotlib import _api, backend_tools, cbook from matplotlib.backend_bases import ( - FigureCanvasBase, ToolContainerBase, - CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent) + ToolContainerBase, CloseEvent, KeyEvent, LocationEvent, MouseEvent, + ResizeEvent) try: import gi @@ -26,8 +26,8 @@ from gi.repository import Gio, GLib, GObject, Gtk, Gdk from . import _backend_gtk -from ._backend_gtk import ( - _BackendGTK, _FigureManagerGTK, _NavigationToolbar2GTK, +from ._backend_gtk import ( # noqa: F401 # pylint: disable=W0611 + _BackendGTK, _FigureCanvasGTK, _FigureManagerGTK, _NavigationToolbar2GTK, TimerGTK as TimerGTK3, ) @@ -52,9 +52,8 @@ def _mpl_to_gtk_cursor(mpl_cursor): _backend_gtk.mpl_to_gtk_cursor_name(mpl_cursor)) -class FigureCanvasGTK3(FigureCanvasBase, Gtk.DrawingArea): +class FigureCanvasGTK3(_FigureCanvasGTK, Gtk.DrawingArea): required_interactive_framework = "gtk3" - _timer_cls = TimerGTK3 manager_class = _api.classproperty(lambda cls: FigureManagerGTK3) # Setting this as a static constant prevents # this resulting expression from leaking diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index 923787150a8d..c776fa7244c2 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -5,8 +5,7 @@ import matplotlib as mpl from matplotlib import _api, backend_tools, cbook from matplotlib.backend_bases import ( - FigureCanvasBase, ToolContainerBase, - KeyEvent, LocationEvent, MouseEvent, ResizeEvent) + ToolContainerBase, KeyEvent, LocationEvent, MouseEvent, ResizeEvent) try: import gi @@ -24,16 +23,15 @@ from gi.repository import Gio, GLib, Gtk, Gdk, GdkPixbuf from . import _backend_gtk -from ._backend_gtk import ( - _BackendGTK, _FigureManagerGTK, _NavigationToolbar2GTK, +from ._backend_gtk import ( # noqa: F401 # pylint: disable=W0611 + _BackendGTK, _FigureCanvasGTK, _FigureManagerGTK, _NavigationToolbar2GTK, TimerGTK as TimerGTK4, ) -class FigureCanvasGTK4(FigureCanvasBase, Gtk.DrawingArea): +class FigureCanvasGTK4(_FigureCanvasGTK, Gtk.DrawingArea): required_interactive_framework = "gtk4" supports_blit = False - _timer_cls = TimerGTK4 manager_class = _api.classproperty(lambda cls: FigureManagerGTK4) _context_is_scaled = False diff --git a/lib/matplotlib/backends/backend_macosx.py b/lib/matplotlib/backends/backend_macosx.py index 10dff3ef33f1..2867c13f430b 100644 --- a/lib/matplotlib/backends/backend_macosx.py +++ b/lib/matplotlib/backends/backend_macosx.py @@ -165,6 +165,10 @@ def _close_button_pressed(self): def close(self): return self._close_button_pressed() + @classmethod + def start_main_loop(cls): + _macosx.show() + def show(self): if not self._shown: self._show() @@ -177,7 +181,4 @@ def show(self): class _BackendMac(_Backend): FigureCanvas = FigureCanvasMac FigureManager = FigureManagerMac - - @staticmethod - def mainloop(): - _macosx.show() + mainloop = FigureManagerMac.start_main_loop diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index c133dfd079c7..8f602d71d7c8 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -583,6 +583,13 @@ def resize(self, width, height): self.canvas.resize(width, height) self.window.resize(width + extra_width, height + extra_height) + @classmethod + def start_main_loop(cls): + qapp = QtWidgets.QApplication.instance() + if qapp: + with _maybe_allow_interrupt(qapp): + qt_compat._exec(qapp) + def show(self): self.window.show() if mpl.rcParams['figure.raise_window']: @@ -1007,9 +1014,4 @@ class _BackendQT(_Backend): backend_version = __version__ FigureCanvas = FigureCanvasQT FigureManager = FigureManagerQT - - @staticmethod - def mainloop(): - qapp = QtWidgets.QApplication.instance() - with _maybe_allow_interrupt(qapp): - qt_compat._exec(qapp) + mainloop = FigureManagerQT.start_main_loop diff --git a/lib/matplotlib/backends/backend_webagg.py b/lib/matplotlib/backends/backend_webagg.py index 4eac97ff9c35..39906e53eefa 100644 --- a/lib/matplotlib/backends/backend_webagg.py +++ b/lib/matplotlib/backends/backend_webagg.py @@ -53,6 +53,24 @@ def run(self): class FigureManagerWebAgg(core.FigureManagerWebAgg): _toolbar2_class = core.NavigationToolbar2WebAgg + @classmethod + def pyplot_show(cls, *, block=None): + WebAggApplication.initialize() + + url = "http://{address}:{port}{prefix}".format( + address=WebAggApplication.address, + port=WebAggApplication.port, + prefix=WebAggApplication.url_prefix) + + if mpl.rcParams['webagg.open_in_browser']: + import webbrowser + if not webbrowser.open(url): + print("To view figure, visit {0}".format(url)) + else: + print("To view figure, visit {0}".format(url)) + + WebAggApplication.start() + class FigureCanvasWebAgg(core.FigureCanvasWebAggCore): manager_class = FigureManagerWebAgg @@ -307,21 +325,3 @@ def ipython_inline_display(figure): class _BackendWebAgg(_Backend): FigureCanvas = FigureCanvasWebAgg FigureManager = FigureManagerWebAgg - - @staticmethod - def show(*, block=None): - WebAggApplication.initialize() - - url = "http://{address}:{port}{prefix}".format( - address=WebAggApplication.address, - port=WebAggApplication.port, - prefix=WebAggApplication.url_prefix) - - if mpl.rcParams['webagg.open_in_browser']: - import webbrowser - if not webbrowser.open(url): - print("To view figure, visit {0}".format(url)) - else: - print("To view figure, visit {0}".format(url)) - - WebAggApplication.start() diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index 84bfa2eed031..226013595dce 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -999,6 +999,13 @@ def create_with_canvas(cls, canvas_class, figure, num): figure.canvas.draw_idle() return manager + @classmethod + def start_main_loop(cls): + if not wx.App.IsMainLoopRunning(): + wxapp = wx.GetApp() + if wxapp is not None: + wxapp.MainLoop() + def show(self): # docstring inherited self.frame.Show() @@ -1365,10 +1372,4 @@ def trigger(self, *args, **kwargs): class _BackendWx(_Backend): FigureCanvas = FigureCanvasWx FigureManager = FigureManagerWx - - @staticmethod - def mainloop(): - if not wx.App.IsMainLoopRunning(): - wxapp = wx.GetApp() - if wxapp is not None: - wxapp.MainLoop() + mainloop = FigureManagerWx.start_main_loop diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 93d2171e9c68..b37e43833318 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -56,7 +56,8 @@ from matplotlib import _pylab_helpers, interactive from matplotlib import cbook from matplotlib import _docstring -from matplotlib.backend_bases import FigureCanvasBase, MouseButton +from matplotlib.backend_bases import ( + FigureCanvasBase, FigureManagerBase, MouseButton) from matplotlib.figure import Figure, FigureBase, figaspect from matplotlib.gridspec import GridSpec, SubplotSpec from matplotlib import rcParams, rcParamsDefault, get_backend, rcParamsOrig @@ -280,7 +281,8 @@ def switch_backend(newbackend): # Classically, backends can directly export these functions. This should # keep working for backcompat. new_figure_manager = getattr(backend_mod, "new_figure_manager", None) - # show = getattr(backend_mod, "show", None) + show = getattr(backend_mod, "show", None) + # In that classical approach, backends are implemented as modules, but # "inherit" default method implementations from backend_bases._Backend. # This is achieved by creating a "class" that inherits from @@ -288,13 +290,14 @@ def switch_backend(newbackend): class backend_mod(matplotlib.backend_bases._Backend): locals().update(vars(backend_mod)) - # However, the newer approach for defining new_figure_manager (and, in - # the future, show) is to derive them from canvas methods. In that case, - # also update backend_mod accordingly; also, per-backend customization of + # However, the newer approach for defining new_figure_manager and + # show is to derive them from canvas methods. In that case, also + # update backend_mod accordingly; also, per-backend customization of # draw_if_interactive is disabled. if new_figure_manager is None: - # only try to get the canvas class if have opted into the new scheme + # Only try to get the canvas class if have opted into the new scheme. canvas_class = backend_mod.FigureCanvas + def new_figure_manager_given_figure(num, figure): return canvas_class.new_manager(figure, num) @@ -313,6 +316,14 @@ def draw_if_interactive(): backend_mod.new_figure_manager = new_figure_manager backend_mod.draw_if_interactive = draw_if_interactive + # If the manager explicitly overrides pyplot_show, use it even if a global + # show is already present, as the latter may be here for backcompat. + manager_class = getattr(getattr(backend_mod, "FigureCanvas", None), + "manager_class", None) + if (manager_class.pyplot_show != FigureManagerBase.pyplot_show + or show is None): + backend_mod.show = manager_class.pyplot_show + _log.debug("Loaded backend %s version %s.", newbackend, backend_mod.backend_version) diff --git a/lib/matplotlib/tests/test_backend_template.py b/lib/matplotlib/tests/test_backend_template.py index 7afe8bae69a9..1bc666ce1814 100644 --- a/lib/matplotlib/tests/test_backend_template.py +++ b/lib/matplotlib/tests/test_backend_template.py @@ -4,6 +4,7 @@ import sys from types import SimpleNamespace +from unittest.mock import MagicMock import matplotlib as mpl from matplotlib import pyplot as plt @@ -27,3 +28,14 @@ def test_load_old_api(monkeypatch): mpl.use("module://mpl_test_backend") assert type(plt.figure().canvas) == FigureCanvasTemplate plt.draw_if_interactive() + + +def test_show(monkeypatch): + mpl_test_backend = SimpleNamespace(**vars(backend_template)) + mock_show = backend_template.FigureManagerTemplate.pyplot_show = \ + MagicMock() + del mpl_test_backend.show + monkeypatch.setitem(sys.modules, "mpl_test_backend", mpl_test_backend) + mpl.use("module://mpl_test_backend") + plt.show() + mock_show.assert_called_with()