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
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