From 22cdf6c689d5356243e00bdf3e70e8f384822d12 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 20:15:24 +0000 Subject: [PATCH 1/4] Remove stale Python 3.6 TODO comment in test_display The TODO comment about removing code when only Python >= 3.6 is supported is obsolete as IPython requires much newer Python versions. The code below the comment (using pathlib.PurePath) is valid and necessary for the test. Co-Authored-By: Claude Fable 5 --- tests/test_display.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_display.py b/tests/test_display.py index ad4acb0670f..41734a5854d 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -44,7 +44,6 @@ def test_instantiation_FileLink(): """FileLink: Test class can be instantiated""" fl = display.FileLink("example.txt") - # TODO: remove if when only Python >= 3.6 is supported fl = display.FileLink(pathlib.PurePath("example.txt")) From a7cdeec5a5c66a8ddf63263cebba7bd0112bddb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 20:16:06 +0000 Subject: [PATCH 2/4] Re-enable test_autoawait_curio with a normal conditional skip curio's module_not_available("curio") check no longer blows up during collection (it was previously wrapped in an unconditional skip plus a stale xfail to work around that). Verified with curio 1.6 (latest on PyPI) that: skip_without("curio") collects fine whether or not curio is installed, the test passes when curio is installed, and it is properly skipped (not errored) when curio is absent. Co-Authored-By: Claude Fable 5 --- tests/test_async_helpers.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_async_helpers.py b/tests/test_async_helpers.py index b79fd8ae7f8..d8974c68895 100644 --- a/tests/test_async_helpers.py +++ b/tests/test_async_helpers.py @@ -434,10 +434,7 @@ def test_memory_error(): iprc("(" * 200 + ")" * 200) -@pytest.mark.xfail(reason="fail on curio 1.6 and before on Python 3.12") -@pytest.mark.skip( - reason="skip_without(curio) fails on 3.12 for now even with other skip so must uncond skip" -) +@skip_without("curio") def test_autoawait_curio(): iprc("%autoawait curio") From 88f3e5d2a1d19533956801c770643fd3d6b8987a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 20:32:28 +0000 Subject: [PATCH 3/4] Re-enable the `__debuggerskip__` decorator-skipping tests `test_decorator_skip`, `test_decorator_skip_disabled` and `test_decorator_skip_with_breakpoint` were all skipped with "recently fail for unknown reason on CI". The reason turns out to be a Python version behaviour change rather than flakiness: since Python 3.13, `set_trace()`/`breakpoint()` stop on the line where they are called instead of on the following line. The shared fixture runs def f(): ipdb.set_trace() bar(3, 4) so on 3.13+ the debugger stops on the `ipdb.set_trace()` line and one extra `step` is needed before the `bar(3, 4)` call is reached. The two `test_decorator_skip*` tests stepped straight into what they assumed was the decorated call, landed on `bar(3, 4)` instead, never saw the `--Call--` frame they expected, and timed out. `test_decorator_skip_with_breakpoint` already had a version guard, but a later "try to fix on CI" tweak added a 3.14 branch expecting the current line to be printed *without* the `-->` marker. That does not match released 3.14/3.15, where the output is identical to 3.13 (`---> 46 ipdb.set_trace()`); the extra branch is dropped and the 3.13 branch now covers everything from 3.13 on. Introduce `SET_TRACE_STOPS_ON_CALLING_LINE` to express the behaviour change once, use it in the three tests, and drop the skip markers. Verified on CPython 3.11, 3.12, 3.13, 3.14, 3.14t (free-threaded) and 3.15.0b4: all three tests pass, including eight consecutive runs on 3.11/3.13/3.14 and runs under CPU oversubscription. Co-Authored-By: Claude Fable 5 --- tests/test_debugger.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/tests/test_debugger.py b/tests/test_debugger.py index 3abd6c2393b..6803ffabd77 100644 --- a/tests/test_debugger.py +++ b/tests/test_debugger.py @@ -454,6 +454,12 @@ def f(): ) +# From 3.13, ``set_trace()``/``breakpoint()`` stop on the line where they are +# called instead of on the next one, so one extra ``step`` is needed to reach +# the line following the ``set_trace()`` call. +SET_TRACE_STOPS_ON_CALLING_LINE = sys.version_info >= (3, 13) + + def _decorator_skip_setup(): import pexpect @@ -484,7 +490,6 @@ def _decorator_skip_setup(): return child -@pytest.mark.skip(reason="recently fail for unknown reason on CI") @skip_win32 def test_decorator_skip(): """test that decorator frames can be skipped.""" @@ -496,6 +501,11 @@ def test_decorator_skip(): child.expect("ipdb>") child.expect("ipdb>") + if SET_TRACE_STOPS_ON_CALLING_LINE: + child.sendline("step") + child.expect_exact("step") + child.expect_exact("----> 3 bar(3, 4)") + child.expect("ipdb>") child.sendline("step") child.expect_exact("step") child.expect_exact("--Call--") @@ -509,7 +519,6 @@ def test_decorator_skip(): child.close() -@pytest.mark.skip(reason="recently fail for unknown reason on CI") @pytest.mark.skipif(platform.python_implementation() == "PyPy", reason="issues on PyPy") @skip_win32 def test_decorator_skip_disabled(): @@ -519,7 +528,12 @@ def test_decorator_skip_disabled(): child.expect_exact("3 bar(3, 4)") - for input_, expected in [ + if SET_TRACE_STOPS_ON_CALLING_LINE: + extra_step = [("step", "----> 3 bar(3, 4)")] + else: + extra_step = [] + + for input_, expected in extra_step + [ ("skip_predicates debuggerskip False", ""), ("skip_predicates", "debuggerskip : False"), ("step", "---> 2 def wrapped_fn"), @@ -538,7 +552,6 @@ def test_decorator_skip_disabled(): child.close() -@pytest.mark.skip(reason="recently fail for unknown reason on CI") @pytest.mark.skipif(platform.python_implementation() == "PyPy", reason="issues on PyPy") @skip_win32 def test_decorator_skip_with_breakpoint(): @@ -582,12 +595,7 @@ def test_decorator_skip_with_breakpoint(): child.expect_exact(line) child.sendline("") - # From 3.13, set_trace()/breakpoint() stop on the line where they're - # called, instead of the next line. - if sys.version_info >= (3, 14): - child.expect_exact(" 46 ipdb.set_trace()") - extra_step = [("step", "--> 47 bar(3, 4)")] - elif sys.version_info >= (3, 13): + if SET_TRACE_STOPS_ON_CALLING_LINE: child.expect_exact("--> 46 ipdb.set_trace()") extra_step = [("step", "--> 47 bar(3, 4)")] else: From 6a3395757d2159859e0b4c3ff529732fe6b72dde Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 20:21:58 +0000 Subject: [PATCH 4/4] Fall back to global completion for malformed attribute contexts `_determine_completion_context` treated any trailing dot as an attribute access, so nonsense expressions like `3a.` or `$).` were routed to attribute completion. It also mis-detected nested f-strings (`f'{f'a.`) because the naive quote tracking sees the inner opening quote as closing the outer one, leaving `is_string` False while still inside a replacement field. Scan backwards from the trailing dot to extract the primary expression it would apply to (handling bracketed groups and string literals), and only report an attribute context if that expression parses. Template string replacement fields are now recursed into whenever we are inside one, regardless of the `is_string` flag. Co-Authored-By: Claude Fable 5 --- IPython/core/completer.py | 120 ++++++++++++++++++++++++++++++++++++-- tests/test_completer.py | 1 - 2 files changed, 115 insertions(+), 6 deletions(-) diff --git a/IPython/core/completer.py b/IPython/core/completer.py index a1fcb4ac36f..f10281fa200 100644 --- a/IPython/core/completer.py +++ b/IPython/core/completer.py @@ -2615,8 +2615,11 @@ def _determine_completion_context(self, line): if is_string and not is_in_expression: return self._CompletionContextType.GLOBAL - # If we're in a template string expression, handle specially - if is_string and is_in_expression: + # If we're in a template string expression, handle specially. + # Note that ``is_string`` may be False here for nested template strings + # (``f'{f'a.``) as the naive quote tracking sees the inner opening quote + # as closing the outer one; the recursion below sorts this out. + if is_in_expression: # Extract the expression part - look for the last { that isn't closed expr_start = line.rfind("{") if expr_start >= 0: @@ -2631,12 +2634,119 @@ def _determine_completion_context(self, line): return self._CompletionContextType.GLOBAL # Handle all other attribute matches np.ran, d[0].k, (a,b).count, obj._private - chain_match = re.search(r".*(.+(? str | None: + """Extract the expression a trailing ``.attr`` would be looked up on. + + Scan *line* backwards from the last dot (which must be followed by at + most an identifier and nothing else) and return the primary expression + preceding it, or ``None`` if there is no trailing attribute access or + no plausible expression in front of it. + """ + match = re.search(r"\.(?:[a-zA-Z_]\w*)?$", line) + if match is None: + return None + dot = match.start() + i = dot - 1 + while i >= 0: + char = line[i] + if char in ")]": + # A call or subscript trailer, or a parenthesized/list atom; + # whatever precedes it may still be part of the expression. + i = self._skip_backwards_over_brackets(line, i) + if i is None: + return None + continue + if char == "}": + # A dict/set display; nothing may precede it. + i = self._skip_backwards_over_brackets(line, i) + if i is None: + return None + break + if char in "\"'": + # A string literal; nothing but its prefix may precede it. + i = self._skip_backwards_over_string(line, i) + if i is None: + return None + break + if char.isalnum() or char == "_": + while i >= 0 and (line[i].isalnum() or line[i] == "_"): + i -= 1 + # a name or number may itself be an attribute of something else + if i >= 0 and line[i] == ".": + i -= 1 + continue + break + # anything else (operator, bracket, whitespace, ...) ends the + # expression + break + return line[i + 1 : dot] or None + + def _skip_backwards_over_brackets(self, line: str, i: int) -> int | None: + """Skip a bracketed group ending at index *i* (a closing bracket). + + Return the index just before the matching opening bracket, or ``None`` + if the brackets are unbalanced. + """ + depth = 0 + while i >= 0: + char = line[i] + if char in "\"'": + skipped = self._skip_backwards_over_string(line, i) + if skipped is None: + return None + i = skipped + continue + if char in ")]}": + depth += 1 + elif char in "([{": + depth -= 1 + if depth == 0: + return i - 1 + if depth < 0: + return None + i -= 1 + return None + + def _skip_backwards_over_string(self, line: str, i: int) -> int | None: + """Skip a string literal ending at index *i* (its closing quote). + + Return the index just before the opening quote (and any string prefix), + or ``None`` if no opening quote was found. + """ + quote = line[i] + j = i - 1 + while j >= 0: + if line[j] == quote: + backslashes = 0 + k = j - 1 + while k >= 0 and line[k] == "\\": + backslashes += 1 + k -= 1 + if backslashes % 2 == 0: + break + j -= 1 + else: + return None + j -= 1 + # skip any string prefix (r, b, u, f, t and combinations thereof) + while j >= 0 and line[j] in "bBrRuUfFtT": + j -= 1 + return j + def _is_completing_in_cli_context(self, text: str) -> bool: """ Determine if we are completing in a CLI alias, line magic, or bang expression context. diff --git a/tests/test_completer.py b/tests/test_completer.py index b3154126f14..afb8d846fd7 100644 --- a/tests/test_completer.py +++ b/tests/test_completer.py @@ -2829,7 +2829,6 @@ def test_completion_in_cli_context(line, expected, expected_after_assignment): ip.user_ns.pop("test_alias", None) -@pytest.mark.xfail(reason="Completion context not yet supported") @pytest.mark.parametrize( "line, expected", [