From fe7d7b7aebe0df0923bc2fa336a337a96c9947b3 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Mon, 29 Apr 2024 11:52:34 +0100 Subject: [PATCH 1/5] Move IPython backend registration to Matplotlib --- .github/workflows/tests.yml | 3 +- doc/users/next_whats_new/backend_registry.rst | 13 +- galleries/users_explain/figure/backends.rst | 14 +- .../users_explain/figure/figure_intro.rst | 31 +- .../writing_a_backend_pyplot_interface.rst | 44 ++ lib/matplotlib/__init__.py | 4 +- lib/matplotlib/backend_bases.py | 15 +- lib/matplotlib/backends/registry.py | 375 ++++++++++++++++-- lib/matplotlib/backends/registry.pyi | 23 +- lib/matplotlib/cbook.py | 9 - lib/matplotlib/cbook.pyi | 1 - lib/matplotlib/pyplot.py | 31 +- lib/matplotlib/rcsetup.py | 12 +- lib/matplotlib/testing/__init__.py | 34 ++ lib/matplotlib/testing/__init__.pyi | 5 + lib/matplotlib/tests/test_backend_inline.py | 46 +++ lib/matplotlib/tests/test_backend_macosx.py | 5 + lib/matplotlib/tests/test_backend_nbagg.py | 10 + lib/matplotlib/tests/test_backend_qt.py | 6 +- lib/matplotlib/tests/test_backend_registry.py | 105 ++++- .../tests/test_backends_interactive.py | 2 +- lib/matplotlib/tests/test_inline_01.ipynb | 79 ++++ lib/matplotlib/tests/test_matplotlib.py | 2 +- lib/matplotlib/tests/test_nbagg_01.ipynb | 27 +- 24 files changed, 798 insertions(+), 98 deletions(-) create mode 100644 lib/matplotlib/tests/test_backend_inline.py create mode 100644 lib/matplotlib/tests/test_inline_01.ipynb diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fcd00e9c41e2..e6608cff6bc4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -59,7 +59,8 @@ jobs: delete-font-cache: true - os: ubuntu-20.04 python-version: 3.9 - extra-requirements: '-r requirements/testing/extra.txt' + # One CI run tests ipython/matplotlib-inline before backend mapping moved to mpl + extra-requirements: '-r requirements/testing/extra.txt "ipython<8.24" "matplotlib-inline<0.1.7"' CFLAGS: "-fno-lto" # Ensure that disabling LTO works. # https://github.com/matplotlib/matplotlib/pull/26052#issuecomment-1574595954 # https://www.riverbankcomputing.com/pipermail/pyqt/2023-November/045606.html diff --git a/doc/users/next_whats_new/backend_registry.rst b/doc/users/next_whats_new/backend_registry.rst index 61b65a9d6470..7632c978f9c5 100644 --- a/doc/users/next_whats_new/backend_registry.rst +++ b/doc/users/next_whats_new/backend_registry.rst @@ -3,4 +3,15 @@ BackendRegistry New :class:`~matplotlib.backends.registry.BackendRegistry` class is the single source of truth for available backends. The singleton instance is -``matplotlib.backends.backend_registry``. +``matplotlib.backends.backend_registry``. It is used internally by Matplotlib, +and also IPython (and therefore Jupyter) starting with IPython 8.24.0. + +There are three sources of backends: built-in (source code is within the +Matplotlib repository), explicit ``module://some.backend`` syntax (backend is +obtained by loading the module), or via an entry point (self-registering +backend in an external package). + +To obtain a list of all registered backends use: + + >>> from matplotlib.backends import backend_registry + >>> backend_registry.list_all() diff --git a/galleries/users_explain/figure/backends.rst b/galleries/users_explain/figure/backends.rst index 0aa20fc58862..dc6d8a89457d 100644 --- a/galleries/users_explain/figure/backends.rst +++ b/galleries/users_explain/figure/backends.rst @@ -175,7 +175,8 @@ QtAgg Agg rendering in a Qt_ canvas (requires PyQt_ or `Qt for Python`_, more details. ipympl Agg rendering embedded in a Jupyter widget (requires ipympl_). This backend can be enabled in a Jupyter notebook with - ``%matplotlib ipympl``. + ``%matplotlib ipympl`` or ``%matplotlib widget``. Works with + Jupyter ``lab`` and ``notebook>=7``. GTK3Agg Agg rendering to a GTK_ 3.x canvas (requires PyGObject_ and pycairo_). This backend can be activated in IPython with ``%matplotlib gtk3``. @@ -188,7 +189,8 @@ TkAgg Agg rendering to a Tk_ canvas (requires TkInter_). This backend can be activated in IPython with ``%matplotlib tk``. nbAgg Embed an interactive figure in a Jupyter classic notebook. This backend can be enabled in Jupyter notebooks via - ``%matplotlib notebook``. + ``%matplotlib notebook`` or ``%matplotlib nbagg``. Works with + Jupyter ``notebook<7`` and ``nbclassic``. WebAgg On ``show()`` will start a tornado server with an interactive figure. GTK3Cairo Cairo rendering to a GTK_ 3.x canvas (requires PyGObject_ and @@ -200,7 +202,7 @@ wxAgg Agg rendering to a wxWidgets_ canvas (requires wxPython_ 4). ========= ================================================================ .. note:: - The names of builtin backends case-insensitive; e.g., 'QtAgg' and + The names of builtin backends are case-insensitive; e.g., 'QtAgg' and 'qtagg' are equivalent. .. _`Anti-Grain Geometry`: http://agg.sourceforge.net/antigrain.com/ @@ -222,11 +224,13 @@ wxAgg Agg rendering to a wxWidgets_ canvas (requires wxPython_ 4). .. _wxWidgets: https://www.wxwidgets.org/ .. _ipympl: https://www.matplotlib.org/ipympl +.. _ipympl_install: + ipympl ^^^^^^ -The Jupyter widget ecosystem is moving too fast to support directly in -Matplotlib. To install ipympl: +The ipympl backend is in a separate package that must be explicitly installed +if you wish to use it, for example: .. code-block:: bash diff --git a/galleries/users_explain/figure/figure_intro.rst b/galleries/users_explain/figure/figure_intro.rst index 462a3fc848dc..80cbb3aeeb45 100644 --- a/galleries/users_explain/figure/figure_intro.rst +++ b/galleries/users_explain/figure/figure_intro.rst @@ -52,14 +52,20 @@ Notebooks and IDEs If you are using a Notebook (e.g. `Jupyter `_) or an IDE that renders Notebooks (PyCharm, VSCode, etc), then they have a backend that -will render the Matplotlib Figure when a code cell is executed. One thing to -be aware of is that the default Jupyter backend (``%matplotlib inline``) will +will render the Matplotlib Figure when a code cell is executed. The default +Jupyter backend (``%matplotlib inline``) creates static plots that by default trim or expand the figure size to have a tight box around Artists -added to the Figure (see :ref:`saving_figures`, below). If you use a backend -other than the default "inline" backend, you will likely need to use an ipython -"magic" like ``%matplotlib notebook`` for the Matplotlib :ref:`notebook -` or ``%matplotlib widget`` for the `ipympl -`_ backend. +added to the Figure (see :ref:`saving_figures`, below). For interactive plots +in Jupyter you will need to use an ipython "magic" like ``%matplotlib widget`` +for the `ipympl `_ backend in ``jupyter lab`` +or ``notebook>=7``, or ``%matplotlib notebook`` for the Matplotlib +:ref:`notebook ` in ``notebook<7`` or +``nbclassic``. + +.. note:: + + The `ipympl `_ backend is in a separate + package, see :ref:`Installing ipympl `. .. figure:: /_static/FigureNotebook.png :alt: Image of figure generated in Jupyter Notebook with notebook @@ -75,15 +81,6 @@ other than the default "inline" backend, you will likely need to use an ipython .. seealso:: :ref:`interactive_figures`. -.. note:: - - If you only need to use the classic notebook (i.e. ``notebook<7``), - you can use: - - .. sourcecode:: ipython - - %matplotlib notebook - .. _standalone-scripts-and-interactive-use: Standalone scripts and interactive use @@ -104,7 +101,7 @@ backend. These are typically chosen either in the user's :ref:`matplotlibrc QtAgg backend. When run from a script, or interactively (e.g. from an -`iPython shell `_) the Figure +`IPython shell `_) the Figure will not be shown until we call ``plt.show()``. The Figure will appear in a new GUI window, and usually will have a toolbar with Zoom, Pan, and other tools for interacting with the Figure. By default, ``plt.show()`` blocks diff --git a/galleries/users_explain/figure/writing_a_backend_pyplot_interface.rst b/galleries/users_explain/figure/writing_a_backend_pyplot_interface.rst index 452f4d7610bb..c8dccc24da43 100644 --- a/galleries/users_explain/figure/writing_a_backend_pyplot_interface.rst +++ b/galleries/users_explain/figure/writing_a_backend_pyplot_interface.rst @@ -84,3 +84,47 @@ Function-based API 2. **Showing figures**: `.pyplot.show()` calls a module-level ``show()`` function, which is typically generated via the ``ShowBase`` class and its ``mainloop`` method. + +Registering a backend +--------------------- + +For a new backend to be usable via ``matplotlib.use()`` or IPython +``%matplotlib`` magic command, it must be compatible with one of the three ways +supported by the :class:`~matplotlib.backends.registry.BackendRegistry`: + +Built-in +^^^^^^^^ + +A backend built into Matplotlib must have its name and +``FigureCanvas.required_interactive_framework`` hard-coded in the +:class:`~matplotlib.backends.registry.BackendRegistry`. If the backend module +is not ``f"matplotlib.backends.backend_{backend_name.lower()}"`` then there +must also be an entry in the ``BackendRegistry._name_to_module``. + +module:// syntax +^^^^^^^^^^^^^^^^ + +Any backend in a separate module (not built into Matplotlib) can be used by +specifying the path to the module in the form ``module://some.backend.module``. +An example is ``module://mplcairo.qt`` for +`mplcairo `_. The backend's +interactive framework will be taken from its +``FigureCanvas.required_interactive_framework``. + +Entry point +^^^^^^^^^^^ + +An external backend module can self-register as a backend using an +``entry point`` in its ``pyproject.toml`` such as the one used by +``matplotlib-inline``: + +.. code-block:: toml + + [project.entry-points."matplotlib.backend"] + inline = "matplotlib_inline.backend_inline" + +The backend's interactive framework will be taken from its +``FigureCanvas.required_interactive_framework``. All entry points are loaded +together but only when first needed, such as when a backend name is not +recognised as a built-in backend, or when +:meth:`~matplotlib.backends.registry.BackendRegistry.list_all` is first called. diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index cc94e530133b..9e9325a27d73 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -1208,7 +1208,7 @@ def use(backend, *, force=True): backend names, which are case-insensitive: - interactive backends: - GTK3Agg, GTK3Cairo, GTK4Agg, GTK4Cairo, MacOSX, nbAgg, QtAgg, + GTK3Agg, GTK3Cairo, GTK4Agg, GTK4Cairo, MacOSX, nbAgg, notebook, QtAgg, QtCairo, TkAgg, TkCairo, WebAgg, WX, WXAgg, WXCairo, Qt5Agg, Qt5Cairo - non-interactive backends: @@ -1216,6 +1216,8 @@ def use(backend, *, force=True): or a string of the form: ``module://my.module.name``. + notebook is a synonym for nbAgg. + Switching to an interactive backend is not possible if an unrelated event loop has already been started (e.g., switching to GTK3Agg if a TkAgg window has already been opened). Switching to a non-interactive diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index e90c110c193b..d7430a4494fd 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -1766,8 +1766,16 @@ def _fix_ipython_backend2gui(cls): # `ipython --auto`). This cannot be done at import time due to # ordering issues, so we do it when creating a canvas, and should only # be done once per class (hence the `cache`). - if sys.modules.get("IPython") is None: + + # This function will not be needed when Python 3.12, the latest version + # supported by IPython < 8.24, reaches end-of-life in late 2028. + # At that time this function can be made a no-op and deprecated. + mod_ipython = sys.modules.get("IPython") + if mod_ipython is None or mod_ipython.version_info[:2] >= (8, 24): + # Use of backend2gui is not needed for IPython >= 8.24 as the + # functionality has been moved to Matplotlib. return + import IPython ip = IPython.get_ipython() if not ip: @@ -2030,9 +2038,8 @@ def _switch_canvas_and_return_print_method(self, fmt, backend=None): canvas = None if backend is not None: # Return a specific canvas class, if requested. - canvas_class = ( - importlib.import_module(cbook._backend_module_name(backend)) - .FigureCanvas) + from .backends.registry import backend_registry + canvas_class = backend_registry.load_backend_module(backend).FigureCanvas if not hasattr(canvas_class, f"print_{fmt}"): raise ValueError( f"The {backend!r} backend does not support {fmt} output") diff --git a/lib/matplotlib/backends/registry.py b/lib/matplotlib/backends/registry.py index 484d6ed5f26d..ca60789e23a8 100644 --- a/lib/matplotlib/backends/registry.py +++ b/lib/matplotlib/backends/registry.py @@ -1,4 +1,5 @@ from enum import Enum +import importlib class BackendFilter(Enum): @@ -20,36 +21,168 @@ class BackendRegistry: All use of ``BackendRegistry`` should be via the singleton instance ``backend_registry`` which can be imported from ``matplotlib.backends``. + Each backend has a name, a module name containing the backend code, and an + optional GUI framework that must be running if the backend is interactive. + There are three sources of backends: built-in (source code is within the + Matplotlib repository), explicit ``module://some.backend`` syntax (backend is + obtained by loading the module), or via an entry point (self-registering + backend in an external package). + .. versionadded:: 3.9 """ - # Built-in backends are those which are included in the Matplotlib repo. - # A backend with name 'name' is located in the module - # f'matplotlib.backends.backend_{name.lower()}' - - # The capitalized forms are needed for ipython at present; this may - # change for later versions. - _BUILTIN_INTERACTIVE = [ - "GTK3Agg", "GTK3Cairo", "GTK4Agg", "GTK4Cairo", - "MacOSX", - "nbAgg", - "QtAgg", "QtCairo", "Qt5Agg", "Qt5Cairo", - "TkAgg", "TkCairo", - "WebAgg", - "WX", "WXAgg", "WXCairo", - ] - _BUILTIN_NOT_INTERACTIVE = [ - "agg", "cairo", "pdf", "pgf", "ps", "svg", "template", - ] - _GUI_FRAMEWORK_TO_BACKEND_MAPPING = { - "qt": "qtagg", + # Mapping of built-in backend name to GUI framework, or "headless" for no + # GUI framework. Built-in backends are those which are included in the + # Matplotlib repo. A backend with name 'name' is located in the module + # f"matplotlib.backends.backend_{name.lower()}" + _BUILTIN_BACKEND_TO_GUI_FRAMEWORK = { + "gtk3agg": "gtk3", + "gtk3cairo": "gtk3", + "gtk4agg": "gtk4", + "gtk4cairo": "gtk4", + "macosx": "macosx", + "nbagg": "nbagg", + "notebook": "nbagg", + "qtagg": "qt", + "qtcairo": "qt", + "qt5agg": "qt5", + "qt5cairo": "qt5", + "tkagg": "tk", + "tkcairo": "tk", + "webagg": "webagg", + "wx": "wx", + "wxagg": "wx", + "wxcairo": "wx", + "agg": "headless", + "cairo": "headless", + "pdf": "headless", + "pgf": "headless", + "ps": "headless", + "svg": "headless", + "template": "headless", + } + + # Reverse mapping of gui framework to preferred built-in backend. + _GUI_FRAMEWORK_TO_BACKEND = { "gtk3": "gtk3agg", "gtk4": "gtk4agg", - "wx": "wxagg", - "tk": "tkagg", - "macosx": "macosx", "headless": "agg", + "macosx": "macosx", + "qt": "qtagg", + "qt5": "qt5agg", + "qt6": "qtagg", + "tk": "tkagg", + "wx": "wxagg", } + def __init__(self): + # Only load entry points when first needed. + self._loaded_entry_points = False + + # Mapping of non-built-in backend to GUI framework, added dynamically from + # entry points and from matplotlib.use("module://some.backend") format. + # New entries have an "unknown" GUI framework that is determined when first + # needed by calling _get_gui_framework_by_loading. + self._backend_to_gui_framework = {} + + # Mapping of backend name to module name, where different from + # f"matplotlib.backends.backend_{backend_name.lower()}". These are either + # hardcoded for backward compatibility, or loaded from entry points or + # "module://some.backend" syntax. + self._name_to_module = { + "notebook": "nbagg", + } + + def _backend_module_name(self, backend): + # Return name of module containing the specified backend. + # Does not check if the backend is valid, use is_valid_backend for that. + backend = backend.lower() + + # Check if have specific name to module mapping. + backend = self._name_to_module.get(backend, backend) + + return (backend[9:] if backend.startswith("module://") + else f"matplotlib.backends.backend_{backend}") + + def _clear(self): + # Clear all dynamically-added data, used for testing only. + self.__init__() + + def _ensure_entry_points_loaded(self): + # Load entry points, if they have not already been loaded. + if not self._loaded_entry_points: + entries = self._read_entry_points() + self._validate_and_store_entry_points(entries) + self._loaded_entry_points = True + + def _get_gui_framework_by_loading(self, backend): + # Determine GUI framework for a backend by loading its module and reading the + # FigureCanvas.required_interactive_framework attribute. + # Returns "headless" if there is no GUI framework. + module = self.load_backend_module(backend) + canvas_class = module.FigureCanvas + return canvas_class.required_interactive_framework or "headless" + + def _read_entry_points(self): + # Read entry points of modules that self-advertise as Matplotlib backends. + # Expects entry points like this one from matplotlib-inline (in pyproject.toml + # format): + # [project.entry-points."matplotlib.backend"] + # inline = "matplotlib_inline.backend_inline" + import importlib.metadata as im + import sys + + # entry_points group keyword not available before Python 3.10 + group = "matplotlib.backend" + if sys.version_info >= (3, 10): + entry_points = im.entry_points(group=group) + else: + entry_points = im.entry_points().get(group, ()) + entries = [(entry.name, entry.value) for entry in entry_points] + + # For backward compatibility, if matplotlib-inline and/or ipympl are installed + # but too old to include entry points, create them. Do not import ipympl + # directly as this calls matplotlib.use() whilst in this function. + def backward_compatible_entry_points( + entries, module_name, threshold_version, names, target): + from matplotlib import _parse_to_version_info + try: + module_version = im.version(module_name) + if _parse_to_version_info(module_version) < threshold_version: + for name in names: + entries.append((name, target)) + except im.PackageNotFoundError: + pass + + names = [entry[0] for entry in entries] + if "inline" not in names: + backward_compatible_entry_points( + entries, "matplotlib_inline", (0, 1, 7), ["inline"], + "matplotlib_inline.backend_inline") + if "ipympl" not in names: + backward_compatible_entry_points( + entries, "ipympl", (0, 9, 4), ["ipympl", "widget"], + "ipympl.backend_nbagg") + + return entries + + def _validate_and_store_entry_points(self, entries): + # Validate and store entry points so that they can be used via matplotlib.use() + # in the normal manner. Entry point names cannot be of module:// format, cannot + # shadow a built-in backend name, and cannot be duplicated. + for name, module in entries: + name = name.lower() + if name.startswith("module://"): + raise RuntimeError( + f"Entry point name '{name}' cannot start with 'module://'") + if name in self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK: + raise RuntimeError(f"Entry point name '{name}' is a built-in backend") + if name in self._backend_to_gui_framework: + raise RuntimeError(f"Entry point name '{name}' duplicated") + + self._name_to_module[name] = "module://" + module + # Do not yet know backend GUI framework, determine it only when necessary. + self._backend_to_gui_framework[name] = "unknown" + def backend_for_gui_framework(self, framework): """ Return the name of the backend corresponding to the specified GUI framework. @@ -61,10 +194,75 @@ def backend_for_gui_framework(self, framework): Returns ------- - str - Backend name. + str or None + Backend name or None if GUI framework not recognised. + """ + return self._GUI_FRAMEWORK_TO_BACKEND.get(framework.lower()) + + def is_valid_backend(self, backend): + """ + Return True if the backend name is valid, False otherwise. + + A backend name is valid if it is one of the built-in backends or has been + dynamically added via an entry point. Those beginning with ``module://`` are + always considered valid and are added to the current list of all backends + within this function. + + Even if a name is valid, it may not be importable or usable. This can only be + determined by loading and using the backend module. + + Parameters + ---------- + backend : str + Name of backend. + + Returns + ------- + bool + True if backend is valid, False otherwise. + """ + backend = backend.lower() + + # For backward compatibility, convert ipympl and matplotlib-inline long + # module:// names to their shortened forms. + backwards_compat = { + "module://ipympl.backend_nbagg": "widget", + "module://matplotlib_inline.backend_inline": "inline", + } + backend = backwards_compat.get(backend, backend) + + if (backend in self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK or + backend in self._backend_to_gui_framework): + return True + + if backend.startswith("module://"): + self._backend_to_gui_framework[backend] = "unknown" + return True + + if not self._loaded_entry_points: + # Only load entry points if really need to and not already done so. + self._ensure_entry_points_loaded() + if backend in self._backend_to_gui_framework: + return True + + return False + + def list_all(self): + """ + Return list of all known backends. + + These include built-in backends and those obtained at runtime either from entry + points or explicit ``module://some.backend`` syntax. + + Entry points will be loaded if they haven't been already. + + Returns + ------- + list of str + Backend names. """ - return self._GUI_FRAMEWORK_TO_BACKEND_MAPPING.get(framework) + self._ensure_entry_points_loaded() + return [*self.list_builtin(), *self._backend_to_gui_framework] def list_builtin(self, filter_=None): """ @@ -82,11 +280,130 @@ def list_builtin(self, filter_=None): Backend names. """ if filter_ == BackendFilter.INTERACTIVE: - return self._BUILTIN_INTERACTIVE + return [k for k, v in self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK.items() + if v != "headless"] elif filter_ == BackendFilter.NON_INTERACTIVE: - return self._BUILTIN_NOT_INTERACTIVE + return [k for k, v in self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK.items() + if v == "headless"] + + return [*self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK] + + def list_gui_frameworks(self): + """ + Return list of GUI frameworks used by Matplotlib backends. + + Returns + ------- + list of str + GUI framework names. + """ + return [k for k in self._GUI_FRAMEWORK_TO_BACKEND if k != "headless"] + + def load_backend_module(self, backend): + """ + Load and return the module containing the specified backend. + + Parameters + ---------- + backend : str + Name of backend to load. + + Returns + ------- + Module + Module containing backend. + """ + module_name = self._backend_module_name(backend) + return importlib.import_module(module_name) + + def resolve_backend(self, backend): + """ + Return the backend and GUI framework for the specified backend name. + + If the GUI framework is not yet known then it will be determined by loading the + backend module and checking the ``FigureCanvas.required_interactive_framework`` + attribute. + + This function only loads entry points if they have not already been loaded and + the backend is not built-in and not of ``module://some.backend`` format. + + Parameters + ---------- + backend : str or None + Name of backend, or None to use the default backend. + + Returns + ------- + Tuple of backend (str) and GUI framework (str or None). + A non-interactive backend returns None for its GUI framework rather than + "headless". + """ + if isinstance(backend, str): + backend = backend.lower() + else: # Might be _auto_backend_sentinel or None + # Use whatever is already running... + from matplotlib import get_backend + backend = get_backend() + + # Is backend already known (built-in or dynamically loaded)? + gui = (self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK.get(backend) or + self._backend_to_gui_framework.get(backend)) + + # Is backend "module://something"? + if gui is None and isinstance(backend, str) and backend.startswith("module://"): + gui = "unknown" + + # Is backend a possible entry point? + if gui is None and not self._loaded_entry_points: + self._ensure_entry_points_loaded() + gui = self._backend_to_gui_framework.get(backend) + + # Backend known but not its gui framework. + if gui == "unknown": + gui = self._get_gui_framework_by_loading(backend) + self._backend_to_gui_framework[backend] = gui + + if gui is None: + raise RuntimeError(f"'{backend}' is not a recognised backend name") + + return backend, gui if gui != "headless" else None + + def resolve_gui_or_backend(self, gui_or_backend): + """ + Return the backend and GUI framework for the specified string that may be + either a GUI framework or a backend name, tested in that order. + + This is for use with the IPython %matplotlib magic command which may be a GUI + framework such as ``%matplotlib qt`` or a backend name such as + ``%matplotlib qtagg``. + + This function only loads entry points if they have not already been loaded and + the backend is not built-in and not of ``module://some.backend`` format. + + Parameters + ---------- + gui_or_backend : str or None + Name of GUI framework or backend, or None to use the default backend. + + Returns + ------- + tuple of (str, str or None) + A non-interactive backend returns None for its GUI framework rather than + "headless". + """ + gui_or_backend = gui_or_backend.lower() + + # First check if it is a gui loop name. + backend = self.backend_for_gui_framework(gui_or_backend) + if backend is not None: + return backend, gui_or_backend - return self._BUILTIN_INTERACTIVE + self._BUILTIN_NOT_INTERACTIVE + # Then check if it is a backend name. + try: + return self.resolve_backend(gui_or_backend) + except Exception: # KeyError ? + raise RuntimeError( + f"'{gui_or_backend} is not a recognised GUI loop or backend name") # Singleton diff --git a/lib/matplotlib/backends/registry.pyi b/lib/matplotlib/backends/registry.pyi index e48531be471d..e1ae5b3e7d3a 100644 --- a/lib/matplotlib/backends/registry.pyi +++ b/lib/matplotlib/backends/registry.pyi @@ -1,4 +1,5 @@ from enum import Enum +from types import ModuleType class BackendFilter(Enum): @@ -7,8 +8,28 @@ class BackendFilter(Enum): class BackendRegistry: - def backend_for_gui_framework(self, interactive_framework: str) -> str | None: ... + _BUILTIN_BACKEND_TO_GUI_FRAMEWORK: dict[str, str] + _GUI_FRAMEWORK_TO_BACKEND: dict[str, str] + + _loaded_entry_points: bool + _backend_to_gui_framework: dict[str, str] + _name_to_module: dict[str, str] + + def _backend_module_name(self, backend: str) -> str: ... + def _clear(self) -> None: ... + def _ensure_entry_points_loaded(self) -> None: ... + def _get_gui_framework_by_loading(self, backend: str) -> str: ... + def _read_entry_points(self) -> list[tuple[str, str]]: ... + def _validate_and_store_entry_points(self, entries: list[tuple[str, str]]) -> None: ... + + def backend_for_gui_framework(self, framework: str) -> str | None: ... + def is_valid_backend(self, backend: str) -> bool: ... + def list_all(self) -> list[str]: ... def list_builtin(self, filter_: BackendFilter | None) -> list[str]: ... + def list_gui_frameworks(self) -> list[str]: ... + def load_backend_module(self, backend: str) -> ModuleType: ... + def resolve_backend(self, backend: str | None) -> tuple[str, str | None]: ... + def resolve_gui_or_backend(self, gui_or_backend: str | None) -> tuple[str, str | None]: ... backend_registry: BackendRegistry diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index d6d48ecc928c..e4f60aac37a8 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -2224,15 +2224,6 @@ def _check_and_log_subprocess(command, logger, **kwargs): return proc.stdout -def _backend_module_name(name): - """ - Convert a backend name (either a standard backend -- "Agg", "TkAgg", ... -- - or a custom backend -- "module://...") to the corresponding module name). - """ - return (name[9:] if name.startswith("module://") - else f"matplotlib.backends.backend_{name.lower()}") - - def _setup_new_guiapp(): """ Perform OS-dependent setup when Matplotlib creates a new GUI application. diff --git a/lib/matplotlib/cbook.pyi b/lib/matplotlib/cbook.pyi index 3216c4c92b9e..d727b8065b7a 100644 --- a/lib/matplotlib/cbook.pyi +++ b/lib/matplotlib/cbook.pyi @@ -176,7 +176,6 @@ class _OrderedSet(collections.abc.MutableSet): def add(self, key) -> None: ... def discard(self, key) -> None: ... -def _backend_module_name(name: str) -> str: ... def _setup_new_guiapp() -> None: ... def _format_approx(number: float, precision: int) -> str: ... def _g_sig_digits(value: float, delta: float) -> int: ... diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 2376c6243929..b1354341617d 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -295,11 +295,16 @@ def install_repl_displayhook() -> None: ip.events.register("post_execute", _draw_all_if_interactive) _REPL_DISPLAYHOOK = _ReplDisplayHook.IPYTHON - from IPython.core.pylabtools import backend2gui - # trigger IPython's eventloop integration, if available - ipython_gui_name = backend2gui.get(get_backend()) - if ipython_gui_name: - ip.enable_gui(ipython_gui_name) + if mod_ipython.version_info[:2] < (8, 24): + # Use of backend2gui is not needed for IPython >= 8.24 as that functionality + # has been moved to Matplotlib. + # This code can be removed when Python 3.12, the latest version supported by + # IPython < 8.24, reaches end-of-life in late 2028. + from IPython.core.pylabtools import backend2gui + # trigger IPython's eventloop integration, if available + ipython_gui_name = backend2gui.get(get_backend()) + if ipython_gui_name: + ip.enable_gui(ipython_gui_name) def uninstall_repl_displayhook() -> None: @@ -402,7 +407,7 @@ def switch_backend(newbackend: str) -> None: # have to escape the switch on access logic old_backend = dict.__getitem__(rcParams, 'backend') - module = importlib.import_module(cbook._backend_module_name(newbackend)) + module = backend_registry.load_backend_module(newbackend) canvas_class = module.FigureCanvas required_framework = canvas_class.required_interactive_framework @@ -477,6 +482,18 @@ def draw_if_interactive() -> None: _log.debug("Loaded backend %s version %s.", newbackend, backend_mod.backend_version) + if newbackend in ("ipympl", "widget"): + # ipympl < 0.9.4 expects rcParams["backend"] to be the fully-qualified backend + # name "module://ipympl.backend_nbagg" not short names "ipympl" or "widget". + import importlib.metadata as im + from matplotlib import _parse_to_version_info # type: ignore[attr-defined] + try: + module_version = im.version("ipympl") + if _parse_to_version_info(module_version) < (0, 9, 4): + newbackend = "module://ipympl.backend_nbagg" + except im.PackageNotFoundError: + pass + rcParams['backend'] = rcParamsDefault['backend'] = newbackend _backend_mod = backend_mod for func_name in ["new_figure_manager", "draw_if_interactive", "show"]: @@ -2586,7 +2603,7 @@ def polar(*args, **kwargs) -> list[Line2D]: if (rcParams["backend_fallback"] and rcParams._get_backend_or_none() in ( # type: ignore[attr-defined] set(backend_registry.list_builtin(BackendFilter.INTERACTIVE)) - - {'WebAgg', 'nbAgg'}) + {'webagg', 'nbagg'}) and cbook._get_running_interactive_framework()): rcParams._set("backend", rcsetup._auto_backend_sentinel) diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index a326d22f039a..b0cd22098489 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -266,16 +266,16 @@ def validate_fonttype(s): return fonttype -_validate_standard_backends = ValidateInStrings( - 'backend', backend_registry.list_builtin(), ignorecase=True) _auto_backend_sentinel = object() def validate_backend(s): - backend = ( - s if s is _auto_backend_sentinel or s.startswith("module://") - else _validate_standard_backends(s)) - return backend + if s is _auto_backend_sentinel or backend_registry.is_valid_backend(s): + return s + else: + msg = (f"'{s}' is not a valid value for backend; supported values are " + f"{backend_registry.list_all()}") + raise ValueError(msg) def _validate_toolbar(s): diff --git a/lib/matplotlib/testing/__init__.py b/lib/matplotlib/testing/__init__.py index 685b98cd99ec..16f675f66aec 100644 --- a/lib/matplotlib/testing/__init__.py +++ b/lib/matplotlib/testing/__init__.py @@ -177,3 +177,37 @@ def _has_tex_package(package): return True except FileNotFoundError: return False + + +def ipython_in_subprocess( + requested_backend_or_gui_framework, + expected_backend_old_ipython, # IPython < 8.24 + expected_backend_new_ipython, # IPython >= 8.24 +): + import pytest + IPython = pytest.importorskip("IPython") + if (IPython.version_info[:3] == (8, 24, 0) and + requested_backend_or_gui_framework == "osx"): + pytest.skip("Bug using macosx backend in IPython 8.24.0 fixed in 8.24.1") + + if IPython.version_info[:2] >= (8, 24): + expected_backend = expected_backend_new_ipython + else: + # This code can be removed when Python 3.12, the latest version supported by + # IPython < 8.24, reaches end-of-life in late 2028. + expected_backend = expected_backend_old_ipython + + code = ("import matplotlib as mpl, matplotlib.pyplot as plt;" + "fig, ax=plt.subplots(); ax.plot([1, 3, 2]); mpl.get_backend()") + proc = subprocess_run_for_testing( + [ + "ipython", + "--no-simple-prompt", + f"--matplotlib={requested_backend_or_gui_framework}", + "-c", code, + ], + check=True, + capture_output=True, + ) + + assert proc.stdout.strip() == f"Out[1]: '{expected_backend}'" diff --git a/lib/matplotlib/testing/__init__.pyi b/lib/matplotlib/testing/__init__.pyi index 30cfd9a9ed2e..b0399476b6aa 100644 --- a/lib/matplotlib/testing/__init__.pyi +++ b/lib/matplotlib/testing/__init__.pyi @@ -47,3 +47,8 @@ def subprocess_run_helper( ) -> subprocess.CompletedProcess[str]: ... def _check_for_pgf(texsystem: str) -> bool: ... def _has_tex_package(package: str) -> bool: ... +def ipython_in_subprocess( + requested_backend_or_gui_framework: str, + expected_backend_old_ipython: str, + expected_backend_new_ipython: str, +) -> None: ... diff --git a/lib/matplotlib/tests/test_backend_inline.py b/lib/matplotlib/tests/test_backend_inline.py new file mode 100644 index 000000000000..6f0d67d51756 --- /dev/null +++ b/lib/matplotlib/tests/test_backend_inline.py @@ -0,0 +1,46 @@ +import os +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest + +from matplotlib.testing import subprocess_run_for_testing + +nbformat = pytest.importorskip('nbformat') +pytest.importorskip('nbconvert') +pytest.importorskip('ipykernel') +pytest.importorskip('matplotlib_inline') + + +def test_ipynb(): + nb_path = Path(__file__).parent / 'test_inline_01.ipynb' + + with TemporaryDirectory() as tmpdir: + out_path = Path(tmpdir, "out.ipynb") + + subprocess_run_for_testing( + ["jupyter", "nbconvert", "--to", "notebook", + "--execute", "--ExecutePreprocessor.timeout=500", + "--output", str(out_path), str(nb_path)], + env={**os.environ, "IPYTHONDIR": tmpdir}, + check=True) + with out_path.open() as out: + nb = nbformat.read(out, nbformat.current_nbformat) + + errors = [output for cell in nb.cells for output in cell.get("outputs", []) + if output.output_type == "error"] + assert not errors + + import IPython + if IPython.version_info[:2] >= (8, 24): + expected_backend = "inline" + else: + # This code can be removed when Python 3.12, the latest version supported by + # IPython < 8.24, reaches end-of-life in late 2028. + expected_backend = "module://matplotlib_inline.backend_inline" + backend_outputs = nb.cells[2]["outputs"] + assert backend_outputs[0]["data"]["text/plain"] == f"'{expected_backend}'" + + image = nb.cells[1]["outputs"][1]["data"] + assert image["text/plain"] == "
" + assert "image/png" in image diff --git a/lib/matplotlib/tests/test_backend_macosx.py b/lib/matplotlib/tests/test_backend_macosx.py index c460da374c8c..a4350fe3b6c6 100644 --- a/lib/matplotlib/tests/test_backend_macosx.py +++ b/lib/matplotlib/tests/test_backend_macosx.py @@ -44,3 +44,8 @@ def new_choose_save_file(title, directory, filename): # Check the savefig.directory rcParam got updated because # we added a subdirectory "test" assert mpl.rcParams["savefig.directory"] == f"{tmp_path}/test" + + +def test_ipython(): + from matplotlib.testing import ipython_in_subprocess + ipython_in_subprocess("osx", "MacOSX", "macosx") diff --git a/lib/matplotlib/tests/test_backend_nbagg.py b/lib/matplotlib/tests/test_backend_nbagg.py index 40bee8f85c43..23af88d95086 100644 --- a/lib/matplotlib/tests/test_backend_nbagg.py +++ b/lib/matplotlib/tests/test_backend_nbagg.py @@ -30,3 +30,13 @@ def test_ipynb(): errors = [output for cell in nb.cells for output in cell.get("outputs", []) if output.output_type == "error"] assert not errors + + import IPython + if IPython.version_info[:2] >= (8, 24): + expected_backend = "notebook" + else: + # This code can be removed when Python 3.12, the latest version supported by + # IPython < 8.24, reaches end-of-life in late 2028. + expected_backend = "nbAgg" + backend_outputs = nb.cells[2]["outputs"] + assert backend_outputs[0]["data"]["text/plain"] == f"'{expected_backend}'" diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py index f4a7ef6755f2..026a49b1441e 100644 --- a/lib/matplotlib/tests/test_backend_qt.py +++ b/lib/matplotlib/tests/test_backend_qt.py @@ -14,7 +14,6 @@ from matplotlib._pylab_helpers import Gcf from matplotlib import _c_internal_utils - try: from matplotlib.backends.qt_compat import QtGui, QtWidgets # type: ignore # noqa from matplotlib.backends.qt_editor import _formlayout @@ -375,3 +374,8 @@ def custom_handler(signum, frame): finally: # Reset SIGINT handler to what it was before the test signal.signal(signal.SIGINT, original_handler) + + +def test_ipython(): + from matplotlib.testing import ipython_in_subprocess + ipython_in_subprocess("qt", "QtAgg", "qtagg") diff --git a/lib/matplotlib/tests/test_backend_registry.py b/lib/matplotlib/tests/test_backend_registry.py index aed258f36413..eaf8417e7a5f 100644 --- a/lib/matplotlib/tests/test_backend_registry.py +++ b/lib/matplotlib/tests/test_backend_registry.py @@ -7,6 +7,15 @@ from matplotlib.backends import BackendFilter, backend_registry +@pytest.fixture +def clear_backend_registry(): + # Fixture that clears the singleton backend_registry before and after use + # so that the test state remains isolated. + backend_registry._clear() + yield + backend_registry._clear() + + def has_duplicates(seq: Sequence[Any]) -> bool: return len(seq) > len(set(seq)) @@ -33,9 +42,10 @@ def test_list_builtin(): assert not has_duplicates(backends) # Compare using sets as order is not important assert {*backends} == { - 'GTK3Agg', 'GTK3Cairo', 'GTK4Agg', 'GTK4Cairo', 'MacOSX', 'nbAgg', 'QtAgg', - 'QtCairo', 'Qt5Agg', 'Qt5Cairo', 'TkAgg', 'TkCairo', 'WebAgg', 'WX', 'WXAgg', - 'WXCairo', 'agg', 'cairo', 'pdf', 'pgf', 'ps', 'svg', 'template', + 'gtk3agg', 'gtk3cairo', 'gtk4agg', 'gtk4cairo', 'macosx', 'nbagg', 'notebook', + 'qtagg', 'qtcairo', 'qt5agg', 'qt5cairo', 'tkagg', + 'tkcairo', 'webagg', 'wx', 'wxagg', 'wxcairo', 'agg', 'cairo', 'pdf', 'pgf', + 'ps', 'svg', 'template', } @@ -43,9 +53,9 @@ def test_list_builtin(): 'filter,expected', [ (BackendFilter.INTERACTIVE, - ['GTK3Agg', 'GTK3Cairo', 'GTK4Agg', 'GTK4Cairo', 'MacOSX', 'nbAgg', 'QtAgg', - 'QtCairo', 'Qt5Agg', 'Qt5Cairo', 'TkAgg', 'TkCairo', 'WebAgg', 'WX', 'WXAgg', - 'WXCairo']), + ['gtk3agg', 'gtk3cairo', 'gtk4agg', 'gtk4cairo', 'macosx', 'nbagg', 'notebook', + 'qtagg', 'qtcairo', 'qt5agg', 'qt5cairo', 'tkagg', + 'tkcairo', 'webagg', 'wx', 'wxagg', 'wxcairo']), (BackendFilter.NON_INTERACTIVE, ['agg', 'cairo', 'pdf', 'pgf', 'ps', 'svg', 'template']), ] @@ -57,6 +67,25 @@ def test_list_builtin_with_filter(filter, expected): assert {*backends} == {*expected} +def test_list_gui_frameworks(): + frameworks = backend_registry.list_gui_frameworks() + assert not has_duplicates(frameworks) + # Compare using sets as order is not important + assert {*frameworks} == { + "gtk3", "gtk4", "macosx", "qt", "qt5", "qt6", "tk", "wx", + } + + +@pytest.mark.parametrize("backend, is_valid", [ + ("agg", True), + ("QtAgg", True), + ("module://anything", True), + ("made-up-name", False), +]) +def test_is_valid_backend(backend, is_valid): + assert backend_registry.is_valid_backend(backend) == is_valid + + def test_deprecated_rcsetup_attributes(): match = "was deprecated in Matplotlib 3.9" with pytest.warns(mpl.MatplotlibDeprecationWarning, match=match): @@ -65,3 +94,67 @@ def test_deprecated_rcsetup_attributes(): mpl.rcsetup.non_interactive_bk with pytest.warns(mpl.MatplotlibDeprecationWarning, match=match): mpl.rcsetup.all_backends + + +def test_entry_points_inline(): + pytest.importorskip('matplotlib_inline') + backends = backend_registry.list_all() + assert 'inline' in backends + + +def test_entry_points_ipympl(): + pytest.importorskip('ipympl') + backends = backend_registry.list_all() + assert 'ipympl' in backends + assert 'widget' in backends + + +def test_entry_point_name_shadows_builtin(clear_backend_registry): + with pytest.raises(RuntimeError): + backend_registry._validate_and_store_entry_points( + [('qtagg', 'module1')]) + + +def test_entry_point_name_duplicate(clear_backend_registry): + with pytest.raises(RuntimeError): + backend_registry._validate_and_store_entry_points( + [('some_name', 'module1'), ('some_name', 'module2')]) + + +def test_entry_point_name_is_module(clear_backend_registry): + with pytest.raises(RuntimeError): + backend_registry._validate_and_store_entry_points( + [('module://backend.something', 'module1')]) + + +@pytest.mark.parametrize('backend', [ + 'agg', + 'module://matplotlib.backends.backend_agg', +]) +def test_load_entry_points_only_if_needed(clear_backend_registry, backend): + assert not backend_registry._loaded_entry_points + check = backend_registry.resolve_backend(backend) + assert check == (backend, None) + assert not backend_registry._loaded_entry_points + backend_registry.list_all() # Force load of entry points + assert backend_registry._loaded_entry_points + + +@pytest.mark.parametrize( + 'gui_or_backend, expected_backend, expected_gui', + [ + ('agg', 'agg', None), + ('qt', 'qtagg', 'qt'), + ('TkCairo', 'tkcairo', 'tk'), + ] +) +def test_resolve_gui_or_backend(gui_or_backend, expected_backend, expected_gui): + backend, gui = backend_registry.resolve_gui_or_backend(gui_or_backend) + assert backend == expected_backend + assert gui == expected_gui + + +def test_resolve_gui_or_backend_invalid(): + match = "is not a recognised GUI loop or backend name" + with pytest.raises(RuntimeError, match=match): + backend_registry.resolve_gui_or_backend('no-such-name') diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index e021405c56b7..6830e7d5c845 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -291,7 +291,7 @@ def _test_thread_impl(): plt.pause(0.5) # flush_events fails here on at least Tkagg (bpo-41176) future.result() # Joins the thread; rethrows any exception. plt.close() # backend is responsible for flushing any events here - if plt.rcParams["backend"].startswith("WX"): + if plt.rcParams["backend"].lower().startswith("wx"): # TODO: debug why WX needs this only on py >= 3.8 fig.canvas.flush_events() diff --git a/lib/matplotlib/tests/test_inline_01.ipynb b/lib/matplotlib/tests/test_inline_01.ipynb new file mode 100644 index 000000000000..b87ae095bdbe --- /dev/null +++ b/lib/matplotlib/tests/test_inline_01.ipynb @@ -0,0 +1,79 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%matplotlib inline\n", + "import matplotlib.pyplot as plt" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "jupyter": { + "outputs_hidden": false + } + }, + "outputs": [], + "source": [ + "fig, ax = plt.subplots(figsize=(3, 2))\n", + "ax.plot([1, 3, 2])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib\n", + "matplotlib.get_backend()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.2" + }, + "toc": { + "colors": { + "hover_highlight": "#DAA520", + "running_highlight": "#FF0000", + "selected_highlight": "#FFD700" + }, + "moveMenuLeft": true, + "nav_menu": { + "height": "12px", + "width": "252px" + }, + "navigate_menu": true, + "number_sections": true, + "sideBar": true, + "threshold": 4, + "toc_cell": false, + "toc_section_display": "block", + "toc_window_display": false, + "widenNotebook": false + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/lib/matplotlib/tests/test_matplotlib.py b/lib/matplotlib/tests/test_matplotlib.py index a2f467ac48de..37b41fafdb78 100644 --- a/lib/matplotlib/tests/test_matplotlib.py +++ b/lib/matplotlib/tests/test_matplotlib.py @@ -54,7 +54,7 @@ def parse(key): for line in matplotlib.use.__doc__.split(key)[1].split('\n'): if not line.strip(): break - backends += [e.strip() for e in line.split(',') if e] + backends += [e.strip().lower() for e in line.split(',') if e] return backends from matplotlib.backends import BackendFilter, backend_registry diff --git a/lib/matplotlib/tests/test_nbagg_01.ipynb b/lib/matplotlib/tests/test_nbagg_01.ipynb index 8505e057fdc3..bd18aa4192b7 100644 --- a/lib/matplotlib/tests/test_nbagg_01.ipynb +++ b/lib/matplotlib/tests/test_nbagg_01.ipynb @@ -8,9 +8,8 @@ }, "outputs": [], "source": [ - "import numpy as np\n", - "import matplotlib.pyplot as plt\n", - "%matplotlib notebook\n" + "%matplotlib notebook\n", + "import matplotlib.pyplot as plt" ] }, { @@ -826,17 +825,31 @@ ], "source": [ "fig, ax = plt.subplots()\n", - "ax.plot(range(10))\n" + "ax.plot(range(10))" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": { "collapsed": true }, - "outputs": [], - "source": [] + "outputs": [ + { + "data": { + "text/plain": [ + "'notebook'" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import matplotlib\n", + "matplotlib.get_backend()" + ] } ], "metadata": { From edceb2927877fc46c9922a003f2890ae0a8160d5 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Mon, 29 Apr 2024 14:51:31 +0100 Subject: [PATCH 2/5] Disable ipython subprocess tests on Windows --- lib/matplotlib/testing/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/matplotlib/testing/__init__.py b/lib/matplotlib/testing/__init__.py index 16f675f66aec..779149dec2dc 100644 --- a/lib/matplotlib/testing/__init__.py +++ b/lib/matplotlib/testing/__init__.py @@ -186,6 +186,10 @@ def ipython_in_subprocess( ): import pytest IPython = pytest.importorskip("IPython") + + if sys.platform == "win32": + pytest.skip("Cannot change backend running IPython in subprocess on Windows") + if (IPython.version_info[:3] == (8, 24, 0) and requested_backend_or_gui_framework == "osx"): pytest.skip("Bug using macosx backend in IPython 8.24.0 fixed in 8.24.1") From 9ffd68edb70ce18704971165903af628dcec8062 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Thu, 2 May 2024 12:47:58 +0100 Subject: [PATCH 3/5] Correct numpydoc returned tuples --- lib/matplotlib/backends/registry.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/matplotlib/backends/registry.py b/lib/matplotlib/backends/registry.py index ca60789e23a8..c7daead6f84b 100644 --- a/lib/matplotlib/backends/registry.py +++ b/lib/matplotlib/backends/registry.py @@ -334,9 +334,10 @@ def resolve_backend(self, backend): Returns ------- - Tuple of backend (str) and GUI framework (str or None). - A non-interactive backend returns None for its GUI framework rather than - "headless". + backend : str + The backend name. + framework : str or None + The GUI framework, which will be None for a backend that is non-interactive. """ if isinstance(backend, str): backend = backend.lower() @@ -387,9 +388,10 @@ def resolve_gui_or_backend(self, gui_or_backend): Returns ------- - tuple of (str, str or None) - A non-interactive backend returns None for its GUI framework rather than - "headless". + backend : str + The backend name. + framework : str or None + The GUI framework, which will be None for a backend that is non-interactive. """ gui_or_backend = gui_or_backend.lower() From e18712ae03d32844f0f5fda998d979b2b4e6957c Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Thu, 2 May 2024 12:58:10 +0100 Subject: [PATCH 4/5] Remove unnecessary check in is_valid_backend --- lib/matplotlib/backends/registry.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/backends/registry.py b/lib/matplotlib/backends/registry.py index c7daead6f84b..628e49cbf50f 100644 --- a/lib/matplotlib/backends/registry.py +++ b/lib/matplotlib/backends/registry.py @@ -239,11 +239,10 @@ def is_valid_backend(self, backend): self._backend_to_gui_framework[backend] = "unknown" return True - if not self._loaded_entry_points: - # Only load entry points if really need to and not already done so. - self._ensure_entry_points_loaded() - if backend in self._backend_to_gui_framework: - return True + # Only load entry points if really need to and not already done so. + self._ensure_entry_points_loaded() + if backend in self._backend_to_gui_framework: + return True return False From e458aa62b66e728775c79195758955da98bdfb06 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Thu, 2 May 2024 13:20:14 +0100 Subject: [PATCH 5/5] resolve_gui_or_backend returns None instead of "headless" --- lib/matplotlib/backends/registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/backends/registry.py b/lib/matplotlib/backends/registry.py index 628e49cbf50f..19b4cba254ab 100644 --- a/lib/matplotlib/backends/registry.py +++ b/lib/matplotlib/backends/registry.py @@ -397,7 +397,7 @@ def resolve_gui_or_backend(self, gui_or_backend): # First check if it is a gui loop name. backend = self.backend_for_gui_framework(gui_or_backend) if backend is not None: - return backend, gui_or_backend + return backend, gui_or_backend if gui_or_backend != "headless" else None # Then check if it is a backend name. try: