From e0e1cad75df2ae47c8d9d8efbbac5cdc7e39b806 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Mon, 18 Mar 2024 14:02:16 +0000 Subject: [PATCH 1/8] Move Matplotlib backend resolution to Matplotlib --- IPython/core/interactiveshell.py | 2 +- IPython/core/magics/pylab.py | 13 +++-- IPython/core/pylabtools.py | 79 ++++++++++++++++++++------- IPython/core/shellapp.py | 3 +- IPython/core/tests/test_pylabtools.py | 14 ++--- IPython/terminal/interactiveshell.py | 2 +- 6 files changed, 77 insertions(+), 36 deletions(-) diff --git a/IPython/core/interactiveshell.py b/IPython/core/interactiveshell.py index 12c120625b2..b155744062f 100644 --- a/IPython/core/interactiveshell.py +++ b/IPython/core/interactiveshell.py @@ -3657,7 +3657,7 @@ def enable_matplotlib(self, gui=None): from IPython.core import pylabtools as pt gui, backend = pt.find_gui_and_backend(gui, self.pylab_gui_select) - if gui != 'inline': + if gui != None: # If we have our first gui selection, store it if self.pylab_gui_select is None: self.pylab_gui_select = gui diff --git a/IPython/core/magics/pylab.py b/IPython/core/magics/pylab.py index 2a69453ac98..d9bdd17fe39 100644 --- a/IPython/core/magics/pylab.py +++ b/IPython/core/magics/pylab.py @@ -18,7 +18,6 @@ from IPython.core.magic import Magics, magics_class, line_magic from IPython.testing.skipdoctest import skip_doctest from warnings import warn -from IPython.core.pylabtools import backends #----------------------------------------------------------------------------- # Magic implementation classes @@ -26,11 +25,11 @@ magic_gui_arg = magic_arguments.argument( 'gui', nargs='?', - help="""Name of the matplotlib backend to use %s. + help="""Name of the matplotlib backend to use such as 'qt' or 'widget'. If given, the corresponding matplotlib backend is used, otherwise it will be matplotlib's default (which you can set in your matplotlib config file). - """ % str(tuple(sorted(backends.keys()))) + """ ) @@ -93,7 +92,13 @@ def matplotlib(self, line=''): """ args = magic_arguments.parse_argstring(self.matplotlib, line) if args.list: - backends_list = list(backends.keys()) + from IPython.core.pylabtools import _matplotlib_manages_backends + if _matplotlib_manages_backends(): + from matplotlib.backends.registry import backend_registry + backends_list = backend_registry.list_all() + else: + from IPython.core.pylabtools import backends + backends_list = list(backends.keys()) print("Available matplotlib backends: %s" % backends_list) else: gui, backend = self.shell.enable_matplotlib(args.gui.lower() if isinstance(args.gui, str) else args.gui) diff --git a/IPython/core/pylabtools.py b/IPython/core/pylabtools.py index e5715a94976..e08ce5ccec5 100644 --- a/IPython/core/pylabtools.py +++ b/IPython/core/pylabtools.py @@ -12,9 +12,12 @@ from IPython.core.display import _pngxy from IPython.utils.decorators import flag_calls -# If user specifies a GUI, that dictates the backend, otherwise we read the -# user's mpl default from the mpl rc structure -backends = { + +# Matplotlib backend resolution functionality moved from IPython to Matplotlib +# in IPython 8.23 and Matplotlib 3.9. Need to keep `backends` and `backend2gui` +# here for earlier Matplotlib and for external backend libraries such as +# mplcairo that might rely upon it. +_deprecated_backends = { "tk": "TkAgg", "gtk": "GTKAgg", "gtk3": "GTK3Agg", @@ -41,29 +44,38 @@ # GUI support to activate based on the desired matplotlib backend. For the # most part it's just a reverse of the above dict, but we also need to add a # few others that map to the same GUI manually: -backend2gui = dict(zip(backends.values(), backends.keys())) +_deprecated_backend2gui = dict(zip(_deprecated_backends.values(), _deprecated_backends.keys())) # In the reverse mapping, there are a few extra valid matplotlib backends that # map to the same GUI support -backend2gui["GTK"] = backend2gui["GTKCairo"] = "gtk" -backend2gui["GTK3Cairo"] = "gtk3" -backend2gui["GTK4Cairo"] = "gtk4" -backend2gui["WX"] = "wx" -backend2gui["CocoaAgg"] = "osx" +_deprecated_backend2gui["GTK"] = _deprecated_backend2gui["GTKCairo"] = "gtk" +_deprecated_backend2gui["GTK3Cairo"] = "gtk3" +_deprecated_backend2gui["GTK4Cairo"] = "gtk4" +_deprecated_backend2gui["WX"] = "wx" +_deprecated_backend2gui["CocoaAgg"] = "osx" # There needs to be a hysteresis here as the new QtAgg Matplotlib backend # supports either Qt5 or Qt6 and the IPython qt event loop support Qt4, Qt5, # and Qt6. -backend2gui["QtAgg"] = "qt" -backend2gui["Qt4Agg"] = "qt4" -backend2gui["Qt5Agg"] = "qt5" +_deprecated_backend2gui["QtAgg"] = "qt" +_deprecated_backend2gui["Qt4Agg"] = "qt4" +_deprecated_backend2gui["Qt5Agg"] = "qt5" # And some backends that don't need GUI integration -del backend2gui["nbAgg"] -del backend2gui["agg"] -del backend2gui["svg"] -del backend2gui["pdf"] -del backend2gui["ps"] -del backend2gui["module://matplotlib_inline.backend_inline"] -del backend2gui["module://ipympl.backend_nbagg"] +del _deprecated_backend2gui["nbAgg"] +del _deprecated_backend2gui["agg"] +del _deprecated_backend2gui["svg"] +del _deprecated_backend2gui["pdf"] +del _deprecated_backend2gui["ps"] +del _deprecated_backend2gui["module://matplotlib_inline.backend_inline"] +del _deprecated_backend2gui["module://ipympl.backend_nbagg"] + + +# Deprecated attributes backends and backend2gui mostly following PEP 562. +def __getattr__(name): + if name in ("backends", "backend2gui"): + warnings.warn(f"{name} is deprecated", DeprecationWarning) + return globals()[f"_deprecated_{name}"] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + #----------------------------------------------------------------------------- # Matplotlib utilities @@ -267,7 +279,7 @@ def select_figure_formats(shell, formats, **kwargs): [ f.pop(Figure, None) for f in shell.display_formatter.formatters.values() ] mplbackend = matplotlib.get_backend().lower() - if mplbackend == 'nbagg' or mplbackend == 'module://ipympl.backend_nbagg': + if mplbackend in ('nbagg', 'ipympl', 'widget', 'module://ipympl.backend_nbagg'): formatter = shell.display_formatter.ipython_display_formatter formatter.for_type(Figure, _reshow_nbagg_figure) @@ -318,9 +330,23 @@ def find_gui_and_backend(gui=None, gui_select=None): """ import matplotlib + if _matplotlib_manages_backends(): + backend_registry = matplotlib.backends.registry.backend_registry + + # gui argument may be a gui event loop or may be a backend name. + if gui in ("auto", None): + backend = matplotlib.rcParamsOrig['backend'] + backend, gui = backend_registry.resolve_backend(backend) + else: + backend, gui = backend_registry.resolve_gui_or_backend(gui) - has_unified_qt_backend = getattr(matplotlib, "__version_info__", (0, 0)) >= (3, 5) + return gui, backend + # Fallback to previous behaviour (Matplotlib < 3.9) + mpl_version_info = getattr(matplotlib, "__version_info__", (0, 0)) + has_unified_qt_backend = mpl_version_info >= (3, 5) + + from IPython.core.pylabtools import backends backends_ = dict(backends) if not has_unified_qt_backend: backends_["qt"] = "qt5agg" @@ -338,6 +364,7 @@ def find_gui_and_backend(gui=None, gui_select=None): backend = matplotlib.rcParamsOrig['backend'] # In this case, we need to find what the appropriate gui selection call # should be for IPython, so we can activate inputhook accordingly + from IPython.core.pylabtools import backend2gui gui = backend2gui.get(backend, None) # If we have already had a gui active, we need it and inline are the @@ -346,6 +373,10 @@ def find_gui_and_backend(gui=None, gui_select=None): gui = gui_select backend = backends_[gui] + # Since IPython 8.23.0 use None for no gui event loop rather than "inline". + if gui == "inline": + gui = None + return gui, backend @@ -431,3 +462,9 @@ def configure_inline_support(shell, backend): ) configure_inline_support_orig(shell, backend) + + +def _matplotlib_manages_backends(): + import matplotlib + mpl_version_info = getattr(matplotlib, "__version_info__", (0, 0)) + return mpl_version_info >= (3, 9) diff --git a/IPython/core/shellapp.py b/IPython/core/shellapp.py index 29325a0ad2b..1b19b7e527f 100644 --- a/IPython/core/shellapp.py +++ b/IPython/core/shellapp.py @@ -31,8 +31,7 @@ gui_keys = tuple(sorted(pt_inputhooks.backends) + sorted(pt_inputhooks.aliases)) -backend_keys = sorted(pylabtools.backends.keys()) -backend_keys.insert(0, 'auto') +backend_keys = [] shell_flags = {} diff --git a/IPython/core/tests/test_pylabtools.py b/IPython/core/tests/test_pylabtools.py index a06ad487577..114a653d3ad 100644 --- a/IPython/core/tests/test_pylabtools.py +++ b/IPython/core/tests/test_pylabtools.py @@ -199,7 +199,7 @@ def test_qt(self): assert s.pylab_gui_select == "qt" gui, backend = s.enable_matplotlib("inline") - assert gui == "inline" + assert gui is None assert s.pylab_gui_select == "qt" gui, backend = s.enable_matplotlib("qt") @@ -207,7 +207,7 @@ def test_qt(self): assert s.pylab_gui_select == "qt" gui, backend = s.enable_matplotlib("inline") - assert gui == "inline" + assert gui is None assert s.pylab_gui_select == "qt" gui, backend = s.enable_matplotlib() @@ -217,11 +217,11 @@ def test_qt(self): def test_inline(self): s = self.Shell() gui, backend = s.enable_matplotlib("inline") - assert gui == "inline" + assert gui is None assert s.pylab_gui_select == None gui, backend = s.enable_matplotlib("inline") - assert gui == "inline" + assert gui is None assert s.pylab_gui_select == None gui, backend = s.enable_matplotlib("qt") @@ -233,14 +233,14 @@ def test_inline_twice(self): ip = self.Shell() gui, backend = ip.enable_matplotlib("inline") - assert gui == "inline" + assert gui is None fmts = {'png'} active_mimes = {_fmt_mime_map[fmt] for fmt in fmts} pt.select_figure_formats(ip, fmts) gui, backend = ip.enable_matplotlib("inline") - assert gui == "inline" + assert gui is None for mime, f in ip.display_formatter.formatters.items(): if mime in active_mimes: @@ -254,7 +254,7 @@ def test_qt_gtk(self): assert gui == "qt" assert s.pylab_gui_select == "qt" - gui, backend = s.enable_matplotlib("gtk") + gui, backend = s.enable_matplotlib("gtk3") assert gui == "qt" assert s.pylab_gui_select == "qt" diff --git a/IPython/terminal/interactiveshell.py b/IPython/terminal/interactiveshell.py index fcb816eb178..4b930e40a23 100644 --- a/IPython/terminal/interactiveshell.py +++ b/IPython/terminal/interactiveshell.py @@ -966,7 +966,7 @@ def enable_gui(self, gui: Optional[str] = None) -> None: if self._inputhook is not None and gui is None: self.active_eventloop = self._inputhook = None - if gui and (gui not in {"inline", "webagg"}): + if gui and (gui not in {None, "webagg"}): # This hook runs with each cycle of the `prompt_toolkit`'s event loop. self.active_eventloop, self._inputhook = get_inputhook_name_and_func(gui) else: From 38e6f14fc59723f5b7801bd6fcc02906ac42271c Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Tue, 19 Mar 2024 13:22:21 +0000 Subject: [PATCH 2/8] Darken and mypy --- IPython/core/magics/pylab.py | 10 +++++++--- IPython/core/pylabtools.py | 13 +++++++++---- IPython/core/shellapp.py | 2 +- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/IPython/core/magics/pylab.py b/IPython/core/magics/pylab.py index d9bdd17fe39..7f7f1d34092 100644 --- a/IPython/core/magics/pylab.py +++ b/IPython/core/magics/pylab.py @@ -24,12 +24,13 @@ #----------------------------------------------------------------------------- magic_gui_arg = magic_arguments.argument( - 'gui', nargs='?', - help="""Name of the matplotlib backend to use such as 'qt' or 'widget'. + "gui", + nargs="?", + help="""Name of the matplotlib backend to use such as 'qt' or 'widget'. If given, the corresponding matplotlib backend is used, otherwise it will be matplotlib's default (which you can set in your matplotlib config file). - """ + """, ) @@ -93,11 +94,14 @@ def matplotlib(self, line=''): args = magic_arguments.parse_argstring(self.matplotlib, line) if args.list: from IPython.core.pylabtools import _matplotlib_manages_backends + if _matplotlib_manages_backends(): from matplotlib.backends.registry import backend_registry + backends_list = backend_registry.list_all() else: from IPython.core.pylabtools import backends + backends_list = list(backends.keys()) print("Available matplotlib backends: %s" % backends_list) else: diff --git a/IPython/core/pylabtools.py b/IPython/core/pylabtools.py index e08ce5ccec5..7c4439a0788 100644 --- a/IPython/core/pylabtools.py +++ b/IPython/core/pylabtools.py @@ -44,7 +44,9 @@ # GUI support to activate based on the desired matplotlib backend. For the # most part it's just a reverse of the above dict, but we also need to add a # few others that map to the same GUI manually: -_deprecated_backend2gui = dict(zip(_deprecated_backends.values(), _deprecated_backends.keys())) +_deprecated_backend2gui = dict( + zip(_deprecated_backends.values(), _deprecated_backends.keys()) +) # In the reverse mapping, there are a few extra valid matplotlib backends that # map to the same GUI support _deprecated_backend2gui["GTK"] = _deprecated_backend2gui["GTKCairo"] = "gtk" @@ -279,7 +281,7 @@ def select_figure_formats(shell, formats, **kwargs): [ f.pop(Figure, None) for f in shell.display_formatter.formatters.values() ] mplbackend = matplotlib.get_backend().lower() - if mplbackend in ('nbagg', 'ipympl', 'widget', 'module://ipympl.backend_nbagg'): + if mplbackend in ("nbagg", "ipympl", "widget", "module://ipympl.backend_nbagg"): formatter = shell.display_formatter.ipython_display_formatter formatter.for_type(Figure, _reshow_nbagg_figure) @@ -330,15 +332,16 @@ def find_gui_and_backend(gui=None, gui_select=None): """ import matplotlib + if _matplotlib_manages_backends(): backend_registry = matplotlib.backends.registry.backend_registry # gui argument may be a gui event loop or may be a backend name. if gui in ("auto", None): - backend = matplotlib.rcParamsOrig['backend'] + backend = matplotlib.rcParamsOrig["backend"] backend, gui = backend_registry.resolve_backend(backend) else: - backend, gui = backend_registry.resolve_gui_or_backend(gui) + backend, gui = backend_registry.resolve_gui_or_backend(gui) return gui, backend @@ -347,6 +350,7 @@ def find_gui_and_backend(gui=None, gui_select=None): has_unified_qt_backend = mpl_version_info >= (3, 5) from IPython.core.pylabtools import backends + backends_ = dict(backends) if not has_unified_qt_backend: backends_["qt"] = "qt5agg" @@ -466,5 +470,6 @@ def configure_inline_support(shell, backend): def _matplotlib_manages_backends(): import matplotlib + mpl_version_info = getattr(matplotlib, "__version_info__", (0, 0)) return mpl_version_info >= (3, 9) diff --git a/IPython/core/shellapp.py b/IPython/core/shellapp.py index 1b19b7e527f..4879e558120 100644 --- a/IPython/core/shellapp.py +++ b/IPython/core/shellapp.py @@ -31,7 +31,7 @@ gui_keys = tuple(sorted(pt_inputhooks.backends) + sorted(pt_inputhooks.aliases)) -backend_keys = [] +backend_keys: list[str] = [] shell_flags = {} From f9ce8c21a550d08c25b575ee55862b2dd76c9a3d Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Tue, 2 Apr 2024 14:54:30 +0100 Subject: [PATCH 3/8] Add temporary CI run using matplotlib-inline's entry-point code --- .github/workflows/test.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 90da8998e4c..1935286a831 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,6 +49,11 @@ jobs: - os: macos-latest python-version: "pypy-3.10" deps: test + # Temporary CI run to use entry point compatible code in matplotlib-inline. + - os: ubuntu-latest + python-version: "3.12" + deps: test_extra + want-latest-entry-point-code: true steps: - uses: actions/checkout@v3 @@ -82,6 +87,16 @@ jobs: - name: Check manifest if: runner.os != 'Windows' # setup.py does not support sdist on Windows run: check-manifest + + - name: Install entry point compatible code (TEMPORARY) + if: matrix.want-latest-entry-point-code + run: | + python -m pip list + # Not installing matplotlib's entry point code as building matplotlib from source is complex. + # Rely upon matplotlib to test all the latest entry point branches together. + python -m pip install --upgrade git+https://github.com/ipython/matplotlib-inline.git@main + python -m pip list + - name: pytest env: COLUMNS: 120 From 6dcf7cf5068970bc07f6ad67fe8a3bb18edbc58d Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Tue, 2 Apr 2024 16:11:20 +0100 Subject: [PATCH 4/8] Add tests for resolving backend name and gui loop --- IPython/core/display_functions.py | 2 +- IPython/core/tests/test_pylabtools.py | 84 +++++++++++++++++++++++++++ IPython/terminal/embed.py | 2 +- 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/IPython/core/display_functions.py b/IPython/core/display_functions.py index 567cf3fa609..3851634b1ec 100644 --- a/IPython/core/display_functions.py +++ b/IPython/core/display_functions.py @@ -111,7 +111,7 @@ def display( display_id=None, raw=False, clear=False, - **kwargs + **kwargs, ): """Display a Python object in all frontends. diff --git a/IPython/core/tests/test_pylabtools.py b/IPython/core/tests/test_pylabtools.py index 114a653d3ad..2ac9d60ddf9 100644 --- a/IPython/core/tests/test_pylabtools.py +++ b/IPython/core/tests/test_pylabtools.py @@ -268,3 +268,87 @@ def test_figure_no_canvas(): fig = Figure() fig.canvas = None pt.print_figure(fig) + + +@pytest.mark.parametrize( + "name, expected_gui, expected_backend", + [ + # name is gui + ("gtk3", "gtk3", "gtk3agg"), + ("gtk4", "gtk4", "gtk4agg"), + ("headless", "headless", "agg"), + ("osx", "osx", "macosx"), + ("qt", "qt", "qtagg"), + ("qt5", "qt5", "qt5agg"), + ("qt6", "qt6", "qt6agg"), + ("tk", "tk", "tkagg"), + ("wx", "wx", "wxagg"), + # name is backend + ("agg", None, "agg"), + ("cairo", None, "cairo"), + ("pdf", None, "pdf"), + ("ps", None, "ps"), + ("svg", None, "svg"), + ("template", None, "template"), + ("gtk3agg", "gtk3", "gtk3agg"), + ("gtk3cairo", "gtk3", "gtk3cairo"), + ("gtk4agg", "gtk4", "gtk4agg"), + ("gtk4cairo", "gtk4", "gtk4cairo"), + ("macosx", "osx", "macosx"), + ("nbagg", "nbagg", "nbagg"), + ("notebook", "nbagg", "notebook"), + ("qtagg", "qt", "qtagg"), + ("qtcairo", "qt", "qtcairo"), + ("qt5agg", "qt5", "qt5agg"), + ("qt5cairo", "qt5", "qt5cairo"), + ("qt6agg", "qt", "qt6agg"), + ("qt6cairo", "qt", "qt6cairo"), + ("tkagg", "tk", "tkagg"), + ("tkcairo", "tk", "tkcairo"), + ("webagg", "webagg", "webagg"), + ("wxagg", "wx", "wxagg"), + ("wxcairo", "wx", "wxcairo"), + ], +) +def test_backend_builtin(name, expected_gui, expected_backend): + # Test correct identification of Matplotlib built-in backends without importing and using them, + # otherwise we would need to ensure all the complex dependencies such as windowing toolkits are + # installed. + + mpl_manages_backends = pt._matplotlib_manages_backends() + if not mpl_manages_backends: + # Backends not supported before _matplotlib_manages_backends or supported + # but with different expected_gui or expected_backend. + if ( + name.endswith("agg") + or name.endswith("cairo") + or name in ("headless", "macosx", "pdf", "ps", "svg", "template") + ): + pytest.skip() + elif name == "qt6": + expected_backend = "qtagg" + elif name == "notebook": + expected_backend, expected_gui = expected_gui, expected_backend + + gui, backend = pt.find_gui_and_backend(name) + if not mpl_manages_backends: + gui = gui.lower() if gui else None + backend = backend.lower() if backend else None + assert gui == expected_gui + assert backend == expected_backend + + +def test_backend_entry_point(): + gui, backend = pt.find_gui_and_backend("inline") + assert gui is None + expected_backend = ( + "inline" + if pt._matplotlib_manages_backends() + else "module://matplotlib_inline.backend_inline" + ) + assert backend == expected_backend + + +def test_backend_unknown(): + with pytest.raises(RuntimeError if pt._matplotlib_manages_backends() else KeyError): + pt.find_gui_and_backend("name-does-not-exist") diff --git a/IPython/terminal/embed.py b/IPython/terminal/embed.py index 59fa6106776..d46fa7441f2 100644 --- a/IPython/terminal/embed.py +++ b/IPython/terminal/embed.py @@ -197,7 +197,7 @@ def __call__( dummy=None, stack_depth=1, compile_flags=None, - **kw + **kw, ): """Activate the interactive interpreter. From e200a04265ccc729894c4e2303ecc5b3fa30442b Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Mon, 8 Apr 2024 10:17:11 +0100 Subject: [PATCH 5/8] Review comments --- IPython/core/pylabtools.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/IPython/core/pylabtools.py b/IPython/core/pylabtools.py index 7c4439a0788..6c7787c9de4 100644 --- a/IPython/core/pylabtools.py +++ b/IPython/core/pylabtools.py @@ -14,7 +14,7 @@ # Matplotlib backend resolution functionality moved from IPython to Matplotlib -# in IPython 8.23 and Matplotlib 3.9. Need to keep `backends` and `backend2gui` +# in IPython 8.24 and Matplotlib 3.9.1. Need to keep `backends` and `backend2gui` # here for earlier Matplotlib and for external backend libraries such as # mplcairo that might rely upon it. _deprecated_backends = { @@ -74,7 +74,10 @@ # Deprecated attributes backends and backend2gui mostly following PEP 562. def __getattr__(name): if name in ("backends", "backend2gui"): - warnings.warn(f"{name} is deprecated", DeprecationWarning) + warnings.warn( + f"{name} is deprecated since IPython 8.24, backends are managed " + "in matplotlib and can be externally registered.", + DeprecationWarning) return globals()[f"_deprecated_{name}"] raise AttributeError(f"module {__name__!r} has no attribute {name!r}") @@ -377,7 +380,8 @@ def find_gui_and_backend(gui=None, gui_select=None): gui = gui_select backend = backends_[gui] - # Since IPython 8.23.0 use None for no gui event loop rather than "inline". + # Matplotlib before _matplotlib_manages_backends() can return "inline" for + # no gui event loop rather than the None that IPython >= 8.24.0 expects. if gui == "inline": gui = None From 4a4fa9308a2e5587bab232f1b2a5beb21df80307 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Mon, 8 Apr 2024 11:17:17 +0100 Subject: [PATCH 6/8] _matplotlib_manages_backends uses hasattr not version check --- IPython/core/pylabtools.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/IPython/core/pylabtools.py b/IPython/core/pylabtools.py index 6c7787c9de4..32c9645a81f 100644 --- a/IPython/core/pylabtools.py +++ b/IPython/core/pylabtools.py @@ -472,8 +472,27 @@ def configure_inline_support(shell, backend): configure_inline_support_orig(shell, backend) -def _matplotlib_manages_backends(): - import matplotlib +# Determine if Matplotlib manages backends only if needed, and cache result. +# Do not read this directly, instead use _matplotlib_manages_backends(). +_matplotlib_manages_backends_value: bool | None = None - mpl_version_info = getattr(matplotlib, "__version_info__", (0, 0)) - return mpl_version_info >= (3, 9) + +def _matplotlib_manages_backends() -> bool: + """Return True if Matplotlib manages backends, False otherwise. + + If it returns True, the caller can be sure that + matplotlib.backends.registry.backend_registry is available along with + member functions resolve_gui_or_backend, resolve_backend and list_all. + """ + global _matplotlib_manages_backends_value + if _matplotlib_manages_backends_value is None: + try: + from matplotlib.backends.registry import backend_registry + + _matplotlib_manages_backends_value = hasattr( + backend_registry, "resolve_gui_or_backend" + ) + except ImportError: + _matplotlib_manages_backends_value = False + + return _matplotlib_manages_backends_value From e666bc96be069c17fc9125b50ef3aaf18a8556cd Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Mon, 8 Apr 2024 11:21:13 +0100 Subject: [PATCH 7/8] Linting --- IPython/core/pylabtools.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/IPython/core/pylabtools.py b/IPython/core/pylabtools.py index 32c9645a81f..3135f337079 100644 --- a/IPython/core/pylabtools.py +++ b/IPython/core/pylabtools.py @@ -77,7 +77,8 @@ def __getattr__(name): warnings.warn( f"{name} is deprecated since IPython 8.24, backends are managed " "in matplotlib and can be externally registered.", - DeprecationWarning) + DeprecationWarning, + ) return globals()[f"_deprecated_{name}"] raise AttributeError(f"module {__name__!r} has no attribute {name!r}") From b1d9d89eda82337fdc7f47689b024a93a38342c0 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Mon, 8 Apr 2024 15:30:35 +0100 Subject: [PATCH 8/8] Correctly handle "ipython --matplotlib=" --- IPython/core/magics/pylab.py | 15 ++---- IPython/core/pylabtools.py | 21 ++++++++- IPython/core/shellapp.py | 89 ++++++++++++++++++++++++++---------- 3 files changed, 90 insertions(+), 35 deletions(-) diff --git a/IPython/core/magics/pylab.py b/IPython/core/magics/pylab.py index 7f7f1d34092..265f860063a 100644 --- a/IPython/core/magics/pylab.py +++ b/IPython/core/magics/pylab.py @@ -93,17 +93,12 @@ def matplotlib(self, line=''): """ args = magic_arguments.parse_argstring(self.matplotlib, line) if args.list: - from IPython.core.pylabtools import _matplotlib_manages_backends + from IPython.core.pylabtools import _list_matplotlib_backends_and_gui_loops - if _matplotlib_manages_backends(): - from matplotlib.backends.registry import backend_registry - - backends_list = backend_registry.list_all() - else: - from IPython.core.pylabtools import backends - - backends_list = list(backends.keys()) - print("Available matplotlib backends: %s" % backends_list) + print( + "Available matplotlib backends: %s" + % _list_matplotlib_backends_and_gui_loops() + ) else: gui, backend = self.shell.enable_matplotlib(args.gui.lower() if isinstance(args.gui, str) else args.gui) self._show_matplotlib_backend(args.gui, backend) diff --git a/IPython/core/pylabtools.py b/IPython/core/pylabtools.py index 3135f337079..1f5a11f37e1 100644 --- a/IPython/core/pylabtools.py +++ b/IPython/core/pylabtools.py @@ -483,7 +483,8 @@ def _matplotlib_manages_backends() -> bool: If it returns True, the caller can be sure that matplotlib.backends.registry.backend_registry is available along with - member functions resolve_gui_or_backend, resolve_backend and list_all. + member functions resolve_gui_or_backend, resolve_backend, list_all, and + list_gui_frameworks. """ global _matplotlib_manages_backends_value if _matplotlib_manages_backends_value is None: @@ -497,3 +498,21 @@ def _matplotlib_manages_backends() -> bool: _matplotlib_manages_backends_value = False return _matplotlib_manages_backends_value + + +def _list_matplotlib_backends_and_gui_loops() -> list[str]: + """Return list of all Matplotlib backends and GUI event loops. + + This is the list returned by + %matplotlib --list + """ + if _matplotlib_manages_backends(): + from matplotlib.backends.registry import backend_registry + + ret = backend_registry.list_all() + backend_registry.list_gui_frameworks() + else: + from IPython.core import pylabtools + + ret = list(pylabtools.backends.keys()) + + return sorted(["auto"] + ret) diff --git a/IPython/core/shellapp.py b/IPython/core/shellapp.py index 4879e558120..99d1d8a9f44 100644 --- a/IPython/core/shellapp.py +++ b/IPython/core/shellapp.py @@ -11,36 +11,45 @@ from itertools import chain import os import sys +import typing as t from traitlets.config.application import boolean_flag from traitlets.config.configurable import Configurable from traitlets.config.loader import Config from IPython.core.application import SYSTEM_CONFIG_DIRS, ENV_CONFIG_DIRS -from IPython.core import pylabtools from IPython.utils.contexts import preserve_keys from IPython.utils.path import filefind from traitlets import ( - Unicode, Instance, List, Bool, CaselessStrEnum, observe, + Unicode, + Instance, + List, + Bool, + CaselessStrEnum, + observe, DottedObjectName, + Undefined, ) from IPython.terminal import pt_inputhooks -#----------------------------------------------------------------------------- +# ----------------------------------------------------------------------------- # Aliases and Flags -#----------------------------------------------------------------------------- +# ----------------------------------------------------------------------------- gui_keys = tuple(sorted(pt_inputhooks.backends) + sorted(pt_inputhooks.aliases)) -backend_keys: list[str] = [] - shell_flags = {} addflag = lambda *args: shell_flags.update(boolean_flag(*args)) -addflag('autoindent', 'InteractiveShell.autoindent', - 'Turn on autoindenting.', 'Turn off autoindenting.' +addflag( + "autoindent", + "InteractiveShell.autoindent", + "Turn on autoindenting.", + "Turn off autoindenting.", ) -addflag('automagic', 'InteractiveShell.automagic', - """Turn on the auto calling of magic commands. Type %%magic at the +addflag( + "automagic", + "InteractiveShell.automagic", + """Turn on the auto calling of magic commands. Type %%magic at the IPython prompt for more information.""", 'Turn off the auto calling of magic commands.' ) @@ -96,6 +105,37 @@ ) shell_aliases['cache-size'] = 'InteractiveShell.cache_size' + +# ----------------------------------------------------------------------------- +# Traitlets +# ----------------------------------------------------------------------------- + + +class MatplotlibBackendCaselessStrEnum(CaselessStrEnum): + """An enum of Matplotlib backend strings where the case should be ignored. + + Prior to Matplotlib 3.9.1 the list of valid backends is hardcoded in + pylabtools.backends. After that, Matplotlib manages backends. + + The list of valid backends is determined when it is first needed to avoid + wasting unnecessary initialisation time. + """ + + def __init__( + self: CaselessStrEnum[t.Any], + default_value: t.Any = Undefined, + **kwargs: t.Any, + ) -> None: + super().__init__(None, default_value=default_value, **kwargs) + + def __getattribute__(self, name): + if name == "values" and object.__getattribute__(self, name) is None: + from IPython.core.pylabtools import _list_matplotlib_backends_and_gui_loops + + self.values = _list_matplotlib_backends_and_gui_loops() + return object.__getattribute__(self, name) + + #----------------------------------------------------------------------------- # Main classes and functions #----------------------------------------------------------------------------- @@ -155,30 +195,31 @@ class InteractiveShellApp(Configurable): exec_lines = List(Unicode(), help="""lines of code to run at IPython startup.""" ).tag(config=True) - code_to_run = Unicode('', - help="Execute the given command string." - ).tag(config=True) - module_to_run = Unicode('', - help="Run the module as a script." + code_to_run = Unicode("", help="Execute the given command string.").tag(config=True) + module_to_run = Unicode("", help="Run the module as a script.").tag(config=True) + gui = CaselessStrEnum( + gui_keys, + allow_none=True, + help="Enable GUI event loop integration with any of {0}.".format(gui_keys), ).tag(config=True) - gui = CaselessStrEnum(gui_keys, allow_none=True, - help="Enable GUI event loop integration with any of {0}.".format(gui_keys) - ).tag(config=True) - matplotlib = CaselessStrEnum(backend_keys, allow_none=True, + matplotlib = MatplotlibBackendCaselessStrEnum( + allow_none=True, help="""Configure matplotlib for interactive use with - the default matplotlib backend.""" + the default matplotlib backend.""", ).tag(config=True) - pylab = CaselessStrEnum(backend_keys, allow_none=True, + pylab = MatplotlibBackendCaselessStrEnum( + allow_none=True, help="""Pre-load matplotlib and numpy for interactive use, selecting a particular matplotlib backend and loop integration. - """ + """, ).tag(config=True) - pylab_import_all = Bool(True, + pylab_import_all = Bool( + True, help="""If true, IPython will populate the user namespace with numpy, pylab, etc. and an ``import *`` is done from numpy and pylab, when using pylab mode. When False, pylab mode should not import any names into the user namespace. - """ + """, ).tag(config=True) ignore_cwd = Bool( False,