Skip to content

Commit c5e3cac

Browse files
committed
@service now supports an optional service name argument to override the default pyscript.FUN_NAME.
@service can be used multiple times and also allows multiple arguments. Added error checking that the same service isn't registered in two different global contexts.
1 parent 09c56f6 commit c5e3cac

File tree

7 files changed

+184
-146
lines changed

7 files changed

+184
-146
lines changed

custom_components/pyscript/eval.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ def __init__(self, func_def, code_list, code_str, global_ctx):
286286
self.exception_obj = None
287287
self.exception_long = None
288288
self.trigger = []
289-
self.trigger_service = False
289+
self.trigger_service = set()
290290
self.has_closure = False
291291

292292
def get_name(self):
@@ -320,9 +320,9 @@ async def trigger_init(self):
320320
"mqtt_trigger": {"arg_cnt": {1, 2}, "rep_ok": True},
321321
"state_active": {"arg_cnt": {1}},
322322
"state_trigger": {"arg_cnt": {"*"}, "type": {list, set}, "rep_ok": True},
323-
"service": {"arg_cnt": {0}},
323+
"service": {"arg_cnt": {0, "*"}},
324324
"task_unique": {"arg_cnt": {1}},
325-
"time_active": {"arg_cnt": {0, "*"}},
325+
"time_active": {"arg_cnt": {"*"}},
326326
"time_trigger": {"arg_cnt": {0, "*"}, "rep_ok": True},
327327
}
328328
kwarg_check = {
@@ -388,8 +388,6 @@ async def trigger_init(self):
388388
if dec_kwargs is None:
389389
dec_kwargs = {}
390390
if dec_name == "service":
391-
if self.name in (SERVICE_RELOAD, SERVICE_JUPYTER_KERNEL_START):
392-
raise SyntaxError(f"{exc_mesg}: @service conflicts with builtin service")
393391
desc = self.doc_string
394392
if desc is None or desc == "":
395393
desc = f"pyscript function {self.name}()"
@@ -436,9 +434,17 @@ async def do_service_call(func, ast_ctx, data):
436434

437435
return pyscript_service_handler
438436

439-
Function.service_register(DOMAIN, self.name, pyscript_service_factory(self.name, self))
440-
async_set_service_schema(Function.hass, DOMAIN, self.name, service_desc)
441-
self.trigger_service = True
437+
for srv_name in dec_args if dec_args else [f"{DOMAIN}.{self.name}"]:
438+
if type(srv_name) is not str or srv_name.count(".") != 1:
439+
raise ValueError(f"{exc_mesg}: @service argument must be a string with one period")
440+
domain, name = srv_name.split(".", 1)
441+
if name in (SERVICE_RELOAD, SERVICE_JUPYTER_KERNEL_START):
442+
raise SyntaxError(f"{exc_mesg}: @service conflicts with builtin service")
443+
Function.service_register(
444+
self.global_ctx_name, domain, name, pyscript_service_factory(self.name, self)
445+
)
446+
async_set_service_schema(Function.hass, domain, name, service_desc)
447+
self.trigger_service.add(srv_name)
442448
continue
443449

444450
if dec_name not in trig_decs:
@@ -457,7 +463,7 @@ async def do_service_call(func, ast_ctx, data):
457463
return
458464

459465
if len(trig_decs) == 0:
460-
if self.trigger_service:
466+
if len(self.trigger_service) > 0:
461467
self.global_ctx.trigger_register(self)
462468
return
463469

@@ -501,9 +507,10 @@ def trigger_stop(self):
501507
for trigger in self.trigger:
502508
trigger.stop()
503509
self.trigger = []
504-
if self.trigger_service:
505-
self.trigger_service = False
506-
Function.service_remove(DOMAIN, self.name)
510+
for srv_name in self.trigger_service:
511+
domain, name = srv_name.split(".", 1)
512+
Function.service_remove(self.global_ctx_name, domain, name)
513+
self.trigger_service = set()
507514

508515
async def eval_decorators(self, ast_ctx):
509516
"""Evaluate the function decorators arguments."""

custom_components/pyscript/function.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ class Function:
7070
#
7171
service_cnt = {}
7272

73+
#
74+
# save the global_ctx name where a service is registered so we can raise
75+
# an exception if it gets registered by a different global_ctx.
76+
#
77+
service2global_ctx = {}
78+
7379
def __init__(self):
7480
"""Warn on Function instantiation."""
7581
_LOGGER.error("Function class is not meant to be instantiated")
@@ -444,23 +450,30 @@ def create_task(cls, coro, ast_ctx=None):
444450
return cls.hass.loop.create_task(cls.run_coro(coro, ast_ctx=ast_ctx))
445451

446452
@classmethod
447-
def service_register(cls, domain, service, callback):
453+
def service_register(cls, global_ctx_name, domain, service, callback):
448454
"""Register a new service callback."""
449455
key = f"{domain}.{service}"
450456
if key not in cls.service_cnt:
451457
cls.service_cnt[key] = 0
458+
if key not in cls.service2global_ctx:
459+
cls.service2global_ctx[key] = global_ctx_name
460+
if cls.service2global_ctx[key] != global_ctx_name:
461+
raise ValueError(
462+
f"{global_ctx_name}: can't register service {key}; already defined in {cls.service2global_ctx[key]}"
463+
)
452464
cls.service_cnt[key] += 1
453465
cls.hass.services.async_register(domain, service, callback)
454466

455467
@classmethod
456-
def service_remove(cls, domain, service):
468+
def service_remove(cls, global_ctx_name, domain, service):
457469
"""Remove a service callback."""
458470
key = f"{domain}.{service}"
459471
if cls.service_cnt.get(key, 0) > 1:
460472
cls.service_cnt[key] -= 1
461473
return
462474
cls.service_cnt[key] = 0
463475
cls.hass.services.async_remove(domain, service)
476+
cls.service2global_ctx.pop(key, None)
464477

465478
@classmethod
466479
def task_done_callback_ctx(cls, task, ast_ctx):

docs/new_features.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ The new features since 1.2.1 in master include:
3434
- Trigger decorators (``@state_trigger``, ``@time_trigger``, ``@event_trigger`` or ``@mqtt_trigger``) support
3535
an optional ``kwargs`` keyword argument that can be set to a ``dict`` of keywords and values, which are
3636
passed to the trigger function. See #157.
37+
- The ``@service`` decorator now takes one of more optional arguments to specify the name of the service of the
38+
form ``"DOMAIN.SERVICE"``. The ``@service`` also can be used multiple times as an alternative to using multiple
39+
arguments. The default continues to be ``pyscript.FUNC_NAME``.
3740
- Added ``@pyscript_executor`` decorator, which does same thing as ``@pyscript_compile`` and additionally wraps
3841
the resulting function with a call to ``task.executor``. See #71.
3942
- Errors in trigger-related decorators (eg, wrong arguments, unregonized decorator type) raise exceptions rather

docs/reference.rst

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -908,15 +908,22 @@ Notice that `read_file` is called like a regular function, and it automatically
908908
``task.executor``, which runs the compiled native python function in a new thread, and
909909
then returns the result.
910910

911-
@service
912-
^^^^^^^^
911+
@service(service_name, ...)
912+
^^^^^^^^^^^^^^^^^^^^^^^^^^^
913913

914914
The ``@service`` decorator causes the function to be registered as a service so it can be called
915-
externally. The ``@state_active`` and ``@time_active`` decorators don't affect the service - those
916-
only apply to time, state and event triggers specified by other decorators.
915+
externally. The string ``service_name`` argument is optional and defaults to ``"pyscript.FUNC_NAME"``,
916+
where ``FUNC_NAME`` is the name of the function. You can override that default by specifying
917+
a string with a single period of the form ``"DOMAIN.SERVICE"``. Multiple arguments and multiple
918+
``@service`` decorators can be used to register multiple names (eg, aliases) for the same function.
919+
920+
Other trigger decorators like ``@state_active`` and ``@time_active`` don't affect the service.
921+
Those still allow state, time or other triggers to be specified in addition.
917922

918923
The function is called with keyword parameters set to the service call parameters, plus
919-
``trigger_type`` is set to ``"service"``.
924+
``trigger_type`` is set to ``"service"``. The function definition should specify all the
925+
expected keyword arguments to match the service call parameters, or use the ``**kwargs``
926+
argument declaration to capture all the keyword arguments.
920927

921928
The ``doc_string`` (the string immediately after the function declaration) is used as the service
922929
description that appears is in the Services tab of the Developer Tools page. The function argument

tests/test_apps_modules.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def func11():
5858
from xyz2 import *
5959
6060
@service
61-
def func2():
61+
def func20():
6262
pyscript.done = [get_x(), get_name(), other_name(), f_add(1, 5), f_mult(3, 6), f_add(10, 30), f_minus(50, 30)]
6363
""",
6464
#
@@ -198,7 +198,7 @@ async def state_changed(event):
198198
ret = await wait_until_done(notify_q)
199199
assert literal_eval(ret) == [1 + 2, 3 * 4, 10 + 20, 50 - 20]
200200

201-
await hass.services.async_call("pyscript", "func2", {})
201+
await hass.services.async_call("pyscript", "func20", {})
202202
ret = await wait_until_done(notify_q)
203203
assert literal_eval(ret) == [99, "xyz2", "xyz2.other", 1 + 5, 3 * 6, 10 + 30, 50 - 30]
204204

tests/test_decorator_errors.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,3 +278,117 @@ def func11():
278278
"TypeError: function 'func11' defined in file.hello: decorator @state_trigger argument 1 should be a string"
279279
in caplog.text
280280
)
281+
282+
283+
async def test_service_reload_error(hass, caplog):
284+
"""Test using a reserved name generates an error."""
285+
286+
await setup_script(
287+
hass,
288+
None,
289+
dt(2020, 7, 1, 11, 59, 59, 999999),
290+
"""
291+
@service
292+
def reload():
293+
pass
294+
""",
295+
)
296+
assert (
297+
"SyntaxError: function 'reload' defined in file.hello: @service conflicts with builtin service"
298+
in caplog.text
299+
)
300+
301+
302+
async def test_service_state_active_extra_args(hass, caplog):
303+
"""Test using extra args to state_active generates an error."""
304+
305+
await setup_script(
306+
hass,
307+
None,
308+
dt(2020, 7, 1, 11, 59, 59, 999999),
309+
"""
310+
@state_active("arg1", "too many args")
311+
def func4():
312+
pass
313+
""",
314+
)
315+
assert (
316+
"TypeError: function 'func4' defined in file.hello: decorator @state_active got 2 arguments, expected 1"
317+
in caplog.text
318+
)
319+
320+
321+
async def test_service_wrong_arg_type(hass, caplog):
322+
"""Test using too many args with service an error."""
323+
324+
await setup_script(
325+
hass,
326+
None,
327+
dt(2020, 7, 1, 11, 59, 59, 999999),
328+
"""
329+
@service(1)
330+
def func5():
331+
pass
332+
""",
333+
)
334+
assert (
335+
"TypeError: function 'func5' defined in file.hello: decorator @service argument 1 should be a string"
336+
in caplog.text
337+
)
338+
339+
340+
async def test_time_trigger_wrong_arg_type(hass, caplog):
341+
"""Test using wrong argument type generates an error."""
342+
343+
await setup_script(
344+
hass,
345+
None,
346+
dt(2020, 7, 1, 11, 59, 59, 999999),
347+
"""
348+
@time_trigger("wrong arg type", 50)
349+
def func6():
350+
pass
351+
""",
352+
)
353+
assert (
354+
"TypeError: function 'func6' defined in file.hello: decorator @time_trigger argument 2 should be a string"
355+
in caplog.text
356+
)
357+
358+
359+
async def test_decorator_kwargs(hass, caplog):
360+
"""Test invalid keyword arguments generates an error."""
361+
362+
await setup_script(
363+
hass,
364+
None,
365+
dt(2020, 7, 1, 11, 59, 59, 999999),
366+
"""
367+
@time_trigger("invalid kwargs", arg=10)
368+
def func7():
369+
pass
370+
""",
371+
)
372+
assert (
373+
"TypeError: function 'func7' defined in file.hello: decorator @time_trigger valid keyword arguments are: kwargs"
374+
in caplog.text
375+
)
376+
377+
378+
async def test_decorator_kwargs2(hass, caplog):
379+
"""Test invalid keyword arguments generates an error."""
380+
381+
await setup_script(
382+
hass,
383+
None,
384+
dt(2020, 7, 1, 11, 59, 59, 999999),
385+
"""
386+
@task_unique("invalid kwargs", arg=10)
387+
def func7():
388+
pass
389+
""",
390+
)
391+
assert (
392+
"TypeError: function 'func7' defined in file.hello: decorator @task_unique valid keyword arguments are: kill_me"
393+
in caplog.text
394+
)

0 commit comments

Comments
 (0)