From 0402db0a95a37a5492e70bebe26d2ce552496645 Mon Sep 17 00:00:00 2001 From: Dmitrii Amelin Date: Fri, 4 Sep 2026 00:27:43 +0200 Subject: [PATCH 1/4] decouple DecoratorManager from eval.py TRIGGER_KWARGS allowlist --- custom_components/pyscript/decorator.py | 11 ++++++++++- tests/test_decorator_manager.py | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/custom_components/pyscript/decorator.py b/custom_components/pyscript/decorator.py index 3cf50ba..f899a1d 100644 --- a/custom_components/pyscript/decorator.py +++ b/custom_components/pyscript/decorator.py @@ -275,7 +275,16 @@ async def _call(self, data: DispatchData) -> None: Function.store_hass_context(data.hass_context) try: - result = await data.call_ast_ctx.call_func(self.eval_func, None, **data.func_args) + # Trigger dispatch offers a fixed set of context kwargs (e.g. value, context, topic), but the + # decorated function is free to declare only the ones it cares about, so keep only what its + # signature can accept. + func_args = self.eval_func.func_def.args + if func_args.kwarg: + call_kwargs = data.func_args + else: + accepted = {a.arg for a in func_args.posonlyargs + func_args.args + func_args.kwonlyargs} + call_kwargs = {k: v for k, v in data.func_args.items() if k in accepted} + result = await data.call_ast_ctx.call_func(self.eval_func, None, **call_kwargs) except Exception as e: for result_handler_dec in result_handlers: await self.safe_await(result_handler_dec.handle_call_exception(data, e)) diff --git a/tests/test_decorator_manager.py b/tests/test_decorator_manager.py index bd633bf..1558742 100644 --- a/tests/test_decorator_manager.py +++ b/tests/test_decorator_manager.py @@ -2,6 +2,7 @@ from __future__ import annotations +import ast from collections.abc import Awaitable import logging from typing import Any, ClassVar @@ -305,6 +306,8 @@ def __init__(self, name: str = "func") -> None: self.name = name self.global_ctx_name = "file.hello" self.logger = logging.getLogger(__name__) + # a real FunctionDef with **kwargs, so FunctionDecoratorManager._call() passes everything through + self.func_def = ast.parse("def _stub(**kwargs): pass").body[0] class DummyEvalFuncVar: From 8e2811643d06fc7518ccc80ee901bd81241b4872 Mon Sep 17 00:00:00 2001 From: Dmitrii Amelin Date: Fri, 4 Sep 2026 19:56:43 +0200 Subject: [PATCH 2/4] docs: correct kwarg-mismatch behavior --- docs/reference.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/reference.rst b/docs/reference.rst index 21f0d64..d2045e6 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -571,9 +571,9 @@ with defaults: 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). +Any keyword parameter the function doesn't declare - including any you specify via ``kwargs`` - +is simply ignored, unless you use the ``**kwargs`` catch-all in the function definition to +capture everything. 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 From e874fb938c8c7477e282b801b64ba5d5ff745c48 Mon Sep 17 00:00:00 2001 From: Dmitrii Amelin Date: Fri, 4 Sep 2026 20:21:15 +0200 Subject: [PATCH 3/4] fix: exclude positional-only params from trigger dispatch kwargs --- custom_components/pyscript/decorator.py | 7 +++---- tests/test_decorator_manager.py | 17 +++++++++++++++++ tests/test_function.py | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/custom_components/pyscript/decorator.py b/custom_components/pyscript/decorator.py index f899a1d..0e801e6 100644 --- a/custom_components/pyscript/decorator.py +++ b/custom_components/pyscript/decorator.py @@ -275,14 +275,13 @@ async def _call(self, data: DispatchData) -> None: Function.store_hass_context(data.hass_context) try: - # Trigger dispatch offers a fixed set of context kwargs (e.g. value, context, topic), but the - # decorated function is free to declare only the ones it cares about, so keep only what its - # signature can accept. + # Calls are always by keyword only, so keep only what the function's signature can accept — + # excluding posonly params, since they can never be filled this way. func_args = self.eval_func.func_def.args if func_args.kwarg: call_kwargs = data.func_args else: - accepted = {a.arg for a in func_args.posonlyargs + func_args.args + func_args.kwonlyargs} + accepted = {a.arg for a in func_args.args + func_args.kwonlyargs} call_kwargs = {k: v for k, v in data.func_args.items() if k in accepted} result = await data.call_ast_ctx.call_func(self.eval_func, None, **call_kwargs) except Exception as e: diff --git a/tests/test_decorator_manager.py b/tests/test_decorator_manager.py index 1558742..d4bebe4 100644 --- a/tests/test_decorator_manager.py +++ b/tests/test_decorator_manager.py @@ -677,6 +677,23 @@ def event_listener(event): store_hass_context.assert_called_once_with(hass_context) +@pytest.mark.asyncio +async def test_function_decorator_manager_call_excludes_posonly_args(hass): + """A positional-only parameter can never be filled by keyword dispatch, so it's excluded from the call.""" + DecoratorManager.hass = hass + manager = FunctionDecoratorManager(DummyAstCtx(), DummyEvalFuncVar()) + manager.eval_func.func_def = ast.parse("def _stub(value=None, /): pass").body[0] + call_ast_ctx = DummyCallAstCtx(result="ok") + + with patch.object(Function, "store_hass_context"): + await call_function_manager( + manager, + make_dispatch_data({"value": 1}, call_ast_ctx=call_ast_ctx, hass_context=Context(id="cid")), + ) + + assert call_ast_ctx.calls == [(manager.eval_func, None, {})] + + @pytest.mark.asyncio async def test_function_decorator_manager_logs_call_exception(hass): """Failed decorated function calls should be routed through the manager.""" diff --git a/tests/test_function.py b/tests/test_function.py index 6b2d190..985c3ce 100644 --- a/tests/test_function.py +++ b/tests/test_function.py @@ -812,6 +812,21 @@ def func2(var_name=None, value=None): assert literal_eval(await wait_until_done(notify_q)) == ["watch_none", "pyscript.var2", "2"] +@pytest.mark.asyncio +async def test_state_trigger_positional_only_param(pyscript): + """Positional-only trigger params keep their default instead of erroring on keyword dispatch.""" + await pyscript.start( + """ +@state_trigger("pyscript.var1 == '1'") +def func1(value=None, /): + pyscript.done = f"value={value}" +""" + ) + + pyscript.hass.states.async_set("pyscript.var1", "1") + await pyscript.wait_done("value=None") + + @pytest.mark.asyncio async def test_time_active_hold_off_send_last(hass): """Test hold_off_send_last runs with the latest suppressed trigger data.""" From af9e27e934d8d0fe850eb7de7067e3c13ca1d0cc Mon Sep 17 00:00:00 2001 From: Dmitrii Amelin Date: Fri, 4 Sep 2026 20:22:01 +0200 Subject: [PATCH 4/4] test: cover dropping of undeclared trigger dispatch kwargs --- tests/test_decorator_manager.py | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_decorator_manager.py b/tests/test_decorator_manager.py index d4bebe4..fd8e28e 100644 --- a/tests/test_decorator_manager.py +++ b/tests/test_decorator_manager.py @@ -677,6 +677,25 @@ def event_listener(event): store_hass_context.assert_called_once_with(hass_context) +@pytest.mark.asyncio +async def test_function_decorator_manager_call_drops_undeclared_kwargs(hass): + """A dispatch kwarg the function doesn't declare (and has no **kwargs) is dropped before the call.""" + DecoratorManager.hass = hass + manager = FunctionDecoratorManager(DummyAstCtx(), DummyEvalFuncVar()) + manager.eval_func.func_def = ast.parse("def _stub(value=None): pass").body[0] + call_ast_ctx = DummyCallAstCtx(result="ok") + + with patch.object(Function, "store_hass_context"): + await call_function_manager( + manager, + make_dispatch_data( + {"value": 1, "extra": "unused"}, call_ast_ctx=call_ast_ctx, hass_context=Context(id="cid") + ), + ) + + assert call_ast_ctx.calls == [(manager.eval_func, None, {"value": 1})] + + @pytest.mark.asyncio async def test_function_decorator_manager_call_excludes_posonly_args(hass): """A positional-only parameter can never be filled by keyword dispatch, so it's excluded from the call.""" @@ -694,6 +713,25 @@ async def test_function_decorator_manager_call_excludes_posonly_args(hass): assert call_ast_ctx.calls == [(manager.eval_func, None, {})] +@pytest.mark.asyncio +async def test_function_decorator_manager_call_includes_kwonly_args(hass): + """A keyword-only parameter can be filled by keyword dispatch, so it's kept in the accepted set.""" + DecoratorManager.hass = hass + manager = FunctionDecoratorManager(DummyAstCtx(), DummyEvalFuncVar()) + manager.eval_func.func_def = ast.parse("def _stub(*, value=None): pass").body[0] + call_ast_ctx = DummyCallAstCtx(result="ok") + + with patch.object(Function, "store_hass_context"): + await call_function_manager( + manager, + make_dispatch_data( + {"value": 1, "extra": "unused"}, call_ast_ctx=call_ast_ctx, hass_context=Context(id="cid") + ), + ) + + assert call_ast_ctx.calls == [(manager.eval_func, None, {"value": 1})] + + @pytest.mark.asyncio async def test_function_decorator_manager_logs_call_exception(hass): """Failed decorated function calls should be routed through the manager."""