diff --git a/custom_components/pyscript/eval.py b/custom_components/pyscript/eval.py index 8632368..6e58367 100644 --- a/custom_components/pyscript/eval.py +++ b/custom_components/pyscript/eval.py @@ -53,7 +53,7 @@ "task_unique", } -ALL_DECORATORS = TRIG_DECORATORS.union({"service"}) +TRIG_SERV_DECORATORS = TRIG_DECORATORS.union({"service"}) COMP_DECORATORS = { "pyscript_compile", @@ -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}, @@ -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 @@ -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): @@ -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 = {} @@ -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)) diff --git a/custom_components/pyscript/trigger.py b/custom_components/pyscript/trigger.py index 2910a30..b8eb93a 100644 --- a/custom_components/pyscript/trigger.py +++ b/custom_components/pyscript/trigger.py @@ -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) @@ -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): # @@ -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 # @@ -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() diff --git a/docs/new_features.rst b/docs/new_features.rst index fd0f993..dd5b7f0 100644 --- a/docs/new_features.rst +++ b/docs/new_features.rst @@ -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 diff --git a/docs/reference.rst b/docs/reference.rst index 0991c26..a340761 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -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 @@ -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: =================== ==================== ================= ======================== @@ -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: @@ -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: @@ -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: @@ -528,7 +546,7 @@ 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. @@ -536,6 +554,9 @@ Then the process repeats. Alternatively, multiple time trigger specifications ca 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: @@ -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: @@ -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 @@ -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: @@ -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 @@ -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. diff --git a/tests/test_function.py b/tests/test_function.py index c03171f..15b3275 100644 --- a/tests/test_function.py +++ b/tests/test_function.py @@ -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") @@ -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 @@ -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 ) @@ -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 @@ -448,6 +449,7 @@ def func9(var_name=None, value=None, old_value=None): 1, 32, "hello", + 321, ] # @@ -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, ] diff --git a/tests/test_init.py b/tests/test_init.py index 9a5e524..19df7aa 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -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 )