diff --git a/doc/api/backend_registry_api.rst b/doc/api/backend_registry_api.rst new file mode 100644 index 000000000000..ca184c67d0a2 --- /dev/null +++ b/doc/api/backend_registry_api.rst @@ -0,0 +1,8 @@ +******************************** +``matplotlib.backends.registry`` +******************************** + +.. automodule:: matplotlib.backends.registry + :members: + :undoc-members: + :show-inheritance: diff --git a/doc/api/index_backend_api.rst b/doc/api/index_backend_api.rst index 6012f71c52a4..66009d86825d 100644 --- a/doc/api/index_backend_api.rst +++ b/doc/api/index_backend_api.rst @@ -17,6 +17,7 @@ backend_pdf_api.rst backend_pgf_api.rst backend_ps_api.rst + backend_registry_api.rst backend_qt_api.rst backend_svg_api.rst backend_tk_api.rst diff --git a/doc/api/next_api_changes/deprecations/27719-IT.rst b/doc/api/next_api_changes/deprecations/27719-IT.rst new file mode 100644 index 000000000000..c41e9d2c396f --- /dev/null +++ b/doc/api/next_api_changes/deprecations/27719-IT.rst @@ -0,0 +1,11 @@ +``rcsetup.interactive_bk``, ``rcsetup.non_interactive_bk`` and ``rcsetup.all_backends`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +... are deprecated and replaced by ``matplotlib.backends.backend_registry.list_builtin`` +with the following arguments + +- ``matplotlib.backends.BackendFilter.INTERACTIVE`` +- ``matplotlib.backends.BackendFilter.NON_INTERACTIVE`` +- ``None`` + +respectively. diff --git a/doc/users/next_whats_new/backend_registry.rst b/doc/users/next_whats_new/backend_registry.rst new file mode 100644 index 000000000000..61b65a9d6470 --- /dev/null +++ b/doc/users/next_whats_new/backend_registry.rst @@ -0,0 +1,6 @@ +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``. diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index a353500f8725..a1e7eb4f3ffc 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -93,32 +93,6 @@ } -def _safe_pyplot_import(): - """ - Import and return ``pyplot``, correctly setting the backend if one is - already forced. - """ - try: - import matplotlib.pyplot as plt - except ImportError: # Likely due to a framework mismatch. - current_framework = cbook._get_running_interactive_framework() - if current_framework is None: - raise # No, something else went wrong, likely with the install... - backend_mapping = { - 'qt': 'qtagg', - 'gtk3': 'gtk3agg', - 'gtk4': 'gtk4agg', - 'wx': 'wxagg', - 'tk': 'tkagg', - 'macosx': 'macosx', - 'headless': 'agg', - } - backend = backend_mapping[current_framework] - rcParams["backend"] = mpl.rcParamsOrig["backend"] = backend - import matplotlib.pyplot as plt # Now this should succeed. - return plt - - def register_backend(format, backend, description=None): """ Register a backend for saving to a given file format. diff --git a/lib/matplotlib/backends/__init__.py b/lib/matplotlib/backends/__init__.py index 3e687f85b0be..cf0f682d5d4b 100644 --- a/lib/matplotlib/backends/__init__.py +++ b/lib/matplotlib/backends/__init__.py @@ -1,3 +1,5 @@ +from .registry import BackendFilter, backend_registry # noqa: F401 + # NOTE: plt.switch_backend() (called at import time) will add a "backend" # attribute here for backcompat. _QT_FORCE_QT5_BINDING = False diff --git a/lib/matplotlib/backends/meson.build b/lib/matplotlib/backends/meson.build index 050cc616b42c..1e3e47c0a915 100644 --- a/lib/matplotlib/backends/meson.build +++ b/lib/matplotlib/backends/meson.build @@ -33,6 +33,7 @@ python_sources = [ 'backend_wxagg.py', 'backend_wxcairo.py', 'qt_compat.py', + 'registry.py', ] typing_sources = [ diff --git a/lib/matplotlib/backends/registry.py b/lib/matplotlib/backends/registry.py new file mode 100644 index 000000000000..484d6ed5f26d --- /dev/null +++ b/lib/matplotlib/backends/registry.py @@ -0,0 +1,93 @@ +from enum import Enum + + +class BackendFilter(Enum): + """ + Filter used with :meth:`~matplotlib.backends.registry.BackendRegistry.list_builtin` + + .. versionadded:: 3.9 + """ + INTERACTIVE = 0 + NON_INTERACTIVE = 1 + + +class BackendRegistry: + """ + Registry of backends available within Matplotlib. + + This is the single source of truth for available backends. + + All use of ``BackendRegistry`` should be via the singleton instance + ``backend_registry`` which can be imported from ``matplotlib.backends``. + + .. 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", + "gtk3": "gtk3agg", + "gtk4": "gtk4agg", + "wx": "wxagg", + "tk": "tkagg", + "macosx": "macosx", + "headless": "agg", + } + + def backend_for_gui_framework(self, framework): + """ + Return the name of the backend corresponding to the specified GUI framework. + + Parameters + ---------- + framework : str + GUI framework such as "qt". + + Returns + ------- + str + Backend name. + """ + return self._GUI_FRAMEWORK_TO_BACKEND_MAPPING.get(framework) + + def list_builtin(self, filter_=None): + """ + Return list of backends that are built into Matplotlib. + + Parameters + ---------- + filter_ : `~.BackendFilter`, optional + Filter to apply to returned backends. For example, to return only + non-interactive backends use `.BackendFilter.NON_INTERACTIVE`. + + Returns + ------- + list of str + Backend names. + """ + if filter_ == BackendFilter.INTERACTIVE: + return self._BUILTIN_INTERACTIVE + elif filter_ == BackendFilter.NON_INTERACTIVE: + return self._BUILTIN_NOT_INTERACTIVE + + return self._BUILTIN_INTERACTIVE + self._BUILTIN_NOT_INTERACTIVE + + +# Singleton +backend_registry = BackendRegistry() diff --git a/lib/matplotlib/backends/registry.pyi b/lib/matplotlib/backends/registry.pyi new file mode 100644 index 000000000000..e48531be471d --- /dev/null +++ b/lib/matplotlib/backends/registry.pyi @@ -0,0 +1,14 @@ +from enum import Enum + + +class BackendFilter(Enum): + INTERACTIVE: int + NON_INTERACTIVE: int + + +class BackendRegistry: + def backend_for_gui_framework(self, interactive_framework: str) -> str | None: ... + def list_builtin(self, filter_: BackendFilter | None) -> list[str]: ... + + +backend_registry: BackendRegistry diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 2cf0d5325a63..778a9e132d43 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -69,6 +69,7 @@ from matplotlib.artist import Artist from matplotlib.axes import Axes from matplotlib.axes import Subplot # noqa: F401 +from matplotlib.backends import BackendFilter, backend_registry from matplotlib.projections import PolarAxes from matplotlib import mlab # for detrend_none, window_hanning from matplotlib.scale import get_scale_names # noqa: F401 @@ -301,16 +302,11 @@ def switch_backend(newbackend: str) -> None: if newbackend is rcsetup._auto_backend_sentinel: current_framework = cbook._get_running_interactive_framework() - mapping = {'qt': 'qtagg', - 'gtk3': 'gtk3agg', - 'gtk4': 'gtk4agg', - 'wx': 'wxagg', - 'tk': 'tkagg', - 'macosx': 'macosx', - 'headless': 'agg'} - - if current_framework in mapping: - candidates = [mapping[current_framework]] + + if (current_framework and + (backend := backend_registry.backend_for_gui_framework( + current_framework))): + candidates = [backend] else: candidates = [] candidates += [ @@ -2510,7 +2506,8 @@ def polar(*args, **kwargs) -> list[Line2D]: # is compatible with the current running interactive framework. if (rcParams["backend_fallback"] and rcParams._get_backend_or_none() in ( # type: ignore[attr-defined] - set(rcsetup.interactive_bk) - {'WebAgg', 'nbAgg'}) + set(backend_registry.list_builtin(BackendFilter.INTERACTIVE)) - + {'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 f730db0dee3b..6abc8372222d 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -23,6 +23,7 @@ import numpy as np from matplotlib import _api, cbook +from matplotlib.backends import BackendFilter, backend_registry from matplotlib.cbook import ls_mapper from matplotlib.colors import Colormap, is_color_like from matplotlib._fontconfig_pattern import parse_fontconfig_pattern @@ -32,20 +33,30 @@ from cycler import Cycler, cycler as ccycler -# The capitalized forms are needed for ipython at present; this may -# change for later versions. -interactive_bk = [ - 'GTK3Agg', 'GTK3Cairo', 'GTK4Agg', 'GTK4Cairo', - 'MacOSX', - 'nbAgg', - 'QtAgg', 'QtCairo', 'Qt5Agg', 'Qt5Cairo', - 'TkAgg', 'TkCairo', - 'WebAgg', - 'WX', 'WXAgg', 'WXCairo', -] -non_interactive_bk = ['agg', 'cairo', - 'pdf', 'pgf', 'ps', 'svg', 'template'] -all_backends = interactive_bk + non_interactive_bk +@_api.caching_module_getattr +class __getattr__: + @_api.deprecated( + "3.9", + alternative="``matplotlib.backends.backend_registry.list_builtin" + "(matplotlib.backends.BackendFilter.INTERACTIVE)``") + @property + def interactive_bk(self): + return backend_registry.list_builtin(BackendFilter.INTERACTIVE) + + @_api.deprecated( + "3.9", + alternative="``matplotlib.backends.backend_registry.list_builtin" + "(matplotlib.backends.BackendFilter.NON_INTERACTIVE)``") + @property + def non_interactive_bk(self): + return backend_registry.list_builtin(BackendFilter.NON_INTERACTIVE) + + @_api.deprecated( + "3.9", + alternative="``matplotlib.backends.backend_registry.list_builtin()``") + @property + def all_backends(self): + return backend_registry.list_builtin() class ValidateInStrings: @@ -256,7 +267,7 @@ def validate_fonttype(s): _validate_standard_backends = ValidateInStrings( - 'backend', all_backends, ignorecase=True) + 'backend', backend_registry.list_builtin(), ignorecase=True) _auto_backend_sentinel = object() diff --git a/lib/matplotlib/tests/test_backend_registry.py b/lib/matplotlib/tests/test_backend_registry.py new file mode 100644 index 000000000000..aed258f36413 --- /dev/null +++ b/lib/matplotlib/tests/test_backend_registry.py @@ -0,0 +1,67 @@ +from collections.abc import Sequence +from typing import Any + +import pytest + +import matplotlib as mpl +from matplotlib.backends import BackendFilter, backend_registry + + +def has_duplicates(seq: Sequence[Any]) -> bool: + return len(seq) > len(set(seq)) + + +@pytest.mark.parametrize( + 'framework,expected', + [ + ('qt', 'qtagg'), + ('gtk3', 'gtk3agg'), + ('gtk4', 'gtk4agg'), + ('wx', 'wxagg'), + ('tk', 'tkagg'), + ('macosx', 'macosx'), + ('headless', 'agg'), + ('does not exist', None), + ] +) +def test_backend_for_gui_framework(framework, expected): + assert backend_registry.backend_for_gui_framework(framework) == expected + + +def test_list_builtin(): + backends = backend_registry.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', + } + + +@pytest.mark.parametrize( + 'filter,expected', + [ + (BackendFilter.INTERACTIVE, + ['GTK3Agg', 'GTK3Cairo', 'GTK4Agg', 'GTK4Cairo', 'MacOSX', 'nbAgg', 'QtAgg', + 'QtCairo', 'Qt5Agg', 'Qt5Cairo', 'TkAgg', 'TkCairo', 'WebAgg', 'WX', 'WXAgg', + 'WXCairo']), + (BackendFilter.NON_INTERACTIVE, + ['agg', 'cairo', 'pdf', 'pgf', 'ps', 'svg', 'template']), + ] +) +def test_list_builtin_with_filter(filter, expected): + backends = backend_registry.list_builtin(filter) + assert not has_duplicates(backends) + # Compare using sets as order is not important + assert {*backends} == {*expected} + + +def test_deprecated_rcsetup_attributes(): + match = "was deprecated in Matplotlib 3.9" + with pytest.warns(mpl.MatplotlibDeprecationWarning, match=match): + mpl.rcsetup.interactive_bk + with pytest.warns(mpl.MatplotlibDeprecationWarning, match=match): + mpl.rcsetup.non_interactive_bk + with pytest.warns(mpl.MatplotlibDeprecationWarning, match=match): + mpl.rcsetup.all_backends diff --git a/lib/matplotlib/tests/test_matplotlib.py b/lib/matplotlib/tests/test_matplotlib.py index a1aa4ec212d6..a2f467ac48de 100644 --- a/lib/matplotlib/tests/test_matplotlib.py +++ b/lib/matplotlib/tests/test_matplotlib.py @@ -57,10 +57,12 @@ def parse(key): backends += [e.strip() for e in line.split(',') if e] return backends + from matplotlib.backends import BackendFilter, backend_registry + assert (set(parse('- interactive backends:\n')) == - set(matplotlib.rcsetup.interactive_bk)) + set(backend_registry.list_builtin(BackendFilter.INTERACTIVE))) assert (set(parse('- non-interactive backends:\n')) == - set(matplotlib.rcsetup.non_interactive_bk)) + set(backend_registry.list_builtin(BackendFilter.NON_INTERACTIVE))) def test_importable_with__OO():