Skip to content

Commit

Permalink
added @pyscript_executor decorator, which does same thing as @pyscrip…
Browse files Browse the repository at this point in the history
…t_compile and

additionally wraps the resulting function with a call to ``task.executor``; see #71.
Also added error checking for @pyscript_compile and @pyscript_executor to enforce
there are no args or kwargs.
  • Loading branch information
craigbarratt committed Feb 16, 2021
1 parent 7171c4f commit 0d8ac62
Show file tree
Hide file tree
Showing 4 changed files with 165 additions and 18 deletions.
68 changes: 51 additions & 17 deletions custom_components/pyscript/eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import asyncio
import builtins
from collections import OrderedDict
import functools
import importlib
import inspect
import io
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions docs/new_features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
31 changes: 31 additions & 0 deletions docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
^^^^^^^^

Expand Down
81 changes: 80 additions & 1 deletion tests/test_unit_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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",
],
]


Expand Down

0 comments on commit 0d8ac62

Please sign in to comment.