From 1cf11928fe6aa58fc81a42c38ce4f9c0cfe13d83 Mon Sep 17 00:00:00 2001 From: Tyler Levy Conde Date: Sat, 24 Aug 2024 23:08:49 -0600 Subject: [PATCH 1/8] Add aeval function --- aioconsole/__init__.py | 3 +- aioconsole/execute.py | 41 ++++++++++++++++++++ tests/test_execute.py | 86 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 128 insertions(+), 2 deletions(-) diff --git a/aioconsole/__init__.py b/aioconsole/__init__.py index 1c8f441..75fb335 100644 --- a/aioconsole/__init__.py +++ b/aioconsole/__init__.py @@ -3,7 +3,7 @@ It also includes an interactive event loop, and a command line interface. """ -from .execute import aexec +from .execute import aexec, aeval from .console import AsynchronousConsole, interact from .stream import ainput, aprint, get_standard_streams from .events import InteractiveEventLoop, InteractiveEventLoopPolicy @@ -17,6 +17,7 @@ __all__ = [ "aexec", + "aeval", "ainput", "aprint", "AsynchronousConsole", diff --git a/aioconsole/execute.py b/aioconsole/execute.py index c9ca50c..0aa9169 100644 --- a/aioconsole/execute.py +++ b/aioconsole/execute.py @@ -1,5 +1,6 @@ """Provide an asynchronous equivalent *to exec*.""" +import asyncio import ast import codeop from io import StringIO @@ -148,3 +149,43 @@ async def aexec(source, local=None, stream=None, filename=""): if isinstance(tree, ast.Interactive): exec_single_result(result, new_local, stream) full_update(local, new_local) + + +async def aeval(input_string: str, local_namespace: dict): + """Asynchronous equivalent to *eval*.""" + # Save the state of the local namespace before executing the code + previous = {k: v for k, v in local_namespace.items()} + + # Split the input string by lines to handle multiline input + lines = input_string.splitlines() + + for i, line in enumerate(lines): + # Capture the result of the expression in a special variable '__result__' + if i == len(lines) - 1: + # Only modify the last line to capture the final result + modified_input = f"__result__ = {line}" + else: + modified_input = line + + try: + # Try to execute the modified input to capture the result in '__result__' + await aexec(modified_input, local_namespace) + except SyntaxError: + # This might be a statement rather than an expression. + # In this case, execute the original input without modification. + await aexec(line, local_namespace) + + # Attempt to retrieve the result of the expression from the local namespace + result = local_namespace.pop("__result__", None) + + # Capture the state of the local namespace after execution + post = {k: v for k, v in local_namespace.items()} + + # Check if the local namespace has changed. + # If not, the code might have been a statement rather than an expression + if previous == post: + # The result is a coroutine and wasn't assigned to a variable, await its result + if asyncio.iscoroutine(result) and result not in local_namespace.values(): + result = await result + + return result diff --git a/tests/test_execute.py b/tests/test_execute.py index 269ba87..a8292e5 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -2,7 +2,7 @@ import asyncio import pytest -from aioconsole import aexec +from aioconsole import aexec, aeval from aioconsole.execute import compile_for_aexec @@ -124,3 +124,87 @@ async def test_correct(): await aexec("async def x(): return") await aexec("def x(): yield") await aexec("async def x(): yield") + + +# Test exception handling in aeval + + +@pytest.mark.asyncio +async def test_aeval_syntax_error(): + with pytest.raises(SyntaxError): + await aeval("1+", {}) + + +@pytest.mark.asyncio +async def test_aeval_name_error(): + with pytest.raises(NameError): + await aeval("undefined_variable", {}) + + +@pytest.mark.asyncio +async def test_aeval_runtime_error(): + with pytest.raises(ZeroDivisionError): + await aeval("1/0", {}) + + +# Test async handling + + +@pytest.mark.asyncio +async def test_aeval_async_function(): + result = await aeval("await coro(5)", {"coro": coro}) + assert result == 5 + + +@pytest.mark.asyncio +async def test_aeval_coroutine_result(): + result = await aeval("coro(5)", {"coro": coro}) + assert result == 5 + + +# Test that statements do not modify the namespace unnecessarily + + +@pytest.mark.asyncio +async def test_aeval_statements(): + namespace = {} + result = await aeval("a = 1", namespace) + assert result == 1 + assert namespace == {"a": 1} + + +@pytest.mark.asyncio +async def test_aeval_if_statement(): + namespace = {"a": 0} + result = await aeval("if a == 0: a = 1", namespace) + assert result is None # 'if' statements don't produce a result + assert namespace == {"a": 1} + + +# Test handling of special cases + + +@pytest.mark.asyncio +async def test_aeval_multiline_input(): + namespace = {} + result = await aeval("x = 1\nx + 1", namespace) + assert result == 2 + assert namespace == {"x": 1} + + +@pytest.mark.asyncio +async def test_aeval_function_definition(): + namespace = {} + result = await aeval("def foo(): return 42", namespace) + assert result is None + assert "foo" in namespace + assert namespace["foo"]() == 42 + + +@pytest.mark.asyncio +async def test_aeval_async_function_definition(): + namespace = {} + result = await aeval("async def foo(): return 42", namespace) + assert result is None + assert "foo" in namespace + assert await namespace["foo"]() == 42 From 3bcb667bea11b48796a77fd323bc5126e72b6318 Mon Sep 17 00:00:00 2001 From: Tyler Levy Conde Date: Mon, 26 Aug 2024 10:56:25 -0600 Subject: [PATCH 2/8] Ensure that aeval functions the same as eval --- aioconsole/execute.py | 55 ++++++---------- tests/test_execute.py | 148 ++++++++++++++++++++---------------------- 2 files changed, 91 insertions(+), 112 deletions(-) diff --git a/aioconsole/execute.py b/aioconsole/execute.py index 0aa9169..54963f5 100644 --- a/aioconsole/execute.py +++ b/aioconsole/execute.py @@ -151,41 +151,26 @@ async def aexec(source, local=None, stream=None, filename=""): full_update(local, new_local) -async def aeval(input_string: str, local_namespace: dict): - """Asynchronous equivalent to *eval*.""" - # Save the state of the local namespace before executing the code - previous = {k: v for k, v in local_namespace.items()} - - # Split the input string by lines to handle multiline input - lines = input_string.splitlines() - - for i, line in enumerate(lines): - # Capture the result of the expression in a special variable '__result__' - if i == len(lines) - 1: - # Only modify the last line to capture the final result - modified_input = f"__result__ = {line}" - else: - modified_input = line +async def aeval(input_string: str, local_namespace: dict = None): + """Asynchronous equivalent to *eval* that evaluates expressions.""" + if local_namespace is None: + local_namespace = {} - try: - # Try to execute the modified input to capture the result in '__result__' - await aexec(modified_input, local_namespace) - except SyntaxError: - # This might be a statement rather than an expression. - # In this case, execute the original input without modification. - await aexec(line, local_namespace) - - # Attempt to retrieve the result of the expression from the local namespace - result = local_namespace.pop("__result__", None) - - # Capture the state of the local namespace after execution - post = {k: v for k, v in local_namespace.items()} - - # Check if the local namespace has changed. - # If not, the code might have been a statement rather than an expression - if previous == post: - # The result is a coroutine and wasn't assigned to a variable, await its result - if asyncio.iscoroutine(result) and result not in local_namespace.values(): - result = await result + # Perform syntax check to ensure the input is a valid eval expression + try: + ast.parse(input_string, mode="eval") + except SyntaxError: + raise + + # Use aexec to execute the input string within an async function context + wrapped_code = f"__result__ = {input_string}" + + # Use aexec to evaluate the wrapped code within the given local namespace + await aexec(wrapped_code, local=local_namespace) + + # Retrieve the result from the local namespace + result = local_namespace.get("__result__") + if asyncio.iscoroutine(result): + result = await result return result diff --git a/tests/test_execute.py b/tests/test_execute.py index a8292e5..87f4d8b 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -126,85 +126,79 @@ async def test_correct(): await aexec("async def x(): yield") -# Test exception handling in aeval - - -@pytest.mark.asyncio -async def test_aeval_syntax_error(): - with pytest.raises(SyntaxError): - await aeval("1+", {}) - - -@pytest.mark.asyncio -async def test_aeval_name_error(): - with pytest.raises(NameError): - await aeval("undefined_variable", {}) - - -@pytest.mark.asyncio -async def test_aeval_runtime_error(): - with pytest.raises(ZeroDivisionError): - await aeval("1/0", {}) - - -# Test async handling - - -@pytest.mark.asyncio -async def test_aeval_async_function(): - result = await aeval("await coro(5)", {"coro": coro}) - assert result == 5 - - -@pytest.mark.asyncio -async def test_aeval_coroutine_result(): - result = await aeval("coro(5)", {"coro": coro}) - assert result == 5 - - -# Test that statements do not modify the namespace unnecessarily - - -@pytest.mark.asyncio -async def test_aeval_statements(): - namespace = {} - result = await aeval("a = 1", namespace) - assert result == 1 - assert namespace == {"a": 1} - - -@pytest.mark.asyncio -async def test_aeval_if_statement(): - namespace = {"a": 0} - result = await aeval("if a == 0: a = 1", namespace) - assert result is None # 'if' statements don't produce a result - assert namespace == {"a": 1} - - -# Test handling of special cases - - -@pytest.mark.asyncio -async def test_aeval_multiline_input(): - namespace = {} - result = await aeval("x = 1\nx + 1", namespace) - assert result == 2 - assert namespace == {"x": 1} +def echo(x): + return x -@pytest.mark.asyncio -async def test_aeval_function_definition(): - namespace = {} - result = await aeval("def foo(): return 42", namespace) - assert result is None - assert "foo" in namespace - assert namespace["foo"]() == 42 +async def aecho(x): + return echo(x) +# Parametrized test with a variety of expressions @pytest.mark.asyncio -async def test_aeval_async_function_definition(): - namespace = {} - result = await aeval("async def foo(): return 42", namespace) - assert result is None - assert "foo" in namespace - assert await namespace["foo"]() == 42 +@pytest.mark.parametrize( + "expression", + [ + # Valid Simple Expressions + "1 + 2", + "sum([i * i for i in range(10)])", + # Invalid Expressions + "def foo(): return 42", + "x = 1", + "x = 1\nx + 1", + "for i in range(10): pass", + "if True: pass", + "while True: break", + "try: pass\nexcept: pass", + # Expressions Involving Undefined Variables + "undefined_variable", + "undefined_function()", + # Expressions with Deliberate Errors + "1/0", + "open('nonexistent_file.txt')", + # Lambda and Anonymous Functions + "(lambda x: x * 2)(5)", + # Expressions with Built-in Functions + "len('test')", + "min([3, 1, 4, 1, 5, 9])", + "max([x * x for x in range(10)])", + # Boolean and Conditional Expressions + "True and False", + "not True", # Boolean negation + "5 if True else 10", + # String Manipulation + "'hello' + ' ' + 'world'", + "f'hello {42}'", + # Complex List Comprehensions + "[x for x in range(5)]", + "[x * x for x in range(10) if x % 2 == 0]", + # Expressions with Syntax Errors + "return 42", + "yield 5", + ], +) +async def test_aeval(expression): + # Set up a namespace that has all the needed functions + namespace = {"aecho": aecho, "echo": echo} + + # Capture the result or exception of the synchronous eval + sync_exc = None + result = None + try: + sync_expression = expression.lstrip("await a") + result = eval(sync_expression, namespace) + except Exception as exc: + sync_exc = exc + + # Capture the result or exception of the asynchronous eval + async_exc = None + async_result = None + try: + async_result = await aeval(expression, namespace) + except Exception as exc: + async_exc = exc + + # Assert that the exceptions are of the same type + assert type(sync_exc) == type(async_exc) + # Assert that the results match + assert result == async_result From a38dcd5dc385f93d58e66f887b9d0406d147813b Mon Sep 17 00:00:00 2001 From: Tyler Levy Conde Date: Mon, 26 Aug 2024 11:06:57 -0600 Subject: [PATCH 3/8] Conform to variable names and style of other functions --- aioconsole/execute.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/aioconsole/execute.py b/aioconsole/execute.py index 54963f5..1e25792 100644 --- a/aioconsole/execute.py +++ b/aioconsole/execute.py @@ -151,25 +151,25 @@ async def aexec(source, local=None, stream=None, filename=""): full_update(local, new_local) -async def aeval(input_string: str, local_namespace: dict = None): - """Asynchronous equivalent to *eval* that evaluates expressions.""" - if local_namespace is None: - local_namespace = {} +async def aeval(source, local=None): + """Asynchronous equivalent to *eval*.""" + if local is None: + local = {} # Perform syntax check to ensure the input is a valid eval expression try: - ast.parse(input_string, mode="eval") + ast.parse(source, mode="eval") except SyntaxError: raise - # Use aexec to execute the input string within an async function context - wrapped_code = f"__result__ = {input_string}" + # Assign the result of the expression to a known variable + wrapped_code = f"__aeval_result__ = {source}" # Use aexec to evaluate the wrapped code within the given local namespace - await aexec(wrapped_code, local=local_namespace) + await aexec(wrapped_code, local=local) # Retrieve the result from the local namespace - result = local_namespace.get("__result__") + result = local.get("__aeval_result__") if asyncio.iscoroutine(result): result = await result From 5a0eeaf329f46854cec02f98df393f16bfd2b7dc Mon Sep 17 00:00:00 2001 From: Tyler Levy Conde Date: Tue, 27 Aug 2024 10:40:54 -0600 Subject: [PATCH 4/8] Add more exhaustive tests for aeval --- aioconsole/execute.py | 18 +++---- tests/test_execute.py | 109 +++++++++++++++++++++++++++++------------- 2 files changed, 85 insertions(+), 42 deletions(-) diff --git a/aioconsole/execute.py b/aioconsole/execute.py index 1e25792..c65330e 100644 --- a/aioconsole/execute.py +++ b/aioconsole/execute.py @@ -1,6 +1,5 @@ """Provide an asynchronous equivalent *to exec*.""" -import asyncio import ast import codeop from io import StringIO @@ -156,6 +155,13 @@ async def aeval(source, local=None): if local is None: local = {} + if not isinstance(local, dict): + raise TypeError("globals must be a dict") + + key = "__result__" + # Ensure that the result key is unique within the local namespace + # while key in local: key += "_" + # Perform syntax check to ensure the input is a valid eval expression try: ast.parse(source, mode="eval") @@ -163,14 +169,10 @@ async def aeval(source, local=None): raise # Assign the result of the expression to a known variable - wrapped_code = f"__aeval_result__ = {source}" + wrapped_code = f"{key} = {source}" # Use aexec to evaluate the wrapped code within the given local namespace await aexec(wrapped_code, local=local) - # Retrieve the result from the local namespace - result = local.get("__aeval_result__") - if asyncio.iscoroutine(result): - result = await result - - return result + # Return the result from the local namespace + return local.pop(key) diff --git a/tests/test_execute.py b/tests/test_execute.py index 87f4d8b..c482948 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -137,56 +137,79 @@ async def aecho(x): # Parametrized test with a variety of expressions @pytest.mark.asyncio @pytest.mark.parametrize( - "expression", + "expression, local", [ # Valid Simple Expressions - "1 + 2", - "sum([i * i for i in range(10)])", + ("1 + 2", None), + ("sum([i * i for i in range(10)])", None), # Invalid Expressions - "def foo(): return 42", - "x = 1", - "x = 1\nx + 1", - "for i in range(10): pass", - "if True: pass", - "while True: break", - "try: pass\nexcept: pass", + ("def foo(): return 42", None), + ("x = 1", None), + ("x = 1\nx + 1", None), + ("for i in range(10): pass", None), + ("if True: pass", None), + ("while True: break", None), + ("try: pass\nexcept: pass", None), # Expressions Involving Undefined Variables - "undefined_variable", - "undefined_function()", + ("undefined_variable", None), + ("undefined_function()", None), # Expressions with Deliberate Errors - "1/0", - "open('nonexistent_file.txt')", + ("1/0", None), + ("open('nonexistent_file.txt')", None), # Lambda and Anonymous Functions - "(lambda x: x * 2)(5)", + ("(lambda x: x * 2)(5)", None), # Expressions with Built-in Functions - "len('test')", - "min([3, 1, 4, 1, 5, 9])", - "max([x * x for x in range(10)])", + ("len('test')", None), + ("min([3, 1, 4, 1, 5, 9])", None), + ("max([x * x for x in range(10)])", None), # Boolean and Conditional Expressions - "True and False", - "not True", # Boolean negation - "5 if True else 10", + ("True and False", None), + ("not True", None), # Boolean negation + ("5 if True else 10", None), # String Manipulation - "'hello' + ' ' + 'world'", - "f'hello {42}'", + ("'hello' + ' ' + 'world'", None), + ("f'hello {42}'", None), # Complex List Comprehensions - "[x for x in range(5)]", - "[x * x for x in range(10) if x % 2 == 0]", + ("[x for x in range(5)]", None), + ("[x * x for x in range(10) if x % 2 == 0]", None), # Expressions with Syntax Errors - "return 42", - "yield 5", + ("return 42", None), + ("yield 5", None), + # Test with await + ("await aecho(5)", {"aecho": aecho, "echo": echo}), + # Test invalid local + ("...", []), + ("...", "string_instead_of_dict"), + ("...", 42), + ("...", set()), + ("...", ...), + ("...", 1.5), + ("...", object()), + ("...", asyncio), + ("...", lambda: ...), + ("...", {"__result__": 99}), + # Invalid expressions + ("", None), + (None, None), + (0, None), + ({}, None), + (object(), None), + (asyncio, None), + (..., None), + (lambda: ..., None), ], ) -async def test_aeval(expression): - # Set up a namespace that has all the needed functions - namespace = {"aecho": aecho, "echo": echo} - +async def test_aeval(expression, local): # Capture the result or exception of the synchronous eval sync_exc = None result = None try: - sync_expression = expression.lstrip("await a") - result = eval(sync_expression, namespace) + if isinstance(expression, str): + sync_expression = expression.lstrip("await a") + else: + sync_expression = expression + + result = eval(sync_expression, local) except Exception as exc: sync_exc = exc @@ -194,7 +217,7 @@ async def test_aeval(expression): async_exc = None async_result = None try: - async_result = await aeval(expression, namespace) + async_result = await aeval(expression, local) except Exception as exc: async_exc = exc @@ -202,3 +225,21 @@ async def test_aeval(expression): assert type(sync_exc) == type(async_exc) # Assert that the results match assert result == async_result + + +# Test calling an async function without awaiting it +@pytest.mark.asyncio +async def test_aeval_async_func_without_await(): + expression = "asyncio.sleep(0)" + local = {"asyncio": asyncio} + result = await aeval(expression, local) + assert asyncio.iscoroutine(result) + await result + + +@pytest.mark.asyncio +async def test_aeval_valid_await_syntax(): + expression = "await aecho(10)" + local = {"aecho": aecho} + result = await aeval(expression, local) + assert result == 10 From 016b3c34dfbccb950a0037bbf0492e029f8fa9b0 Mon Sep 17 00:00:00 2001 From: Tyler Levy Conde Date: Tue, 27 Aug 2024 10:44:20 -0600 Subject: [PATCH 5/8] Add test for coro in local --- tests/test_execute.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_execute.py b/tests/test_execute.py index c482948..8d5f0ea 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -243,3 +243,11 @@ async def test_aeval_valid_await_syntax(): local = {"aecho": aecho} result = await aeval(expression, local) assert result == 10 + + +@pytest.mark.asyncio +async def test_aeval_coro_in_local(): + expression = "await coro" + local = {"coro": aecho(10)} + result = await aeval(expression, local) + assert result == 10 From 54e6c0911733f2993dc33a59fb3758808d408a73 Mon Sep 17 00:00:00 2001 From: Vincent Michel Date: Sat, 31 Aug 2024 11:38:51 +0200 Subject: [PATCH 6/8] Avoid local name conflict in aeval --- aioconsole/execute.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/aioconsole/execute.py b/aioconsole/execute.py index c65330e..8e2e2c3 100644 --- a/aioconsole/execute.py +++ b/aioconsole/execute.py @@ -158,9 +158,10 @@ async def aeval(source, local=None): if not isinstance(local, dict): raise TypeError("globals must be a dict") - key = "__result__" # Ensure that the result key is unique within the local namespace - # while key in local: key += "_" + key = "__aeval_result__" + while key in local: + key += "_" # Perform syntax check to ensure the input is a valid eval expression try: From c1bc21ec7d4440161c9dd74e27747e85f67aee64 Mon Sep 17 00:00:00 2001 From: Vincent Michel Date: Sat, 31 Aug 2024 11:40:11 +0200 Subject: [PATCH 7/8] Minor refactoring of aeval test --- tests/test_execute.py | 46 +++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/tests/test_execute.py b/tests/test_execute.py index 8d5f0ea..8739e5f 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -127,10 +127,12 @@ async def test_correct(): def echo(x): + """Sync function for aeval parameterized test.""" return x async def aecho(x): + """Async function for aeval parameterized test.""" return echo(x) @@ -200,31 +202,27 @@ async def aecho(x): ], ) async def test_aeval(expression, local): - # Capture the result or exception of the synchronous eval - sync_exc = None - result = None - try: - if isinstance(expression, str): - sync_expression = expression.lstrip("await a") + + async def capture(func, *args, **kwargs): + try: + if asyncio.iscoroutinefunction(func): + result = await func(*args, **kwargs) + else: + result = func(*args, **kwargs) + except Exception as exc: + return (type(exc), None) else: - sync_expression = expression - - result = eval(sync_expression, local) - except Exception as exc: - sync_exc = exc - - # Capture the result or exception of the asynchronous eval - async_exc = None - async_result = None - try: - async_result = await aeval(expression, local) - except Exception as exc: - async_exc = exc - - # Assert that the exceptions are of the same type - assert type(sync_exc) == type(async_exc) - # Assert that the results match - assert result == async_result + return (None, result) + + # Remove the await keyword from the expression for the synchronous evaluation + sync_expression = ( + expression.replace("await a", "") if isinstance(expression, str) else expression + ) + + # Capture and compare the results of the synchronous and asynchronous evaluations + sync_capture = await capture(eval, sync_expression, local) + async_capture = await capture(aeval, expression, local) + assert sync_capture == async_capture # Test calling an async function without awaiting it From 43306a06e3fd79a3a61ba0e188c7033a0b93ecb1 Mon Sep 17 00:00:00 2001 From: Vincent Michel Date: Sat, 31 Aug 2024 11:43:57 +0200 Subject: [PATCH 8/8] Add extra test case to test_aeval --- tests/test_execute.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_execute.py b/tests/test_execute.py index 8739e5f..e7d574d 100644 --- a/tests/test_execute.py +++ b/tests/test_execute.py @@ -199,6 +199,8 @@ async def aecho(x): (asyncio, None), (..., None), (lambda: ..., None), + # Conflicting name in local + ("x", {"x": 1, "__aeval_result__": 99}), ], ) async def test_aeval(expression, local):