Skip to content

gh-130698: Add safe methods to get prompts for new REPL #131110

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 12 commits into
base: main
Choose a base branch
from
Open
29 changes: 24 additions & 5 deletions Lib/_pyrepl/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@


from . import commands, console, input
from .utils import DEFAULT_PS1, DEFAULT_PS2, DEFAULT_PS3, DEFAULT_PS4
from .utils import wlen, unbracket, disp_str
from .trace import trace

Expand Down Expand Up @@ -474,22 +475,40 @@ def get_arg(self, default: int = 1) -> int:
return default
return self.arg

@staticmethod
def __get_prompt_str(prompt: object, default_prompt: str) -> str:
"""
Convert prompt object to string.

If str(prompt) raises BaseException, MemoryError or SystemError then stop
the REPL. For other exceptions return default_prompt.
"""
try:
return str(prompt)
except (MemoryError, SystemError):
raise
except Exception:
return default_prompt

def get_prompt(self, lineno: int, cursor_on_line: bool) -> str:
"""Return what should be in the left-hand margin for line
'lineno'."""
if self.arg is not None and cursor_on_line:
prompt = f"(arg: {self.arg}) "
prompt = DEFAULT_PS1
arg = self.__get_prompt_str(self.arg, "")
if arg:
prompt = f"(arg: {self.arg}) "
elif self.paste_mode and not self.in_bracketed_paste:
prompt = "(paste) "
elif "\n" in self.buffer:
if lineno == 0:
prompt = self.ps2
prompt = self.__get_prompt_str(self.ps2, DEFAULT_PS2)
elif self.ps4 and lineno == self.buffer.count("\n"):
prompt = self.ps4
prompt = self.__get_prompt_str(self.ps4, DEFAULT_PS4)
else:
prompt = self.ps3
prompt = self.__get_prompt_str(self.ps3, DEFAULT_PS3)
else:
prompt = self.ps1
prompt = self.__get_prompt_str(self.ps1, DEFAULT_PS1)

if self.can_colorize:
prompt = f"{ANSIColors.BOLD_MAGENTA}{prompt}{ANSIColors.RESET}"
Expand Down
6 changes: 6 additions & 0 deletions Lib/_pyrepl/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
ZERO_WIDTH_TRANS = str.maketrans({"\x01": "", "\x02": ""})


DEFAULT_PS1 = ">>> "
DEFAULT_PS2 = ">>> "
Copy link
Member

@chris-eibl chris-eibl Apr 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
DEFAULT_PS2 = ">>> "
DEFAULT_PS2 = "... "

See https://docs.python.org/3/library/sys.html#sys.ps1

sys.ps1
sys.ps2
Strings specifying the primary and secondary prompt of the interpreter. These are only defined if the interpreter is in interactive mode. Their initial values in this case are '>>> ' and '... '.

>>> import sys
>>> sys.ps1
'>>> '
>>> sys.ps2
'... '
>>> def foo():
...     pass

Maybe here the DEFAULT_PS* shall be used, too? Are there more places?

ps1 = getattr(sys, "ps1", ">>> ")
ps2 = getattr(sys, "ps2", "... ")

IMHO, the comment in reader.py is wrong (or at least confusing):

* ps1, ps2, ps3, ps4:
prompts. ps1 is the prompt for a one-line input; for a
multiline input it looks like:
ps2> first line of input goes here
ps3> second and further
ps3> lines get ps3
...
ps4> and the last one gets ps4

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As @tomasr8 pointed above

ps1: str = "->> "
ps2: str = "/>> "
ps3: str = "|.. "
ps4: str = R"\__ "

there are different defaults too. So IDK what is really default :)

Copy link
Contributor Author

@sergey-miryanov sergey-miryanov Apr 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe here the DEFAULT_PS* shall be used, too? Are there more places?

Yes, but I'm not sure that usage of DEFAULT_PS* approved overall.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IDK either, but IMHO we should stick with what is documented and the status quo, i.e. DEFAULT_PS2 = "... ".

The reason why

ps1: str = "->> "
ps2: str = "/>> "
ps3: str = "|.. "
ps4: str = R"\__ "

is not in effect:
# set sys.{ps1,ps2} just before invoking the interactive interpreter. This
# mimics what CPython does in pythonrun.c
if not hasattr(sys, "ps1"):
sys.ps1 = ">>> "
if not hasattr(sys, "ps2"):
sys.ps2 = "... "

and then later

ps1 = getattr(sys, "ps1", ">>> ")
ps2 = getattr(sys, "ps2", "... ")
try:
statement = multiline_input(more_lines, ps1, ps2)
except EOFError:
break

and then

def multiline_input(self, more_lines: MoreLinesCallable, ps1: str, ps2: str) -> str:
"""Read an input on possibly multiple lines, asking for more
lines as long as 'more_lines(unicodetext)' returns an object whose
boolean value is true.
"""
reader = self.get_reader()
saved = reader.more_lines
try:
reader.more_lines = more_lines
reader.ps1 = ps1
reader.ps2 = ps1
reader.ps3 = ps2
reader.ps4 = ""

