diff --git a/src/py/pyodide/console.py b/src/py/pyodide/console.py index 4ea6f3a9a..e5f7173ef 100644 --- a/src/py/pyodide/console.py +++ b/src/py/pyodide/console.py @@ -1,6 +1,7 @@ import ast import asyncio import code +from codeop import Compile, CommandCompiler, _features # type: ignore from contextlib import ( contextmanager, redirect_stdout, @@ -14,8 +15,7 @@ import sys import traceback from typing import Optional, Callable, Any, List, Tuple - -from ._base import eval_code_async +from ._base import eval_code_async, should_quiet, CodeRunner # this import can fail when we are outside a browser (e.g. for tests) try: @@ -89,6 +89,74 @@ class _ReadStream: pass +class _CodeRunnerCompile(Compile): + """Compile code with CodeRunner, and remember future imports + + Instances of this class behave much like the built-in compile function, + but if one is used to compile text containing a future statement, it + "remembers" and compiles all subsequent program texts with the statement in + force. It uses CodeRunner instead of the built in compile. + """ + + def __init__( + self, + *, + return_mode="last_expr", + quiet_trailing_semicolon=True, + flags=0x0, + ): + super().__init__() + self.flags |= flags + self.return_mode = return_mode + self.quiet_trailing_semicolon = quiet_trailing_semicolon + + def __call__(self, source, filename, symbol) -> CodeRunner: # type: ignore + return_mode = self.return_mode + if self.quiet_trailing_semicolon and should_quiet(source): + return_mode = None + code_runner = CodeRunner( + source, + mode=symbol, + filename=filename, + return_mode=return_mode, + flags=self.flags, + ).compile() + for feature in _features: + if code_runner.code.co_flags & feature.compiler_flag: + self.flags |= feature.compiler_flag + return code_runner + + +class _CodeRunnerCommandCompiler(CommandCompiler): + """Compile code with CodeRunner, and remember future imports, return None if + code is incomplete. + + Instances of this class have __call__ methods identical in signature to + compile; the difference is that if the instance compiles program text + containing a __future__ statement, the instance 'remembers' and compiles all + subsequent program texts with the statement in force. + + If the source is determined to be incomplete, will suppress the SyntaxError + and return ``None``. + """ + + def __init__( + self, + *, + return_mode="last_expr", + quiet_trailing_semicolon=True, + flags=0x0, + ): + self.compiler = _CodeRunnerCompile( + return_mode=return_mode, + quiet_trailing_semicolon=quiet_trailing_semicolon, + flags=flags, + ) + + def __call__(self, source, filename="", symbol="single") -> CodeRunner: # type: ignore + return super().__call__(source, filename, symbol) # type: ignore + + class _InteractiveConsole(code.InteractiveConsole): """Interactive Pyodide console diff --git a/src/tests/test_console.py b/src/tests/test_console.py index fc54bfedf..ff0796124 100644 --- a/src/tests/test_console.py +++ b/src/tests/test_console.py @@ -7,6 +7,31 @@ from conftest import selenium_common sys.path.append(str(Path(__file__).resolve().parents[2] / "src" / "py")) from pyodide import console # noqa: E402 +from pyodide.console import _CodeRunnerCompile, _CodeRunnerCommandCompiler # noqa: E402 +from pyodide import CodeRunner + + +def test_command_compiler(): + c = _CodeRunnerCompile() + with pytest.raises(SyntaxError, match="unexpected EOF while parsing"): + c("def test():\n 1", "", "exec") + assert isinstance(c("def test():\n 1\n", "", "exec"), CodeRunner) + with pytest.raises(SyntaxError, match="invalid syntax"): + c("1<>2", "", "exec") + assert isinstance( + c("from __future__ import barry_as_FLUFL", "", "exec"), CodeRunner + ) + assert isinstance(c("1<>2", "", "exec"), CodeRunner) + + c = _CodeRunnerCommandCompiler() + assert c("def test():\n 1", "", "exec") is None + assert isinstance(c("def test():\n 1\n", "", "exec"), CodeRunner) + with pytest.raises(SyntaxError, match="invalid syntax"): + c("1<>2", "", "exec") + assert isinstance( + c("from __future__ import barry_as_FLUFL", "", "exec"), CodeRunner + ) + assert isinstance(c("1<>2", "", "exec"), CodeRunner) def test_stream_redirection():