From 2fec35ac6007eabd5d84244fe096297b723c98d4 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Thu, 2 May 2024 15:06:23 -0400 Subject: [PATCH] Backport PR #27948: Move IPython backend mapping to Matplotlib and support entry points --- .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 | 376 ++++++++++++++++-- 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 | 38 ++ 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, 803 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 1cead94098a8..13f6e8352d73 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..19b4cba254ab 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,74 @@ 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 + + # 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 +279,132 @@ 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 + ------- + 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() + 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 + ------- + 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() + + # 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 if gui_or_backend != "headless" else None - 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 a41bfe56744f..a156ac200abf 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..779149dec2dc 100644 --- a/lib/matplotlib/testing/__init__.py +++ b/lib/matplotlib/testing/__init__.py @@ -177,3 +177,41 @@ 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 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") + + 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": { pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy