diff --git a/docs/project/changelog.md b/docs/project/changelog.md index cdd9c4c3e..cf9c2423d 100644 --- a/docs/project/changelog.md +++ b/docs/project/changelog.md @@ -183,6 +183,8 @@ substitutions: some completion issues (see [#821](https://github.com/pyodide/pyodide/issues/821) and [#1160](https://github.com/pyodide/pyodide/issues/1160) +- {{ Enhancement }} Support top-level await in the console + [#1459](https://github.com/pyodide/pyodide/issues/1459) ### Packages diff --git a/src/pyodide-py/pyodide/console.py b/src/pyodide-py/pyodide/console.py index b1e50d4c0..37ad9ca69 100644 --- a/src/pyodide-py/pyodide/console.py +++ b/src/pyodide-py/pyodide/console.py @@ -4,42 +4,36 @@ import io import sys import platform from contextlib import contextmanager -import builtins import rlcompleter +import asyncio +from pyodide import eval_code_async +import ast # this import can fail when we are outside a browser (e.g. for tests) try: - import js - import pyodide_js - - _dummy_promise = js.Promise.resolve() - _load_packages_from_imports = pyodide_js.loadPackagesFromImports - + from pyodide_js import loadPackagesFromImports as _load_packages_from_imports + from asyncio import ensure_future except ImportError: + from asyncio import Future - class _FakePromise: - """A promise that mimic the JS promises. + def ensure_future(co): # type: ignore + fut = Future() + try: + co.send(None) + except StopIteration as v: + result = v.args[0] if v.args else None + fut.set_result(result) + except BaseException as e: + fut.set_exception(e) + else: + raise Exception("coroutine didn't finish in one pass") + return fut - Only `then is supported` and there is no asynchronicity. - execution occurs when then is call. - - This is mainly to fake `load_packages_from_imports` - and `InteractiveConsole.run_complete` in contexts - where JS promises are not available (tests).""" - - def __init__(self, args=None): - self.args = (args,) if args is not None else () - - def then(self, func, *args): - return _FakePromise(func(*self.args)) - - _dummy_promise = _FakePromise() - - def _load_packages_from_imports(*args): - return _dummy_promise + async def _load_packages_from_imports(*args): + pass -__all__ = ["InteractiveConsole", "repr_shorten", "displayhook"] +__all__ = ["InteractiveConsole", "repr_shorten"] class _StdStream(io.TextIOWrapper): @@ -97,8 +91,7 @@ class InteractiveConsole(code.InteractiveConsole): Base implementation for an interactive console that manages stdout/stderr redirection. Since packages are loaded before running - code, `runcode` returns a JS promise. Override `sys.displayhook` to - catch the result of an execution. + code, `runcode` returns a JS promise. `self.stdout_callback` and `self.stderr_callback` can be overloaded. @@ -111,7 +104,7 @@ class InteractiveConsole(code.InteractiveConsole): stderr_callback Function to call at each `sys.stderr` flush. persistent_stream_redirection - Wether or not the std redirection should be kept between calls to + Whether or not the std redirection should be kept between calls to `runcode`. """ @@ -130,13 +123,16 @@ class InteractiveConsole(code.InteractiveConsole): self._streams_redirected = False if persistent_stream_redirection: self.redirect_stdstreams() - self.run_complete = _dummy_promise + self.run_complete: asyncio.Future = asyncio.Future() + self.run_complete.set_result(None) self._completer = rlcompleter.Completer(self.locals) # type: ignore # all nonalphanums except '.' # see https://github.com/python/cpython/blob/a4258e8cd776ba655cc54ba54eaeffeddb0a267c/Modules/readline.c#L1211 self.completer_word_break_characters = ( """ \t\n`~!@#$%^&*()-=+[{]}\\|;:'\",<>/?""" ) + self.output_truncated_text = "\\n[[;orange;]]\\n" + self.compile.compiler.flags |= ast.PyCF_ALLOW_TOP_LEVEL_AWAIT # type: ignore def redirect_stdstreams(self): """ Toggle stdout/stderr redirections. """ @@ -220,22 +216,34 @@ class InteractiveConsole(code.InteractiveConsole): function sets the promise `self.run_complete`. If you need to wait for the end of the computation, you should await for it.""" - parent_runcode = super().runcode source = "\n".join(self.buffer) + self.run_complete = ensure_future( + self.load_packages_and_run(self.run_complete, source) + ) - def load_packages_and_run(*args): - def run(*args): - with self.stdstreams_redirections(): - parent_runcode(code) - # in CPython's REPL, flush is performed - # by input(prompt) at each new prompt ; - # since we are not using input, we force - # flushing here - self.flush_all() + async def load_packages_and_run(self, run_complete, source): + try: + await run_complete + except BaseException: + # Throw away old error + pass + with self.stdstreams_redirections(): + await _load_packages_from_imports(source) + try: + result = await eval_code_async(source, self.locals) + except BaseException as e: + from traceback import print_exception - return _load_packages_from_imports(source).then(run) - - self.run_complete = self.run_complete.then(load_packages_and_run) + print_exception(type(e), e, e.__traceback__) + raise e + else: + self.display(result) + # in CPython's REPL, flush is performed + # by input(prompt) at each new prompt ; + # since we are not using input, we force + # flushing here + self.flush_all() + return result def __del__(self): self.restore_stdstreams() @@ -282,6 +290,11 @@ class InteractiveConsole(code.InteractiveConsole): completions = self._completer.global_matches(source) # type: ignore return completions, start + def display(self, value): + if value is None: + return + print(repr_shorten(value, separator=self.output_truncated_text)) + def repr_shorten( value: Any, limit: int = 1000, split: Optional[int] = None, separator: str = "..." @@ -299,33 +312,3 @@ def repr_shorten( if len(text) > limit: text = f"{text[:split]}{separator}{text[-split:]}" return text - - -def displayhook(value, repr: Callable[[Any], str]): - """A displayhook with custom `repr` function. - - It is intendend to overload `sys.displayhook`. Note that monkeypatch - `builtins.repr` does not work in `sys.displayhook`. The pointer to - `repr` seems hardcoded in default `sys.displayhook` version - (which is written in C).""" - # from https://docs.python.org/3/library/sys.html#sys.displayhook - # If value is not None, this function prints repr(value) to - # sys.stdout, and saves value in builtins._. If repr(value) is not - # encodable to sys.stdout.encoding with sys.stdout.errors error - # handler (which is probably 'strict'), encode it to - # sys.stdout.encoding with 'backslashreplace' error handler. - if value is None: - return - builtins._ = None # type: ignore - text = repr(value) - try: - sys.stdout.write(text) - except UnicodeEncodeError: - bytes = text.encode(sys.stdout.encoding, "backslashreplace") - if hasattr(sys.stdout, "buffer"): - sys.stdout.buffer.write(bytes) - else: - text = bytes.decode(sys.stdout.encoding, "strict") - sys.stdout.write(text) - sys.stdout.write("\n") - builtins._ = value # type: ignore diff --git a/src/templates/console.html b/src/templates/console.html index 3e4920264..500072953 100644 --- a/src/templates/console.html +++ b/src/templates/console.html @@ -22,15 +22,6 @@ from pyodide import console import __main__ - - def displayhook(value): - separator = "\\n[[;orange;]]\\n" - _repr = lambda v: console.repr_shorten(v, separator=separator) - return console.displayhook(value, _repr) - - sys.displayhook = displayhook - - class PyConsole(console.InteractiveConsole): def __init__(self): super().__init__( @@ -54,7 +45,12 @@ for( const c of command.split('\n') ) { const prompt = pyconsole.push(c) ? ps2 : ps1; term.set_prompt(prompt); - await pyconsole.run_complete; + let run_complete = pyconsole.run_complete; + try { + let r = await run_complete; + r.destroy(); + } catch(_){ } + run_complete.destroy(); } term.resume(); } diff --git a/src/tests/test_console.py b/src/tests/test_console.py index 1b3a575af..28c9f7401 100644 --- a/src/tests/test_console.py +++ b/src/tests/test_console.py @@ -1,7 +1,6 @@ import pytest from pathlib import Path import sys -import io sys.path.append(str(Path(__file__).parents[2] / "src" / "pyodide-py")) @@ -72,6 +71,16 @@ def test_interactive_console_streams(safe_sys_redirections): shell.push("1+1") assert my_stdout == "foo\nfoobar\nfoobar\n2\n" + assert shell.run_complete.result() == 2 + + my_stderr = "" + shell.push("raise Exception('hi')") + assert my_stderr.endswith("Exception: hi\n") + assert shell.run_complete.exception() is not None + my_stderr = "" + shell.push("1+1") + assert my_stderr == "" + assert shell.run_complete.result() == 2 shell.restore_stdstreams() @@ -121,12 +130,6 @@ def test_repr(safe_sys_redirections): console.repr_shorten(string, limit=limit, separator=sep) ) == 2 * (limit // 2) + len(sep) - sys.stdout = io.StringIO() - console.displayhook( - [0] * 100, lambda v: console.repr_shorten(v, 100, separator=sep) - ) - assert len(sys.stdout.getvalue()) == 100 + len(sep) + 1 # for \n - @pytest.fixture def safe_selenium_sys_redirections(selenium): @@ -153,12 +156,12 @@ def test_interactive_console(selenium, safe_selenium_sys_redirections): result = None - def displayhook(value): + def display(value): global result result = value shell = InteractiveConsole() - sys.displayhook = displayhook + shell.display = display """ ) @@ -238,3 +241,24 @@ def test_completion(selenium, safe_selenium_sys_redirections): ], 8, ] + + +def test_interactive_console_top_level_await(selenium, safe_selenium_sys_redirections): + selenium.run( + """ + import sys + from pyodide.console import InteractiveConsole + + result = None + + def display(value): + global result + result = value + + shell = InteractiveConsole() + shell.display = display + """ + ) + selenium.run("shell.push('from js import fetch')") + selenium.run("shell.push('await (await fetch(`packages.json`)).json()')") + assert selenium.run("result") == None