So IMHO we should consistently use DEFAULT_PS1 and DEFAULT_PS2 in those code parts above, but let @pablogsal decide on it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moreover, this part

reader.ps1 = ps1 
reader.ps2 = ps1 
reader.ps3 = ps2 

IMHO explains this confusion

* ps1, ps2, ps3, ps4:
prompts. ps1 is the prompt for a one-line input; for a
multiline input it looks like:
ps2> first line of input goes here
ps3> second and further
ps3> lines get ps3
...
ps4> and the last one gets ps4

ISTM, this is an implementation detail, and ps2=ps1, etc, maps to what we are used to in the cPython REPL.

Most probably, because the new PYREPL comes from PyPy?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moreover, this part

Yeah, I saw this and this is a reason that I changed DEFAULT_PS2 yesterday.

Most probably, because the new PYREPL comes from PyPy?

I'm not familiar with PyPy, so 🤷‍♂️

DEFAULT_PS3 = "... "
DEFAULT_PS4 = "... "


@functools.cache
def str_width(c: str) -> int:
if ord(c) < 128:
Expand Down
101 changes: 101 additions & 0 deletions Lib/test/test_pyrepl/test_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@
from .support import prepare_reader, prepare_console
from _pyrepl.console import Event
from _pyrepl.reader import Reader
from _pyrepl.utils import DEFAULT_PS1, DEFAULT_PS2, DEFAULT_PS3, DEFAULT_PS4


def prepare_reader_with_prompt(
console, ps1=DEFAULT_PS1, ps2=DEFAULT_PS2, ps3=DEFAULT_PS3, ps4=DEFAULT_PS4):
reader = prepare_reader(
console,
can_colorize=False,
paste_mode=False,
ps1=ps1,
ps2=ps2,
ps3=ps3,
ps4=ps4
)

# we should use original get_prompt from reader to get exceptions
del reader.get_prompt
return reader


class TestReader(ScreenEqualMixin, TestCase):
Expand Down Expand Up @@ -298,6 +316,89 @@ def test_prompt_length(self):
self.assertEqual(prompt, "\033[0;32m樂>\033[0m> ")
self.assertEqual(l, 5)

def test_prompt_ps1_raise_exception(self):
# Handles exceptions from ps1 prompt
class Prompt:
def __str__(self): 1/0

_prepare_reader = functools.partial(
prepare_reader_with_prompt,
ps1=Prompt(),
)

reader, _ = handle_all_events(
events=code_to_events("a=1"),
prepare_reader=_prepare_reader
)

prompt = reader.get_prompt(0, False)
self.assertEqual(prompt, DEFAULT_PS1)

def test_prompt_ps2_ps3_ps4_raise_exception(self):
# Handles exceptions from ps2, ps3 and ps4 prompts
class Prompt:
def __str__(self): 1/0

_prepare_reader = functools.partial(
prepare_reader_with_prompt,
ps1=Prompt(),
ps2=Prompt(),
ps3=Prompt(),
ps4=Prompt(),
)

reader, _ = handle_all_events(
events=code_to_events("if cond:\nfunc()\nfunc()"),
prepare_reader=_prepare_reader
)

prompt = reader.get_prompt(0, False)
self.assertEqual(prompt, DEFAULT_PS2)

prompt = reader.get_prompt(1, False)
self.assertEqual(prompt, DEFAULT_PS3)

prompt = reader.get_prompt(2, False)
self.assertEqual(prompt, DEFAULT_PS4)

def test_prompt_arg_raise_exception(self):
# Handles exceptions from arg prompt
class Prompt:
def __str__(self): 1/0
def __rmul__(self, b): return b

reader, _ = handle_all_events(
events=code_to_events("if some_condition:\nsome_function()"),
prepare_reader=prepare_reader_with_prompt,
)

reader.arg = Prompt()
prompt = reader.get_prompt(0, True)
self.assertEqual(prompt, DEFAULT_PS1)

def test_prompt_raise_exception(self):
# Tests unrecoverable exceptions from prompts
cases = [
(MemoryError, "No memory for prompt"),
(SystemError, "System error for prompt"),
]
for cls, msg in cases:
with self.subTest(msg):

class Prompt:
def __str__(self): raise cls(msg)

_prepare_reader = functools.partial(
prepare_reader_with_prompt,
ps1=Prompt(),
)

with self.assertRaisesRegex(cls, msg):
handle_events_narrow_console(
events=code_to_events("a=1"),
prepare_reader=_prepare_reader,
)

def test_completions_updated_on_key_press(self):
namespace = {"itertools": itertools}
code = "itertools."
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Avoid exiting the new REPL when prompt object raises an exception.
Loading