From 6b67ae2ac5f9f3524331bd7cf2a18ae9e55a0ca7 2022-02-08 12:07:20 From: Matthias Bussonnier Date: 2022-02-08 12:07:20 Subject: [PATCH] Merge pull request #13522 from Carreau/auto-backport-of-pr-13506-on-7.x Backport PR #13506: Allow to configure lazy loadable magics. --- diff --git a/IPython/core/interactiveshell.py b/IPython/core/interactiveshell.py index 54f9897..89191a7 100644 --- a/IPython/core/interactiveshell.py +++ b/IPython/core/interactiveshell.py @@ -631,7 +631,6 @@ class InteractiveShell(SingletonConfigurable): def __init__(self, ipython_dir=None, profile_dir=None, user_module=None, user_ns=None, custom_exceptions=((), None), **kwargs): - # This is where traits with a config_key argument are updated # from the values on config. super(InteractiveShell, self).__init__(**kwargs) @@ -1787,7 +1786,7 @@ class InteractiveShell(SingletonConfigurable): formatter, info, enable_html_pager=self.enable_html_pager, - **kw + **kw, ) else: pmethod(info.obj, oname) @@ -2331,7 +2330,34 @@ class InteractiveShell(SingletonConfigurable): func, magic_kind=magic_kind, magic_name=magic_name ) - def run_line_magic(self, magic_name, line, _stack_depth=1): + def _find_with_lazy_load(self, type_, magic_name: str): + """ + Try to find a magic potentially lazy-loading it. + + Parameters + ---------- + + type_: "line"|"cell" + the type of magics we are trying to find/lazy load. + magic_name: str + The name of the magic we are trying to find/lazy load + + + Note that this may have any side effects + """ + finder = {"line": self.find_line_magic, "cell": self.find_cell_magic}[type_] + fn = finder(magic_name) + if fn is not None: + return fn + lazy = self.magics_manager.lazy_magics.get(magic_name) + if lazy is None: + return None + + self.run_line_magic("load_ext", lazy) + res = finder(magic_name) + return res + + def run_line_magic(self, magic_name: str, line, _stack_depth=1): """Execute the given line magic. Parameters @@ -2346,7 +2372,12 @@ class InteractiveShell(SingletonConfigurable): If run_line_magic() is called from magic() then _stack_depth=2. This is added to ensure backward compatibility for use of 'get_ipython().magic()' """ - fn = self.find_line_magic(magic_name) + fn = self._find_with_lazy_load("line", magic_name) + if fn is None: + lazy = self.magics_manager.lazy_magics.get(magic_name) + if lazy: + self.run_line_magic("load_ext", lazy) + fn = self.find_line_magic(magic_name) if fn is None: cm = self.find_cell_magic(magic_name) etpl = "Line magic function `%%%s` not found%s." @@ -2399,7 +2430,7 @@ class InteractiveShell(SingletonConfigurable): cell : str The body of the cell as a (possibly multiline) string. """ - fn = self.find_cell_magic(magic_name) + fn = self._find_with_lazy_load("cell", magic_name) if fn is None: lm = self.find_line_magic(magic_name) etpl = "Cell magic `%%{0}` not found{1}." diff --git a/IPython/core/magic.py b/IPython/core/magic.py index bc51677..63b6bec 100644 --- a/IPython/core/magic.py +++ b/IPython/core/magic.py @@ -310,6 +310,34 @@ class MagicsManager(Configurable): # holding the actual callable object as value. This is the dict used for # magic function dispatch magics = Dict() + lazy_magics = Dict( + help=""" + Mapping from magic names to modules to load. + + This can be used in IPython/IPykernel configuration to declare lazy magics + that will only be imported/registered on first use. + + For example:: + + c.MagicsManger.lazy_magics = { + "my_magic": "slow.to.import", + "my_other_magic": "also.slow", + } + + On first invocation of `%my_magic`, `%%my_magic`, `%%my_other_magic` or + `%%my_other_magic`, the corresponding module will be loaded as an ipython + extensions as if you had previously done `%load_ext ipython`. + + Magics names should be without percent(s) as magics can be both cell + and line magics. + + Lazy loading happen relatively late in execution process, and + complex extensions that manipulate Python/IPython internal state or global state + might not support lazy loading. + """ + ).tag( + config=True, + ) # A registry of the original objects that we've been given holding magics. registry = Dict() @@ -374,6 +402,24 @@ class MagicsManager(Configurable): docs[m_type] = m_docs return docs + def register_lazy(self, name: str, fully_qualified_name: str): + """ + Lazily register a magic via an extension. + + + Parameters + ---------- + name : str + Name of the magic you wish to register. + fully_qualified_name : + Fully qualified name of the module/submodule that should be loaded + as an extensions when the magic is first called. + It is assumed that loading this extensions will register the given + magic. + """ + + self.lazy_magics[name] = fully_qualified_name + def register(self, *magic_objects): """Register one or more instances of Magics. diff --git a/IPython/core/tests/test_magic.py b/IPython/core/tests/test_magic.py index d6e5d2f..03d3949 100644 --- a/IPython/core/tests/test_magic.py +++ b/IPython/core/tests/test_magic.py @@ -32,8 +32,15 @@ from IPython.utils.io import capture_output from IPython.utils.tempdir import (TemporaryDirectory, TemporaryWorkingDirectory) from IPython.utils.process import find_cmd +from IPython.utils.tempdir import TemporaryDirectory, TemporaryWorkingDirectory +from IPython.utils.syspathcontext import prepended_to_syspath + from .test_debugger import PdbTestInput +from tempfile import NamedTemporaryFile + +import pytest +from pathlib import Path @magic.magics_class class DummyMagics(magic.Magics): pass @@ -1236,3 +1243,44 @@ def test_timeit_arguments(): # 3.7 optimize no-op statement like above out, and complain there is # nothing in the for loop. _ip.magic("timeit -n1 -r1 a=('#')") + + +MINIMAL_LAZY_MAGIC = """ +from IPython.core.magic import ( + Magics, + magics_class, + line_magic, + cell_magic, +) + + +@magics_class +class LazyMagics(Magics): + @line_magic + def lazy_line(self, line): + print("Lazy Line") + + @cell_magic + def lazy_cell(self, line, cell): + print("Lazy Cell") + + +def load_ipython_extension(ipython): + ipython.register_magics(LazyMagics) +""" + + +def test_lazy_magics(): + with pytest.raises(UsageError): + ip.run_line_magic("lazy_line", "") + + startdir = os.getcwd() + + with TemporaryDirectory() as tmpdir: + with prepended_to_syspath(tmpdir): + ptempdir = Path(tmpdir) + tf = ptempdir / "lazy_magic_module.py" + tf.write_text(MINIMAL_LAZY_MAGIC) + ip.magics_manager.register_lazy("lazy_line", Path(tf.name).name[:-3]) + with tt.AssertPrints("Lazy Line"): + ip.run_line_magic("lazy_line", "") diff --git a/IPython/core/tests/test_magic_terminal.py b/IPython/core/tests/test_magic_terminal.py index 79e2d3e..c6bd1c1 100644 --- a/IPython/core/tests/test_magic_terminal.py +++ b/IPython/core/tests/test_magic_terminal.py @@ -14,11 +14,11 @@ from unittest import TestCase import nose.tools as nt from IPython.testing import tools as tt - #----------------------------------------------------------------------------- # Test functions begin #----------------------------------------------------------------------------- + def check_cpaste(code, should_fail=False): """Execute code via 'cpaste' and ensure it was executed, unless should_fail is set. @@ -39,7 +39,7 @@ def check_cpaste(code, should_fail=False): try: context = tt.AssertPrints if should_fail else tt.AssertNotPrints with context("Traceback (most recent call last)"): - ip.magic('cpaste') + ip.run_line_magic("cpaste", "") if not should_fail: assert ip.user_ns['code_ran'], "%r failed" % code @@ -76,13 +76,14 @@ def test_cpaste(): check_cpaste(code, should_fail=True) + class PasteTestCase(TestCase): """Multiple tests for clipboard pasting""" def paste(self, txt, flags='-q'): """Paste input text, by default in quiet mode""" - ip.hooks.clipboard_get = lambda : txt - ip.magic('paste '+flags) + ip.hooks.clipboard_get = lambda: txt + ip.run_line_magic("paste", flags) def setUp(self): # Inject fake clipboard hook but save original so we can restore it later diff --git a/IPython/terminal/ipapp.py b/IPython/terminal/ipapp.py index defe3e7..b2b8d5f 100755 --- a/IPython/terminal/ipapp.py +++ b/IPython/terminal/ipapp.py @@ -25,6 +25,7 @@ from IPython.core.history import HistoryManager from IPython.core.application import ( ProfileDir, BaseIPythonApplication, base_flags, base_aliases ) +from IPython.core.magic import MagicsManager from IPython.core.magics import ( ScriptMagics, LoggingMagics ) @@ -200,6 +201,7 @@ class TerminalIPythonApp(BaseIPythonApplication, InteractiveShellApp): self.__class__, # it will also affect subclasses (e.g. QtConsole) TerminalInteractiveShell, HistoryManager, + MagicsManager, ProfileDir, PlainTextFormatter, IPCompleter, diff --git a/docs/source/whatsnew/version7.rst b/docs/source/whatsnew/version7.rst index 7a8bafb..561e8bf 100644 --- a/docs/source/whatsnew/version7.rst +++ b/docs/source/whatsnew/version7.rst @@ -10,6 +10,24 @@ IPython 7.31.1 (CVE-2022-21699) Fixed CVE-2022-21699, see IPython 8.0.1 release notes for informations. +.. _version 7.32: + +IPython 7.32 +============ + + +The ability to configure magics to be lazily loaded has been added to IPython. +See the ``ipython --help-all`` section on ``MagicsManager.lazy_magic``. +One can now use:: + + c.MagicsManger.lazy_magics = { + "my_magic": "slow.to.import", + "my_other_magic": "also.slow", + } + +And on first use of ``%my_magic``, or corresponding cell magic, or other line magic, +the corresponding ``load_ext`` will be called just before trying to invoke the magic. + .. _version 7.31: IPython 7.31