ENH Customizable eval_code behavior (#1056)

This commit is contained in:
casatir 2021-01-08 17:31:31 +01:00 committed by GitHub
parent 9c7ff5658f
commit 1bfe31a41c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 156 additions and 12 deletions

View File

@ -8,7 +8,7 @@ import ast
from asyncio import iscoroutine
from io import StringIO
from textwrap import dedent
from typing import Dict, List, Any, Tuple
from typing import Dict, List, Any, Tuple, Optional
import tokenize
@ -52,6 +52,21 @@ class CodeRunner:
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'.
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
interpret behavior.
filename:
file from which the code was read.
Examples
--------
@ -64,15 +79,22 @@ class CodeRunner:
6
"""
def __init__(self, ns: Dict[str, Any] = None):
def __init__(
self,
ns: Optional[Dict[str, Any]] = None,
mode: str = "last_expr",
quiet_trailing_semicolon: bool = True,
filename: str = "<exec>",
):
self.ns = ns if ns is not None else {}
self.filename = "<exec>"
self.quiet_trailing_semicolon = quiet_trailing_semicolon
self.filename = filename
self.mode = mode
def quiet(self, code: str) -> bool:
"""
Does the last nonwhitespace character of code is a semicolon?
This can be overridden to customize the way run() is silenced.
If `quiet_trailing_semicolon` is set tot True in the constructor,
does the last nonwhitespace character of code is a semicolon?
Examples
--------
@ -82,10 +104,15 @@ class CodeRunner:
True
>>> CodeRunner().quiet('1 + 1 # comment ;')
False
>>> CodeRunner(quiet_trailing_semicolon=False).quiet('1 + 1 ;')
False
"""
# largely inspired from IPython:
# https://github.com/ipython/ipython/blob/86d24741188b0cedd78ab080d498e775ed0e5272/IPython/core/displayhook.py#L84
if not self.quiet_trailing_semicolon:
return False
# We need to wrap tokens in a buffer because:
# "Tokenize requires one argument, readline, which must be
# a callable object which provides the same interface as the
@ -105,6 +132,31 @@ class CodeRunner:
return False
def _last_assign_to_expr(self, mod: ast.Module):
"""
Implementation of 'last_expr_or_assign' mode.
It modify the supplyied AST module so that the last
statement's value can be returned in 'last_expr' mode.
"""
# Largely inspired from IPython:
# https://github.com/ipython/ipython/blob/3587f5bb6c8570e7bbb06cf5f7e3bc9b9467355a/IPython/core/interactiveshell.py#L3229
last_node = mod.body[-1]
if isinstance(last_node, ast.Assign):
# In this case there can be multiple targets as in `a = b = 1`.
# We just take the first one.
target = last_node.targets[0]
elif isinstance(last_node, (ast.AugAssign, ast.AnnAssign)):
target = last_node.target
else:
return
if isinstance(target, ast.Name):
last_node = ast.Expr(ast.Name(target.id, ast.Load()))
mod.body.append(last_node)
# Update the line numbers shown in error messages.
ast.fix_missing_locations(mod)
def _split_and_compile(self, code: str, flags: int = 0x0) -> Tuple[Any, Any]:
"""
Split code in two parts, everything but last expression and
@ -124,9 +176,19 @@ class CodeRunner:
if not mod.body:
return None, None
if self.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 isinstance(mod.body[-1], (ast.Expr, ast.Await)) and not self.quiet(code):
if (
self.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
# we compile
@ -137,7 +199,7 @@ class CodeRunner:
return mod, last_expr
def run(self, code: str) -> Any:
"""
"""Runs a code string.
Parameters
----------
@ -150,6 +212,8 @@ 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
constructor to modify this default behavior.
"""
mod, last_expr = self._split_and_compile(code)
@ -185,7 +249,13 @@ class CodeRunner:
return res
def eval_code(code: str, ns: Dict[str, Any]) -> Any:
def eval_code(
code: str,
ns: Dict[str, Any],
mode: str = "last_expr",
quiet_trailing_semicolon: bool = True,
filename: str = "<exec>",
) -> Any:
"""Runs a code string.
Parameters
@ -194,17 +264,39 @@ def eval_code(code: str, ns: Dict[str, Any]) -> Any:
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'.
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
filename:
file from which the code was read.
Returns
-------
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
this default behavior.
"""
return CodeRunner(ns).run(code)
return CodeRunner(ns, mode, quiet_trailing_semicolon, filename).run(code)
async def _eval_code_async(code: str, ns: Dict[str, Any]) -> Any:
async def _eval_code_async(
code: str,
ns: Dict[str, Any],
mode: str = "last_expr",
quiet_trailing_semicolon: bool = True,
filename: str = "<exec>",
) -> Any:
""" //!\\ WARNING //!\\
This is not working yet. For use once we add an EventLoop.
@ -215,7 +307,9 @@ async def _eval_code_async(code: str, ns: Dict[str, Any]) -> Any:
- add tests
"""
raise NotImplementedError("Async is not yet supported in Pyodide.")
return await CodeRunner(ns).run_async(code)
return await CodeRunner(ns, mode, quiet_trailing_semicolon, filename).run_async(
code
)
def find_imports(code: str) -> List[str]:

View File

@ -28,6 +28,24 @@ def test_code_runner():
assert not runner.quiet("1+1#;")
assert not runner.quiet("5-2 # comment with trailing semicolon ;")
assert runner.run("4//2\n") == 2
assert runner.run("4//2;") is None
assert runner.run("x = 2\nx") == 2
assert runner.run("def f(x):\n return x*x+1\n[f(x) for x in range(6)]") == [
1,
2,
5,
10,
17,
26,
]
# with 'quiet_trailing_semicolon' set to False
runner = CodeRunner(quiet_trailing_semicolon=False)
assert not runner.quiet("1+1;")
assert not runner.quiet("1+1#;")
assert not runner.quiet("5-2 # comment with trailing semicolon ;")
assert runner.run("4//2\n") == 2
assert runner.run("4//2;") == 2
def test_eval_code():
@ -50,6 +68,7 @@ def test_eval_code():
assert eval_code("x=7", ns) is None
assert ns["x"] == 7
# default 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
@ -58,6 +77,37 @@ 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
# '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
# with 'quiet_trailing_semicolon' set to False
assert eval_code("1+1;", ns, quiet_trailing_semicolon=False) == 2
assert eval_code("1+1#;", ns, quiet_trailing_semicolon=False) == 2
assert (
eval_code(
"5-2 # comment with trailing semicolon ;",
ns,
quiet_trailing_semicolon=False,
)
== 3
)
assert eval_code("4//2\n", ns, quiet_trailing_semicolon=False) == 2
assert eval_code("2**1\n\n", ns, quiet_trailing_semicolon=False) == 2
assert eval_code("4//2;\n", ns, quiet_trailing_semicolon=False) == 2
assert eval_code("2**1;\n\n", ns, quiet_trailing_semicolon=False) == 2
def test_monkeypatch_eval_code(selenium):
selenium.run(