From 85cee1b23288fd72716e98bdad87015d8d1f46dc Mon Sep 17 00:00:00 2001 From: Clint Lawrence Date: Fri, 15 Mar 2024 13:04:16 +1100 Subject: [PATCH 01/15] Add a trio repl --- src/trio/__main__.py | 3 + src/trio/_repl.py | 79 +++++++++++++++++++++++ src/trio/_tests/test_repl.py | 118 +++++++++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 src/trio/__main__.py create mode 100644 src/trio/_repl.py create mode 100644 src/trio/_tests/test_repl.py diff --git a/src/trio/__main__.py b/src/trio/__main__.py new file mode 100644 index 0000000000..3b7c898ad5 --- /dev/null +++ b/src/trio/__main__.py @@ -0,0 +1,3 @@ +from trio._repl import main + +main(locals()) diff --git a/src/trio/_repl.py b/src/trio/_repl.py new file mode 100644 index 0000000000..21490e5d91 --- /dev/null +++ b/src/trio/_repl.py @@ -0,0 +1,79 @@ +import ast +import contextlib +import inspect +import sys +import warnings +from code import InteractiveConsole + +import trio +import trio.lowlevel + + +class TrioInteractiveConsole(InteractiveConsole): + def __init__(self, repl_locals=None): + super().__init__(repl_locals) + self.compile.compiler.flags |= ast.PyCF_ALLOW_TOP_LEVEL_AWAIT + + def runcode(self, code): + async def _runcode_in_trio(): + try: + coro = eval(code, self.locals) + except BaseException as e: + return e + + if inspect.iscoroutine(coro): + try: + await coro + except BaseException as e: + return e + return None + + e = trio.from_thread.run(_runcode_in_trio) + + if e is not None: + try: + raise e + except SystemExit: + raise + except BaseException: # Only SystemExit should quit the repl + self.showtraceback() + + async def task(self, repl_func): + await trio.to_thread.run_sync(repl_func, self) + + +def run_repl(console): + banner = ( + f"trio REPL {sys.version} on {sys.platform}\n" + f'Use "await" directly instead of "trio.run()".\n' + f'Type "help", "copyright", "credits" or "license" ' + f"for more information.\n" + f'{getattr(sys, "ps1", ">>> ")}import trio' + ) + try: + console.interact(banner=banner) + finally: + warnings.filterwarnings( + "ignore", + message=r"^coroutine .* was never awaited$", + category=RuntimeWarning, + ) + + +def main(original_locals): + with contextlib.suppress(ImportError): + import readline # noqa: F401 + + repl_locals = {"trio": trio} + for key in { + "__name__", + "__package__", + "__loader__", + "__spec__", + "__builtins__", + "__file__", + }: + repl_locals[key] = original_locals[key] + + console = TrioInteractiveConsole(repl_locals) + trio.run(console.task, run_repl) diff --git a/src/trio/_tests/test_repl.py b/src/trio/_tests/test_repl.py new file mode 100644 index 0000000000..aac00e8266 --- /dev/null +++ b/src/trio/_tests/test_repl.py @@ -0,0 +1,118 @@ +import subprocess +import sys + +import pytest + +import trio._repl + + +def build_raw_input(cmds): + """ + Pass in a list of strings. + Returns a callable that returns each string, each time its called + When there are not more strings to return, raise EOFError + """ + cmds_iter = iter(cmds) + prompts = [] + + def _raw_helper(prompt=""): + prompts.append(prompt) + try: + return next(cmds_iter) + except StopIteration: + raise EOFError from None + + return _raw_helper + + +def test_build_raw_input(): + """Quick test of our helper function.""" + raw_input = build_raw_input(["cmd1"]) + assert raw_input() == "cmd1" + with pytest.raises(EOFError): + raw_input() + + +async def test_basic_interaction(capsys): + """ + Run some basic commands through the interpreter while capturing stdout. + Ensure that the interpreted prints the expected results. + """ + console = trio._repl.TrioInteractiveConsole(repl_locals={"trio": trio}) + console.raw_input = build_raw_input( + [ + # evaluate simple expression and recall the value + "x = 1", + "print(f'{x=}')", + # Literal gets printed + "'hello'", + # define and call sync function + "def func():", + " print(x)", + "", + "func()", + # define and call async function + "async def afunc():", + " return 4", + "", + "await afunc()", + # import works + "import sys", + "sys.stdout.write('hello stdout\\n')", + ] + ) + await console.task(console.interact) + out, err = capsys.readouterr() + assert out.splitlines() == ["x=1", "'hello'", "1", "4", "hello stdout", "13"] + + +async def test_system_exits_quit_interpreter(): + console = trio._repl.TrioInteractiveConsole(repl_locals={"trio": trio}) + console.raw_input = build_raw_input( + [ + # evaluate simple expression and recall the value + "raise SystemExit", + ] + ) + with pytest.raises(SystemExit): + await console.task(console.interact) + + +async def test_base_exception_captured(capsys): + console = trio._repl.TrioInteractiveConsole(repl_locals={"trio": trio}) + console.raw_input = build_raw_input( + [ + # The statement after raise should still get executed + "raise BaseException", + "print('AFTER BaseException')", + ] + ) + await console.task(console.interact) + out, err = capsys.readouterr() + assert "AFTER BaseException" in out + + +async def test_base_exception_capture_from_coroutine(capsys): + console = trio._repl.TrioInteractiveConsole(repl_locals={"trio": trio}) + console.raw_input = build_raw_input( + [ + "async def async_func_raises_base_exception():", + " raise BaseException", + "", + # This will raise, but the statement after should still + # be executed + "await async_func_raises_base_exception()", + "print('AFTER BaseException')", + ] + ) + await console.task(console.interact) + out, err = capsys.readouterr() + assert "AFTER BaseException" in out + + +def test_main_entrypoint(): + """ + Basic smoke test when running via the package __main__ entrypoint. + """ + repl = subprocess.run([sys.executable, "-m", "trio"], input=b"exit()") + assert repl.returncode == 0 From 8fa88ec9be2bc4b49401ee47b0bb62da840947e5 Mon Sep 17 00:00:00 2001 From: Clint Lawrence Date: Fri, 15 Mar 2024 13:04:18 +1100 Subject: [PATCH 02/15] Add type annotations, fixing some issues along the way. Using eval only worked by accident, because "locals" was being passed into the "globals" value. InteractiveInterpreter in the stdlib correctly uses exec, but this doesn't work for us, because we need to get the values of the expression and check if it is a coroutine that need to be awaited. asyncio.__main__ uses the same type.FunctionType idea, which I originally avoided because I didn't really understand it and thought eval was simpler... --- src/trio/_repl.py | 32 ++++++++++++-------- src/trio/_tests/test_repl.py | 57 ++++++++++++++++++++++++------------ 2 files changed, 58 insertions(+), 31 deletions(-) diff --git a/src/trio/_repl.py b/src/trio/_repl.py index 21490e5d91..c7d6fbb36c 100644 --- a/src/trio/_repl.py +++ b/src/trio/_repl.py @@ -1,7 +1,11 @@ +from __future__ import annotations + import ast import contextlib import inspect import sys +import types +import typing import warnings from code import InteractiveConsole @@ -10,14 +14,19 @@ class TrioInteractiveConsole(InteractiveConsole): - def __init__(self, repl_locals=None): - super().__init__(repl_locals) + def __init__(self, repl_locals: dict[str, object] | None = None): + super().__init__(locals=repl_locals) self.compile.compiler.flags |= ast.PyCF_ALLOW_TOP_LEVEL_AWAIT - def runcode(self, code): - async def _runcode_in_trio(): + def runcode(self, code: types.CodeType) -> None: + async def _runcode_in_trio() -> BaseException | None: + # code.InteractiveInterpreter defines locals as Mapping[str, Any] + # However FunctionType expects a dict. We know our copy of + # locals will be a dict due to the annotation on repl_locals in __init__ + # so the cast is safe. + func = types.FunctionType(code, typing.cast(dict[str, object], self.locals)) try: - coro = eval(code, self.locals) + coro = func() except BaseException as e: return e @@ -38,11 +47,8 @@ async def _runcode_in_trio(): except BaseException: # Only SystemExit should quit the repl self.showtraceback() - async def task(self, repl_func): - await trio.to_thread.run_sync(repl_func, self) - -def run_repl(console): +async def run_repl(console: TrioInteractiveConsole) -> None: banner = ( f"trio REPL {sys.version} on {sys.platform}\n" f'Use "await" directly instead of "trio.run()".\n' @@ -51,7 +57,7 @@ def run_repl(console): f'{getattr(sys, "ps1", ">>> ")}import trio' ) try: - console.interact(banner=banner) + await trio.to_thread.run_sync(console.interact, banner) finally: warnings.filterwarnings( "ignore", @@ -60,11 +66,11 @@ def run_repl(console): ) -def main(original_locals): +def main(original_locals: dict[str, object]) -> None: with contextlib.suppress(ImportError): import readline # noqa: F401 - repl_locals = {"trio": trio} + repl_locals: dict[str, object] = {"trio": trio} for key in { "__name__", "__package__", @@ -76,4 +82,4 @@ def main(original_locals): repl_locals[key] = original_locals[key] console = TrioInteractiveConsole(repl_locals) - trio.run(console.task, run_repl) + trio.run(run_repl, console) diff --git a/src/trio/_tests/test_repl.py b/src/trio/_tests/test_repl.py index aac00e8266..0f5b742214 100644 --- a/src/trio/_tests/test_repl.py +++ b/src/trio/_tests/test_repl.py @@ -1,12 +1,19 @@ +from __future__ import annotations + import subprocess import sys +from typing import Protocol import pytest import trio._repl -def build_raw_input(cmds): +class RawInput(Protocol): + def __call__(self, prompt: str = "") -> str: ... + + +def build_raw_input(cmds: list[str]) -> RawInput: """ Pass in a list of strings. Returns a callable that returns each string, each time its called @@ -15,7 +22,7 @@ def build_raw_input(cmds): cmds_iter = iter(cmds) prompts = [] - def _raw_helper(prompt=""): + def _raw_helper(prompt: str = "") -> str: prompts.append(prompt) try: return next(cmds_iter) @@ -25,7 +32,7 @@ def _raw_helper(prompt=""): return _raw_helper -def test_build_raw_input(): +def test_build_raw_input() -> None: """Quick test of our helper function.""" raw_input = build_raw_input(["cmd1"]) assert raw_input() == "cmd1" @@ -33,13 +40,16 @@ def test_build_raw_input(): raw_input() -async def test_basic_interaction(capsys): +async def test_basic_interaction( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: """ Run some basic commands through the interpreter while capturing stdout. Ensure that the interpreted prints the expected results. """ console = trio._repl.TrioInteractiveConsole(repl_locals={"trio": trio}) - console.raw_input = build_raw_input( + raw_input = build_raw_input( [ # evaluate simple expression and recall the value "x = 1", @@ -48,7 +58,7 @@ async def test_basic_interaction(capsys): "'hello'", # define and call sync function "def func():", - " print(x)", + " print(x + 1)", "", "func()", # define and call async function @@ -61,40 +71,50 @@ async def test_basic_interaction(capsys): "sys.stdout.write('hello stdout\\n')", ] ) - await console.task(console.interact) + monkeypatch.setattr(console, "raw_input", raw_input) + await trio._repl.run_repl(console) out, err = capsys.readouterr() - assert out.splitlines() == ["x=1", "'hello'", "1", "4", "hello stdout", "13"] + print(err) + assert out.splitlines() == ["x=1", "'hello'", "2", "4", "hello stdout", "13"] -async def test_system_exits_quit_interpreter(): +async def test_system_exits_quit_interpreter(monkeypatch: pytest.MonkeyPatch) -> None: console = trio._repl.TrioInteractiveConsole(repl_locals={"trio": trio}) - console.raw_input = build_raw_input( + raw_input = build_raw_input( [ # evaluate simple expression and recall the value "raise SystemExit", ] ) + monkeypatch.setattr(console, "raw_input", raw_input) with pytest.raises(SystemExit): - await console.task(console.interact) + await trio._repl.run_repl(console) -async def test_base_exception_captured(capsys): +async def test_base_exception_captured( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: console = trio._repl.TrioInteractiveConsole(repl_locals={"trio": trio}) - console.raw_input = build_raw_input( + raw_input = build_raw_input( [ # The statement after raise should still get executed "raise BaseException", "print('AFTER BaseException')", ] ) - await console.task(console.interact) + monkeypatch.setattr(console, "raw_input", raw_input) + await trio._repl.run_repl(console) out, err = capsys.readouterr() assert "AFTER BaseException" in out -async def test_base_exception_capture_from_coroutine(capsys): +async def test_base_exception_capture_from_coroutine( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: console = trio._repl.TrioInteractiveConsole(repl_locals={"trio": trio}) - console.raw_input = build_raw_input( + raw_input = build_raw_input( [ "async def async_func_raises_base_exception():", " raise BaseException", @@ -105,12 +125,13 @@ async def test_base_exception_capture_from_coroutine(capsys): "print('AFTER BaseException')", ] ) - await console.task(console.interact) + monkeypatch.setattr(console, "raw_input", raw_input) + await trio._repl.run_repl(console) out, err = capsys.readouterr() assert "AFTER BaseException" in out -def test_main_entrypoint(): +def test_main_entrypoint() -> None: """ Basic smoke test when running via the package __main__ entrypoint. """ From 50ccf770ad098a71dd62199490b28fd3a2101645 Mon Sep 17 00:00:00 2001 From: Clint Lawrence Date: Fri, 15 Mar 2024 13:04:19 +1100 Subject: [PATCH 03/15] Fix index of dict type, in typing.cast --- src/trio/_repl.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/trio/_repl.py b/src/trio/_repl.py index c7d6fbb36c..b6cd84feef 100644 --- a/src/trio/_repl.py +++ b/src/trio/_repl.py @@ -8,6 +8,7 @@ import typing import warnings from code import InteractiveConsole +from typing import Dict import trio import trio.lowlevel @@ -24,7 +25,9 @@ async def _runcode_in_trio() -> BaseException | None: # However FunctionType expects a dict. We know our copy of # locals will be a dict due to the annotation on repl_locals in __init__ # so the cast is safe. - func = types.FunctionType(code, typing.cast(dict[str, object], self.locals)) + # In addition, we need to use typing.Dict here, because this is _not_ an + # annotation, so from __future__ import annotations doesn't help. + func = types.FunctionType(code, typing.cast(Dict[str, object], self.locals)) try: coro = func() except BaseException as e: From bc57699c4e5c91fe5a39750cb3cdca01f7473b71 Mon Sep 17 00:00:00 2001 From: Clint Lawrence Date: Fri, 15 Mar 2024 13:04:21 +1100 Subject: [PATCH 04/15] Fix tests for python 3.8 and 3.9 --- src/trio/_tests/test_repl.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/trio/_tests/test_repl.py b/src/trio/_tests/test_repl.py index 0f5b742214..b4b35cf229 100644 --- a/src/trio/_tests/test_repl.py +++ b/src/trio/_tests/test_repl.py @@ -40,6 +40,13 @@ def test_build_raw_input() -> None: raw_input() +# In 3.10 or later, types.FunctionType (used internally) will automatically +# attach __builtins__ to the function objects. However we need to explicitly +# include it for 3.8 & 3.9 +def build_locals() -> dict[str, object]: + return {"__builtins__": __builtins__} + + async def test_basic_interaction( capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, @@ -48,7 +55,7 @@ async def test_basic_interaction( Run some basic commands through the interpreter while capturing stdout. Ensure that the interpreted prints the expected results. """ - console = trio._repl.TrioInteractiveConsole(repl_locals={"trio": trio}) + console = trio._repl.TrioInteractiveConsole(repl_locals=build_locals()) raw_input = build_raw_input( [ # evaluate simple expression and recall the value @@ -74,12 +81,11 @@ async def test_basic_interaction( monkeypatch.setattr(console, "raw_input", raw_input) await trio._repl.run_repl(console) out, err = capsys.readouterr() - print(err) assert out.splitlines() == ["x=1", "'hello'", "2", "4", "hello stdout", "13"] async def test_system_exits_quit_interpreter(monkeypatch: pytest.MonkeyPatch) -> None: - console = trio._repl.TrioInteractiveConsole(repl_locals={"trio": trio}) + console = trio._repl.TrioInteractiveConsole(repl_locals=build_locals()) raw_input = build_raw_input( [ # evaluate simple expression and recall the value @@ -95,7 +101,7 @@ async def test_base_exception_captured( capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: - console = trio._repl.TrioInteractiveConsole(repl_locals={"trio": trio}) + console = trio._repl.TrioInteractiveConsole(repl_locals=build_locals()) raw_input = build_raw_input( [ # The statement after raise should still get executed @@ -113,7 +119,7 @@ async def test_base_exception_capture_from_coroutine( capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: - console = trio._repl.TrioInteractiveConsole(repl_locals={"trio": trio}) + console = trio._repl.TrioInteractiveConsole(repl_locals=build_locals()) raw_input = build_raw_input( [ "async def async_func_raises_base_exception():", From 9d2c724e8b36039a236bbb4a2e4a727cc0daf4b4 Mon Sep 17 00:00:00 2001 From: Clint Lawrence Date: Fri, 15 Mar 2024 13:13:00 +1100 Subject: [PATCH 05/15] Remove the case on the locals dict --- src/trio/_repl.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/trio/_repl.py b/src/trio/_repl.py index b6cd84feef..3474d9e545 100644 --- a/src/trio/_repl.py +++ b/src/trio/_repl.py @@ -5,29 +5,26 @@ import inspect import sys import types -import typing import warnings from code import InteractiveConsole -from typing import Dict import trio import trio.lowlevel class TrioInteractiveConsole(InteractiveConsole): + # code.InteractiveInterpreter defines locals as Mapping[str, Any] + # but when we pass this to FunctionType it expects a dict. So + # we make the type more specific on our subclass + locals: dict[str, object] + def __init__(self, repl_locals: dict[str, object] | None = None): super().__init__(locals=repl_locals) self.compile.compiler.flags |= ast.PyCF_ALLOW_TOP_LEVEL_AWAIT def runcode(self, code: types.CodeType) -> None: async def _runcode_in_trio() -> BaseException | None: - # code.InteractiveInterpreter defines locals as Mapping[str, Any] - # However FunctionType expects a dict. We know our copy of - # locals will be a dict due to the annotation on repl_locals in __init__ - # so the cast is safe. - # In addition, we need to use typing.Dict here, because this is _not_ an - # annotation, so from __future__ import annotations doesn't help. - func = types.FunctionType(code, typing.cast(Dict[str, object], self.locals)) + func = types.FunctionType(code, self.locals) try: coro = func() except BaseException as e: From f806db6d99cbb8c9e1173895dab02a520b9b93c8 Mon Sep 17 00:00:00 2001 From: Clint Lawrence Date: Sat, 16 Mar 2024 14:38:45 +1100 Subject: [PATCH 06/15] SystemExit should always exist the repl. Even when it is in an exception group --- src/trio/_repl.py | 31 +++++++++++++++++++++++-------- src/trio/_tests/test_repl.py | 13 ++++++++++++- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/trio/_repl.py b/src/trio/_repl.py index 3474d9e545..36b1791643 100644 --- a/src/trio/_repl.py +++ b/src/trio/_repl.py @@ -11,6 +11,9 @@ import trio import trio.lowlevel +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + class TrioInteractiveConsole(InteractiveConsole): # code.InteractiveInterpreter defines locals as Mapping[str, Any] @@ -37,15 +40,27 @@ async def _runcode_in_trio() -> BaseException | None: return e return None - e = trio.from_thread.run(_runcode_in_trio) + maybe_exc_or_excgroup = trio.from_thread.run(_runcode_in_trio) - if e is not None: - try: - raise e - except SystemExit: - raise - except BaseException: # Only SystemExit should quit the repl - self.showtraceback() + if maybe_exc_or_excgroup is not None: + # maybe_exc_or_excgroup is an exception, or an exception group. + # If it is SystemExit or if the exception group contains + # a SystemExit, quit the repl. Otherwise, print the traceback. + if isinstance(maybe_exc_or_excgroup, SystemExit): + raise SystemExit() + elif isinstance(maybe_exc_or_excgroup, BaseExceptionGroup): + for exc in maybe_exc_or_excgroup.exceptions: + if isinstance(exc, SystemExit): + raise SystemExit + try: + raise maybe_exc_or_excgroup + except BaseException: + self.showtraceback() + else: + try: + raise maybe_exc_or_excgroup + except BaseException: + self.showtraceback() async def run_repl(console: TrioInteractiveConsole) -> None: diff --git a/src/trio/_tests/test_repl.py b/src/trio/_tests/test_repl.py index b4b35cf229..9c359d6665 100644 --- a/src/trio/_tests/test_repl.py +++ b/src/trio/_tests/test_repl.py @@ -88,7 +88,6 @@ async def test_system_exits_quit_interpreter(monkeypatch: pytest.MonkeyPatch) -> console = trio._repl.TrioInteractiveConsole(repl_locals=build_locals()) raw_input = build_raw_input( [ - # evaluate simple expression and recall the value "raise SystemExit", ] ) @@ -97,6 +96,18 @@ async def test_system_exits_quit_interpreter(monkeypatch: pytest.MonkeyPatch) -> await trio._repl.run_repl(console) +async def test_system_exits_in_exc_group(monkeypatch: pytest.MonkeyPatch) -> None: + console = trio._repl.TrioInteractiveConsole(repl_locals=build_locals()) + raw_input = build_raw_input( + [ + "raise BaseExceptionGroup('', [RuntimeError(), SystemExit()])", + ] + ) + monkeypatch.setattr(console, "raw_input", raw_input) + with pytest.raises(SystemExit): + await trio._repl.run_repl(console) + + async def test_base_exception_captured( capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, From 36752ead9a9535f1ac95c018b85fdc6b6f291d11 Mon Sep 17 00:00:00 2001 From: Clint Lawrence Date: Sat, 16 Mar 2024 14:49:33 +1100 Subject: [PATCH 07/15] Add new fragment and documentation. --- docs/source/reference-core.rst | 42 ++++++++++++++++++++++++++++++++++ newsfragments/2972.feature.rst | 15 ++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 newsfragments/2972.feature.rst diff --git a/docs/source/reference-core.rst b/docs/source/reference-core.rst index 24d7cee380..06e9327939 100644 --- a/docs/source/reference-core.rst +++ b/docs/source/reference-core.rst @@ -1903,6 +1903,48 @@ explicit and might be easier to reason about. ``contextvars``. + + .. _interactive debugging: + + Interactive debugging + --------------------- + + When you start an interactive Python session to debug any async program + (whether it's based on ``asyncio``, Trio, or something else), every await + expression needs to be inside an async function: + + .. code-block:: console + $ python + Python 3.10.6 + Type "help", "copyright", "credits" or "license" for more information. + >>> import trio + >>> await trio.sleep(1) + File "", line 1 + SyntaxError: 'await' outside function + >>> async def main(): + ... print("hello...") + ... await trio.sleep(1) + ... print("world!") + ... + >>> trio.run(main) + hello... + world! + This can make it difficult to iterate quickly since you have to redefine the + whole function body whenever you make a tweak. + + Trio provides a modified interactive console that lets you ``await`` at the top + level. You can access this console by running ``python -m trio``: + + .. code-block:: console + $ python -m trio + Trio 0.21.0+dev, Python 3.10.6 + Use "await" directly instead of "trio.run()". + Type "help", "copyright", "credits" or "license" for more information. + >>> import trio + >>> print("hello..."); await trio.sleep(1); print("world!") + hello... + world! + Exceptions and warnings ----------------------- diff --git a/newsfragments/2972.feature.rst b/newsfragments/2972.feature.rst new file mode 100644 index 0000000000..b43c5bc6b7 --- /dev/null +++ b/newsfragments/2972.feature.rst @@ -0,0 +1,15 @@ +Added an interactive interpreter ``python -m trio``. + +This makes it easier to try things and experiment with trio in the a python repl. Use the +``await`` keyword without needing to call ``trio.run()`` + + .. code-block:: console + $ python -m trio + Trio 0.21.0+dev, Python 3.10.6 + Use "await" directly instead of "trio.run()". + Type "help", "copyright", "credits" or "license" for more information. + >>> import trio + >>> await trio.sleep(1); print("hi") # prints after one second + hi + + See :ref:`interactive debugging` for further detail. From 339cff4a75e91fbd386114970adc1d9738dbcb45 Mon Sep 17 00:00:00 2001 From: Clint Lawrence Date: Sat, 16 Mar 2024 15:06:54 +1100 Subject: [PATCH 08/15] fix test for python < 3.11 --- src/trio/_tests/test_repl.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/trio/_tests/test_repl.py b/src/trio/_tests/test_repl.py index 9c359d6665..f0144e7fe0 100644 --- a/src/trio/_tests/test_repl.py +++ b/src/trio/_tests/test_repl.py @@ -100,6 +100,10 @@ async def test_system_exits_in_exc_group(monkeypatch: pytest.MonkeyPatch) -> Non console = trio._repl.TrioInteractiveConsole(repl_locals=build_locals()) raw_input = build_raw_input( [ + "import sys", + "if sys.version_info < (3, 11):", + " from exceptiongroup import BaseExceptionGroup", + "", "raise BaseExceptionGroup('', [RuntimeError(), SystemExit()])", ] ) From 7c2b09188d656a89dd632353ce0a23af4ec070f3 Mon Sep 17 00:00:00 2001 From: Clint Lawrence Date: Sun, 17 Mar 2024 09:25:50 +1100 Subject: [PATCH 09/15] Handle nested ExceptionGroups correctly --- src/trio/_repl.py | 40 +++++++++++++++++++---------- src/trio/_tests/test_repl.py | 49 ++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/src/trio/_repl.py b/src/trio/_repl.py index 36b1791643..e994a32d3b 100644 --- a/src/trio/_repl.py +++ b/src/trio/_repl.py @@ -7,6 +7,7 @@ import types import warnings from code import InteractiveConsole +from typing import Generator import trio import trio.lowlevel @@ -15,6 +16,16 @@ from exceptiongroup import BaseExceptionGroup +def _flatten_exception_group( + excgroup: BaseExceptionGroup[BaseException], +) -> Generator[BaseException, None, None]: + for exc in excgroup.exceptions: + if isinstance(exc, BaseExceptionGroup): + yield from _flatten_exception_group(exc) + else: + yield exc + + class TrioInteractiveConsole(InteractiveConsole): # code.InteractiveInterpreter defines locals as Mapping[str, Any] # but when we pass this to FunctionType it expects a dict. So @@ -47,20 +58,23 @@ async def _runcode_in_trio() -> BaseException | None: # If it is SystemExit or if the exception group contains # a SystemExit, quit the repl. Otherwise, print the traceback. if isinstance(maybe_exc_or_excgroup, SystemExit): - raise SystemExit() + raise maybe_exc_or_excgroup elif isinstance(maybe_exc_or_excgroup, BaseExceptionGroup): - for exc in maybe_exc_or_excgroup.exceptions: - if isinstance(exc, SystemExit): - raise SystemExit - try: - raise maybe_exc_or_excgroup - except BaseException: - self.showtraceback() - else: - try: - raise maybe_exc_or_excgroup - except BaseException: - self.showtraceback() + sys_exit_exc = maybe_exc_or_excgroup.subgroup(SystemExit) + if sys_exit_exc: + # There is a SystemExit exception, but it might be nested + # If there are more than one SystemExit exception in + # the group, this will only find and re-raise the first. + raise next(_flatten_exception_group(sys_exit_exc)) + + # If we didn't raise in either of the conditions above, + # there was an exception, but no SystemExit. So we raise + # here and except, so that the console can print the traceback + # to the user. + try: + raise maybe_exc_or_excgroup + except BaseException: + self.showtraceback() async def run_repl(console: TrioInteractiveConsole) -> None: diff --git a/src/trio/_tests/test_repl.py b/src/trio/_tests/test_repl.py index f0144e7fe0..bef0e29b28 100644 --- a/src/trio/_tests/test_repl.py +++ b/src/trio/_tests/test_repl.py @@ -8,6 +8,9 @@ import trio._repl +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + class RawInput(Protocol): def __call__(self, prompt: str = "") -> str: ... @@ -112,6 +115,25 @@ async def test_system_exits_in_exc_group(monkeypatch: pytest.MonkeyPatch) -> Non await trio._repl.run_repl(console) +async def test_system_exits_in_nested_exc_group( + monkeypatch: pytest.MonkeyPatch, +) -> None: + console = trio._repl.TrioInteractiveConsole(repl_locals=build_locals()) + raw_input = build_raw_input( + [ + "import sys", + "if sys.version_info < (3, 11):", + " from exceptiongroup import BaseExceptionGroup", + "", + "raise BaseExceptionGroup(", + " '', [BaseExceptionGroup('', [RuntimeError(), SystemExit()])])", + ] + ) + monkeypatch.setattr(console, "raw_input", raw_input) + with pytest.raises(SystemExit): + await trio._repl.run_repl(console) + + async def test_base_exception_captured( capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, @@ -130,6 +152,24 @@ async def test_base_exception_captured( assert "AFTER BaseException" in out +async def test_exc_group_captured( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + console = trio._repl.TrioInteractiveConsole(repl_locals=build_locals()) + raw_input = build_raw_input( + [ + # The statement after raise should still get executed + "raise ExceptionGroup('', [KeyError()])", + "print('AFTER ExceptionGroup')", + ] + ) + monkeypatch.setattr(console, "raw_input", raw_input) + await trio._repl.run_repl(console) + out, err = capsys.readouterr() + assert "AFTER ExceptionGroup" in out + + async def test_base_exception_capture_from_coroutine( capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, @@ -158,3 +198,12 @@ def test_main_entrypoint() -> None: """ repl = subprocess.run([sys.executable, "-m", "trio"], input=b"exit()") assert repl.returncode == 0 + + +def test_flatten_exception_group() -> None: + ex1 = RuntimeError() + ex2 = IndexError() + ex3 = OSError() + + eg = ExceptionGroup("", [ExceptionGroup("", [ex2, ex3]), ex1]) + assert set(trio._repl._flatten_exception_group(eg)) == {ex1, ex2, ex3} From 9d91484b522ff3096d9a737b6263667eb5906a52 Mon Sep 17 00:00:00 2001 From: Clint Lawrence Date: Mon, 18 Mar 2024 08:19:59 +1100 Subject: [PATCH 10/15] Fix the failing docs build --- docs/source/reference-core.rst | 98 ++++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 40 deletions(-) diff --git a/docs/source/reference-core.rst b/docs/source/reference-core.rst index 06e9327939..fc3ff97371 100644 --- a/docs/source/reference-core.rst +++ b/docs/source/reference-core.rst @@ -1903,47 +1903,65 @@ explicit and might be easier to reason about. ``contextvars``. +.. _interactive debugging: - .. _interactive debugging: - - Interactive debugging - --------------------- - - When you start an interactive Python session to debug any async program - (whether it's based on ``asyncio``, Trio, or something else), every await - expression needs to be inside an async function: - - .. code-block:: console - $ python - Python 3.10.6 - Type "help", "copyright", "credits" or "license" for more information. - >>> import trio - >>> await trio.sleep(1) - File "", line 1 - SyntaxError: 'await' outside function - >>> async def main(): - ... print("hello...") - ... await trio.sleep(1) - ... print("world!") - ... - >>> trio.run(main) - hello... - world! - This can make it difficult to iterate quickly since you have to redefine the - whole function body whenever you make a tweak. - - Trio provides a modified interactive console that lets you ``await`` at the top - level. You can access this console by running ``python -m trio``: - - .. code-block:: console - $ python -m trio - Trio 0.21.0+dev, Python 3.10.6 - Use "await" directly instead of "trio.run()". - Type "help", "copyright", "credits" or "license" for more information. - >>> import trio - >>> print("hello..."); await trio.sleep(1); print("world!") - hello... - world! + +Interactive debugging +--------------------- + +When you start an interactive Python session to debug any async program +(whether it's based on ``asyncio``, Trio, or something else), every await +expression needs to be inside an async function: + +.. code-block:: console + + $ python + Python 3.10.6 + Type "help", "copyright", "credits" or "license" for more information. + >>> import trio + >>> await trio.sleep(1) + File "", line 1 + SyntaxError: 'await' outside function + >>> async def main(): + ... print("hello...") + ... await trio.sleep(1) + ... print("world!") + ... + >>> trio.run(main) + hello... + world! + +This can make it difficult to iterate quickly since you have to redefine the +whole function body whenever you make a tweak. + +Trio provides a modified interactive console that lets you ``await`` at the top +level. You can access this console by running ``python -m trio``: + +.. code-block:: console + + $ python -m trio + Trio 0.21.0+dev, Python 3.10.6 + Use "await" directly instead of "trio.run()". + Type "help", "copyright", "credits" or "license" for more information. + >>> import trio + >>> print("hello..."); await trio.sleep(1); print("world!") + hello... + world! + +If you are an IPython user, you can use IPython's `autoawait +`__ +function. This can be enabled within the IPython shell by running the magic command +``%autoawait trio``. To have ``autoawait`` enabled whenever Trio installed, you can +add the following to your IPython startup files. +(e.g. ``~/.ipython/profile_default/startup/10-async.py``) + +.. code-block:: + + try: + import trio + get_ipython().run_line_magic("autoawait", "trio") + except ImportError: + pass Exceptions and warnings ----------------------- From 2551d049061126b1998c9b93d60f2b481864bf31 Mon Sep 17 00:00:00 2001 From: Clint Lawrence Date: Mon, 18 Mar 2024 08:39:29 +1100 Subject: [PATCH 11/15] Fix the news fragement --- newsfragments/2972.feature.rst | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/newsfragments/2972.feature.rst b/newsfragments/2972.feature.rst index b43c5bc6b7..863174ce4a 100644 --- a/newsfragments/2972.feature.rst +++ b/newsfragments/2972.feature.rst @@ -1,15 +1,16 @@ Added an interactive interpreter ``python -m trio``. -This makes it easier to try things and experiment with trio in the a python repl. Use the -``await`` keyword without needing to call ``trio.run()`` - - .. code-block:: console - $ python -m trio - Trio 0.21.0+dev, Python 3.10.6 - Use "await" directly instead of "trio.run()". - Type "help", "copyright", "credits" or "license" for more information. - >>> import trio - >>> await trio.sleep(1); print("hi") # prints after one second - hi - - See :ref:`interactive debugging` for further detail. +This makes it easier to try things and experiment with trio in the a python repl. +Use the ``await`` keyword without needing to call ``trio.run()`` + +.. code-block:: console + + $ python -m trio + Trio 0.21.0+dev, Python 3.10.6 + Use "await" directly instead of "trio.run()". + Type "help", "copyright", "credits" or "license" for more information. + >>> import trio + >>> await trio.sleep(1); print("hi") # prints after one second + hi + +See :ref:`interactive debugging` for further detail. From 78d71bbfe6d5eb17d0a5aa990922ed38fe279ded Mon Sep 17 00:00:00 2001 From: clint-lawrence Date: Sat, 6 Apr 2024 10:32:47 +1100 Subject: [PATCH 12/15] Capital P for Python Co-authored-by: EXPLOSION --- newsfragments/2972.feature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/newsfragments/2972.feature.rst b/newsfragments/2972.feature.rst index 863174ce4a..64557ffdf6 100644 --- a/newsfragments/2972.feature.rst +++ b/newsfragments/2972.feature.rst @@ -1,6 +1,6 @@ Added an interactive interpreter ``python -m trio``. -This makes it easier to try things and experiment with trio in the a python repl. +This makes it easier to try things and experiment with trio in the a Python repl. Use the ``await`` keyword without needing to call ``trio.run()`` .. code-block:: console From e7078d7e579395fcf4260644624e68085c186c32 Mon Sep 17 00:00:00 2001 From: Clint Lawrence Date: Sat, 6 Apr 2024 19:27:33 +1100 Subject: [PATCH 13/15] Ignore SystemExit inside an exception group --- src/trio/_repl.py | 38 ++++++++++-------------------------- src/trio/_tests/test_repl.py | 34 ++++++++++++++++---------------- 2 files changed, 27 insertions(+), 45 deletions(-) diff --git a/src/trio/_repl.py b/src/trio/_repl.py index e994a32d3b..09d0748800 100644 --- a/src/trio/_repl.py +++ b/src/trio/_repl.py @@ -7,24 +7,10 @@ import types import warnings from code import InteractiveConsole -from typing import Generator import trio import trio.lowlevel -if sys.version_info < (3, 11): - from exceptiongroup import BaseExceptionGroup - - -def _flatten_exception_group( - excgroup: BaseExceptionGroup[BaseException], -) -> Generator[BaseException, None, None]: - for exc in excgroup.exceptions: - if isinstance(exc, BaseExceptionGroup): - yield from _flatten_exception_group(exc) - else: - yield exc - class TrioInteractiveConsole(InteractiveConsole): # code.InteractiveInterpreter defines locals as Mapping[str, Any] @@ -55,22 +41,18 @@ async def _runcode_in_trio() -> BaseException | None: if maybe_exc_or_excgroup is not None: # maybe_exc_or_excgroup is an exception, or an exception group. - # If it is SystemExit or if the exception group contains - # a SystemExit, quit the repl. Otherwise, print the traceback. + # If it is SystemExit quit the repl. Otherwise, print the + # traceback. + # There could be a SystemExit inside a BaseExceptionGroup. If + # that happens, it probably isn't the user trying to quit the + # repl, but an error in the code. So we print the exception + # and stay in the repl. if isinstance(maybe_exc_or_excgroup, SystemExit): raise maybe_exc_or_excgroup - elif isinstance(maybe_exc_or_excgroup, BaseExceptionGroup): - sys_exit_exc = maybe_exc_or_excgroup.subgroup(SystemExit) - if sys_exit_exc: - # There is a SystemExit exception, but it might be nested - # If there are more than one SystemExit exception in - # the group, this will only find and re-raise the first. - raise next(_flatten_exception_group(sys_exit_exc)) - - # If we didn't raise in either of the conditions above, - # there was an exception, but no SystemExit. So we raise - # here and except, so that the console can print the traceback - # to the user. + + # If we didn't raise above, there was an exception, but no + # SystemExit. So we raise here and except, so that the console + # can print the traceback to the user. try: raise maybe_exc_or_excgroup except BaseException: diff --git a/src/trio/_tests/test_repl.py b/src/trio/_tests/test_repl.py index bef0e29b28..9c37205ff5 100644 --- a/src/trio/_tests/test_repl.py +++ b/src/trio/_tests/test_repl.py @@ -8,9 +8,6 @@ import trio._repl -if sys.version_info < (3, 11): - from exceptiongroup import ExceptionGroup - class RawInput(Protocol): def __call__(self, prompt: str = "") -> str: ... @@ -99,7 +96,10 @@ async def test_system_exits_quit_interpreter(monkeypatch: pytest.MonkeyPatch) -> await trio._repl.run_repl(console) -async def test_system_exits_in_exc_group(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_system_exits_in_exc_group( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: console = trio._repl.TrioInteractiveConsole(repl_locals=build_locals()) raw_input = build_raw_input( [ @@ -108,14 +108,19 @@ async def test_system_exits_in_exc_group(monkeypatch: pytest.MonkeyPatch) -> Non " from exceptiongroup import BaseExceptionGroup", "", "raise BaseExceptionGroup('', [RuntimeError(), SystemExit()])", + "print('AFTER BaseExceptionGroup')", ] ) monkeypatch.setattr(console, "raw_input", raw_input) - with pytest.raises(SystemExit): - await trio._repl.run_repl(console) + await trio._repl.run_repl(console) + out, err = capsys.readouterr() + # assert that raise SystemExit in an exception group + # doesn't quit + assert "AFTER BaseExceptionGroup" in out async def test_system_exits_in_nested_exc_group( + capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: console = trio._repl.TrioInteractiveConsole(repl_locals=build_locals()) @@ -127,11 +132,15 @@ async def test_system_exits_in_nested_exc_group( "", "raise BaseExceptionGroup(", " '', [BaseExceptionGroup('', [RuntimeError(), SystemExit()])])", + "print('AFTER BaseExceptionGroup')", ] ) monkeypatch.setattr(console, "raw_input", raw_input) - with pytest.raises(SystemExit): - await trio._repl.run_repl(console) + await trio._repl.run_repl(console) + out, err = capsys.readouterr() + # assert that raise SystemExit in an exception group + # doesn't quit + assert "AFTER BaseExceptionGroup" in out async def test_base_exception_captured( @@ -198,12 +207,3 @@ def test_main_entrypoint() -> None: """ repl = subprocess.run([sys.executable, "-m", "trio"], input=b"exit()") assert repl.returncode == 0 - - -def test_flatten_exception_group() -> None: - ex1 = RuntimeError() - ex2 = IndexError() - ex3 = OSError() - - eg = ExceptionGroup("", [ExceptionGroup("", [ex2, ex3]), ex1]) - assert set(trio._repl._flatten_exception_group(eg)) == {ex1, ex2, ex3} From e83b96e5b3c1aa7629e91477b682220f1e15d0f4 Mon Sep 17 00:00:00 2001 From: Clint Lawrence Date: Sat, 6 Apr 2024 20:27:50 +1100 Subject: [PATCH 14/15] trigger CI. pypy test might be flaky? From d6f4bd1a4c7a04e2a7b4cdd766ef55ab31fed3bb Mon Sep 17 00:00:00 2001 From: Clint Lawrence Date: Sun, 7 Apr 2024 20:40:22 +1000 Subject: [PATCH 15/15] Simplify runcode() using outcome.Outcome --- src/trio/_repl.py | 41 ++++++++++++++--------------------------- 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/src/trio/_repl.py b/src/trio/_repl.py index 09d0748800..6095f2e9b3 100644 --- a/src/trio/_repl.py +++ b/src/trio/_repl.py @@ -8,6 +8,8 @@ import warnings from code import InteractiveConsole +import outcome + import trio import trio.lowlevel @@ -23,40 +25,25 @@ def __init__(self, repl_locals: dict[str, object] | None = None): self.compile.compiler.flags |= ast.PyCF_ALLOW_TOP_LEVEL_AWAIT def runcode(self, code: types.CodeType) -> None: - async def _runcode_in_trio() -> BaseException | None: + async def _runcode_in_trio() -> outcome.Outcome[object]: func = types.FunctionType(code, self.locals) - try: - coro = func() - except BaseException as e: - return e - - if inspect.iscoroutine(coro): - try: - await coro - except BaseException as e: - return e - return None - - maybe_exc_or_excgroup = trio.from_thread.run(_runcode_in_trio) - - if maybe_exc_or_excgroup is not None: - # maybe_exc_or_excgroup is an exception, or an exception group. + if inspect.iscoroutinefunction(func): + return await outcome.acapture(func) + else: + return outcome.capture(func) + + try: + trio.from_thread.run(_runcode_in_trio).unwrap() + except SystemExit: # If it is SystemExit quit the repl. Otherwise, print the # traceback. # There could be a SystemExit inside a BaseExceptionGroup. If # that happens, it probably isn't the user trying to quit the # repl, but an error in the code. So we print the exception # and stay in the repl. - if isinstance(maybe_exc_or_excgroup, SystemExit): - raise maybe_exc_or_excgroup - - # If we didn't raise above, there was an exception, but no - # SystemExit. So we raise here and except, so that the console - # can print the traceback to the user. - try: - raise maybe_exc_or_excgroup - except BaseException: - self.showtraceback() + raise + except BaseException: + self.showtraceback() async def run_repl(console: TrioInteractiveConsole) -> None: