Skip to content
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
95 changes: 95 additions & 0 deletions micropython/aiorepl/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# aiorepl

This library provides "asyncio REPL", a simple REPL that can be used even
while your program is running, allowing you to inspect program state, create
tasks, and await asynchronous functions.

This is inspired by Python's `asyncio` module when run via `python -m asyncio`.

## Background

The MicroPython REPL is unavailable while your program is running. This
library runs a background REPL using the asyncio scheduler.

Furthermore, it is not possible to `await` at the main REPL because it does
not know about the asyncio scheduler.

## Usage

To use this library, you need to import the library and then start the REPL task.

For example, in main.py:

```py
import uasyncio as asyncio
import aiorepl

async def demo():
await asyncio.sleep_ms(1000)
print("async demo")

state = 20

async def task1():
while state:
#print("task 1")
await asyncio.sleep_ms(500)
print("done")

async def main():
print("Starting tasks...")

# Start other program tasks.
t1 = asyncio.create_task(task1())

# Start the aiorepl task.
repl = asyncio.create_task(aiorepl.task())

await asyncio.gather(t1, repl)

asyncio.run(main())
```

The optional globals passed to `task([globals])` allows you to specify what
will be in scope for the REPL. By default it uses `__main__`, which is the
same scope as the regular REPL (and `main.py`). In the example above, the
REPL will be able to call the `demo()` function as well as get/set the
`state` variable.

Instead of the regular `>>> ` prompt, the asyncio REPL will show `--> `.

```
--> 1+1
2
--> await demo()
async demo
--> state
20
--> import myapp.core
--> state = await myapp.core.query_state()
--> 1/0
ZeroDivisionError: divide by zero
--> def foo(x): return x + 1
--> await asyncio.sleep(foo(3))
-->
```

History is supported via the up/down arrow keys.

## Cancellation

During command editing (the "R" phase), pressing Ctrl-C will cancel the current command and display a new prompt, like the regular REPL.

While a command is being executed, Ctrl-C will cancel the task that is executing the command. This will have no effect on blocking code (e.g. `time.sleep()`), but this should be rare in an asyncio-based program.

Ctrl-D at the asyncio REPL command prompt will terminate the current event loop, which will stop the running program and return to the regular REPL.

## Limitations

The following features are unsupported:

* Tab completion is not supported (also unsupported in `python -m asyncio`).
* Multi-line continuation. However you can do single-line definitions of functions, see demo above.
* Exception tracebacks. Only the exception type and message is shown, see demo above.
* Emacs shortcuts (e.g. Ctrl-A, Ctrl-E, to move to start/end of line).
* Unicode handling for input.
178 changes: 178 additions & 0 deletions micropython/aiorepl/aiorepl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# MIT license; Copyright (c) 2022 Jim Mussared

import micropython
import re
import sys
import time
import uasyncio as asyncio

# Import statement (needs to be global, and does not return).
_RE_IMPORT = re.compile("^import ([^ ]+)( as ([^ ]+))?")
_RE_FROM_IMPORT = re.compile("^from [^ ]+ import ([^ ]+)( as ([^ ]+))?")
# Global variable assignment.
_RE_GLOBAL = re.compile("^([a-zA-Z0-9_]+) ?=[^=]")
# General assignment expression or import statement (does not return a value).
_RE_ASSIGN = re.compile("[^=]=[^=]")

# Command hist (One reserved slot for the current command).
_HISTORY_LIMIT = const(5 + 1)


async def execute(code, g, s):
if not code.strip():
return

try:
if "await " in code:
# Execute the code snippet in an async context.
if m := _RE_IMPORT.match(code) or _RE_FROM_IMPORT.match(code):
code = f"global {m.group(3) or m.group(1)}\n {code}"
elif m := _RE_GLOBAL.match(code):
code = f"global {m.group(1)}\n {code}"
elif not _RE_ASSIGN.search(code):
code = f"return {code}"

code = f"""
import uasyncio as asyncio
async def __code():
{code}

__exec_task = asyncio.create_task(__code())
"""

