diff --git a/Lib/_pyrepl/main.py b/Lib/_pyrepl/main.py index 447eb1e551e774..9ae2e44839f0ed 100644 --- a/Lib/_pyrepl/main.py +++ b/Lib/_pyrepl/main.py @@ -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 diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 0ebd9162eca4bb..93055a275d9348 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -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 @@ -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 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() diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 23b8fa6b9c7625..12fa83da8d0dff 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -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] @@ -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: diff --git a/Lib/_pyrepl/simple_interact.py b/Lib/_pyrepl/simple_interact.py index ff1bdab9fea078..71ab25be74361b 100644 --- a/Lib/_pyrepl/simple_interact.py +++ b/Lib/_pyrepl/simple_interact.py @@ -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] @@ -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: diff --git a/Lib/_pyrepl/utils.py b/Lib/_pyrepl/utils.py index 06cddef851bb40..a5527f6f688a3d 100644 --- a/Lib/_pyrepl/utils.py +++ b/Lib/_pyrepl/utils.py @@ -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: diff --git a/Lib/asyncio/__main__.py b/Lib/asyncio/__main__.py index d078ebfa4cedbe..80d0119de6ae63 100644 --- a/Lib/asyncio/__main__.py +++ b/Lib/asyncio/__main__.py @@ -14,6 +14,7 @@ from _colorize import get_theme from _pyrepl.console import InteractiveColoredConsole +from _pyrepl.utils import DEFAULT_PS1 from . import futures @@ -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}" diff --git a/Lib/code.py b/Lib/code.py index f7e275d8801b7c..b8a501f2fd855f 100644 --- a/Lib/code.py +++ b/Lib/code.py @@ -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.' diff --git a/Lib/test/test_pyrepl/support.py b/Lib/test/test_pyrepl/support.py index 4f7f9d77933336..210ce9421d466f 100644 --- a/Lib/test/test_pyrepl/support.py +++ b/Lib/test/test_pyrepl/support.py @@ -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: @@ -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 diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index b1b6ae16a1e592..4e49465454b99b 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -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()} @@ -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 @@ -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." diff --git a/Misc/NEWS.d/next/Library/2025-03-13-00-39-54.gh-issue-130698.o2aU3e.rst b/Misc/NEWS.d/next/Library/2025-03-13-00-39-54.gh-issue-130698.o2aU3e.rst new file mode 100644 index 00000000000000..609345dddc04fa --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-03-13-00-39-54.gh-issue-130698.o2aU3e.rst @@ -0,0 +1 @@ +Avoid exiting the new REPL when prompt object raises an exception.