Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7e633d7
gh-130698: Add safe methods to get prompts for new REPL
sergey-miryanov Mar 11, 2025
707bd38
Fix missing types
sergey-miryanov Mar 11, 2025
ab84ee6
Fix handling of arg prompt and add tests for it
sergey-miryanov Mar 12, 2025
5d91db7
Make mypy happy again
sergey-miryanov Mar 12, 2025
e0b474b
Add news entry
sergey-miryanov Mar 12, 2025
02e7e84
Update Misc/NEWS.d/next/Library/2025-03-13-00-39-54.gh-issue-130698.o…
sergey-miryanov Apr 19, 2025
c68a695
Merge branch 'main' into gh-130698-pyrepl-ps1-exception
sergey-miryanov Apr 19, 2025
5293313
Merge branch 'gh-130698-pyrepl-ps1-exception' of github.com:sergey-mi…
sergey-miryanov Apr 19, 2025
4fc6853
Simplify tests for reader with buggy prompt
sergey-miryanov Apr 19, 2025
ba8633b
Fix DEFAULT_PS2 string
sergey-miryanov Apr 19, 2025
cac161d
Make __get_prompt_str less stricter
sergey-miryanov Apr 19, 2025
60c189f
Update docstring for __get_prompt_str
sergey-miryanov Apr 20, 2025
fb66f37
Merge branch 'main' into gh-130698-pyrepl-ps1-exception
sergey-miryanov Jun 13, 2025
abd1737
Merge branch 'gh-130698-pyrepl-ps1-exception' of github.com:sergey-mi…
sergey-miryanov Jun 13, 2025
9baac77
Merge branch 'main' into gh-130698-pyrepl-ps1-exception
efimov-mikhail Nov 1, 2025
e99d76a
Merge branch 'main' into gh-130698-pyrepl-ps1-exception
sergey-miryanov Nov 2, 2025
575007b
Use DEFAULT_PS* in other places and add MULTILINE_PS*
sergey-miryanov Nov 2, 2025
a1ac233
Fix import
sergey-miryanov Nov 3, 2025
aac7470
Add comment for MULTILINE_PS*
sergey-miryanov Nov 3, 2025
8b23989
Update Lib/_pyrepl/reader.py
sergey-miryanov Nov 3, 2025
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
5 changes: 3 additions & 2 deletions Lib/_pyrepl/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,11 @@ def interactive_console(mainmodule=None, quiet=False, pythonstartup=False):

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

from .console import InteractiveColoredConsole
from .simple_interact import run_multiline_interactive_console
Expand Down
30 changes: 25 additions & 5 deletions Lib/_pyrepl/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
from dataclasses import dataclass, field, fields

from . import commands, console, input
from .utils import DEFAULT_PS1
from .utils import MULTILINE_PS2, MULTILINE_PS3, MULTILINE_PS4
from .utils import wlen, unbracket, disp_str, gen_colors, THEME
from .trace import trace

Expand Down Expand Up @@ -473,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:
prompt = "(paste) "
elif "\n" in self.buffer:
if lineno == 0:
prompt = self.ps2
prompt = self.__get_prompt_str(self.ps2, MULTILINE_PS2)
elif self.ps4 and lineno == self.buffer.count("\n"):
prompt = self.ps4
prompt = self.__get_prompt_str(self.ps4, MULTILINE_PS4)
else:
prompt = self.ps3
prompt = self.__get_prompt_str(self.ps3, MULTILINE_PS3)
else:
prompt = self.ps1
prompt = self.__get_prompt_str(self.ps1, DEFAULT_PS1)

if self.can_colorize:
t = THEME()
Expand Down
3 changes: 2 additions & 1 deletion Lib/_pyrepl/readline.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from .completing_reader import CompletingReader
from .console import Console as ConsoleType
from ._module_completer import ModuleCompleter, make_default_module_completer
from .utils import MULTILINE_PS4

Console: type[ConsoleType]
_error: tuple[type[Exception], ...] | type[Exception]
Expand Down Expand Up @@ -390,7 +391,7 @@ def multiline_input(self, more_lines: MoreLinesCallable, ps1: str, ps2: str) ->
reader.ps1 = ps1
reader.ps2 = ps1
reader.ps3 = ps2
reader.ps4 = ""
reader.ps4 = MULTILINE_PS4
with warnings.catch_warnings(action="ignore"):
return reader.readline()
finally:
Expand Down
5 changes: 3 additions & 2 deletions Lib/_pyrepl/simple_interact.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import errno

from .readline import _get_reader, multiline_input, append_history_file
from .utils import DEFAULT_PS1, DEFAULT_PS2


_error: tuple[type[Exception], ...] | type[Exception]
Expand Down Expand Up @@ -137,8 +138,8 @@ def maybe_run_command(statement: str) -> bool:
except Exception:
pass