async def kbd_intr_task(exec_task, s):
while True:
if ord(await s.read(1)) == 0x03:
exec_task.cancel()
return

l = {"__exec_task": None}
exec(code, g, l)
exec_task = l["__exec_task"]

# Concurrently wait for either Ctrl-C from the stream or task
# completion.
intr_task = asyncio.create_task(kbd_intr_task(exec_task, s))

try:
try:
return await exec_task
except asyncio.CancelledError:
pass
finally:
intr_task.cancel()
try:
await intr_task
except asyncio.CancelledError:
pass
else:
# Excute code snippet directly.
try:
try:
micropython.kbd_intr(3)
try:
return eval(code, g)
except SyntaxError:
# Maybe an assignment, try with exec.
return exec(code, g)
except KeyboardInterrupt:
pass
finally:
micropython.kbd_intr(-1)

except Exception as err:
print(f"{type(err).__name__}: {err}")


# REPL task. Invoke this with an optional mutable globals dict.
async def task(g=None, prompt="--> "):
print("Starting asyncio REPL...")
if g is None:
g = __import__("__main__").__dict__
try:
micropython.kbd_intr(-1)
s = asyncio.StreamReader(sys.stdin)
# clear = True
hist = [None] * _HISTORY_LIMIT
hist_i = 0 # Index of most recent entry.
hist_n = 0 # Number of history entries.
c = 0 # ord of most recent character.
t = 0 # timestamp of most recent character.
while True:
hist_b = 0 # How far back in the history are we currently.
sys.stdout.write(prompt)
cmd = ""
while True:
b = await s.read(1)
c = ord(b)
pc = c # save previous character
pt = t # save previous time
t = time.ticks_ms()
if c < 0x20 or c > 0x7E:
if c == 0x0A:
# CR
sys.stdout.write("\n")
if cmd:
# Push current command.
hist[hist_i] = cmd
# Increase history length if possible, and rotate ring forward.
hist_n = min(_HISTORY_LIMIT - 1, hist_n + 1)
hist_i = (hist_i + 1) % _HISTORY_LIMIT

result = await execute(cmd, g, s)
if result is not None:
sys.stdout.write(repr(result))
sys.stdout.write("\n")
break
elif c == 0x08 or c == 0x7F:
# Backspace.
if cmd:
cmd = cmd[:-1]
sys.stdout.write("\x08 \x08")
elif c == 0x02:
# Ctrl-B
continue
elif c == 0x03:
# Ctrl-C
if pc == 0x03 and time.ticks_diff(t, pt) < 20:
# Two very quick Ctrl-C (faster than a human
# typing) likely means mpremote trying to
# escape.
asyncio.new_event_loop()
return
sys.stdout.write("\n")
break
elif c == 0x04:
# Ctrl-D
sys.stdout.write("\n")
# Shutdown asyncio.
asyncio.new_event_loop()
return
elif c == 0x1B:
# Start of escape sequence.
key = await s.read(2)
if key in ("[A", "[B"):
# Stash the current command.
hist[(hist_i - hist_b) % _HISTORY_LIMIT] = cmd
# Clear current command.
b = "\x08" * len(cmd)
sys.stdout.write(b)
sys.stdout.write(" " * len(cmd))
sys.stdout.write(b)
# Go backwards or forwards in the history.
if key == "[A":
hist_b = min(hist_n, hist_b + 1)
else:
hist_b = max(0, hist_b - 1)
# Update current command.
cmd = hist[(hist_i - hist_b) % _HISTORY_LIMIT]
sys.stdout.write(cmd)
else:
# sys.stdout.write("\\x")
# sys.stdout.write(hex(c))
pass
else:
sys.stdout.write(b)
cmd += b
finally:
micropython.kbd_intr(3)
6 changes: 6 additions & 0 deletions micropython/aiorepl/manifest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
metadata(
version="0.1",
description="Provides an asynchronous REPL that can run concurrently with an asyncio, also allowing await expressions.",
)

module("aiorepl.py")