diff --git a/custom_components/pyscript/eval.py b/custom_components/pyscript/eval.py index 479ef55..8632368 100644 --- a/custom_components/pyscript/eval.py +++ b/custom_components/pyscript/eval.py @@ -4,6 +4,7 @@ import asyncio import builtins from collections import OrderedDict +import functools import importlib import inspect import io @@ -54,6 +55,11 @@ ALL_DECORATORS = TRIG_DECORATORS.union({"service"}) +COMP_DECORATORS = { + "pyscript_compile", + "pyscript_executor", +} + def ast_eval_exec_factory(ast_ctx, mode): """Generate a function that executes eval() or exec() with given ast_ctx.""" @@ -954,31 +960,59 @@ async def ast_classdef(self, arg): async def ast_functiondef(self, arg): """Evaluate function definition.""" + other_dec = [] + dec_name = None + pyscript_compile = None for dec in arg.decorator_list: - if isinstance(dec, ast.Name): - if dec.id != "pyscript_compile": - continue - arg.decorator_list = [] - local_var = None - if arg.name in self.sym_table and isinstance(self.sym_table[arg.name], EvalLocalVar): - local_var = self.sym_table[arg.name] - code = compile(ast.Module(body=[arg], type_ignores=[]), filename=self.filename, mode="exec") - exec(code, self.global_sym_table, self.sym_table) # pylint: disable=exec-used - - func = self.sym_table[arg.name] + if isinstance(dec, ast.Name) and dec.id in COMP_DECORATORS: + dec_name = dec.id + elif ( + isinstance(dec, ast.Call) + and isinstance(dec.func, ast.Name) + and dec.func.id in COMP_DECORATORS + ): + dec_name = dec.func.id + else: + other_dec.append(dec) + continue + if pyscript_compile: + raise SyntaxError( + f"can only specify single decorator of {', '.join(sorted(COMP_DECORATORS))}" + ) + pyscript_compile = dec + + if pyscript_compile: + if isinstance(pyscript_compile, ast.Call): + if len(pyscript_compile.args) > 0: + raise TypeError(f"@{dec_name}() takes 0 positional arguments") + if len(pyscript_compile.keywords) > 0: + raise TypeError(f"@{dec_name}() takes no keyword arguments") + arg.decorator_list = other_dec + local_var = None + if arg.name in self.sym_table and isinstance(self.sym_table[arg.name], EvalLocalVar): + local_var = self.sym_table[arg.name] + code = compile(ast.Module(body=[arg], type_ignores=[]), filename=self.filename, mode="exec") + exec(code, self.global_sym_table, self.sym_table) # pylint: disable=exec-used + + func = self.sym_table[arg.name] + if dec_name == "pyscript_executor": if not asyncio.iscoroutinefunction(func): def executor_wrap_factory(func): - def executor_wrap(*args, **kwargs): - return func(*args, **kwargs) + async def executor_wrap(*args, **kwargs): + return await Function.hass.async_add_executor_job( + functools.partial(func, **kwargs), *args + ) return executor_wrap self.sym_table[arg.name] = executor_wrap_factory(func) - if local_var: - self.sym_table[arg.name] = local_var - self.sym_table[arg.name].set(func) - return + else: + raise TypeError("@pyscript_executor() needs a regular, not async, function") + if local_var: + self.sym_table[arg.name] = local_var + self.sym_table[arg.name].set(func) + return func = EvalFunc(arg, self.code_list, self.code_str, self.global_ctx) await func.eval_defaults(self) diff --git a/docs/new_features.rst b/docs/new_features.rst index 13a40b2..fd0f993 100644 --- a/docs/new_features.rst +++ b/docs/new_features.rst @@ -32,8 +32,11 @@ The new features since 1.2.1 in master include: - Multiple trigger decorators (``@state_trigger``, ``@time_trigger``, ``@event_trigger`` or ``@mqtt_trigger``) per function are now supported. See #157. +- Added ``@pyscript_executor`` decorator, which does same thing as ``@pyscript_compile`` and additionally wraps + the resulting function with a call to ``task.executor``. See #71. - Errors in trigger-related decorators (eg, wrong arguments, unregonized decorator type) raise exceptions rather than logging an error. +- Added error checking for ``@pyscript_compile`` and ``@pyscript_executor`` to enforce there are no args or kwargs. Breaking changes since 1.2.1 include: diff --git a/docs/reference.rst b/docs/reference.rst index 6a4a6be..0991c26 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -852,6 +852,37 @@ This is an experimental feature and might change in the future. Restrictions inc function), then binding of variables defined outside the scope of the inner function does not work. +@pyscript_executor +^^^^^^^^^^^^^^^^^^ + +The ``@pyscript_executor`` decorator does the same thing as ``@pyscript_compile`` and +additionally wraps the compiled function with a call to ``task.executor``. The resulting +function is now a pyscript (async) function that can be called like any other pyscript +function. This provides the cleanest way of defining a native python function that is +executed in a new thread each time it is called, which is required for functions that +does I/O or otherwise might block. + +The file reading example above is simplified with the use of ``@pyscript_executor``: + +.. code:: python + + @pyscript_executor + def read_file(file_name): + try: + with open(file_name, encoding="utf-8") as file_desc: + return file_desc.read(), None + except Exception as exc: + return None, exc + + contents, exception = read_file("config/configuration.yaml") + if exception: + raise exception + log.info(f"contents = {contents}") + +Notice that `read_file` is called like a regular function, and it automatically calls +``task.executor``, which runs the compiled native python function in a new thread, and +then returns the result. + @service ^^^^^^^^ diff --git a/tests/test_unit_eval.py b/tests/test_unit_eval.py index 19255b2..084db3e 100644 --- a/tests/test_unit_eval.py +++ b/tests/test_unit_eval.py @@ -631,6 +631,60 @@ def foo2(): ], [ """ +@pyscript_compile +def twice(func): + def twice_func(*args, **kwargs): + func(*args, **kwargs) + return func(*args, **kwargs) + return twice_func + +val = 0 +val_list = [] + +@pyscript_compile +@twice +def foo1(): + global val, val_list + val_list.append(val) + val += 1 + +@pyscript_compile +@twice +@twice +@twice +def foo2(): + global val, val_list + val_list.append(val) + val += 1 + +foo1() +foo2() +val_list +""", + list(range(0, 10)), + ], + [ + """ +import threading + +# will run in the same thread, and return a different thread ident +@pyscript_compile() +def func1(): + return threading.get_ident() + +# will run in a different thread, and return a different thread ident +@pyscript_compile() +def func2(): + return threading.get_ident() + +[threading.get_ident() == func1(), threading.get_ident() != func2()] + +[True, True] +""", + [True, True], + ], + [ + """ def twice(func): def twice_func(*args, **kwargs): func(*args, **kwargs) @@ -1174,7 +1228,7 @@ async def run_one_test(test_data): async def test_eval(hass): """Test interpreter.""" - hass.data[DOMAIN] = {CONFIG_ENTRY: MockConfigEntry(domain=DOMAIN, data={CONF_ALLOW_ALL_IMPORTS: False})} + hass.data[DOMAIN] = {CONFIG_ENTRY: MockConfigEntry(domain=DOMAIN, data={CONF_ALLOW_ALL_IMPORTS: True})} Function.init(hass) State.init(hass) State.register_functions() @@ -1364,6 +1418,31 @@ def func2(): """, "Exception in func2(), test line 4 column 12: name 'x' is not defined", ], + [ + """ +@pyscript_compile(1) +def func(): + pass +""", + "Exception in test line 3 column 0: @pyscript_compile() takes 0 positional arguments", + ], + [ + """ +@pyscript_compile(invald_kw=True) +def func(): + pass +""", + "Exception in test line 3 column 0: @pyscript_compile() takes no keyword arguments", + ], + [ + """ +@pyscript_executor() +@pyscript_compile() +def func(): + pass +""", + "Exception in test line 4 column 0: can only specify single decorator of pyscript_compile, pyscript_executor", + ], ]