From e0e5ea658028eebf96291d3cfda1dddff2c36209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Wed, 22 May 2024 20:50:45 +0200 Subject: [PATCH 1/7] gh-118894: Make asyncio REPL use pyrepl --- Lib/_pyrepl/simple_interact.py | 11 +++++++--- Lib/asyncio/__main__.py | 37 ++++++++++++++++++++++++++-------- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/Lib/_pyrepl/simple_interact.py b/Lib/_pyrepl/simple_interact.py index 8ab4dab757685e..7c716ababd1a7e 100644 --- a/Lib/_pyrepl/simple_interact.py +++ b/Lib/_pyrepl/simple_interact.py @@ -95,7 +95,7 @@ def runsource(self, source, filename="", symbol="single"): the_symbol = symbol if stmt is last_stmt else "exec" item = wrapper([stmt]) try: - code = compile(item, filename, the_symbol) + code = self.compile.compiler(item, filename, the_symbol) except (OverflowError, ValueError): self.showsyntaxerror(filename) return False @@ -108,14 +108,19 @@ def runsource(self, source, filename="", symbol="single"): def run_multiline_interactive_console( - mainmodule: ModuleType | None= None, future_flags: int = 0 + mainmodule: ModuleType | None = None, + future_flags: int = 0, + console: code.InteractiveConsole | None = None, ) -> None: import __main__ from .readline import _setup _setup() mainmodule = mainmodule or __main__ - console = InteractiveColoredConsole(mainmodule.__dict__, filename="") + if console is None: + console = InteractiveColoredConsole( + mainmodule.__dict__, filename="" + ) if future_flags: console.compile.compiler.flags |= future_flags diff --git a/Lib/asyncio/__main__.py b/Lib/asyncio/__main__.py index 9041b8b8316c1e..10a5a2b0882c40 100644 --- a/Lib/asyncio/__main__.py +++ b/Lib/asyncio/__main__.py @@ -1,21 +1,24 @@ import ast import asyncio -import code import concurrent.futures import inspect +import os import site import sys import threading import types import warnings +from _pyrepl.simple_interact import InteractiveColoredConsole, check +from _pyrepl.simple_interact import run_multiline_interactive_console + from . import futures -class AsyncIOInteractiveConsole(code.InteractiveConsole): +class AsyncIOInteractiveConsole(InteractiveColoredConsole): def __init__(self, locals, loop): - super().__init__(locals) + super().__init__(locals, filename="") self.compile.compiler.flags |= ast.PyCF_ALLOW_TOP_LEVEL_AWAIT self.loop = loop @@ -75,12 +78,30 @@ def run(self): f'Use "await" directly instead of "asyncio.run()".\n' f'Type "help", "copyright", "credits" or "license" ' f'for more information.\n' - f'{getattr(sys, "ps1", ">>> ")}import asyncio' ) + exit_message = 'exiting asyncio REPL...' + + console.write(banner) + if startup_path := os.getenv("PYTHONSTARTUP"): + import tokenize + with tokenize.open(startup_path) as f: + startup_code = compile(f.read(), startup_path, "exec") + exec(startup_code, console.locals) - console.interact( - banner=banner, - exitmsg='exiting asyncio REPL...') + try: + import errno + if not os.isatty(sys.stdin.fileno()): + raise OSError(errno.ENOTTY, "tty required", "stdin") + if err := check(): + raise RuntimeError(err) + except Exception as e: + console.interact(banner="", exitmsg=exit_message) + else: + try: + run_multiline_interactive_console(console=console) + except BaseException: + console.showtraceback() + console.write(exit_message + '\n') finally: warnings.filterwarnings( 'ignore', @@ -126,7 +147,7 @@ def run(self): completer = rlcompleter.Completer(console.locals) readline.set_completer(completer.complete) - repl_thread = REPLThread() + repl_thread = REPLThread(name="Interactive thread") repl_thread.daemon = True repl_thread.start() From 85392e16990974ec86aaac1cee985fb87af00d0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Wed, 22 May 2024 21:20:51 +0200 Subject: [PATCH 2/7] Add Blurb --- .../next/Library/2024-05-22-21-20-43.gh-issue-118894.xHdxR_.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2024-05-22-21-20-43.gh-issue-118894.xHdxR_.rst diff --git a/Misc/NEWS.d/next/Library/2024-05-22-21-20-43.gh-issue-118894.xHdxR_.rst b/Misc/NEWS.d/next/Library/2024-05-22-21-20-43.gh-issue-118894.xHdxR_.rst new file mode 100644 index 00000000000000..ffc4ae336dc54f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-05-22-21-20-43.gh-issue-118894.xHdxR_.rst @@ -0,0 +1 @@ +:mod:`asyncio` REPL now has the same capabilities as PyREPL. From 59ec23d9618fb47d7dc49fe7e991fa78f73406f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Thu, 23 May 2024 20:10:43 +0200 Subject: [PATCH 3/7] Fix Windows support --- Lib/_pyrepl/console.py | 45 +++++++++++++++++++++++++++ Lib/_pyrepl/simple_interact.py | 45 +-------------------------- Lib/asyncio/__main__.py | 9 ++++-- Lib/test/test_pyrepl/test_interact.py | 2 +- 4 files changed, 54 insertions(+), 47 deletions(-) diff --git a/Lib/_pyrepl/console.py b/Lib/_pyrepl/console.py index d7e86e768671dc..8f2dda4a7f76a3 100644 --- a/Lib/_pyrepl/console.py +++ b/Lib/_pyrepl/console.py @@ -19,7 +19,10 @@ from __future__ import annotations +import _colorize # type: ignore[import-not-found] from abc import ABC, abstractmethod +import ast +import code from dataclasses import dataclass, field @@ -110,3 +113,45 @@ def wait(self) -> None: @abstractmethod def repaint(self) -> None: ... + + +class InteractiveColoredConsole(code.InteractiveConsole): + def __init__( + self, + locals: dict[str, object] | None = None, + filename: str = "", + *, + local_exit: bool = False, + ) -> None: + super().__init__(locals=locals, filename=filename, local_exit=local_exit) # type: ignore[call-arg] + self.can_colorize = _colorize.can_colorize() + + def showsyntaxerror(self, filename=None): + super().showsyntaxerror(colorize=self.can_colorize) + + def showtraceback(self): + super().showtraceback(colorize=self.can_colorize) + + def runsource(self, source, filename="", symbol="single"): + try: + tree = ast.parse(source) + except (OverflowError, SyntaxError, ValueError): + self.showsyntaxerror(filename) + return False + if tree.body: + *_, last_stmt = tree.body + for stmt in tree.body: + wrapper = ast.Interactive if stmt is last_stmt else ast.Module + the_symbol = symbol if stmt is last_stmt else "exec" + item = wrapper([stmt]) + try: + code = self.compile.compiler(item, filename, the_symbol) + except (OverflowError, ValueError): + self.showsyntaxerror(filename) + return False + + if code is None: + return True + + self.runcode(code) + return False diff --git a/Lib/_pyrepl/simple_interact.py b/Lib/_pyrepl/simple_interact.py index 7c716ababd1a7e..07c3f99ed67ef7 100644 --- a/Lib/_pyrepl/simple_interact.py +++ b/Lib/_pyrepl/simple_interact.py @@ -25,14 +25,13 @@ from __future__ import annotations -import _colorize # type: ignore[import-not-found] import _sitebuiltins import linecache import sys import code -import ast from types import ModuleType +from .console import InteractiveColoredConsole from .readline import _get_reader, multiline_input from .unix_console import _error @@ -65,48 +64,6 @@ def _strip_final_indent(text: str) -> str: "clear": "clear_screen", } -class InteractiveColoredConsole(code.InteractiveConsole): - def __init__( - self, - locals: dict[str, object] | None = None, - filename: str = "", - *, - local_exit: bool = False, - ) -> None: - super().__init__(locals=locals, filename=filename, local_exit=local_exit) # type: ignore[call-arg] - self.can_colorize = _colorize.can_colorize() - - def showsyntaxerror(self, filename=None): - super().showsyntaxerror(colorize=self.can_colorize) - - def showtraceback(self): - super().showtraceback(colorize=self.can_colorize) - - def runsource(self, source, filename="", symbol="single"): - try: - tree = ast.parse(source) - except (OverflowError, SyntaxError, ValueError): - self.showsyntaxerror(filename) - return False - if tree.body: - *_, last_stmt = tree.body - for stmt in tree.body: - wrapper = ast.Interactive if stmt is last_stmt else ast.Module - the_symbol = symbol if stmt is last_stmt else "exec" - item = wrapper([stmt]) - try: - code = self.compile.compiler(item, filename, the_symbol) - except (OverflowError, ValueError): - self.showsyntaxerror(filename) - return False - - if code is None: - return True - - self.runcode(code) - return False - - def run_multiline_interactive_console( mainmodule: ModuleType | None = None, future_flags: int = 0, diff --git a/Lib/asyncio/__main__.py b/Lib/asyncio/__main__.py index 10a5a2b0882c40..8acc53c8528211 100644 --- a/Lib/asyncio/__main__.py +++ b/Lib/asyncio/__main__.py @@ -9,8 +9,7 @@ import types import warnings -from _pyrepl.simple_interact import InteractiveColoredConsole, check -from _pyrepl.simple_interact import run_multiline_interactive_console +from _pyrepl.console import InteractiveColoredConsole from . import futures @@ -92,6 +91,12 @@ def run(self): import errno if not os.isatty(sys.stdin.fileno()): raise OSError(errno.ENOTTY, "tty required", "stdin") + + # This import will fail on operating systems with no termios. + from _pyrepl.simple_interact import ( + check, + run_multiline_interactive_console, + ) if err := check(): raise RuntimeError(err) except Exception as e: diff --git a/Lib/test/test_pyrepl/test_interact.py b/Lib/test/test_pyrepl/test_interact.py index 10e34045bcf92d..947b4cb9820e56 100644 --- a/Lib/test/test_pyrepl/test_interact.py +++ b/Lib/test/test_pyrepl/test_interact.py @@ -6,7 +6,7 @@ from test.support import force_not_colorized -from _pyrepl.simple_interact import InteractiveColoredConsole +from _pyrepl.console import InteractiveColoredConsole class TestSimpleInteract(unittest.TestCase): From 6ba913df6139e58ebc8b3234ada647b34145a3f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Thu, 23 May 2024 23:06:58 +0200 Subject: [PATCH 4/7] Make `exit()` and `exit` behave the same; support PYTHON_BASIC_REPL --- Lib/asyncio/__main__.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/Lib/asyncio/__main__.py b/Lib/asyncio/__main__.py index 8acc53c8528211..d43fb043a67788 100644 --- a/Lib/asyncio/__main__.py +++ b/Lib/asyncio/__main__.py @@ -23,9 +23,11 @@ def __init__(self, locals, loop): self.loop = loop def runcode(self, code): + global return_code future = concurrent.futures.Future() def callback(): + global return_code global repl_future global repl_future_interrupted @@ -35,8 +37,10 @@ def callback(): func = types.FunctionType(code, self.locals) try: coro = func() - except SystemExit: - raise + except SystemExit as se: + return_code = se.code + self.loop.stop() + return except KeyboardInterrupt as ex: repl_future_interrupted = True future.set_exception(ex) @@ -59,8 +63,10 @@ def callback(): try: return future.result() - except SystemExit: - raise + except SystemExit as se: + return_code = se.code + self.loop.stop() + return except BaseException: if repl_future_interrupted: self.write("\nKeyboardInterrupt\n") @@ -71,6 +77,8 @@ def callback(): class REPLThread(threading.Thread): def run(self): + global return_code + try: banner = ( f'asyncio REPL {sys.version} on {sys.platform}\n' @@ -78,7 +86,6 @@ def run(self): f'Type "help", "copyright", "credits" or "license" ' f'for more information.\n' ) - exit_message = 'exiting asyncio REPL...' console.write(banner) if startup_path := os.getenv("PYTHONSTARTUP"): @@ -89,6 +96,8 @@ def run(self): try: import errno + if os.getenv("PYTHON_BASIC_REPL"): + raise RuntimeError("user environment requested basic REPL") if not os.isatty(sys.stdin.fileno()): raise OSError(errno.ENOTTY, "tty required", "stdin") @@ -104,9 +113,14 @@ def run(self): else: try: run_multiline_interactive_console(console=console) + except SystemExit: + # expected via the `exit` and `quit` commands + pass except BaseException: + # unexpected issue console.showtraceback() - console.write(exit_message + '\n') + console.write("Internal error, ") + return_code = 1 finally: warnings.filterwarnings( 'ignore', @@ -117,6 +131,7 @@ def run(self): if __name__ == '__main__': + return_code = 0 loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -166,3 +181,6 @@ def run(self): continue else: break + + console.write('exiting asyncio REPL...\n') + sys.exit(return_code) From 25ef8d7908244bc0d9faaee3d9c614460b8faac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Fri, 24 May 2024 00:27:13 +0200 Subject: [PATCH 5/7] Write history by using dark magic --- Lib/asyncio/__main__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lib/asyncio/__main__.py b/Lib/asyncio/__main__.py index d43fb043a67788..3e67e4eca199ce 100644 --- a/Lib/asyncio/__main__.py +++ b/Lib/asyncio/__main__.py @@ -131,6 +131,8 @@ def run(self): if __name__ == '__main__': + CAN_USE_PYREPL = True + return_code = 0 loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) From 6b07c4d35a97f16849a0076818c0801335262f1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Fri, 31 May 2024 18:34:35 +0200 Subject: [PATCH 6/7] Force ETX (CTRL-C) to raise KeyboardInterrupt even in a thread; show "import asyncio" --- Lib/_pyrepl/commands.py | 5 +++++ Lib/_pyrepl/reader.py | 1 + Lib/asyncio/__main__.py | 19 +++++++++++++------ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py index ed977f84baac4e..2ef5dada9d9e58 100644 --- a/Lib/_pyrepl/commands.py +++ b/Lib/_pyrepl/commands.py @@ -219,6 +219,11 @@ def do(self) -> None: os.kill(os.getpid(), signal.SIGINT) +class ctrl_c(Command): + def do(self) -> None: + raise KeyboardInterrupt + + class suspend(Command): def do(self) -> None: import signal diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 0045425cdddb79..5401ae7b0ae32d 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -131,6 +131,7 @@ def make_default_commands() -> dict[CommandName, type[Command]]: ("\\\\", "self-insert"), (r"\x1b[200~", "enable_bracketed_paste"), (r"\x1b[201~", "disable_bracketed_paste"), + (r"\x03", "ctrl-c"), ] + [(c, "self-insert") for c in map(chr, range(32, 127)) if c != "\\"] + [(c, "self-insert") for c in map(chr, range(128, 256)) if c.isalpha()] diff --git a/Lib/asyncio/__main__.py b/Lib/asyncio/__main__.py index 3e67e4eca199ce..91fff9aaee337b 100644 --- a/Lib/asyncio/__main__.py +++ b/Lib/asyncio/__main__.py @@ -9,6 +9,7 @@ import types import warnings +from _colorize import can_colorize, ANSIColors # type: ignore[import-not-found] from _pyrepl.console import InteractiveColoredConsole from . import futures @@ -29,10 +30,10 @@ def runcode(self, code): def callback(): global return_code global repl_future - global repl_future_interrupted + global keyboard_interrupted repl_future = None - repl_future_interrupted = False + keyboard_interrupted = False func = types.FunctionType(code, self.locals) try: @@ -42,7 +43,7 @@ def callback(): self.loop.stop() return except KeyboardInterrupt as ex: - repl_future_interrupted = True + keyboard_interrupted = True future.set_exception(ex) return except BaseException as ex: @@ -68,7 +69,7 @@ def callback(): self.loop.stop() return except BaseException: - if repl_future_interrupted: + if keyboard_interrupted: self.write("\nKeyboardInterrupt\n") else: self.showtraceback() @@ -88,12 +89,18 @@ def run(self): ) console.write(banner) + if startup_path := os.getenv("PYTHONSTARTUP"): import tokenize with tokenize.open(startup_path) as f: startup_code = compile(f.read(), startup_path, "exec") exec(startup_code, console.locals) + ps1 = getattr(sys, "ps1", ">>> ") + if can_colorize(): + ps1 = f"{ANSIColors.BOLD_MAGENTA}{ps1}{ANSIColors.RESET}" + console.write(f"{ps1}import asyncio\n") + try: import errno if os.getenv("PYTHON_BASIC_REPL"): @@ -146,7 +153,7 @@ def run(self): console = AsyncIOInteractiveConsole(repl_locals, loop) repl_future = None - repl_future_interrupted = False + keyboard_interrupted = False try: import readline # NoQA @@ -177,9 +184,9 @@ def run(self): try: loop.run_forever() except KeyboardInterrupt: + keyboard_interrupted = True if repl_future and not repl_future.done(): repl_future.cancel() - repl_future_interrupted = True continue else: break From 5890cc9c4167809d2d972f78f1bbaf2f76af10c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Langa?= Date: Fri, 31 May 2024 18:56:36 +0200 Subject: [PATCH 7/7] Advertise `python -m asyncio` if the user tried top-level await --- Lib/_pyrepl/console.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Lib/_pyrepl/console.py b/Lib/_pyrepl/console.py index 1623d0d087d16a..aa0bde865825c9 100644 --- a/Lib/_pyrepl/console.py +++ b/Lib/_pyrepl/console.py @@ -25,6 +25,7 @@ import ast import code from dataclasses import dataclass, field +import os.path import sys @@ -161,7 +162,7 @@ def showtraceback(self): def runsource(self, source, filename="", symbol="single"): try: tree = ast.parse(source) - except (OverflowError, SyntaxError, ValueError): + except (SyntaxError, OverflowError, ValueError): self.showsyntaxerror(filename) return False if tree.body: @@ -172,9 +173,18 @@ def runsource(self, source, filename="", symbol="single"): item = wrapper([stmt]) try: code = self.compile.compiler(item, filename, the_symbol, dont_inherit=True) - except (OverflowError, ValueError, SyntaxError): - self.showsyntaxerror(filename) - return False + except SyntaxError as e: + if e.args[0] == "'await' outside function": + python = os.path.basename(sys.executable) + e.add_note( + f"Try the asyncio REPL ({python} -m asyncio) to use" + f" top-level 'await' and run background asyncio tasks." + ) + self.showsyntaxerror(filename) + return False + except (OverflowError, ValueError): + self.showsyntaxerror(filename) + return False if code is None: return True