Skip to content

Commit

Permalink
state/time/event/mqtt trigger decorators take optional kwargs argumen…
Browse files Browse the repository at this point in the history
…t that can be set to a dict of keyword/value paris that are passed to the trigger function; see #157.
  • Loading branch information
craigbarratt committed Feb 16, 2021
1 parent 0d8ac62 commit c4a269d
Show file tree
Hide file tree
Showing 6 changed files with 73 additions and 28 deletions.
24 changes: 15 additions & 9 deletions custom_components/pyscript/eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
"task_unique",
}

ALL_DECORATORS = TRIG_DECORATORS.union({"service"})
TRIG_SERV_DECORATORS = TRIG_DECORATORS.union({"service"})

COMP_DECORATORS = {
"pyscript_compile",
Expand Down Expand Up @@ -297,10 +297,10 @@ async def trigger_init(self):
got_reqd_dec = False
exc_mesg = f"function '{self.name}' defined in {self.global_ctx_name}"
trig_decorators_reqd = {
"time_trigger",
"state_trigger",
"event_trigger",
"mqtt_trigger",
"state_trigger",
"time_trigger",
}
arg_check = {
"event_trigger": {"arg_cnt": {1, 2}, "rep_ok": True},
Expand All @@ -313,14 +313,17 @@ async def trigger_init(self):
"time_trigger": {"arg_cnt": {0, "*"}, "rep_ok": True},
}
kwarg_check = {
"event_trigger": {"kwargs"},
"mqtt_trigger": {"kwargs"},
"time_trigger": {"kwargs"},
"task_unique": {"kill_me"},
"time_active": {"hold_off"},
"state_trigger": {"state_hold", "state_check_now", "state_hold_false"},
"state_trigger": {"kwargs", "state_hold", "state_check_now", "state_hold_false"},
}

for dec in self.decorators:
dec_name, dec_args, dec_kwargs = dec[0], dec[1], dec[2]
if dec_name not in ALL_DECORATORS:
if dec_name not in TRIG_SERV_DECORATORS:
raise SyntaxError(f"{exc_mesg}: unknown decorator @{dec_name}")
if dec_name in trig_decorators_reqd:
got_reqd_dec = True
Expand All @@ -335,7 +338,9 @@ async def trigger_init(self):
if dec_args:
if "*" not in arg_cnt and len(dec_args) not in arg_cnt:
raise TypeError(
f"{exc_mesg}: decorator @{dec_name} got {len(dec_args)} argument{'s' if len(dec_args) > 1 else ''}, expected {' or '.join([str(cnt) for cnt in sorted(arg_cnt)])}"
f"{exc_mesg}: decorator @{dec_name} got {len(dec_args)}"
f" argument{'s' if len(dec_args) > 1 else ''}, expected"
f" {' or '.join([str(cnt) for cnt in sorted(arg_cnt)])}"
)
for arg_num, arg in enumerate(dec_args):
if isinstance(arg, str):
Expand Down Expand Up @@ -364,7 +369,8 @@ async def trigger_init(self):
used_kw = set(dec_kwargs.keys())
if not used_kw.issubset(kwarg_check[dec_name]):
raise TypeError(
f"{exc_mesg}: decorator @{dec_name} valid keyword arguments are: {', '.join(sorted(kwarg_check[dec_name]))}"
f"{exc_mesg}: decorator @{dec_name} valid keyword arguments are: "
+ ", ".join(sorted(kwarg_check[dec_name]))
)
if dec_kwargs is None:
dec_kwargs = {}
Expand Down Expand Up @@ -497,12 +503,12 @@ async def eval_decorators(self, ast_ctx):
if (
isinstance(dec, ast.Call)
and isinstance(dec.func, ast.Name)
and dec.func.id in ALL_DECORATORS
and dec.func.id in TRIG_SERV_DECORATORS
):
args = [await ast_ctx.aeval(arg) for arg in dec.args]
kwargs = {keyw.arg: await ast_ctx.aeval(keyw.value) for keyw in dec.keywords}
dec_trig.append([dec.func.id, args, kwargs if len(kwargs) > 0 else None])
elif isinstance(dec, ast.Name) and dec.id in ALL_DECORATORS:
elif isinstance(dec, ast.Name) and dec.id in TRIG_SERV_DECORATORS:
dec_trig.append([dec.id, None, None])
else:
dec_other.append(await ast_ctx.aeval(dec))
Expand Down
9 changes: 9 additions & 0 deletions custom_components/pyscript/trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -758,8 +758,11 @@ def __init__(
self.state_hold_false = self.state_trigger_kwargs.get("state_hold_false", None)
self.state_check_now = self.state_trigger_kwargs.get("state_check_now", False)
self.time_trigger = trig_cfg.get("time_trigger", {}).get("args", None)
self.time_trigger_kwargs = trig_cfg.get("time_trigger", {}).get("kwargs", {})
self.event_trigger = trig_cfg.get("event_trigger", {}).get("args", None)
self.event_trigger_kwargs = trig_cfg.get("event_trigger", {}).get("kwargs", {})
self.mqtt_trigger = trig_cfg.get("mqtt_trigger", {}).get("args", None)
self.mqtt_trigger_kwargs = trig_cfg.get("mqtt_trigger", {}).get("kwargs", {})
self.state_active = trig_cfg.get("state_active", {}).get("args", None)
self.time_active = trig_cfg.get("time_active", {}).get("args", None)
self.time_active_hold_off = trig_cfg.get("time_active", {}).get("kwargs", {}).get("hold_off", None)
Expand Down Expand Up @@ -991,11 +994,13 @@ async def trigger_watch(self):
#
trig_ok = True
new_vars = {}
user_kwargs = {}
if state_trig_timeout:
new_vars, func_args = state_trig_notify_info
state_trig_waiting = False
elif notify_type == "state":
new_vars, func_args = notify_info
user_kwargs = self.state_trigger_kwargs.get("kwargs", {})

if not ident_any_values_changed(func_args, self.state_trig_ident_any):
#
Expand Down Expand Up @@ -1077,14 +1082,17 @@ async def trigger_watch(self):

elif notify_type == "event":
func_args = notify_info
user_kwargs = self.event_trigger_kwargs.get("kwargs", {})
if self.event_trig_expr:
trig_ok = await self.event_trig_expr.eval(notify_info)
elif notify_type == "mqtt":
func_args = notify_info
user_kwargs = self.mqtt_trigger_kwargs.get("kwargs", {})
if self.mqtt_trig_expr:
trig_ok = await self.mqtt_trig_expr.eval(notify_info)

else:
user_kwargs = self.time_trigger_kwargs.get("kwargs", {})
func_args = notify_info

#
Expand Down Expand Up @@ -1119,6 +1127,7 @@ async def trigger_watch(self):
)
continue

func_args.update(user_kwargs)
if self.call_action(notify_type, func_args):
last_trig_time = time.monotonic()

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,6 +32,9 @@ 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.
- Trigger decorators (``@state_trigger``, ``@time_trigger``, ``@event_trigger`` or ``@mqtt_trigger``) support
an optional ``kwargs`` keyword argument that can be set to a ``dict`` of keywords and values, which are
passed to the trigger function. 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
Expand Down
43 changes: 34 additions & 9 deletions docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ function.

.. code:: python
@state_trigger(str_expr, ..., state_hold=None, state_hold_false=None, state_check_now=False)
@state_trigger(str_expr, ..., state_hold=None, state_hold_false=None, state_check_now=False, kwargs=None)
``@state_trigger`` takes one or more string arguments that contain any expression based on one or
more state variables, and evaluates to ``True`` or ``False`` (or non-zero or zero). Whenever any
Expand Down Expand Up @@ -391,6 +391,15 @@ Optional arguments are:
``True`` at startup, while the ``state_hold_false`` logic will continue to wait until the expression
is ``False`` for that period before the next future trigger.

``kwargs=None``
Additional keyword arguments can be passed to the trigger function by setting ``kwargs`` to a dict
of additional keywords and values. These will override the standard keyword arguments such as
``value`` or ``var_name`` if you include those keywords in ``kwargs``. A typical use for
``kwargs`` is if you have multiple ``@state_trigger`` decorators on a single trigger function
and you want to pass additional parameters (based on the trigger, such as a setting or action)
to the function. That could save several lines of code in the function determining which trigger
occurred.

Here's a summary of the trigger behavior with these parameter settings:

=================== ==================== ================= ========================
Expand Down Expand Up @@ -474,6 +483,9 @@ The ``value`` and ``old_value`` represent the current and old values of the stat
If the trigger occurs when the state variable is newly created, ``old_value`` will be ``None``,
and if the trigger occurs when a state variable is deleted, ``value`` will be ``None``.

Additional keyword parameters can be passed to the trigger function by setting the optional
``kwargs`` parameter to a ``dict`` with the keyword/value pairs.

If your function needs to know any of these values, you can list the keyword arguments you need,
with defaults:

Expand All @@ -483,6 +495,11 @@ with defaults:
def light_turned_on(trigger_type=None, var_name=None, value=None):
pass
You don't have to list all the default keyword parameters - just the ones your function needs.
In contrast, if you specify additional keyword parameters via ``kwargs``, you will get an excepton
if the function doesn't have matching keyword arguments (unless you use the ``**kwargs`` catch-all
in the function definition).

Using ``trigger_type`` is helpful if you have multiple trigger decorators. The function can now tell
which type of trigger, and which of the two variables changed to cause the trigger. You can also use
the keyword catch-all declaration instead:
Expand All @@ -493,9 +510,10 @@ the keyword catch-all declaration instead:
def light_turned_on(**kwargs)
log.info(f"got arguments {kwargs}")
and all those values will simply get passed in into kwargs as a ``dict``. That's the most useful
form to use if you have multiple decorators, since each one passes different variables into the
function (although all of them set ``trigger_type``).
and all those values (including optional ones you specify with the ``kwargs`` argument to
``@state_trigger``) will simply get passed via ``kwargs`` as a ``dict``. That's the most useful form
to use if you have multiple decorators, since each one passes different variables into the function
(although all of them set ``trigger_type``).

If ``state_check_now`` is set to ``True`` and the trigger occurs during its immediate check, since
there is no underlying state variable change, the trigger function is called with only this argument:
Expand Down Expand Up @@ -528,14 +546,17 @@ variables will be ``None`` for that evaluation).

.. code:: python
@time_trigger(time_spec, ...)
@time_trigger(time_spec, ..., kwargs=None)
``@time_trigger`` takes one or more string specifications that specify time-based triggers. When
multiple time triggers are specified, each are evaluated, and the earliest one is the next trigger.
Then the process repeats. Alternatively, multiple time trigger specifications can be specified by using
multiple ``@time_trigger`` decorators, although that is less efficient than passing multiple arguments
to a single one.

