Skip to content

Commit 71309dc

Browse files
authored
Merge pull request #818 from dmamelin/decorator-manager
Decorator Manager
2 parents 9fa0236 + 4288aab commit 71309dc

30 files changed

Lines changed: 2480 additions & 44 deletions

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,14 @@ this [README](https://github.com/craigbarratt/hass-pyscript-jupyter/blob/master/
6262

6363
## Configuration
6464

65-
* Go to the Integrations menu in the Home Assistant Configuration UI and add `Pyscript Python scripting` from there. Alternatively, add `pyscript:` to `<config>/configuration.yaml`; pyscript has two optional configuration parameters that allow any python package to be imported if set and to expose `hass` as a variable; both default to `false`:
65+
* Go to the Integrations menu in the Home Assistant Configuration UI and add `Pyscript Python scripting` from there. Alternatively, add `pyscript:` to `<config>/configuration.yaml`; pyscript has three optional configuration parameters that allow any python package to be imported if set, expose `hass` as a variable, and temporarily switch back to the legacy decorator subsystem; all three default to `false`:
6666
```yaml
6767
pyscript:
6868
allow_all_imports: true
6969
hass_is_global: true
70+
legacy_decorators: true
7071
```
72+
Starting with version `2.0.0`, pyscript uses the new decorator subsystem by default. If you find a problem in the new implementation, you can temporarily set `legacy_decorators: true` to switch back to the legacy one. If you do, please also file a bug report in [GitHub Issues](https://github.com/custom-components/pyscript/issues) so the new subsystem can be fixed.
7173
* Add files with a suffix of `.py` in the folder `<config>/pyscript`.
7274
* Restart HASS.
7375
* Whenever you change a script file, make a `reload` service call to `pyscript`.

custom_components/pyscript/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from .const import (
3434
CONF_ALLOW_ALL_IMPORTS,
3535
CONF_HASS_IS_GLOBAL,
36+
CONF_LEGACY_DECORATORS,
3637
CONFIG_ENTRY,
3738
CONFIG_ENTRY_OLD,
3839
DOMAIN,
@@ -44,6 +45,7 @@
4445
UNSUB_LISTENERS,
4546
WATCHDOG_TASK,
4647
)
48+
from .decorator import DecoratorRegistry
4749
from .eval import AstEval
4850
from .event import Event
4951
from .function import Function
@@ -62,6 +64,7 @@
6264
{
6365
vol.Optional(CONF_ALLOW_ALL_IMPORTS, default=False): cv.boolean,
6466
vol.Optional(CONF_HASS_IS_GLOBAL, default=False): cv.boolean,
67+
vol.Optional(CONF_LEGACY_DECORATORS, default=False): cv.boolean,
6568
},
6669
extra=vol.ALLOW_EXTRA,
6770
)
@@ -114,14 +117,15 @@ async def update_yaml_config(hass: HomeAssistant, config_entry: ConfigEntry) ->
114117
# since they affect all scripts
115118
#
116119
config_save = {
117-
param: config_entry.data.get(param, False) for param in [CONF_HASS_IS_GLOBAL, CONF_ALLOW_ALL_IMPORTS]
120+
param: config_entry.data.get(param, False)
121+
for param in [CONF_HASS_IS_GLOBAL, CONF_ALLOW_ALL_IMPORTS, CONF_LEGACY_DECORATORS]
118122
}
119123
if DOMAIN not in hass.data:
120124
hass.data.setdefault(DOMAIN, {})
121125
if CONFIG_ENTRY_OLD in hass.data[DOMAIN]:
122126
old_entry = hass.data[DOMAIN][CONFIG_ENTRY_OLD]
123127
hass.data[DOMAIN][CONFIG_ENTRY_OLD] = config_save
124-
for param in [CONF_HASS_IS_GLOBAL, CONF_ALLOW_ALL_IMPORTS]:
128+
for param in [CONF_HASS_IS_GLOBAL, CONF_ALLOW_ALL_IMPORTS, CONF_LEGACY_DECORATORS]:
125129
if old_entry.get(param, False) != config_entry.data.get(param, False):
126130
return True
127131
hass.data[DOMAIN][CONFIG_ENTRY_OLD] = config_save
@@ -272,6 +276,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
272276
Webhook.init(hass)
273277
State.register_functions()
274278
GlobalContextMgr.init()
279+
DecoratorRegistry.init(hass, config_entry)
275280

276281
pyscript_folder = hass.config.path(FOLDER)
277282
if not await hass.async_add_executor_job(os.path.isdir, pyscript_folder):

custom_components/pyscript/config_flow.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,21 @@
99
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
1010
from homeassistant.core import callback
1111

12-
from .const import CONF_ALLOW_ALL_IMPORTS, CONF_HASS_IS_GLOBAL, CONF_INSTALLED_PACKAGES, DOMAIN
12+
from .const import (
13+
CONF_ALLOW_ALL_IMPORTS,
14+
CONF_HASS_IS_GLOBAL,
15+
CONF_INSTALLED_PACKAGES,
16+
CONF_LEGACY_DECORATORS,
17+
DOMAIN,
18+
)
1319

14-
CONF_BOOL_ALL = {CONF_ALLOW_ALL_IMPORTS, CONF_HASS_IS_GLOBAL}
20+
CONF_BOOL_ALL = (CONF_ALLOW_ALL_IMPORTS, CONF_HASS_IS_GLOBAL, CONF_LEGACY_DECORATORS)
1521

1622
PYSCRIPT_SCHEMA = vol.Schema(
1723
{
1824
vol.Optional(CONF_ALLOW_ALL_IMPORTS, default=False): bool,
1925
vol.Optional(CONF_HASS_IS_GLOBAL, default=False): bool,
26+
vol.Optional(CONF_LEGACY_DECORATORS, default=False): bool,
2027
},
2128
extra=vol.ALLOW_EXTRA,
2229
)

custom_components/pyscript/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
CONF_ALLOW_ALL_IMPORTS = "allow_all_imports"
1818
CONF_HASS_IS_GLOBAL = "hass_is_global"
1919
CONF_INSTALLED_PACKAGES = "_installed_packages"
20+
CONF_LEGACY_DECORATORS = "legacy_decorators"
2021

2122
SERVICE_JUPYTER_KERNEL_START = "jupyter_kernel_start"
2223
SERVICE_GENERATE_STUBS = "generate_stubs"
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
"""Decorator registry and manager logic for pyscript decorators."""
2+
3+
from __future__ import annotations
4+
5+
import ast
6+
import asyncio
7+
import logging
8+
import os
9+
from typing import Any, ClassVar
10+
import weakref
11+
12+
from homeassistant.config_entries import ConfigEntry
13+
from homeassistant.core import Context, HomeAssistant
14+
15+
from .const import CONF_LEGACY_DECORATORS
16+
from .decorator_abc import (
17+
CallHandlerDecorator,
18+
CallResultHandlerDecorator,
19+
Decorator,
20+
DecoratorManager,
21+
DecoratorManagerStatus,
22+
DispatchData,
23+
TriggerDecorator,
24+
TriggerHandlerDecorator,
25+
)
26+
from .eval import AstEval, EvalFunc, EvalFuncVar
27+
from .function import Function
28+
from .state import State
29+
30+
_LOGGER = logging.getLogger(__name__)
31+
32+
33+
class DecoratorRegistry:
34+
"""Decorator registry."""
35+
36+
_decorators: dict[str, type[Decorator]] # decorator name to class
37+
hass: ClassVar[HomeAssistant]
38+
39+
@classmethod
40+
def init(cls, hass: HomeAssistant, config_entry: ConfigEntry = None) -> None:
41+
"""Initialize the decorator registry."""
42+
cls.hass = hass
43+
cls._decorators = {}
44+
disabled = False
45+
if config_entry is not None and config_entry.data.get(CONF_LEGACY_DECORATORS, False):
46+
disabled = True
47+
elif "PYTEST_CURRENT_TEST" in os.environ and "NODM" in os.environ:
48+
disabled = True
49+
50+
if disabled:
51+
_LOGGER.warning("Using legacy decorators")
52+
return
53+
54+
DecoratorManager.hass = hass
55+
56+
Function.register_ast({"task.wait_until": DecoratorRegistry.wait_until_factory})
57+
58+
from .decorators import DECORATORS # pylint: disable=import-outside-toplevel
59+
60+
for dec_type in DECORATORS:
61+
cls.register(dec_type)
62+
63+
@classmethod
64+
def register(cls, dec_type: type[Decorator]) -> None:
65+
"""Register a decorator."""
66+
if not dec_type.name:
67+
raise TypeError(f"Decorator name is required {dec_type}")
68+
69+
_LOGGER.debug("Registering decorator @%s %s", dec_type.name, dec_type)
70+
if dec_type.name in cls._decorators:
71+
_LOGGER.warning(
72+
"Overriding decorator: %s %s with %s",
73+
dec_type.name,
74+
cls._decorators[dec_type.name],
75+
dec_type,
76+
)
77+
cls._decorators[dec_type.name] = dec_type
78+
79+
@classmethod
80+
async def get_decorator_by_expr(cls, ast_ctx: AstEval, dec_expr: ast.expr) -> Decorator | None:
81+
"""Return decorator instance from an AST decorator expression."""
82+
dec_name = None
83+
has_args = False
84+
85+
if isinstance(dec_expr, ast.Name): # decorator without ()
86+
dec_name = dec_expr.id
87+
elif isinstance(dec_expr, ast.Call) and isinstance(dec_expr.func, ast.Name):
88+
dec_name = dec_expr.func.id
89+
has_args = True
90+
91+
if know_decorator := cls._decorators.get(dec_name):
92+
if has_args:
93+
args = await ast_ctx.eval_elt_list(dec_expr.args)
94+
kwargs = {keyw.arg: await ast_ctx.aeval(keyw.value) for keyw in dec_expr.keywords}
95+
else:
96+
args = []
97+
kwargs = {}
98+
99+
decorator = know_decorator(args, kwargs)
100+
return decorator
101+
102+
return None
103+
104+
@classmethod
105+
async def wait_until(cls, ast_ctx: AstEval, *_arg: Any, **kwargs: Any) -> Any:
106+
"""Build a temporary decorator manager that waits until one of trigger decorators fires."""
107+
func_args = set(kwargs.keys())
108+
if len(func_args) == 0:
109+
return {"trigger_type": "none"}
110+
111+
found_args = set()
112+
dm = WaitUntilDecoratorManager(ast_ctx, **kwargs)
113+
114+
found_args.add("timeout")
115+
found_args.add("__test_handshake__")
116+
117+
for dec_name, dec_class in cls._decorators.items():
118+
if not issubclass(dec_class, TriggerDecorator):
119+
continue
120+
if dec_name not in func_args:
121+
continue
122+
123+
dec_args = kwargs[dec_name]
124+
if not isinstance(dec_args, list):
125+
dec_args = [dec_args]
126+
found_args.add(dec_name)
127+
128+
dec_kwargs = {}
129+
func_args.remove(dec_name)
130+
kwargs_schema_keys = dec_class.kwargs_schema.schema.keys()
131+
for key in kwargs_schema_keys:
132+
if key in kwargs:
133+
dec_kwargs[key] = kwargs[key]
134+
found_args.add(key)
135+
dec = dec_class(dec_args, dec_kwargs)
136+
dm.add(dec)
137+
138+
unknown_args = set(kwargs.keys()).difference(found_args)
139+
if unknown_args:
140+
raise ValueError(f"Unknown arguments: {unknown_args}")
141+
await dm.validate()
142+
143+
# state_trigger sets __test_handshake__ after the initial checks.
144+
# In some cases, it returns a value before __test_handshake__ is set.
145+
if "state_trigger" not in kwargs:
146+
if test_handshake := kwargs.get("__test_handshake__"):
147+
#
148+
# used for testing to avoid race conditions
149+
# we use this as a handshake that we are about to
150+
# listen to the queue
151+
#
152+
State.set(test_handshake[0], test_handshake[1])
153+
await dm.start()
154+
155+
ret = await dm.wait_until()
156+
157+
return ret
158+
159+
@classmethod
160+
def wait_until_factory(cls, ast_ctx):
161+
"""Return wrapper to call to astFunction with the ast context."""
162+
163+
async def wait_until_call(*arg, **kw):
164+
return await cls.wait_until(ast_ctx, *arg, **kw)
165+
166+
return wait_until_call
167+
168+
169+
class WaitUntilDecoratorManager(DecoratorManager):
170+
"""Decorator manager for task.wait_until."""
171+
172+
def __init__(self, ast_ctx: AstEval, **kwargs: dict[str, Any]) -> None:
173+
"""Initialize the task.wait_until decorator manager."""
174+
super().__init__(ast_ctx, ast_ctx.name)
175+
self.kwargs = kwargs
176+
self._future: asyncio.Future[DispatchData] = self.hass.loop.create_future()
177+
self.timeout_decorator = None
178+
if timeout := kwargs.get("timeout"):
179+
to_dec = DecoratorRegistry._decorators.get("time_trigger")
180+
self.timeout_decorator = to_dec([f"once(now + {timeout}s)"], {})
181+
self.add(self.timeout_decorator)
182+
183+
async def dispatch(self, data: DispatchData) -> None:
184+
"""Resolve the waiting future on the first incoming dispatch."""
185+
_LOGGER.debug("task.wait_until dispatch: %s", data)
186+
if self._future.done():
187+
_LOGGER.debug("task.wait_until future already completed: %s", self._future.exception())
188+
# ignore another calls
189+
return
190+
await self.stop()
191+
self._future.set_result(data)
192+
193+
async def handle_exception(self, exc: Exception) -> None:
194+
"""Propagate an evaluation exception to the waiting caller."""
195+
if self._future.done():
196+
_LOGGER.debug("task.wait_until future already completed: %s", self._future.exception())
197+
return
198+
await self.stop()
199+
self._future.set_exception(exc)
200+
201+
async def wait_until(self) -> dict[str, Any]:
202+
"""Wait for dispatch and normalize the return payload."""
203+
data = await self._future
204+
if data.trigger == self.timeout_decorator:
205+
ret = {"trigger_type": "timeout"}
206+
else:
207+
ret = data.func_args
208+
_LOGGER.debug("task.wait_until finish: %s", ret)
209+
return ret
210+
211+
212+
class FunctionDecoratorManager(DecoratorManager):
213+
"""Maintain and validate a set of decorators applied to a function."""
214+
215+
def __init__(self, ast_ctx: AstEval, eval_func_var: EvalFuncVar) -> None:
216+
"""Initialize the function decorator manager."""
217+
super().__init__(ast_ctx, f"{ast_ctx.get_global_ctx_name()}.{eval_func_var.get_name()}")
218+
self.eval_func: EvalFunc = eval_func_var.func
219+
220+
self.logger = self.eval_func.logger
221+
222+
def on_func_var_deleted():
223+
if self.status is DecoratorManagerStatus.RUNNING:
224+
self.hass.async_create_task(self.stop())
225+
226+
weakref.finalize(eval_func_var, on_func_var_deleted)
227+
228+
async def _call(self, data: DispatchData) -> None:
229+
handlers = self.get_decorators(CallHandlerDecorator)
230+
result_handlers = self.get_decorators(CallResultHandlerDecorator)
231+
232+
for handler_dec in handlers:
233+
if await handler_dec.handle_call(data) is False:
234+
self.logger.debug("Calling canceled by %s", handler_dec)
235+
# notify handlers with "None"
236+
for result_handler_dec in result_handlers:
237+
await result_handler_dec.handle_call_result(data, None)
238+
return
239+
# Fire an event indicating that pyscript is running
240+
# Note: the event must have an entity_id for logbook to work correctly.
241+
ev_name = self.name.replace(".", "_")
242+
ev_entity_id = f"pyscript.{ev_name}"
243+
244+
event_data = {"name": ev_name, "entity_id": ev_entity_id, "func_args": data.func_args}
245+
self.hass.bus.async_fire("pyscript_running", event_data, context=data.hass_context)
246+
# Store HASS Context for this Task
247+
Function.store_hass_context(data.hass_context)
248+
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)
252+
253+
async def dispatch(self, data: DispatchData) -> None:
254+
"""Handle a trigger dispatch: run guards, create a context, and invoke the function."""
255+
_LOGGER.debug("Dispatching for %s: %s", self.name, data)
256+
257+
decorators = self.get_decorators(TriggerHandlerDecorator)
258+
for dec in decorators:
259+
if await dec.handle_dispatch(data) is False:
260+
self.logger.debug("Trigger not active due to %s", dec)
261+
return
262+
263+
action_ast_ctx = AstEval(
264+
f"{self.eval_func.global_ctx_name}.{self.eval_func.name}", self.eval_func.global_ctx
265+
)
266+
Function.install_ast_funcs(action_ast_ctx)
267+
data.call_ast_ctx = action_ast_ctx
268+
269+
# Create new HASS Context with incoming as parent
270+
if "context" in data.func_args and isinstance(data.func_args["context"], Context):
271+
data.hass_context = Context(parent_id=data.func_args["context"].id)
272+
else:
273+
data.hass_context = Context()
274+
275+
self.logger.debug(
276+
"trigger %s got %s trigger, running action (kwargs = %s)",
277+
self.name,
278+
data.trigger,
279+
data.func_args,
280+
)
281+
282+
task = Function.create_task(self._call(data), ast_ctx=action_ast_ctx)
283+
Function.task_done_callback_ctx(task, action_ast_ctx)

0 commit comments

Comments
 (0)