Skip to content

gh-122379: Make REPL completions match only syntactically valid keywords and values #122380

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Lib/_pyrepl/completing_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ def get_stem(self) -> str:
p = self.pos - 1
while p >= 0 and st.get(b[p], SW) == SW:
p -= 1
return ''.join(b[p+1:self.pos])
return ''.join(b[i] for i in range(p + 1, self.pos))

def get_completions(self, stem: str) -> list[str]:
return []
1 change: 1 addition & 0 deletions Lib/_pyrepl/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ def __init__(
) -> None:
super().__init__(locals=locals, filename=filename, local_exit=local_exit) # type: ignore[call-arg]
self.can_colorize = _colorize.can_colorize()
self.multi_statement = True

def showsyntaxerror(self, filename=None):
super().showsyntaxerror(colorize=self.can_colorize)
Expand Down
11 changes: 7 additions & 4 deletions Lib/_pyrepl/readline.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from . import commands, historical_reader
from .completing_reader import CompletingReader
from .console import Console as ConsoleType
from code import InteractiveConsole as InteractiveConsoleType

Console: type[ConsoleType]
_error: tuple[type[Exception], ...] | type[Exception]
Expand Down Expand Up @@ -129,7 +130,7 @@ def get_stem(self) -> str:
completer_delims = self.config.completer_delims
while p >= 0 and b[p] not in completer_delims:
p -= 1
return "".join(b[p + 1 : self.pos])
return "".join(b[i] for i in range(p + 1, self.pos))

def get_completions(self, stem: str) -> list[str]:
if len(stem) == 0 and self.more_lines is not None:
Expand All @@ -149,7 +150,7 @@ def get_completions(self, stem: str) -> list[str]:
state = 0
while True:
try:
next = function(stem, state)
next = function(stem, state, self.buffer)
except Exception:
break
if not isinstance(next, str):
Expand Down Expand Up @@ -559,7 +560,7 @@ def stub(*args: object, **kwds: object) -> None:
# ____________________________________________________________


def _setup(namespace: Mapping[str, Any]) -> None:
def _setup(console: InteractiveConsoleType) -> None:
global raw_input
if raw_input is not None:
return # don't run _setup twice
Expand All @@ -576,9 +577,11 @@ def _setup(namespace: Mapping[str, Any]) -> None:
_wrapper.f_out = f_out

# set up namespace in rlcompleter, which requires it to be a bona fide dict
namespace = console.locals
if not isinstance(namespace, dict):
namespace = dict(namespace)
_wrapper.config.readline_completer = RLCompleter(namespace).complete
mode = "exec" if getattr(console, "multi_statement", False) else "single"
_wrapper.config.readline_completer = RLCompleter(namespace, mode).complete

# this is not really what readline.c does. Better than nothing I guess
import builtins
Expand Down
2 changes: 1 addition & 1 deletion Lib/_pyrepl/simple_interact.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def run_multiline_interactive_console(
future_flags: int = 0,
) -> None:
from .readline import _setup
_setup(console.locals)
_setup(console)
if future_flags:
console.compile.compiler.flags |= future_flags

Expand Down
46 changes: 34 additions & 12 deletions Lib/rlcompleter.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,15 @@
import re
import __main__
import warnings
from itertools import chain
import codeop

__all__ = ["Completer"]

_keywords = keyword.kwlist + keyword.softkwlist

class Completer:
def __init__(self, namespace = None):
def __init__(self, namespace = None, mode="single"):
"""Create a new completer for the command line.

Completer([namespace]) -> completer instance.
Expand All @@ -66,8 +70,9 @@ def __init__(self, namespace = None):
else:
self.use_main_ns = 0
self.namespace = namespace
self.mode = mode

def complete(self, text, state):
def complete(self, text, state, buffer=None):
"""Return the next possible completion for 'text'.

This is called successively with state == 0, 1, 2, ... until it
Expand All @@ -93,7 +98,7 @@ def complete(self, text, state):
if "." in text:
self.matches = self.attr_matches(text)
else:
self.matches = self.global_matches(text)
self.matches = self.global_matches(text, buffer)
try:
return self.matches[state]
except IndexError:
Expand All @@ -110,7 +115,19 @@ def _callable_postfix(self, val, word):

return word