ps1 = getattr(sys, "ps1", ">>> ")
ps2 = getattr(sys, "ps2", "... ")
ps1 = getattr(sys, "ps1", DEFAULT_PS1)
ps2 = getattr(sys, "ps2", DEFAULT_PS2)
try:
statement = multiline_input(more_lines, ps1, ps2)
except EOFError:
Expand Down
10 changes: 10 additions & 0 deletions Lib/_pyrepl/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ class ColorSpan(NamedTuple):
tag: str


DEFAULT_PS1 = ">>> "
DEFAULT_PS2 = "... "

# mimics behavior of _ReadlineWrapper.multiline
MULTILINE_PS1 = DEFAULT_PS1
MULTILINE_PS2 = DEFAULT_PS1
MULTILINE_PS3 = DEFAULT_PS2
MULTILINE_PS4 = ""


@functools.cache
def str_width(c: str) -> int:
if ord(c) < 128:
Expand Down
3 changes: 2 additions & 1 deletion Lib/asyncio/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from _colorize import get_theme
from _pyrepl.console import InteractiveColoredConsole
from _pyrepl.utils import DEFAULT_PS1

from . import futures

Expand Down Expand Up @@ -103,7 +104,7 @@ def run(self):
startup_code = compile(f.read(), startup_path, "exec")
exec(startup_code, console.locals)

ps1 = getattr(sys, "ps1", ">>> ")
ps1 = getattr(sys, "ps1", DEFAULT_PS1)
if CAN_USE_PYREPL:
theme = get_theme().syntax
ps1 = f"{theme.prompt}{ps1}{theme.reset}"
Expand Down
5 changes: 3 additions & 2 deletions Lib/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,17 +217,18 @@ def interact(self, banner=None, exitmsg=None):
a default message is printed.

"""
from _pyrepl.utils import DEFAULT_PS1, DEFAULT_PS2
try:
sys.ps1
delete_ps1_after = False
except AttributeError:
sys.ps1 = ">>> "
sys.ps1 = DEFAULT_PS1
delete_ps1_after = True
try:
sys.ps2
delete_ps2_after = False
except AttributeError:
sys.ps2 = "... "
sys.ps2 = DEFAULT_PS2
delete_ps2_after = True

cprt = 'Type "help", "copyright", "credits" or "license" for more information.'
Expand Down
6 changes: 3 additions & 3 deletions Lib/test/test_pyrepl/support.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from _pyrepl.console import Console, Event
from _pyrepl.readline import ReadlineAlikeReader, ReadlineConfig
from _pyrepl.simple_interact import _strip_final_indent
from _pyrepl.utils import unbracket, ANSI_ESCAPE_SEQUENCE
from _pyrepl.utils import unbracket, ANSI_ESCAPE_SEQUENCE, DEFAULT_PS1, DEFAULT_PS2


class ScreenEqualMixin:
Expand All @@ -22,8 +22,8 @@ def multiline_input(reader: ReadlineAlikeReader, namespace: dict | None = None):
saved = reader.more_lines
try:
reader.more_lines = partial(more_lines, namespace=namespace)
reader.ps1 = reader.ps2 = ">>> "
reader.ps3 = reader.ps4 = "... "
reader.ps1 = reader.ps2 = DEFAULT_PS1
reader.ps3 = reader.ps4 = DEFAULT_PS2
return reader.readline()
finally:
reader.more_lines = saved
Expand Down
110 changes: 106 additions & 4 deletions Lib/test/test_pyrepl/test_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,28 @@
from .support import prepare_reader, prepare_console
from _pyrepl.console import Event
from _pyrepl.reader import Reader
from _pyrepl.utils import DEFAULT_PS1
from _pyrepl.utils import MULTILINE_PS1, MULTILINE_PS2, MULTILINE_PS3, MULTILINE_PS4
from _colorize import default_theme


def prepare_reader_with_prompt(
console, ps1=MULTILINE_PS1, ps2=MULTILINE_PS2, ps3=MULTILINE_PS3, ps4=MULTILINE_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


overrides = {"reset": "z", "soft_keyword": "K"}
colors = {overrides.get(k, k[0].lower()): v for k, v in default_theme.syntax.items()}

Expand All @@ -39,10 +58,10 @@ def test_calc_screen_prompt_handling(self):
def prepare_reader_keep_prompts(*args, **kwargs):
reader = prepare_reader(*args, **kwargs)
del reader.get_prompt
reader.ps1 = ">>> "
reader.ps2 = ">>> "
reader.ps3 = "... "
reader.ps4 = ""
reader.ps1 = MULTILINE_PS1
reader.ps2 = MULTILINE_PS2
reader.ps3 = MULTILINE_PS3
reader.ps4 = MULTILINE_PS4
reader.can_colorize = False
reader.paste_mode = False
return reader
Expand Down Expand Up @@ -300,6 +319,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, MULTILINE_PS2)

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

prompt = reader.get_prompt(2, False)
self.assertEqual(prompt, MULTILINE_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