diff --git a/micropython/aiorepl/README.md b/micropython/aiorepl/README.md new file mode 100644 index 00000000..2c3ed843 --- /dev/null +++ b/micropython/aiorepl/README.md @@ -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. diff --git a/micropython/aiorepl/aiorepl.py b/micropython/aiorepl/aiorepl.py new file mode 100644 index 00000000..326e00ba --- /dev/null +++ b/micropython/aiorepl/aiorepl.py @@ -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) diff --git a/micropython/aiorepl/manifest.py b/micropython/aiorepl/manifest.py new file mode 100644 index 00000000..5cce6c79 --- /dev/null +++ b/micropython/aiorepl/manifest.py @@ -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")