Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 34 additions & 27 deletions custom_components/pyscript/eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -1280,43 +1280,50 @@ async def ast_raise(self, arg):
raise # pylint: disable=misplaced-bare-raise

async def ast_with(self, arg, async_attr=""):
"""Execute with statement."""
hit_except = False
ctx_list = []
"""
Execute with statement.

Several items behave like nested with statements: each manager is
evaluated and entered in turn, only managers that were entered are
exited, and they are exited innermost first. An exit that returns true
suppresses the error, so the outer exits see no error; an exit that
raises replaces the error for the outer exits.
"""
entered = []
val = None
error = None
enter_attr = f"__{async_attr}enter__"
exit_attr = f"__{async_attr}exit__"
try:
for item in arg.items:
manager = await self.aeval(item.context_expr)
ctx_list.append(
{
"manager": manager,
"enter": getattr(type(manager), enter_attr),
"exit": getattr(type(manager), exit_attr),
"target": item.optional_vars,
}
)
for ctx in ctx_list:
value = await self.call_func(ctx["enter"], enter_attr, ctx["manager"])
if ctx["target"]:
await self.recurse_assign(ctx["target"], value)
enter_func = getattr(type(manager), enter_attr)
exit_func = getattr(type(manager), exit_attr)
value = await self.call_func(enter_func, enter_attr, manager)
entered.append((manager, exit_func))
if item.optional_vars:
await self.recurse_assign(item.optional_vars, value)
for arg1 in arg.body:
val = await self.aeval(arg1)
if isinstance(val, EvalStopFlow):
break
except Exception:
hit_except = True
exit_ok = True
for ctx in reversed(ctx_list):
ret = await self.call_func(ctx["exit"], exit_attr, ctx["manager"], *sys.exc_info())
exit_ok = exit_ok and ret
if not exit_ok:
raise
finally:
if not hit_except:
for ctx in reversed(ctx_list):
await self.call_func(ctx["exit"], exit_attr, ctx["manager"], None, None, None)
except BaseException as exc: # pylint: disable=broad-exception-caught

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With BaseException, an exit returning True now suppresses CancelledError, so task.cancel()/task.unique() can no longer stop the task (see point 3 in the review). Please run the exits for CancelledError, KeyboardInterrupt and SystemExit but always re-raise them.

# also CancelledError, which the exits must see like in Python
error = exc
for manager, exit_func in reversed(entered):
try:
if error is None:
await self.call_func(exit_func, exit_attr, manager, None, None, None)
elif await self.call_func(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs after the except block has finished, so sys.exc_info() is empty inside __exit__. A bare raise in a pyscript __exit__ gives RuntimeError: No active exception to reraise (point 1).

exit_func, exit_attr, manager, type(error), error, error.__traceback__
):
error = None
except BaseException as exc: # pylint: disable=broad-exception-caught
if error is not None and exc is not error and exc.__context__ is None:
exc.__context__ = error

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the exit's exception already has a __context__ (raised from inside an except in __exit__), the body's error is dropped from the chain. CPython keeps it at the end: IndexError → KeyError → ZeroDivisionError (point 2).

error = exc
if error is not None:
raise error

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: re-raising a long-lived local creates a frame → exception → traceback → frame cycle, which keeps the managers alive until GC. If you keep this structure, use try: raise error / finally: error = None. The recursive version in the review avoids it.

return val

async def ast_asyncwith(self, arg):
Expand Down
123 changes: 123 additions & 0 deletions tests/test_unit_eval.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""Unit tests for Python interpreter."""

import ast as ast_module
import contextlib
from dataclasses import dataclass
import types

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: from types import ModuleType is already imported. Use from types import FunctionType, ModuleType instead of adding import types.

from types import ModuleType

import pytest
Expand Down Expand Up @@ -2185,3 +2188,123 @@ def test_reraise():
await Function.waiter_sync()
await Function.waiter_stop()
await Function.reaper_stop()


WITH_SEMANTICS_SOURCE = """
class Ctx:
def __init__(self, msgs, name, fail_enter=False, suppress=False, fail_exit=False):
self.msgs = msgs
self.name = name
self.fail_enter = fail_enter
self.suppress = suppress
self.fail_exit = fail_exit

def __enter__(self):
self.msgs.append(f"enter {self.name}")
if self.fail_enter:
raise ValueError(f"enter {self.name}")
return self.name

def __exit__(self, exc_type, exc, tb):
self.msgs.append(f"exit {self.name} {exc_type.__name__ if exc_type else None}")
if self.fail_exit:
raise KeyError(f"exit {self.name}")
return self.suppress

class ACtx:
def __init__(self, msgs, name, **kwargs):
self.ctx = Ctx(msgs, name, **kwargs)

async def __aenter__(self):
return self.ctx.__enter__()

async def __aexit__(self, exc_type, exc, tb):
return self.ctx.__exit__(exc_type, exc, tb)

def make(msgs, name, **kwargs):
msgs.append(f"make {name}")
return Ctx(msgs, name, **kwargs)

def amake(msgs, name, **kwargs):
msgs.append(f"make {name}")
return ACtx(msgs, name, **kwargs)

def describe(exc):
context = type(exc.__context__).__name__ if exc.__context__ else None
return f"raised {type(exc).__name__} {exc} context {context}"

results = []
cases = [
{"b": {"fail_enter": True}},
{"body": True, "b": {"suppress": True}},
{"body": True, "a": {"suppress": True}},
{"body": True, "b": {"fail_exit": True}},
{"b": {"fail_exit": True}},
{"body": True, "a": {"fail_exit": True}, "b": {"suppress": True}},
{},
]
for case in cases:
msgs = []
try:
with make(msgs, "a", **case.get("a", {})) as a, make(msgs, "b", **case.get("b", {})) as b:
msgs.append(f"body {a} {b}")
if case.get("body"):
raise ZeroDivisionError("body")
msgs.append("no error")
except Exception as exc:
msgs.append(describe(exc))
results.append(msgs)

msgs = []
try:
async with amake(msgs, "a", **case.get("a", {})) as a, amake(msgs, "b", **case.get("b", {})) as b:
msgs.append(f"body {a} {b}")
if case.get("body"):
raise ZeroDivisionError("body")
msgs.append("no error")
except Exception as exc:
msgs.append(describe(exc))
results.append(msgs)

msgs = []
try:
async with failing_acm():
msgs.append("body")
except Exception as exc:
msgs.append(describe(exc))
results.append(msgs)
"""


@contextlib.asynccontextmanager
async def failing_acm():
"""Fail on enter, like a generator based manager whose connection is refused."""
raise ConnectionRefusedError("refused")
yield # pylint: disable=unreachable


async def test_with_matches_python(hass):
"""Test that with and async with behave like Python, compared with CPython itself."""
hass.data[DOMAIN] = {CONFIG_ENTRY: MockConfigEntry(domain=DOMAIN, data={CONF_ALLOW_ALL_IMPORTS: True})}
Function.init(hass)
State.init(hass)
State.register_functions()
TrigTime.init(hass)

expected_ns = {"failing_acm": failing_acm}
code = compile(WITH_SEMANTICS_SOURCE, "test", "exec", flags=ast_module.PyCF_ALLOW_TOP_LEVEL_AWAIT)
# code compiled with top level await runs as a coroutine function, like the asyncio REPL
await types.FunctionType(code, expected_ns)()
expected = expected_ns["results"]

global_ctx = GlobalContext(
"test", global_sym_table={"failing_acm": failing_acm}, manager=GlobalContextMgr
)
ast = AstEval("test", global_ctx=global_ctx)
Function.install_ast_funcs(ast)
ast.parse(WITH_SEMANTICS_SOURCE + "results\n")
assert await ast.eval() == expected

await Function.waiter_sync()
await Function.waiter_stop()
await Function.reaper_stop()