Additional keyword parameters can be passed to the trigger function by setting the optional
``kwargs`` parameter to a ``dict`` with the keywords and values.

Several of the time specifications use a ``datetime`` format, which is ISO: ``yyyy/mm/dd hh:mm:ss``,
with the following features:

Expand Down Expand Up @@ -609,6 +630,8 @@ is set to ``"time"``, and ``trigger_time`` is the exact ``datetime`` of the time
caused the trigger (it will be slightly before the current time), or ``startup`` or ``shutdown``
in the case of a ``startup`` or ``shutdown`` trigger.

Additional optional keyword parameters can be specified in the ``kwargs`` parameter to ``@time_trigger``.

A final special form of ``@time_trigger`` has no arguments, which causes the function to run once
automatically on startup or reload, which is the same as providing a single ``"startup"`` time
specification:
Expand Down Expand Up @@ -638,7 +661,7 @@ completes.

.. code:: python
@event_trigger(event_type, str_expr=None)
@event_trigger(event_type, str_expr=None, kwargs=None)
``@event_trigger`` triggers on the given ``event_type``. Multiple ``@event_trigger`` decorators
can be applied to a single function if you want to trigger the same function with different event
Expand All @@ -656,7 +679,8 @@ Note unlike state variables, the event data values are not forced to be strings,
data has its native type.

When the ``@event_trigger`` occurs, those same variables are passed as keyword arguments to the
function in case it needs them.
function in case it needs them. Additional keyword parameters can be specified by setting the
optional ``kwargs`` argument to a ``dict`` with the keywords and values.

The ``event_type`` could be a user-defined string, or it could be one of the built-in events. You
can access the names of those events by importing from ``homeassistant.const``, eg:
Expand Down Expand Up @@ -700,7 +724,7 @@ more examples of built-in and user events and how to create triggers for them.

