Skip to content
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

Add a trio repl #2972

Merged
merged 17 commits into from
May 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
60 changes: 60 additions & 0 deletions docs/source/reference-core.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1903,6 +1903,66 @@ 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 "<stdin>", 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
<https://ipython.readthedocs.io/en/stable/interactive/magics.html#magic-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
-----------------------

Expand Down
16 changes: 16 additions & 0 deletions newsfragments/2972.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +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.
3 changes: 3 additions & 0 deletions src/trio/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from trio._repl import main

main(locals())
A5rocks marked this conversation as resolved.
Show resolved Hide resolved
83 changes: 83 additions & 0 deletions src/trio/_repl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from __future__ import annotations

import ast
import contextlib
import inspect
import sys
import types
import warnings
from code import InteractiveConsole

import outcome

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() -> outcome.Outcome[object]:
func = types.FunctionType(code, self.locals)
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.
raise
except BaseException:
self.showtraceback()


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'
f'Type "help", "copyright", "credits" or "license" '
f"for more information.\n"
f'{getattr(sys, "ps1", ">>> ")}import trio'
)
try:
await trio.to_thread.run_sync(console.interact, banner)
finally:
warnings.filterwarnings(
"ignore",
message=r"^coroutine .* was never awaited$",
category=RuntimeWarning,
)


def main(original_locals: dict[str, object]) -> None:
with contextlib.suppress(ImportError):
import readline # noqa: F401
CoolCat467 marked this conversation as resolved.
Show resolved Hide resolved

repl_locals: dict[str, object] = {"trio": trio}
for key in {
"__name__",
"__package__",
"__loader__",
"__spec__",
"__builtins__",
"__file__",
}:
repl_locals[key] = original_locals[key]
A5rocks marked this conversation as resolved.
Show resolved Hide resolved

console = TrioInteractiveConsole(repl_locals)
trio.run(run_repl, console)
209 changes: 209 additions & 0 deletions src/trio/_tests/test_repl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
from __future__ import annotations

import subprocess
import sys
from typing import Protocol

import pytest

import trio._repl


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
When there are not more strings to return, raise EOFError
"""
cmds_iter = iter(cmds)
prompts = []

def _raw_helper(prompt: str = "") -> str:
prompts.append(prompt)
try:
return next(cmds_iter)
except StopIteration:
raise EOFError from None

return _raw_helper


def test_build_raw_input() -> None:
"""Quick test of our helper function."""
raw_input = build_raw_input(["cmd1"])
assert raw_input() == "cmd1"
with pytest.raises(EOFError):
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,
) -> 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=build_locals())
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 + 1)",
"",
"func()",
# define and call async function
"async def afunc():",
" return 4",
"",
"await afunc()",
# import works
"import sys",
"sys.stdout.write('hello stdout\\n')",
]
)
monkeypatch.setattr(console, "raw_input", raw_input)
await trio._repl.run_repl(console)
out, err = capsys.readouterr()
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=build_locals())
raw_input = build_raw_input(
[
"raise SystemExit",
]
)
monkeypatch.setattr(console, "raw_input", raw_input)
with pytest.raises(SystemExit):
await trio._repl.run_repl(console)


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(
[
"import sys",
"if sys.version_info < (3, 11):",
" from exceptiongroup import BaseExceptionGroup",
"",
"raise BaseExceptionGroup('', [RuntimeError(), SystemExit()])",
"print('AFTER BaseExceptionGroup')",
]
)
monkeypatch.setattr(console, "raw_input", raw_input)
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())
raw_input = build_raw_input(
[
"import sys",
"if sys.version_info < (3, 11):",
" from exceptiongroup import BaseExceptionGroup",
"",
"raise BaseExceptionGroup(",
" '', [BaseExceptionGroup('', [RuntimeError(), SystemExit()])])",
"print('AFTER BaseExceptionGroup')",
]
)
monkeypatch.setattr(console, "raw_input", raw_input)
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(
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 BaseException",
"print('AFTER BaseException')",
]
)
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_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,
) -> None:
console = trio._repl.TrioInteractiveConsole(repl_locals=build_locals())
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')",
]
)
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() -> None:
"""
Basic smoke test when running via the package __main__ entrypoint.
"""
repl = subprocess.run([sys.executable, "-m", "trio"], input=b"exit()")
assert repl.returncode == 0
Loading