def global_matches(self, text):
def _check_word(self, source, word) -> bool:
source += word + " "
while True:
try:
compile(source, "<completer>", self.mode,
flags=codeop.PyCF_ALLOW_INCOMPLETE_INPUT)
return True
except _IncompleteInputError:
return True
except (SyntaxError, ValueError, OverflowError):
return False

def global_matches(self, text, buffer=None):
"""Compute matches when text is a simple name.

Return a list of all keywords, built-in functions and names currently
Expand All @@ -119,9 +136,12 @@ def global_matches(self, text):
"""
matches = []
seen = {"__builtins__"}
n = len(text)
for word in keyword.kwlist + keyword.softkwlist:
if word[:n] == text:
source = "".join(buffer[i] for i in range(len(buffer) - len(text))) \
if buffer else ""
for word in _keywords:
if not word.startswith(text):
continue
if self._check_word(source, word):
seen.add(word)
if word in {'finally', 'try'}:
word = word + ':'
Expand All @@ -130,11 +150,13 @@ def global_matches(self, text):
'else', '_'}:
word = word + ' '
matches.append(word)
for nspace in [self.namespace, builtins.__dict__]:
for word, val in nspace.items():
if word[:n] == text and word not in seen:
seen.add(word)
matches.append(self._callable_postfix(val, word))
for word, val in chain(self.namespace.items(),
builtins.__dict__.items()):
if not word.startswith(text) or word in seen:
continue
if self._check_word(source, word):
seen.add(word)
matches.append(self._callable_postfix(val, word))
return matches

def attr_matches(self, text):
Expand Down
21 changes: 15 additions & 6 deletions Lib/test/test_rlcompleter.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,19 +133,28 @@ class Foo:
def test_complete(self):
completer = rlcompleter.Completer()
self.assertEqual(completer.complete('', 0), '\t')
self.assertEqual(completer.complete('a', 0), 'and ')
self.assertEqual(completer.complete('a', 1), 'as ')
self.assertEqual(completer.complete('as', 2), 'assert ')
self.assertEqual(completer.complete('an', 0), 'and ')
self.assertEqual(completer.complete('as', 0), 'assert ')
self.assertEqual(completer.complete('an', 0), 'any(')
self.assertEqual(completer.complete('pa', 0), 'pass')
self.assertEqual(completer.complete('Fa', 0), 'False')
self.assertEqual(completer.complete('el', 0), 'elif ')
self.assertEqual(completer.complete('el', 1), 'else')
self.assertEqual(completer.complete('tr', 0), 'try:')
self.assertEqual(completer.complete('_', 0), '_')
self.assertEqual(completer.complete('match', 0), 'match ')
self.assertEqual(completer.complete('case', 0), 'case ')

@unittest.mock.patch('rlcompleter._readline_available', False)
def test_complete_with_buffer(self):
completer = rlcompleter.Completer()
buffer = list('if ...:\n ...\nel')
self.assertEqual(completer.complete('el', 0, buffer), 'elif ')
self.assertEqual(completer.complete('el', 1, buffer), 'else')
buffer = list('match foo:\n c')
self.assertEqual(completer.complete('c', 0, buffer), 'case ')
buffer = list('True a')
self.assertEqual(completer.complete('a', 0, buffer), 'and ')
buffer = list('a if True e')
self.assertEqual(completer.complete('e', 0, buffer), 'else')

def test_duplicate_globals(self):
namespace = {
'False': None, # Keyword vs builtin vs namespace
Expand Down
4 changes: 3 additions & 1 deletion Parser/pegen.c
Original file line number Diff line number Diff line change
Expand Up @@ -960,7 +960,9 @@ mod_ty
_PyPegen_run_parser_from_string(const char *str, int start_rule, PyObject *filename_ob,
PyCompilerFlags *flags, PyArena *arena)
{
int exec_input = start_rule == Py_file_input;
int exec_input = start_rule == Py_file_input &&
(flags == NULL || !(flags->cf_flags & PyCF_ALLOW_INCOMPLETE_INPUT) ||
flags->cf_flags & PyCF_DONT_IMPLY_DEDENT);

struct tok_state *tok;
if (flags != NULL && flags->cf_flags & PyCF_IGNORE_COOKIE) {
Expand Down
Loading