Skip to content
15 changes: 10 additions & 5 deletions custom_components/pyscript/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ def __init__(self, ast_ctx: AstEval, eval_func_var: EvalFuncVar) -> None:

def on_func_var_deleted():
if self.status is DecoratorManagerStatus.RUNNING:
self.hass.async_create_task(self.stop())
self.hass.async_create_task(self.safe_await(self.stop()))

weakref.finalize(eval_func_var, on_func_var_deleted)

Expand All @@ -261,9 +261,8 @@ async def _call(self, data: DispatchData) -> None:
for handler_dec in handlers:
if await handler_dec.handle_call(data) is False:
self.logger.debug("Calling canceled by %s", handler_dec)
# notify handlers with "None"
for result_handler_dec in result_handlers:
await result_handler_dec.handle_call_result(data, None)
await self.safe_await(result_handler_dec.handle_call_canceled(data))
return
# Fire an event indicating that pyscript is running
# Note: the event must have an entity_id for logbook to work correctly.
Expand All @@ -277,10 +276,14 @@ async def _call(self, data: DispatchData) -> None:

try:
result = await data.call_ast_ctx.call_func(self.eval_func, None, **data.func_args)
for result_handler_dec in result_handlers:
await result_handler_dec.handle_call_result(data, result)
except Exception as e:
for result_handler_dec in result_handlers:
await self.safe_await(result_handler_dec.handle_call_exception(data, e))
await self.handle_exception(e)
return

for result_handler_dec in result_handlers:
await self.safe_await(result_handler_dec.handle_call_result(data, result))

async def dispatch(self, data: DispatchData) -> None:
"""Handle a trigger dispatch: run guards, create a context, and invoke the function."""
Expand All @@ -290,6 +293,8 @@ async def dispatch(self, data: DispatchData) -> None:
for dec in decorators:
if await dec.handle_dispatch(data) is False:
self.logger.debug("Trigger not active due to %s", dec)
for result_handler_dec in self.get_decorators(CallResultHandlerDecorator):
await self.safe_await(result_handler_dec.handle_call_canceled(data))
return

action_ast_ctx = AstEval(
Expand Down
25 changes: 23 additions & 2 deletions custom_components/pyscript/decorator_abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from abc import ABC, abstractmethod
from collections.abc import Awaitable
from dataclasses import dataclass, field
from enum import StrEnum
import logging
Expand Down Expand Up @@ -179,8 +180,7 @@ async def start(self):
try:
await decorator.start()
started.append(decorator)
except Exception as err:
self.logger.exception("%s start failed: %s", self, err)
except Exception:
for started_dec in started:
await self._stop_decorator(started_dec)
self.startup_time = None
Expand Down Expand Up @@ -209,6 +209,19 @@ async def handle_exception(self, exc: Exception) -> None:
"""Handle a decorator exception."""
self.ast_ctx.log_exception(exc)

async def safe_await(self, coro: Awaitable[Any]) -> None:
"""
Await a coroutine, routing (but not propagating) bugs through ``handle_exception``.

Intended for extension points where a defective subclass shouldn't break
sibling work: the exception surfaces in the same place as user-code errors,
and the caller carries on.
"""
try:
await coro
except Exception as err:
await self.handle_exception(err)

@abstractmethod
async def dispatch(self, data: DispatchData) -> None:
"""Dispatch a trigger call."""
Expand Down Expand Up @@ -281,3 +294,11 @@ class CallResultHandlerDecorator(Decorator, ABC):
@abstractmethod
async def handle_call_result(self, data: DispatchData, result: Any) -> None:
"""Handle an action call result."""

async def handle_call_exception(self, data: DispatchData, exc: Exception) -> None:
"""Handle an exception raised by the action call. Default: forward as None result."""
await self.handle_call_result(data, None)

async def handle_call_canceled(self, data: DispatchData) -> None:
"""Handle a canceled action call (skipped by a handler or trigger). Default: forward as None result."""
await self.handle_call_result(data, None)
3 changes: 2 additions & 1 deletion custom_components/pyscript/decorators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from .state import StateActiveDecorator, StateTriggerDecorator
from .task import TaskUniqueDecorator
from .timing import TimeActiveDecorator, TimeTriggerDecorator
from .webhook import WebhookTriggerDecorator
from .webhook import WebhookHandlerDecorator, WebhookTriggerDecorator

DECORATORS = [
StateTriggerDecorator,
Expand All @@ -17,5 +17,6 @@
EventTriggerDecorator,
MQTTTriggerDecorator,
WebhookTriggerDecorator,
WebhookHandlerDecorator,
ServiceDecorator,
]
16 changes: 14 additions & 2 deletions custom_components/pyscript/decorators/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from abc import ABC
import logging
from typing import Any
from typing import Any, ClassVar

import voluptuous as vol

Expand All @@ -16,13 +16,25 @@
class AutoKwargsDecorator(Decorator, ABC):
"""Mixin that copies validated kwargs into instance attributes based on annotations."""

_auto_kw_attrs: ClassVar[frozenset[str]] = frozenset()

def __init_subclass__(cls, **kwargs):
"""Collect names of typed attributes declared on subclasses up to (but not including) Decorator."""
super().__init_subclass__(**kwargs)
attrs: set[str] = set()
for klass in cls.mro():
if klass is Decorator:
break
attrs.update(getattr(klass, "__annotations__", {}))
cls._auto_kw_attrs = frozenset(attrs)

async def validate(self) -> None:
"""Run base validation and materialize annotated kwargs as attributes."""
await super().validate()
for k in self.__class__.kwargs_schema.schema:
if isinstance(k, vol.Marker):
k = k.schema
if k in self.__class__.__annotations__:
if k in self._auto_kw_attrs:
setattr(self, k, self.kwargs.get(k, None))


Expand Down
Loading
Loading