Skip to content

Commit 5dbfa4e

Browse files
authored
Merge pull request #837 from dmamelin/dm-fix2
DM fixes (detailed log & webhook)
2 parents a1335f1 + 59e6541 commit 5dbfa4e

3 files changed

Lines changed: 73 additions & 21 deletions

File tree

custom_components/pyscript/decorator.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -246,9 +246,12 @@ async def _call(self, data: DispatchData) -> None:
246246
# Store HASS Context for this Task
247247
Function.store_hass_context(data.hass_context)
248248

249-
result = await data.call_ast_ctx.call_func(self.eval_func, None, **data.func_args)
250-
for result_handler_dec in result_handlers:
251-
await result_handler_dec.handle_call_result(data, result)
249+
try:
250+
result = await data.call_ast_ctx.call_func(self.eval_func, None, **data.func_args)
251+
for result_handler_dec in result_handlers:
252+
await result_handler_dec.handle_call_result(data, result)
253+
except Exception as e:
254+
await self.handle_exception(e)
252255

253256
async def dispatch(self, data: DispatchData) -> None:
254257
"""Handle a trigger dispatch: run guards, create a context, and invoke the function."""

custom_components/pyscript/decorators/webhook.py

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
"""Webhook decorator."""
22

3+
from __future__ import annotations
4+
35
import logging
6+
from typing import ClassVar
47

58
from aiohttp import hdrs
69
import voluptuous as vol
@@ -36,6 +39,8 @@ class WebhookTriggerDecorator(TriggerDecorator, ExpressionDecorator, AutoKwargsD
3639
local_only: bool
3740
methods: set[str]
3841

42+
webhook_id2triggers: ClassVar[dict[str, set[WebhookTriggerDecorator]]] = {}
43+
3944
async def validate(self):
4045
"""Validate the webhook trigger configuration."""
4146
await super().validate()
@@ -44,7 +49,8 @@ async def validate(self):
4449
if len(self.args) == 2:
4550
self.create_expression(self.args[1])
4651

47-
async def _handler(self, hass, webhook_id, request):
52+
@staticmethod
53+
async def _handler(_hass, webhook_id, request):
4854
func_args = {
4955
"trigger_type": "webhook",
5056
"webhook_id": webhook_id,
@@ -57,28 +63,50 @@ async def _handler(self, hass, webhook_id, request):
5763
payload_multidict = await request.post()
5864
func_args["payload"] = {k: payload_multidict.getone(k) for k in payload_multidict.keys()}
5965

60-
if self.has_expression():
61-
if not await self.check_expression_vars(func_args):
62-
return
63-
64-
await self.dispatch(DispatchData(func_args))
66+
for trigger in WebhookTriggerDecorator.webhook_id2triggers.get(webhook_id, set()).copy():
67+
trigger_args = func_args.copy()
68+
if trigger.has_expression():
69+
if not await trigger.check_expression_vars(trigger_args):
70+
continue
71+
await trigger.dispatch(DispatchData(trigger_args))
72+
73+
@staticmethod
74+
def _add_trigger(trigger: WebhookTriggerDecorator) -> None:
75+
webhook_id = trigger.webhook_id
76+
if webhook_id not in WebhookTriggerDecorator.webhook_id2triggers:
77+
webhook.async_register(
78+
trigger.dm.hass,
79+
"pyscript", # DOMAIN
80+
"pyscript", # NAME
81+
webhook_id,
82+
WebhookTriggerDecorator._handler,
83+
local_only=trigger.local_only,
84+
allowed_methods=trigger.methods,
85+
)
86+
WebhookTriggerDecorator.webhook_id2triggers[webhook_id] = set()
87+
88+
WebhookTriggerDecorator.webhook_id2triggers[webhook_id].add(trigger)
89+
90+
@staticmethod
91+
def _remove_trigger(trigger: WebhookTriggerDecorator) -> None:
92+
webhook_id = trigger.webhook_id
93+
triggers = WebhookTriggerDecorator.webhook_id2triggers.get(webhook_id)
94+
if not triggers:
95+
return
96+
97+
triggers.discard(trigger)
98+
if len(triggers) == 0:
99+
webhook.async_unregister(trigger.dm.hass, webhook_id)
100+
del WebhookTriggerDecorator.webhook_id2triggers[webhook_id]
65101

66102
async def start(self):
67103
"""Start the webhook trigger."""
68104
await super().start()
69-
webhook.async_register(
70-
self.dm.hass,
71-
"pyscript", # DOMAIN
72-
"pyscript", # NAME
73-
self.webhook_id,
74-
self._handler,
75-
local_only=self.local_only,
76-
allowed_methods=self.methods,
77-
)
105+
self._add_trigger(self)
78106

79107
_LOGGER.debug("webhook trigger %s listening on id %s", self.dm.name, self.webhook_id)
80108

81109
async def stop(self):
82110
"""Stop the webhook trigger."""
83111
await super().stop()
84-
webhook.async_unregister(self.dm.hass, self.webhook_id)
112+
self._remove_trigger(self)

tests/test_decorator_manager.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,14 +271,17 @@ def get_name(self) -> str:
271271
class DummyCallAstCtx:
272272
"""Minimal action AstEval stub for manager call tests."""
273273

274-
def __init__(self, result: object) -> None:
274+
def __init__(self, result: object = None, exc: Exception | None = None) -> None:
275275
"""Initialize the dummy action context."""
276276
self.result = result
277+
self.exc = exc
277278
self.calls: list[tuple[object, object, dict]] = []
278279

279280
async def call_func(self, func: object, func_name: object, **kwargs: object) -> object:
280-
"""Record the function call and return the configured result."""
281+
"""Record the function call and return or raise the configured result."""
281282
self.calls.append((func, func_name, kwargs))
283+
if self.exc is not None:
284+
raise self.exc
282285
return self.result
283286

284287

@@ -578,6 +581,24 @@ def event_listener(event):
578581
store_hass_context.assert_called_once_with(hass_context)
579582

580583

584+
@pytest.mark.asyncio
585+
async def test_function_decorator_manager_logs_call_exception(hass):
586+
"""Failed decorated function calls should be routed through the manager."""
587+
DecoratorManager.hass = hass
588+
ast_ctx = DummyAstCtx()
589+
manager = FunctionDecoratorManager(ast_ctx, DummyEvalFuncVar())
590+
call_ast_ctx = DummyCallAstCtx(exc=RuntimeError("decorated call failed"))
591+
592+
await call_function_manager(
593+
manager,
594+
make_dispatch_data({"arg1": 1}, call_ast_ctx=call_ast_ctx, hass_context=Context(id="call-parent")),
595+
)
596+
597+
assert call_ast_ctx.calls == [(manager.eval_func, None, {"arg1": 1})]
598+
assert len(ast_ctx.logged_exceptions) == 1
599+
assert str(ast_ctx.logged_exceptions[0]) == "decorated call failed"
600+
601+
581602
def test_decorator_registry_register_requires_name():
582603
"""Registry should reject decorators without a declared name."""
583604

0 commit comments

Comments
 (0)