From d1ddf37ac9a772ae99a0c75afde14cccad35a6dd Mon Sep 17 00:00:00 2001 From: dmamelin Date: Wed, 26 Nov 2025 02:25:32 +0100 Subject: [PATCH 1/4] full traceback for debug level --- custom_components/pyscript/eval.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/custom_components/pyscript/eval.py b/custom_components/pyscript/eval.py index 85bad74..0afa0f8 100644 --- a/custom_components/pyscript/eval.py +++ b/custom_components/pyscript/eval.py @@ -12,6 +12,7 @@ import logging import sys import time +import traceback import weakref import yaml @@ -2197,11 +2198,9 @@ def format_exc(self, exc, lineno=None, col_offset=None, short=False, code_list=N else: mesg = f"Exception in <{self.filename}>:\n" mesg += f"{type(exc).__name__}: {exc}" - # - # to get a more detailed traceback on exception (eg, when chasing an internal - # error), add an "import traceback" above, and uncomment this next line - # - # return mesg + "\n" + traceback.format_exc(-1) + + if _LOGGER.isEnabledFor(logging.DEBUG): + mesg += "\n" + traceback.format_exc() return mesg def get_exception(self): From 02c078059aa7fcd4c1a035be45f0ce6e6cad1fb0 Mon Sep 17 00:00:00 2001 From: dmamelin Date: Wed, 26 Nov 2025 20:51:14 +0100 Subject: [PATCH 2/4] improve class loading (dataclass, enum, metaclass, keywords, etc). --- custom_components/pyscript/eval.py | 34 +++++++++++++--- tests/test_unit_eval.py | 62 ++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/custom_components/pyscript/eval.py b/custom_components/pyscript/eval.py index 0afa0f8..a20106e 100644 --- a/custom_components/pyscript/eval.py +++ b/custom_components/pyscript/eval.py @@ -1091,12 +1091,18 @@ async def ast_while(self, arg): async def ast_classdef(self, arg): """Evaluate class definition.""" bases = [(await self.aeval(base)) for base in arg.bases] + keywords = {kw.arg: await self.aeval(kw.value) for kw in arg.keywords} + metaclass = keywords.pop("metaclass", type(bases[0]) if bases else type) + if self.curr_func and arg.name in self.curr_func.global_names: sym_table_assign = self.global_sym_table else: sym_table_assign = self.sym_table sym_table_assign[arg.name] = EvalLocalVar(arg.name) - sym_table = {} + if hasattr(metaclass, "__prepare__"): + sym_table = metaclass.__prepare__(arg.name, tuple(bases), **keywords) + else: + sym_table = {} self.sym_table_stack.append(self.sym_table) self.sym_table = sym_table for arg1 in arg.body: @@ -1107,11 +1113,17 @@ async def ast_classdef(self, arg): raise SyntaxError(f"{val.name()} statement outside loop") self.sym_table = self.sym_table_stack.pop() + decorators = [await self.aeval(dec) for dec in arg.decorator_list] sym_table["__init__evalfunc_wrap__"] = None if "__init__" in sym_table: sym_table["__init__evalfunc_wrap__"] = sym_table["__init__"] del sym_table["__init__"] - sym_table_assign[arg.name].set(type(arg.name, tuple(bases), sym_table)) + cls = metaclass(arg.name, tuple(bases), sym_table, **keywords) + if inspect.iscoroutine(cls): + cls = await cls + for dec_func in reversed(decorators): + cls = await self.call_func(dec_func, None, cls) + sym_table_assign[arg.name].set(cls) async def ast_functiondef(self, arg, async_func=False): """Evaluate function definition.""" @@ -1488,7 +1500,11 @@ async def ast_augassign(self, arg): await self.recurse_assign(arg.target, new_val) async def ast_annassign(self, arg): - """Execute type hint assignment statement (just ignore the type hint).""" + """Execute type hint assignment statement and track __annotations__.""" + if isinstance(arg.target, ast.Name): + annotations = self.sym_table.setdefault("__annotations__", {}) + if arg.annotation: + annotations[arg.target.id] = await self.aeval(arg.annotation) if arg.value is not None: rhs = await self.aeval(arg.value) await self.recurse_assign(arg.target, rhs) @@ -1962,7 +1978,8 @@ async def call_func(self, func, func_name, *args, **kwargs): if isinstance(func, (EvalFunc, EvalFuncVar)): return await func.call(self, *args, **kwargs) if inspect.isclass(func) and hasattr(func, "__init__evalfunc_wrap__"): - inst = func() + has_init_wrapper = getattr(func, "__init__evalfunc_wrap__") is not None + inst = func(*args, **kwargs) if not has_init_wrapper else func() # # we use weak references when we bind the method calls to the instance inst; # otherwise these self references cause the object to not be deleted until @@ -1970,11 +1987,16 @@ async def call_func(self, func, func_name, *args, **kwargs): # inst_weak = weakref.ref(inst) for name in dir(inst): - value = getattr(inst, name) + try: + value = getattr(inst, name) + except AttributeError: + # same effect as hasattr (which also catches AttributeError) + # dir() may list names that aren't actually accessible attributes + continue if type(value) is not EvalFuncVar: continue setattr(inst, name, EvalFuncVarClassInst(value.get_func(), value.get_ast_ctx(), inst_weak)) - if getattr(func, "__init__evalfunc_wrap__") is not None: + if has_init_wrapper: # # since our __init__ function is async, call the renamed one # diff --git a/tests/test_unit_eval.py b/tests/test_unit_eval.py index cebf205..a4a1721 100644 --- a/tests/test_unit_eval.py +++ b/tests/test_unit_eval.py @@ -144,6 +144,68 @@ ["x: int = [10, 20]; x", [10, 20]], ["Foo = type('Foo', (), {'x': 100}); Foo.x = 10; Foo.x", 10], ["Foo = type('Foo', (), {'x': 100}); Foo.x += 10; Foo.x", 110], + [ + """ +from enum import IntEnum + +class TestIntMode(IntEnum): + VAL1 = 1 + VAL2 = 2 + VAL3 = 3 +[TestIntMode.VAL2 == 2, isinstance(TestIntMode.VAL3, IntEnum)] +""", + [True, True], + ], + [ + """ +from enum import StrEnum + +class TestStrEnum(StrEnum): + VAL1 = "val1" + VAL2 = "val2" + VAL3 = "val3" +[TestStrEnum.VAL2 == "val2", isinstance(TestStrEnum.VAL3, StrEnum)] +""", + [True, True], + ], + [ + """ +from enum import Enum, EnumMeta + +class Color(Enum): + RED = 1 + BLUE = 2 +[type(Color) is EnumMeta, isinstance(Color.RED, Color), list(Color.__members__.keys())] +""", + [True, True, ["RED", "BLUE"]], + ], + [ + """ +from dataclasses import dataclass + +@dataclass() +class DT: + name: str + num: int = 32 +obj1 = DT(name="abc") +obj2 = DT("xyz", 5) +[obj1.name, obj1.num, obj2.name, obj2.num] +""", + ["abc", 32, "xyz", 5], + ], + [ + """ +class Meta(type): + def __new__(mcls, name, bases, ns, flag=False): + ns["flag"] = flag + return type.__new__(mcls, name, bases, ns) + +class Foo(metaclass=Meta, flag=True): + pass +[Foo.flag, isinstance(Foo, Meta)] +""", + [True, True], + ], ["Foo = [type('Foo', (), {'x': 100})]; Foo[0].x = 10; Foo[0].x", 10], ["Foo = [type('Foo', (), {'x': [100, 101]})]; Foo[0].x[1] = 10; Foo[0].x", [100, 10]], ["Foo = [type('Foo', (), {'x': [0, [[100, 101]]]})]; Foo[0].x[1][0][1] = 10; Foo[0].x[1]", [[100, 10]]], From 16a789a227bbe372f49ada4ab5d917c7c668b924 Mon Sep 17 00:00:00 2001 From: dmamelin Date: Thu, 27 Nov 2025 15:04:35 +0100 Subject: [PATCH 3/4] add class decorator test --- tests/test_unit_eval.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_unit_eval.py b/tests/test_unit_eval.py index a4a1721..0531946 100644 --- a/tests/test_unit_eval.py +++ b/tests/test_unit_eval.py @@ -206,6 +206,22 @@ class Foo(metaclass=Meta, flag=True): """, [True, True], ], + [ + """ +def deco(label): + def wrap(cls): + cls.labels.append(label) + return cls + return wrap + +@deco("first") +@deco("second") +class Decorated: + labels = [] +Decorated.labels +""", + ["second", "first"], + ], ["Foo = [type('Foo', (), {'x': 100})]; Foo[0].x = 10; Foo[0].x", 10], ["Foo = [type('Foo', (), {'x': [100, 101]})]; Foo[0].x[1] = 10; Foo[0].x", [100, 10]], ["Foo = [type('Foo', (), {'x': [0, [[100, 101]]]})]; Foo[0].x[1][0][1] = 10; Foo[0].x[1]", [[100, 10]]], From e4e4eda015a15eddedffb9b0138328906de046a9 Mon Sep 17 00:00:00 2001 From: dmamelin Date: Thu, 27 Nov 2025 15:05:59 +0100 Subject: [PATCH 4/4] add class annotation test --- tests/test_unit_eval.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_unit_eval.py b/tests/test_unit_eval.py index 0531946..2bef2df 100644 --- a/tests/test_unit_eval.py +++ b/tests/test_unit_eval.py @@ -222,6 +222,22 @@ class Decorated: """, ["second", "first"], ], + [ + """ +hits = [] + +def anno(): + hits.append("ok") + return int + +class Annotated: + a: anno() + b: int = 3 + c = "skip" +[hits, Annotated.__annotations__, Annotated.b, hasattr(Annotated, "c")] +""", + [["ok"], {"a": int, "b": int}, 3, True], + ], ["Foo = [type('Foo', (), {'x': 100})]; Foo[0].x = 10; Foo[0].x", 10], ["Foo = [type('Foo', (), {'x': [100, 101]})]; Foo[0].x[1] = 10; Foo[0].x", [100, 10]], ["Foo = [type('Foo', (), {'x': [0, [[100, 101]]]})]; Foo[0].x[1][0][1] = 10; Foo[0].x[1]", [[100, 10]]],