.. code:: python
@mqtt_trigger(topic, str_expr=None)
@mqtt_trigger(topic, str_expr=None, kwargs=None)
``@mqtt_trigger`` subscribes to the given MQTT ``topic`` and triggers whenever a message is received
on that topic. Multiple ``@mqtt_trigger`` decorators can be applied to a single function if you want
Expand All @@ -717,7 +741,8 @@ variables:
representing that payload.

When the ``@mqtt_trigger`` occurs, those same variables are passed as keyword arguments to the
function in case it needs them.
function in case it needs them. Additional keyword parameters can be specified by setting the
optional ``kwargs`` argument to a ``dict`` with the keywords and values.

Wildcards in topics are supported. The ``topic`` variables will be set to the full expanded topic
the message arrived on.
Expand Down
20 changes: 11 additions & 9 deletions tests/test_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,21 +200,21 @@ async def test_state_trigger(hass, caplog):
seq_num = 0
@time_trigger("startup")
def func_startup_sync(trigger_type=None, trigger_time=None):
@time_trigger("startup", kwargs={"var1": 123})
def func_startup_sync(trigger_type=None, trigger_time=None, var1=None):
global seq_num
seq_num += 1
log.info(f"func_startup_sync setting pyscript.done = {seq_num}, trigger_type = {trigger_type}, trigger_time = {trigger_time}")
log.info(f"func_startup_sync setting pyscript.done = {seq_num}, trigger_type = {trigger_type}, trigger_time = {trigger_time}, var1 = {var1}")
pyscript.done = seq_num
@state_trigger("pyscript.f1var1 == '1'", state_check_now=True, state_hold_false=0)
def func1(var_name=None, value=None):
@state_trigger("pyscript.f1var1 == '1'", state_check_now=True, state_hold_false=0, kwargs={"var1": 321})
def func1(var_name=None, value=None, var1=None):
global seq_num
seq_num += 1
log.info(f"func1 var = {var_name}, value = {value}")
pyscript.done = [seq_num, var_name, int(value), sqrt(1024), __name__]
pyscript.done = [seq_num, var_name, int(value), sqrt(1024), __name__, var1]
@state_trigger("pyscript.f1var1 == '1'", "pyscript.f2var2 == '2'")
@state_trigger("pyscript.no_such_var == '10'", "pyscript.no_such_var.attr == 100")
Expand Down Expand Up @@ -271,7 +271,7 @@ def fire_event(**kwargs):
event.fire(kwargs["new_event"], arg1=kwargs["arg1"], arg2=kwargs["arg2"], context=context)
@event_trigger("test_event3", "arg1 == 20 and arg2 == 131")
@event_trigger("test_event3", "arg1 == 20 and arg2 == 30")
@event_trigger("test_event3", "arg1 == 20 and arg2 == 30", kwargs=dict(var1=987, var2='test'))
def func3(trigger_type=None, event_type=None, **kwargs):
global seq_num
Expand Down Expand Up @@ -413,7 +413,7 @@ def func9(var_name=None, value=None, old_value=None):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
assert literal_eval(await wait_until_done(notify_q)) == seq_num
assert (
"func_startup_sync setting pyscript.done = 1, trigger_type = time, trigger_time = startup"
"func_startup_sync setting pyscript.done = 1, trigger_type = time, trigger_time = startup, var1 = 123"
in caplog.text
)

Expand All @@ -430,6 +430,7 @@ def func9(var_name=None, value=None, old_value=None):
1,
32,
"hello",
321,
]
assert "func1 var = pyscript.f1var1, value = 1" in caplog.text
# try some other settings that should not cause it to re-trigger
Expand All @@ -448,6 +449,7 @@ def func9(var_name=None, value=None, old_value=None):
1,
32,
"hello",
321,
]

#
Expand Down Expand Up @@ -486,7 +488,7 @@ def func9(var_name=None, value=None, old_value=None):
seq_num,
"event",
"test_event3",
{"arg1": 20, "arg2": 30, "context": context},
{"arg1": 20, "arg2": 30, "var1": 987, "var2": "test", "context": context},
10,
]

Expand Down
2 changes: 1 addition & 1 deletion tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ def func7():
""",
)
assert (
"TypeError: function 'func7' defined in file.hello: decorator @time_trigger doesn't take keyword arguments"
"TypeError: function 'func7' defined in file.hello: decorator @time_trigger valid keyword arguments are: kwargs"
in caplog.text
)

Expand Down

0 comments on commit c4a269d

Please sign in to comment.