Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion custom_components/pyscript/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
6 changes: 3 additions & 3 deletions docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions tests/test_decorator_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import ast
from collections.abc import Awaitable
import logging
from typing import Any, ClassVar
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down
15 changes: 15 additions & 0 deletions tests/test_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading