diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7396855..62cdea4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -62,7 +62,7 @@ jobs: run: | python -m pip install --upgrade pip setuptools wheel build python -m pip install --upgrade -e .[${{ matrix.deps }}] - python -m pip install --upgrade check-manifest pytest-cov + python -m pip install --upgrade check-manifest pytest-cov pytest-json-report - name: Try building with Python build if: runner.os != 'Windows' # setup.py does not support sdist on Windows run: | @@ -75,7 +75,13 @@ jobs: env: COLUMNS: 120 run: | - pytest --color=yes -raXxs ${{ startsWith(matrix.python-version, 'pypy') && ' ' || '--cov --cov-report=xml' }} + pytest --color=yes -raXxs ${{ startsWith(matrix.python-version, 'pypy') && ' ' || '--cov --cov-report=xml' }} --json-report --json-report-file=./report-${{ matrix.python-version }}-${{runner.os}}.json + - uses: actions/upload-artifact@v3 + with: + name: upload pytest timing reports as json + path: | + ./report-*.json + - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: diff --git a/IPython/core/history.py b/IPython/core/history.py index 1a89060..fd5a868 100644 --- a/IPython/core/history.py +++ b/IPython/core/history.py @@ -567,8 +567,11 @@ class HistoryManager(HistoryAccessor): conn = self.db with conn: - cur = conn.execute("""INSERT INTO sessions VALUES (NULL, ?, NULL, - NULL, "") """, (datetime.datetime.now(),)) + cur = conn.execute( + """INSERT INTO sessions VALUES (NULL, ?, NULL, + NULL, '') """, + (datetime.datetime.now(),), + ) self.session_number = cur.lastrowid def end_session(self): diff --git a/IPython/core/interactiveshell.py b/IPython/core/interactiveshell.py index 45ed4e2..4fa266a 100644 --- a/IPython/core/interactiveshell.py +++ b/IPython/core/interactiveshell.py @@ -32,7 +32,7 @@ from io import open as io_open from logging import error from pathlib import Path from typing import Callable -from typing import List as ListType +from typing import List as ListType, Dict as DictType, Any as AnyType from typing import Optional, Tuple from warnings import warn @@ -90,6 +90,8 @@ from IPython.utils.process import getoutput, system from IPython.utils.strdispatch import StrDispatch from IPython.utils.syspathcontext import prepended_to_syspath from IPython.utils.text import DollarFormatter, LSString, SList, format_screen +from IPython.core.oinspect import OInfo + sphinxify: Optional[Callable] @@ -1560,15 +1562,28 @@ class InteractiveShell(SingletonConfigurable): #------------------------------------------------------------------------- # Things related to object introspection #------------------------------------------------------------------------- + @staticmethod + def _find_parts(oname: str) -> ListType[str]: + """ + Given an object name, return a list of parts of this object name. + + Basically split on docs when using attribute access, + and extract the value when using square bracket. + + + For example foo.bar[3].baz[x] -> foo, bar, 3, baz, x + + + Returns + ------- + parts_ok: bool + wether we were properly able to parse parts. + parts: list of str + extracted parts - def _ofind(self, oname, namespaces=None): - """Find an object in the available namespaces. - self._ofind(oname) -> dict with keys: found,obj,ospace,ismagic - Has special code to detect magic functions. """ - oname = oname.strip() raw_parts = oname.split(".") parts = [] parts_ok = True @@ -1590,12 +1605,31 @@ class InteractiveShell(SingletonConfigurable): parts_ok = False parts.append(p) + return parts_ok, parts + + def _ofind(self, oname: str, namespaces: DictType[str, AnyType] = None): + """Find an object in the available namespaces. + + self._ofind(oname) -> dict with keys: found,obj,ospace,ismagic + + Has special code to detect magic functions. + """ + oname = oname.strip() + parts_ok, parts = self._find_parts(oname) + if ( not oname.startswith(ESC_MAGIC) and not oname.startswith(ESC_MAGIC2) and not parts_ok ): - return {"found": False} + return OInfo( + ismagic=False, + isalias=False, + found=False, + obj=None, + namespace="", + parent=None, + ) if namespaces is None: # Namespaces to search in: @@ -1675,14 +1709,16 @@ class InteractiveShell(SingletonConfigurable): found = True ospace = 'Interactive' - return { - 'obj':obj, - 'found':found, - 'parent':parent, - 'ismagic':ismagic, - 'isalias':isalias, - 'namespace':ospace - } + return OInfo( + **{ + "obj": obj, + "found": found, + "parent": parent, + "ismagic": ismagic, + "isalias": isalias, + "namespace": ospace, + } + ) @staticmethod def _getattr_property(obj, attrname): @@ -1726,9 +1762,9 @@ class InteractiveShell(SingletonConfigurable): # Nothing helped, fall back. return getattr(obj, attrname) - def _object_find(self, oname, namespaces=None): + def _object_find(self, oname, namespaces=None) -> OInfo: """Find an object and return a struct with info about it.""" - return Struct(self._ofind(oname, namespaces)) + return self._ofind(oname, namespaces) def _inspect(self, meth, oname, namespaces=None, **kw): """Generic interface to the inspector system. diff --git a/IPython/core/magics/execution.py b/IPython/core/magics/execution.py index c4c3a4b..228cbd9 100644 --- a/IPython/core/magics/execution.py +++ b/IPython/core/magics/execution.py @@ -422,7 +422,8 @@ class ExecutionMagics(Magics): ) @no_var_expand @line_cell_magic - def debug(self, line='', cell=None): + @needs_local_scope + def debug(self, line="", cell=None, local_ns=None): """Activate the interactive debugger. This magic command support two ways of activating debugger. @@ -453,7 +454,7 @@ class ExecutionMagics(Magics): self._debug_post_mortem() elif not (args.breakpoint or cell): # If there is no breakpoints, the line is just code to execute - self._debug_exec(line, None) + self._debug_exec(line, None, local_ns) else: # Here we try to reconstruct the code from the output of # parse_argstring. This might not work if the code has spaces @@ -461,18 +462,20 @@ class ExecutionMagics(Magics): code = "\n".join(args.statement) if cell: code += "\n" + cell - self._debug_exec(code, args.breakpoint) + self._debug_exec(code, args.breakpoint, local_ns) def _debug_post_mortem(self): self.shell.debugger(force=True) - def _debug_exec(self, code, breakpoint): + def _debug_exec(self, code, breakpoint, local_ns=None): if breakpoint: (filename, bp_line) = breakpoint.rsplit(':', 1) bp_line = int(bp_line) else: (filename, bp_line) = (None, None) - self._run_with_debugger(code, self.shell.user_ns, filename, bp_line) + self._run_with_debugger( + code, self.shell.user_ns, filename, bp_line, local_ns=local_ns + ) @line_magic def tb(self, s): @@ -867,8 +870,9 @@ class ExecutionMagics(Magics): return stats - def _run_with_debugger(self, code, code_ns, filename=None, - bp_line=None, bp_file=None): + def _run_with_debugger( + self, code, code_ns, filename=None, bp_line=None, bp_file=None, local_ns=None + ): """ Run `code` in debugger with a break point. @@ -885,6 +889,8 @@ class ExecutionMagics(Magics): bp_file : str, optional Path to the file in which break point is specified. `filename` is used if not given. + local_ns : dict, optional + A local namespace in which `code` is executed. Raises ------ @@ -941,7 +947,7 @@ class ExecutionMagics(Magics): while True: try: trace = sys.gettrace() - deb.run(code, code_ns) + deb.run(code, code_ns, local_ns) except Restart: print("Restarting") if filename: diff --git a/IPython/core/oinspect.py b/IPython/core/oinspect.py index bcaa95c..399a52b 100644 --- a/IPython/core/oinspect.py +++ b/IPython/core/oinspect.py @@ -46,6 +46,19 @@ from pygments import highlight from pygments.lexers import PythonLexer from pygments.formatters import HtmlFormatter +from typing import Any +from dataclasses import dataclass + + +@dataclass +class OInfo: + ismagic: bool + isalias: bool + found: bool + namespace: str + parent: Any + obj: Any + def pylight(code): return highlight(code, PythonLexer(), HtmlFormatter(noclasses=True)) diff --git a/IPython/core/prefilter.py b/IPython/core/prefilter.py index 0038e5c..e7e82e3 100644 --- a/IPython/core/prefilter.py +++ b/IPython/core/prefilter.py @@ -499,7 +499,7 @@ class AutocallChecker(PrefilterChecker): return None oinfo = line_info.ofind(self.shell) # This can mutate state via getattr - if not oinfo['found']: + if not oinfo.found: return None ignored_funs = ['b', 'f', 'r', 'u', 'br', 'rb', 'fr', 'rf'] @@ -508,10 +508,12 @@ class AutocallChecker(PrefilterChecker): if ifun.lower() in ignored_funs and (line.startswith(ifun + "'") or line.startswith(ifun + '"')): return None - if callable(oinfo['obj']) \ - and (not self.exclude_regexp.match(line_info.the_rest)) \ - and self.function_name_regexp.match(line_info.ifun): - return self.prefilter_manager.get_handler_by_name('auto') + if ( + callable(oinfo.obj) + and (not self.exclude_regexp.match(line_info.the_rest)) + and self.function_name_regexp.match(line_info.ifun) + ): + return self.prefilter_manager.get_handler_by_name("auto") else: return None @@ -601,7 +603,7 @@ class AutoHandler(PrefilterHandler): the_rest = line_info.the_rest esc = line_info.esc continue_prompt = line_info.continue_prompt - obj = line_info.ofind(self.shell)['obj'] + obj = line_info.ofind(self.shell).obj # This should only be active for single-line input! if continue_prompt: diff --git a/IPython/core/splitinput.py b/IPython/core/splitinput.py index 63cdce7..5bc3e32 100644 --- a/IPython/core/splitinput.py +++ b/IPython/core/splitinput.py @@ -25,6 +25,7 @@ import sys from IPython.utils import py3compat from IPython.utils.encoding import get_stream_enc +from IPython.core.oinspect import OInfo #----------------------------------------------------------------------------- # Main function @@ -118,7 +119,7 @@ class LineInfo(object): else: self.pre_whitespace = self.pre - def ofind(self, ip): + def ofind(self, ip) -> OInfo: """Do a full, attribute-walking lookup of the ifun in the various namespaces for the given IPython InteractiveShell instance. diff --git a/IPython/core/tests/test_completerlib.py b/IPython/core/tests/test_completerlib.py index 0e8bf19..b832806 100644 --- a/IPython/core/tests/test_completerlib.py +++ b/IPython/core/tests/test_completerlib.py @@ -177,7 +177,7 @@ def test_module_without_init(): try: os.makedirs(os.path.join(tmpdir, fake_module_name)) s = try_import(mod=fake_module_name) - assert s == [] + assert s == [], f"for module {fake_module_name}" finally: sys.path.remove(tmpdir) diff --git a/IPython/core/tests/test_interactiveshell.py b/IPython/core/tests/test_interactiveshell.py index 920d911..0ac9f60 100644 --- a/IPython/core/tests/test_interactiveshell.py +++ b/IPython/core/tests/test_interactiveshell.py @@ -25,6 +25,7 @@ from os.path import join from IPython.core.error import InputRejected from IPython.core.inputtransformer import InputTransformer from IPython.core import interactiveshell +from IPython.core.oinspect import OInfo from IPython.testing.decorators import ( skipif, skip_win32, onlyif_unicode_paths, onlyif_cmds_exist, ) @@ -360,7 +361,7 @@ class InteractiveShellTestCase(unittest.TestCase): # Get info on line magic lfind = ip._ofind("lmagic") - info = dict( + info = OInfo( found=True, isalias=False, ismagic=True, @@ -379,7 +380,7 @@ class InteractiveShellTestCase(unittest.TestCase): # Get info on cell magic find = ip._ofind("cmagic") - info = dict( + info = OInfo( found=True, isalias=False, ismagic=True, @@ -397,9 +398,15 @@ class InteractiveShellTestCase(unittest.TestCase): a = A() - found = ip._ofind('a.foo', [('locals', locals())]) - info = dict(found=True, isalias=False, ismagic=False, - namespace='locals', obj=A.foo, parent=a) + found = ip._ofind("a.foo", [("locals", locals())]) + info = OInfo( + found=True, + isalias=False, + ismagic=False, + namespace="locals", + obj=A.foo, + parent=a, + ) self.assertEqual(found, info) def test_ofind_multiple_attribute_lookups(self): @@ -412,9 +419,15 @@ class InteractiveShellTestCase(unittest.TestCase): a.a = A() a.a.a = A() - found = ip._ofind('a.a.a.foo', [('locals', locals())]) - info = dict(found=True, isalias=False, ismagic=False, - namespace='locals', obj=A.foo, parent=a.a.a) + found = ip._ofind("a.a.a.foo", [("locals", locals())]) + info = OInfo( + found=True, + isalias=False, + ismagic=False, + namespace="locals", + obj=A.foo, + parent=a.a.a, + ) self.assertEqual(found, info) def test_ofind_slotted_attributes(self): @@ -424,14 +437,26 @@ class InteractiveShellTestCase(unittest.TestCase): self.foo = 'bar' a = A() - found = ip._ofind('a.foo', [('locals', locals())]) - info = dict(found=True, isalias=False, ismagic=False, - namespace='locals', obj=a.foo, parent=a) + found = ip._ofind("a.foo", [("locals", locals())]) + info = OInfo( + found=True, + isalias=False, + ismagic=False, + namespace="locals", + obj=a.foo, + parent=a, + ) self.assertEqual(found, info) - found = ip._ofind('a.bar', [('locals', locals())]) - info = dict(found=False, isalias=False, ismagic=False, - namespace=None, obj=None, parent=a) + found = ip._ofind("a.bar", [("locals", locals())]) + info = OInfo( + found=False, + isalias=False, + ismagic=False, + namespace=None, + obj=None, + parent=a, + ) self.assertEqual(found, info) def test_ofind_prefers_property_to_instance_level_attribute(self): @@ -443,7 +468,7 @@ class InteractiveShellTestCase(unittest.TestCase): a.__dict__["foo"] = "baz" self.assertEqual(a.foo, "bar") found = ip._ofind("a.foo", [("locals", locals())]) - self.assertIs(found["obj"], A.foo) + self.assertIs(found.obj, A.foo) def test_custom_syntaxerror_exception(self): called = [] diff --git a/IPython/core/tests/test_magic.py b/IPython/core/tests/test_magic.py index 8963705..da998fa 100644 --- a/IPython/core/tests/test_magic.py +++ b/IPython/core/tests/test_magic.py @@ -715,6 +715,7 @@ def doctest_precision(): Out[5]: '3.141593e+00' """ + def test_debug_magic(): """Test debugging a small code with %debug @@ -727,6 +728,22 @@ def test_debug_magic(): In [2]: """ + +def test_debug_magic_locals(): + """Test debugging a small code with %debug with locals + + In [1]: with PdbTestInput(['c']): + ...: def fun(): + ...: res = 1 + ...: %debug print(res) + ...: fun() + ...: + ... + ipdb> c + 1 + In [2]: + """ + def test_psearch(): with tt.AssertPrints("dict.fromkeys"): _ip.run_cell("dict.fr*?") diff --git a/IPython/core/ultratb.py b/IPython/core/ultratb.py index d19d472..b8066b2 100644 --- a/IPython/core/ultratb.py +++ b/IPython/core/ultratb.py @@ -994,7 +994,7 @@ class VerboseTB(TBTools): pygments_formatter=formatter, ) - # let's estimate the amount of code we eill have to parse/highlight. + # Let's estimate the amount of code we will have to parse/highlight. cf = etb max_len = 0 tbs = [] diff --git a/IPython/external/qt_loaders.py b/IPython/external/qt_loaders.py index c900c8f..1486cf9 100644 --- a/IPython/external/qt_loaders.py +++ b/IPython/external/qt_loaders.py @@ -10,6 +10,7 @@ be accessed directly from the outside """ import importlib.abc import sys +import os import types from functools import partial, lru_cache import operator @@ -368,6 +369,10 @@ def load_qt(api_options): commit_api(api) return result else: + # Clear the environment variable since it doesn't work. + if "QT_API" in os.environ: + del os.environ["QT_API"] + raise ImportError( """ Could not load requested Qt binding. Please ensure that diff --git a/IPython/terminal/interactiveshell.py b/IPython/terminal/interactiveshell.py index c73acf9..96a9687 100644 --- a/IPython/terminal/interactiveshell.py +++ b/IPython/terminal/interactiveshell.py @@ -913,10 +913,19 @@ class TerminalInteractiveShell(InteractiveShell): active_eventloop = None def enable_gui(self, gui=None): + if self._inputhook is None and gui is None: + print("No event loop hook running.") + return + if self._inputhook is not None and gui is not None: - warn( - f"Shell was already running a gui event loop for {self.active_eventloop}; switching to {gui}." + print( + f"Shell is already running a gui event loop for {self.active_eventloop}. " + "Call with no arguments to disable the current loop." ) + return + if self._inputhook is not None and gui is None: + self.active_eventloop = self._inputhook = None + if gui and (gui not in {"inline", "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) @@ -934,15 +943,18 @@ class TerminalInteractiveShell(InteractiveShell): # same event loop as the rest of the code. don't use an actual # input hook. (Asyncio is not made for nesting event loops.) self.pt_loop = get_asyncio_loop() + print("Installed asyncio event loop hook.") elif self._inputhook: # If an inputhook was set, create a new asyncio event loop with # this inputhook for the prompt. self.pt_loop = new_eventloop_with_inputhook(self._inputhook) + print(f"Installed {self.active_eventloop} event loop hook.") else: # When there's no inputhook, run the prompt in a separate # asyncio event loop. self.pt_loop = asyncio.new_event_loop() + print("GUI event loop hook disabled.") # Run !system commands directly, not through pipes, so terminal programs # work correctly. diff --git a/IPython/terminal/pt_inputhooks/__init__.py b/IPython/terminal/pt_inputhooks/__init__.py index 57960e4..9043f15 100644 --- a/IPython/terminal/pt_inputhooks/__init__.py +++ b/IPython/terminal/pt_inputhooks/__init__.py @@ -69,9 +69,9 @@ def set_qt_api(gui): if loaded is not None and gui != "qt": if qt_env2gui[loaded] != gui: print( - f"Cannot switch Qt versions for this session; must use {qt_env2gui[loaded]}." + f"Cannot switch Qt versions for this session; will use {qt_env2gui[loaded]}." ) - return + return qt_env2gui[loaded] if qt_api is not None and gui != "qt": if qt_env2gui[qt_api] != gui: @@ -79,6 +79,7 @@ def set_qt_api(gui): f'Request for "{gui}" will be ignored because `QT_API` ' f'environment variable is set to "{qt_api}"' ) + return qt_env2gui[qt_api] else: if gui == "qt5": try: @@ -112,6 +113,11 @@ def set_qt_api(gui): print(f'Unrecognized Qt version: {gui}. Should be "qt5", "qt6", or "qt".') return + # Import it now so we can figure out which version it is. + from IPython.external.qt_for_kernel import QT_API + + return qt_env2gui[QT_API] + def get_inputhook_name_and_func(gui): if gui in registered: @@ -125,7 +131,7 @@ def get_inputhook_name_and_func(gui): gui_mod = gui if gui.startswith("qt"): - set_qt_api(gui) + gui = set_qt_api(gui) gui_mod = "qt" mod = importlib.import_module("IPython.terminal.pt_inputhooks." + gui_mod) diff --git a/IPython/terminal/shortcuts/__init__.py b/IPython/terminal/shortcuts/__init__.py index 471d205..41533c8 100644 --- a/IPython/terminal/shortcuts/__init__.py +++ b/IPython/terminal/shortcuts/__init__.py @@ -29,6 +29,8 @@ from IPython.terminal.shortcuts import auto_suggest from IPython.terminal.shortcuts.filters import filter_from_string from IPython.utils.decorators import undoc +from prompt_toolkit.enums import DEFAULT_BUFFER + __all__ = ["create_ipython_shortcuts"] diff --git a/IPython/terminal/tests/test_pt_inputhooks.py b/IPython/terminal/tests/test_pt_inputhooks.py index bb4baaa..3f788c7 100644 --- a/IPython/terminal/tests/test_pt_inputhooks.py +++ b/IPython/terminal/tests/test_pt_inputhooks.py @@ -33,18 +33,18 @@ _get_qt_vers() len(guis_avail) == 0, reason="No viable version of PyQt or PySide installed." ) def test_inputhook_qt(): - gui = guis_avail[0] - - # Choose a qt version and get the input hook function. This will import Qt... - get_inputhook_name_and_func(gui) - - # ...and now we're stuck with this version of Qt for good; can't switch. - for not_gui in ["qt6", "qt5"]: - if not_gui not in guis_avail: - break - - with pytest.raises(ImportError): - get_inputhook_name_and_func(not_gui) - - # A gui of 'qt' means "best available", or in this case, the last one that was used. - get_inputhook_name_and_func("qt") + # Choose the "best" Qt version. + gui_ret, _ = get_inputhook_name_and_func("qt") + + assert gui_ret != "qt" # you get back the specific version that was loaded. + assert gui_ret in guis_avail + + if len(guis_avail) > 2: + # ...and now we're stuck with this version of Qt for good; can't switch. + for not_gui in ["qt6", "qt5"]: + if not_gui != gui_ret: + break + # Try to import the other gui; it won't work. + gui_ret2, _ = get_inputhook_name_and_func(not_gui) + assert gui_ret2 == gui_ret + assert gui_ret2 != not_gui