diff --git a/docs/changelog.md b/docs/changelog.md index 3c0fb70ba..0e70a0308 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -32,6 +32,8 @@ - Reduce the size of the core pyodide package [#987](https://github.com/iodide-project/pyodide/pull/987). - Updated packages: bleach 3.2.1, packaging 20.8 +- `eval_code` now accepts separate `globals` and `locals` parameters. + [#1083](https://github.com/iodide-project/pyodide/pull/1083) ### Fixed - getattr and dir on JsProxy now report consistent results and include all names defined on the Python dictionary backing JsProxy. [#1017](https://github.com/iodide-project/pyodide/pull/1017) diff --git a/src/pyodide-py/pyodide/_base.py b/src/pyodide-py/pyodide/_base.py index 5922ee0b2..b0e6a1ffe 100644 --- a/src/pyodide-py/pyodide/_base.py +++ b/src/pyodide-py/pyodide/_base.py @@ -49,17 +49,25 @@ class CodeRunner: Parameters ---------- - ns - `locals()` or `globals()` context where to execute code. - This namespace is updated by the subsequent calls to `run()`. - mode - 'last_expr' , 'last_expr_or_assign' or 'none', - specifying what should be evaluated and what should be executed. - 'last_expr' will return the last expression - 'last_expr_or_assign' will return the last expression - or the last (named) assignment. - 'none' will always return `None`. - Other values will be interpreted as 'none'. + globals + The global scope in which to execute code. This is used as the `exec` + `globals` parameter. See + [the exec documentation](https://docs.python.org/3/library/functions.html#exec) + for more info. + locals + The local scope in which to execute code. This is used as the `exec` + `locals` parameter. As with `exec`, if `locals` is absent, it is set equal + to `globals`. See + [the exec documentation](https://docs.python.org/3/library/functions.html#exec) + for more info. + return_mode + Specifies what should be returned, must be one of 'last_expr', + 'last_expr_or_assign' or `None`. On other values an exception is raised. + + 'last_expr' -- return the last expression + 'last_expr_or_assign' -- return the last expression or the last + (named) assignment. + 'none' -- always return `None`. quiet_trailing_semicolon wether a trailing semicolon should 'quiet' the result or not. Setting this to `True` (default) mimic the CPython's interpret @@ -81,15 +89,19 @@ class CodeRunner: def __init__( self, - ns: Optional[Dict[str, Any]] = None, - mode: str = "last_expr", + globals: Optional[Dict[str, Any]] = None, + locals: Optional[Dict[str, Any]] = None, + return_mode: str = "last_expr", quiet_trailing_semicolon: bool = True, filename: str = "", ): - self.ns = ns if ns is not None else {} + self.globals = globals if globals is not None else {} + self.locals = locals if locals is not None else self.globals self.quiet_trailing_semicolon = quiet_trailing_semicolon self.filename = filename - self.mode = mode + if return_mode not in ["last_expr", "last_expr_or_assign", "none", None]: + raise ValueError(f"Unrecognized return_mode {return_mode!r}") + self.return_mode = return_mode def quiet(self, code: str) -> bool: """ @@ -134,9 +146,9 @@ class CodeRunner: def _last_assign_to_expr(self, mod: ast.Module): """ - Implementation of 'last_expr_or_assign' mode. + Implementation of 'last_expr_or_assign' return_mode. It modify the supplyied AST module so that the last - statement's value can be returned in 'last_expr' mode. + statement's value can be returned in 'last_expr' return_mode. """ # Largely inspired from IPython: # https://github.com/ipython/ipython/blob/3587f5bb6c8570e7bbb06cf5f7e3bc9b9467355a/IPython/core/interactiveshell.py#L3229 @@ -176,20 +188,21 @@ class CodeRunner: if not mod.body: return None, None - if self.mode == "last_expr_or_assign": + if self.return_mode == "last_expr_or_assign": # If the last statement is a named assignment, add an extra # expression to the end with just the L-value so that we can # handle it with the last_expr code. self._last_assign_to_expr(mod) # we extract last expression - last_expr = None if ( - self.mode.startswith("last_expr") # last_expr or last_expr_or_assign + self.return_mode.startswith("last_expr") # last_expr or last_expr_or_assign and isinstance(mod.body[-1], (ast.Expr, ast.Await)) and not self.quiet(code) ): last_expr = ast.Expression(mod.body.pop().value) # type: ignore + else: + last_expr = None # type: ignore # we compile mod = compile(mod, self.filename, "exec", flags=flags) # type: ignore @@ -212,18 +225,18 @@ class CodeRunner: return `None`. If the last statement is an expression, return the result of the expression. - Use the `mode` and `quiet_trailing_semicolon` parameters in the + Use the `return_mode` and `quiet_trailing_semicolon` parameters in the constructor to modify this default behavior. """ mod, last_expr = self._split_and_compile(code) # running first part if mod is not None: - exec(mod, self.ns, self.ns) + exec(mod, self.globals, self.locals) # evaluating last expression if last_expr is not None: - return eval(last_expr, self.ns, self.ns) + return eval(last_expr, self.globals, self.locals) async def run_async(self, code: str) -> Any: """ //!\\ WARNING //!\\ @@ -237,13 +250,13 @@ class CodeRunner: ) # running first part if mod is not None: - coro = eval(mod, self.ns, self.ns) + coro = eval(mod, self.globals, self.locals) if iscoroutine(coro): await coro # evaluating last expression if last_expr is not None: - res = eval(last_expr, self.ns, self.ns) + res = eval(last_expr, self.globals, self.locals) if iscoroutine(res): res = await res return res @@ -251,8 +264,9 @@ class CodeRunner: def eval_code( code: str, - ns: Dict[str, Any], - mode: str = "last_expr", + globals: Optional[Dict[str, Any]] = None, + locals: Optional[Dict[str, Any]] = None, + return_mode: str = "last_expr", quiet_trailing_semicolon: bool = True, filename: str = "", ) -> Any: @@ -262,20 +276,29 @@ def eval_code( ---------- code the Python code to run. - ns - `locals()` or `globals()` context where to execute code. - mode - 'last_expr' , 'last_expr_or_assign' or 'none', - specifying what should be evaluated and what should be executed. - 'last_expr' will return the last expression - 'last_expr_or_assign' will return the last expression - or the last (named) assignment. - 'none' will always return `None`. - Other values will be interpreted as 'none'. + globals + The global scope in which to execute code. This is used as the `exec` + `globals` parameter. See + [the exec documentation](https://docs.python.org/3/library/functions.html#exec) + for more info. + locals + The local scope in which to execute code. This is used as the `exec` + `locals` parameter. As with `exec`, if `locals` is absent, it is set equal + to `globals`. See + [the exec documentation](https://docs.python.org/3/library/functions.html#exec) + for more info. + return_mode + Specifies what should be returned, must be one of 'last_expr', + 'last_expr_or_assign' or `None`. On other values an exception is raised. + + 'last_expr' -- return the last expression + 'last_expr_or_assign' -- return the last expression or the last + (named) assignment. + 'none' -- always return `None`. quiet_trailing_semicolon - wether a trailing semicolon should 'quiet' the result or not. - Setting this to `True` (default) mimic the CPython's interpret - behavior ; whereas setting it to `False` mimic the IPython's + whether a trailing semicolon should 'quiet' the result or not. + Setting this to `True` (default) mimic the CPython's interpret + behavior ; whereas setting it to `False` mimic the IPython's filename: file from which the code was read. @@ -284,16 +307,23 @@ def eval_code( If the last nonwhitespace character of code is a semicolon return `None`. If the last statement is an expression, return the result of the expression. - Use the `mode` and `quiet_trailing_semicolon` parameters to modify + Use the `return_mode` and `quiet_trailing_semicolon` parameters to modify this default behavior. """ - return CodeRunner(ns, mode, quiet_trailing_semicolon, filename).run(code) + return CodeRunner( + globals=globals, + locals=locals, + return_mode=return_mode, + quiet_trailing_semicolon=quiet_trailing_semicolon, + filename=filename, + ).run(code) -async def _eval_code_async( +async def eval_code_async( code: str, - ns: Dict[str, Any], - mode: str = "last_expr", + globals: Optional[Dict[str, Any]] = None, + locals: Optional[Dict[str, Any]] = None, + return_mode: str = "last_expr", quiet_trailing_semicolon: bool = True, filename: str = "", ) -> Any: @@ -307,9 +337,13 @@ async def _eval_code_async( - add tests """ raise NotImplementedError("Async is not yet supported in Pyodide.") - return await CodeRunner(ns, mode, quiet_trailing_semicolon, filename).run_async( - code - ) + return await CodeRunner( + globals=globals, + locals=locals, + return_mode=return_mode, + quiet_trailing_semicolon=quiet_trailing_semicolon, + filename=filename, + ).run_async(code) def find_imports(code: str) -> List[str]: diff --git a/src/tests/test_pyodide.py b/src/tests/test_pyodide.py index 1f7046b15..9ce71a081 100644 --- a/src/tests/test_pyodide.py +++ b/src/tests/test_pyodide.py @@ -1,3 +1,4 @@ +import pytest from pathlib import Path import sys from textwrap import dedent @@ -68,7 +69,7 @@ def test_eval_code(): assert eval_code("x=7", ns) is None assert ns["x"] == 7 - # default mode ('last_expr'), semicolon + # default return_mode ('last_expr'), semicolon assert eval_code("1+1;", ns) is None assert eval_code("1+1#;", ns) == 2 assert eval_code("5-2 # comment with trailing semicolon ;", ns) == 3 @@ -77,20 +78,23 @@ def test_eval_code(): assert eval_code("4//2;\n", ns) is None assert eval_code("2**1;\n\n", ns) is None - # 'last_expr_or_assign' mode, semicolon - assert eval_code("1 + 1", ns, mode="last_expr_or_assign") == 2 - assert eval_code("x = 1 + 1", ns, mode="last_expr_or_assign") == 2 - assert eval_code("a = 5 ; a += 1", ns, mode="last_expr_or_assign") == 6 - assert eval_code("a = 5 ; a += 1;", ns, mode="last_expr_or_assign") is None - assert eval_code("l = [1, 1, 2] ; l[0] = 0", ns, mode="last_expr_or_assign") is None - assert eval_code("a = b = 2", ns, mode="last_expr_or_assign") == 2 + # 'last_expr_or_assign' return_mode, semicolon + assert eval_code("1 + 1", ns, return_mode="last_expr_or_assign") == 2 + assert eval_code("x = 1 + 1", ns, return_mode="last_expr_or_assign") == 2 + assert eval_code("a = 5 ; a += 1", ns, return_mode="last_expr_or_assign") == 6 + assert eval_code("a = 5 ; a += 1;", ns, return_mode="last_expr_or_assign") is None + assert ( + eval_code("l = [1, 1, 2] ; l[0] = 0", ns, return_mode="last_expr_or_assign") + is None + ) + assert eval_code("a = b = 2", ns, return_mode="last_expr_or_assign") == 2 - # 'none' mode, (useless) semicolon - assert eval_code("1 + 1", ns, mode="none") is None - assert eval_code("x = 1 + 1", ns, mode="none") is None - assert eval_code("a = 5 ; a += 1", ns, mode="none") is None - assert eval_code("a = 5 ; a += 1;", ns, mode="none") is None - assert eval_code("l = [1, 1, 2] ; l[0] = 0", ns, mode="none") is None + # 'none' return_mode, (useless) semicolon + assert eval_code("1 + 1", ns, return_mode="none") is None + assert eval_code("x = 1 + 1", ns, return_mode="none") is None + assert eval_code("a = 5 ; a += 1", ns, return_mode="none") is None + assert eval_code("a = 5 ; a += 1;", ns, return_mode="none") is None + assert eval_code("l = [1, 1, 2] ; l[0] = 0", ns, return_mode="none") is None # with 'quiet_trailing_semicolon' set to False assert eval_code("1+1;", ns, quiet_trailing_semicolon=False) == 2 @@ -109,6 +113,26 @@ def test_eval_code(): assert eval_code("2**1;\n\n", ns, quiet_trailing_semicolon=False) == 2 +def test_eval_code_locals(): + globals = {} + eval_code("x=2", globals, {}) + with pytest.raises(NameError): + eval_code("x", globals, {}) + + locals = {} + eval_code("import sys; sys.getrecursionlimit()", globals, locals) + with pytest.raises(NameError): + eval_code("sys.getrecursionlimit()", globals, {}) + eval_code("sys.getrecursionlimit()", globals, locals) + + eval_code( + "from importlib import invalidate_caches; invalidate_caches()", globals, locals + ) + with pytest.raises(NameError): + eval_code("invalidate_caches()", globals, globals) + eval_code("invalidate_caches()", globals, locals) + + def test_monkeypatch_eval_code(selenium): selenium.run( """