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_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") 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", [ 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: 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"))