diff --git a/custom_components/pyscript/decorator.py b/custom_components/pyscript/decorator.py index 3cf50ba..0e801e6 100644 --- a/custom_components/pyscript/decorator.py +++ b/custom_components/pyscript/decorator.py @@ -275,7 +275,15 @@ 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) + # 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.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/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 diff --git a/tests/test_decorator_manager.py b/tests/test_decorator_manager.py index bd633bf..fd8e28e 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: @@ -674,6 +677,61 @@ 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.""" + 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_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.""" 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."""