Skip to content

Commit fe03993

Browse files
authored
Merge branch 'master' into only-changes
2 parents 0d4e32c + a25063d commit fe03993

File tree

9 files changed

+227
-37
lines changed

9 files changed

+227
-37
lines changed

custom_components/pyscript/function.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -190,13 +190,18 @@ def service_has_service(cls, domain, name):
190190
async def service_call(cls, domain, name, **kwargs):
191191
"""Implement service.call()."""
192192
curr_task = asyncio.current_task()
193-
if "context" in kwargs and isinstance(kwargs["context"], Context):
194-
context = kwargs["context"]
195-
del kwargs["context"]
196-
else:
197-
context = cls.task2context.get(curr_task, None)
198-
199-
await cls.hass.services.async_call(domain, name, kwargs, context=context)
193+
hass_args = {}
194+
for keyword, typ, default in [
195+
("context", [Context], cls.task2context.get(curr_task, None)),
196+
("blocking", [bool], None),
197+
("limit", [float, int], None),
198+
]:
199+
if keyword in kwargs and type(kwargs[keyword]) in typ:
200+
hass_args[keyword] = kwargs.pop(keyword)
201+
elif default:
202+
hass_args[keyword] = default
203+
204+
await cls.hass.services.async_call(domain, name, kwargs, **hass_args)
200205

201206
@classmethod
202207
async def service_completions(cls, root):
@@ -255,15 +260,21 @@ def get(cls, name):
255260
def service_call_factory(domain, service):
256261
async def service_call(*args, **kwargs):
257262
curr_task = asyncio.current_task()
258-
if "context" in kwargs and isinstance(kwargs["context"], Context):
259-
context = kwargs["context"]
260-
del kwargs["context"]
261-
else:
262-
context = cls.task2context.get(curr_task, None)
263+
hass_args = {}
264+
for keyword, typ, default in [
265+
("context", [Context], cls.task2context.get(curr_task, None)),
266+
("blocking", [bool], None),
267+
("limit", [float, int], None),
268+
]:
269+
if keyword in kwargs and type(kwargs[keyword]) in typ:
270+
hass_args[keyword] = kwargs.pop(keyword)
271+
elif default:
272+
hass_args[keyword] = default
263273

264274
if len(args) != 0:
265275
raise (TypeError, f"service {domain}.{service} takes no positional arguments")
266-
await cls.hass.services.async_call(domain, service, kwargs, context=context)
276+
277+
await cls.hass.services.async_call(domain, service, kwargs, **hass_args)
267278

268279
return service_call
269280

custom_components/pyscript/global_ctx.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,6 @@ def read_file(path):
266266
_LOGGER.error("%s", exc)
267267
return None
268268

269-
_LOGGER.debug("reading and parsing %s", filepath)
270-
271269
source = await Function.hass.async_add_executor_job(read_file, filepath)
272270

273271
if source is None:
@@ -288,4 +286,7 @@ def read_file(path):
288286
global_ctx.stop()
289287
return ast_ctx
290288
cls.set(global_ctx.get_name(), global_ctx)
289+
290+
_LOGGER.info("Loaded %s", filepath)
291+
291292
return None

custom_components/pyscript/state.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
_LOGGER = logging.getLogger(LOGGER_PATH + ".state")
1414

15-
STATE_VIRTUAL_ATTRS = {"last_updated", "last_changed"}
15+
STATE_VIRTUAL_ATTRS = {"last_changed", "last_updated"}
1616

1717
class StateVar(str):
1818
"""Class for representing the value and attributes of a state variable."""
@@ -144,6 +144,15 @@ async def set(cls, var_name, value=None, new_attributes=None, **kwargs):
144144
if var_name.count(".") != 1:
145145
raise NameError(f"invalid name {var_name} (should be 'domain.entity')")
146146

147+
if isinstance(value, StateVar) and new_attributes is None:
148+
#
149+
# value is a StateVar, so extract the attributes and value
150+
#
151+
new_attributes = value.__dict__.copy()
152+
for discard in STATE_VIRTUAL_ATTRS:
153+
new_attributes.pop(discard, None)
154+
value = str(value)
155+
147156
state_value = None
148157
if value is None or new_attributes is None:
149158
state_value = cls.hass.states.get(var_name)
@@ -219,7 +228,7 @@ def exist(cls, var_name):
219228
len(parts) == 2
220229
or (parts[0] in cls.service2args and parts[2] in cls.service2args[parts[0]])
221230
or parts[2] in value.attributes
222-
or parts[2] in {"last_changed", "last_updated"}
231+
or parts[2] in STATE_VIRTUAL_ATTRS
223232
):
224233
return True
225234
return False
@@ -248,11 +257,16 @@ async def get(cls, var_name):
248257
def service_call_factory(domain, service, entity_id, params):
249258
async def service_call(*args, **kwargs):
250259
curr_task = asyncio.current_task()
251-
if "context" in kwargs and isinstance(kwargs["context"], Context):
252-
context = kwargs["context"]
253-
del kwargs["context"]
254-
else:
255-
context = Function.task2context.get(curr_task, None)
260+
hass_args = {}
261+
for keyword, typ, default in [
262+
("context", [Context], Function.task2context.get(curr_task, None)),
263+
("blocking", [bool], None),
264+
("limit", [float, int], None),
265+
]:
266+
if keyword in kwargs and type(kwargs[keyword]) in typ:
267+
hass_args[keyword] = kwargs.pop(keyword)
268+
elif default:
269+
hass_args[keyword] = default
256270

257271
kwargs["entity_id"] = entity_id
258272
if len(args) == 1 and len(params) == 1:
@@ -263,7 +277,7 @@ async def service_call(*args, **kwargs):
263277
kwargs[param_name] = args[0]
264278
elif len(args) != 0:
265279
raise TypeError(f"service {domain}.{service} takes no positional arguments")
266-
await cls.hass.services.async_call(domain, service, kwargs, context=context)
280+
await cls.hass.services.async_call(domain, service, kwargs, **hass_args)
267281

268282
return service_call
269283

@@ -303,7 +317,7 @@ def completions(cls, root):
303317
value = cls.hass.states.get(name)
304318
if value:
305319
attr_root = root[last_period + 1 :]
306-
attrs = set(value.attributes.keys()).union({"last_changed", "last_updated"})
320+
attrs = set(value.attributes.keys()).union(STATE_VIRTUAL_ATTRS)
307321
if parts[0] in cls.service2args:
308322
attrs.update(set(cls.service2args[parts[0]].keys()))
309323
for attr_name in attrs:

custom_components/pyscript/trigger.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,6 @@ async def wait_until(
166166
event_trigger=None,
167167
timeout=None,
168168
state_hold=None,
169-
**kwargs,
170169
):
171170
"""Wait for zero or more triggers, until an optional timeout."""
172171
if state_trigger is None and time_trigger is None and event_trigger is None:

docs/new_features.rst

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
New Features
22
============
33

4-
The releases and releae notes are available on `GitHub <https://github.com/custom-components/pyscript/releases>`__.
4+
The releases and release notes are available on `GitHub <https://github.com/custom-components/pyscript/releases>`__.
55
Use HACS to install different versions of pyscript.
66

77
You can also install the master (head of tree) version from GitHub, either using HACS or manually.
@@ -54,9 +54,14 @@ The new features since 0.32 in master include:
5454
messages get displayed in VSCode. One benign but unresolved bug with VSCode is that when you connect
5555
to the pyscript kernel, VSCode starts a second pyscript Jupyter kernel, before shutting that second one
5656
down.
57+
- Service calls now accept ``blocking`` and ``limit`` parameters. The default behavior for a service call is
58+
to run it in the background, but using ``blocking=True`` will force a task to wait up to ``limit`` seconds
59+
for the service call to finish executing before continuing. Contributed by @raman325 (#85)
5760

5861
The bug fixes since 0.32 in master include:
5962

6063
- Jupyter autocomplete now works on multiline code blocks.
6164
- Improved error message reporting for syntax errors inside f-strings.
6265
- Fixed incorrect global context update on calling module that, in turn, does a callback (#58)
66+
- `task.wait_until` no longer silently ignores unrecognized keyword arguments (#80)
67+
- `task.wait_until` incorrectly ignored the keyword optional state_check_now argument (#81)

docs/reference.rst

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ For example, applications ``my_app1`` and ``my_app2`` would be configured as:
4848
4949
Note that if you used the UI flow to configure pyscript, the ``allow_all_imports`` and
5050
``hass_is_global`` configuration settings will be ignored in the yaml file. In that case
51-
you should omit them from the yaml, and just use yaml for pycript app configuration.
51+
you should omit them from the yaml, and just use yaml for pyscript app configuration.
5252

5353
As explained below, the use of ``apps`` with entries for each application by name below,
5454
is used to determine which application scripts are autoloaded. That's the only configuration
@@ -210,6 +210,11 @@ called by:
210210
211211
service.call("myservice", "flash_light", light_name="front", light_color="red")
212212
213+
When making a service call, either using the ``service.call`` function or the service name as the
214+
function, you can optionally pass the keyword argument ``blocking=True`` if you would like to wait
215+
for the service to finish execution before continuing execution in your function. You can also
216+
specify a timeout for a blocking service call using the ``limit=<number_of_seconds>`` parameters.
217+
213218
Firing events
214219
-------------
215220

@@ -535,7 +540,7 @@ When any trigger occurs (whether time, state or event), the ``@state_active`` ex
535540
evaluated. If it evaluates to ``False`` (or zero), the trigger is ignored and the trigger function
536541
is not called. This decorator is roughly equivalent to starting the trigger function with an
537542
``if`` statement with the ``str_expr`` (the minor difference is that this decorator uses the
538-
``@state_trigger`` variable value, if present, when evalauting ``str_expr``, whereas an
543+
``@state_trigger`` variable value, if present, when evaluating ``str_expr``, whereas an
539544
``if`` statement at the start of the function uses its current value, which might be different
540545
if the state variable was changed immediately after the trigger, and the ``.old`` value is
541546
not available).
@@ -691,8 +696,11 @@ or ``float()``). Attributes keep their native type.
691696
Service Calls
692697
^^^^^^^^^^^^^
693698

694-
``service.call(domain, name, **kwargs)``
695-
calls the service ``domain.name`` with the given keyword arguments as parameters.
699+
``service.call(domain, name, blocking=False, limit=10, **kwargs)``
700+
calls the service ``domain.name`` with the given keyword arguments as parameters. If ``blocking``
701+
is ``True``, pyscript will wait for the service to finish executing before continuing the current
702+
routine, or will wait a maximum of the number of seconds specified in the `limit` keyword
703+
argument.
696704
``service.has_service(domain, name)``
697705
returns whether the service ``domain.name`` exists.
698706

@@ -992,7 +1000,7 @@ makes it convenient to just reload the script file or application you are develo
9921000
affecting the others.
9931001

9941002
A much better alternative to repeatedly modifying a script file and reloading it is to use Jupyter
995-
notebook to interactively deveop and test functions, triggers and services.
1003+
notebook to interactively develop and test functions, triggers and services.
9961004

9971005
Jupyter auto-completion (with `<TAB>`) is supported in Jupyter notebook, console and lab. It should
9981006
work after you have typed at least the first character. After you hit `<TAB>` you should see a list
@@ -1275,7 +1283,7 @@ Access to Hass
12751283
^^^^^^^^^^^^^^
12761284

12771285
If the ``hass_is_global`` configuration setting is set (default is off), then the variable ``hass``
1278-
is available as a global variable in all pyscript contexts. That provides significant flexiblity
1286+
is available as a global variable in all pyscript contexts. That provides significant flexibility
12791287
in accessing HASS internals for cases where pyscript doesn't provide some binding or access.
12801288

12811289
Ideally you should only use ``hass`` for read-only access. However, you do need a good understanding
@@ -1297,7 +1305,7 @@ You can use ``hass`` to compute sunrise and sunset times using the same method H
12971305
print(f"today sunrise = {sunrise}, sunset = {sunset}")
12981306
12991307
Here's another method that uses the installed version of ``astral`` directly, rather than the HASS
1300-
helper function. It's a bit more crytpic since it's a very old version of ``astral``, but you can
1308+
helper function. It's a bit more cryptic since it's a very old version of ``astral``, but you can
13011309
see how the HASS configuration values are used:
13021310

13031311
.. code:: python
@@ -1332,7 +1340,7 @@ be blocked, which will delay all other tasks.
13321340

13331341
All the built-in functionality in pyscript is written using asynchronous code, which runs seamlessly
13341342
together with all the other tasks in the main event loop. However, if you import Python packages and
1335-
call functions that block (eg, file or networrk I/O) then you need to run those functions outside
1343+
call functions that block (eg, file or network I/O) then you need to run those functions outside
13361344
the main event loop. That can be accomplished wrapping those function calls with the
13371345
``task.executor`` function, which runs the function in a separate thread:
13381346

@@ -1440,5 +1448,5 @@ A handful of language features are not supported:
14401448
functions that can be called and used in-line. There is a feature request to add this.
14411449

14421450
Pyscript can call Python modules and packages, so you can always write your own native Python code
1443-
(eg, if you need a generator or other unsupported feature) that can be called by psycript
1451+
(eg, if you need a generator or other unsupported feature) that can be called by pyscript
14441452
(see `Importing <#importing>`__ for how to create and import native Python modules in pyscript).

tests/test_function.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from pytest_homeassistant_custom_component.async_mock import MagicMock, Mock, mock_open, patch
1313

1414
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED
15+
from homeassistant.core import Context
1516
from homeassistant.setup import async_setup_component
1617

1718

@@ -804,3 +805,95 @@ def set_add(entity_id=None, val1=None, val2=None):
804805
assert literal_eval(await wait_until_done(notify_q)) == [4, "pyscript.var1", "32"]
805806
assert literal_eval(await wait_until_done(notify_q)) == [5, "pyscript.var1", "50", "HomeAssistant"]
806807
assert "TypeError: service pyscript.set_add takes no positional arguments" in caplog.text
808+
809+
810+
async def test_service_call_params(hass):
811+
"""Test that hass params get set properly on service calls."""
812+
with patch.object(hass.services, "async_call") as call, patch.object(
813+
Function, "service_has_service", return_value=True
814+
):
815+
Function.init(hass)
816+
await Function.service_call(
817+
"test", "test", context=Context(id="test"), blocking=True, limit=1, other_service_data="test"
818+
)
819+
assert call.called
820+
assert call.call_args[0] == ("test", "test", {"other_service_data": "test"})
821+
assert call.call_args[1] == {"context": Context(id="test"), "blocking": True, "limit": 1}
822+
call.reset_mock()
823+
824+
await Function.service_call(
825+
"test", "test", context=Context(id="test"), blocking=False, other_service_data="test"
826+
)
827+
assert call.called
828+
assert call.call_args[0] == ("test", "test", {"other_service_data": "test"})
829+
assert call.call_args[1] == {"context": Context(id="test"), "blocking": False}
830+
call.reset_mock()
831+
832+
await Function.get("test.test")(
833+
context=Context(id="test"), blocking=True, limit=1, other_service_data="test"
834+
)
835+
assert call.called
836+
assert call.call_args[0] == ("test", "test", {"other_service_data": "test"})
837+
assert call.call_args[1] == {"context": Context(id="test"), "blocking": True, "limit": 1}
838+
call.reset_mock()
839+
840+
await Function.get("test.test")(
841+
context=Context(id="test"), blocking=False, other_service_data="test"
842+
)
843+
assert call.called
844+
assert call.call_args[0] == ("test", "test", {"other_service_data": "test"})
845+
assert call.call_args[1] == {"context": Context(id="test"), "blocking": False}
846+
847+
# Stop all tasks to avoid conflicts with other tests
848+
await Function.reaper_stop()
849+
850+
851+
async def test_serive_call_blocking(hass, caplog):
852+
"""Test that service calls with blocking=True actually block."""
853+
notify_q = asyncio.Queue(0)
854+
855+
await setup_script(
856+
hass,
857+
notify_q,
858+
[dt(2020, 7, 1, 12, 0, 0, 0)],
859+
"""
860+
seq_num = 0
861+
862+
@time_trigger("startup")
863+
def func_startup():
864+
global seq_num
865+
866+
seq_num += 1
867+
pyscript.var1 = 1
868+
pyscript.service1(blocking=True)
869+
pyscript.done = [seq_num, pyscript.var1]
870+
871+
seq_num += 1
872+
pyscript.service1(blocking=True)
873+
pyscript.done = [seq_num, pyscript.var1]
874+
875+
seq_num += 1
876+
service.call("pyscript", "service1", blocking=True)
877+
pyscript.done = [seq_num, pyscript.var1]
878+
879+
seq_num += 1
880+
pyscript.var1 = int(pyscript.var1) + 1
881+
service.call("pyscript", "long_sleep", blocking=True, limit=1e-6)
882+
pyscript.done = [seq_num, pyscript.var1]
883+
884+
@service
885+
def long_sleep():
886+
task.delay(10000)
887+
888+
@service
889+
def service1():
890+
pyscript.var1 = int(pyscript.var1) + 1
891+
892+
""",
893+
config={DOMAIN: {CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}},
894+
)
895+
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
896+
assert literal_eval(await wait_until_done(notify_q)) == [1, "2"]
897+
assert literal_eval(await wait_until_done(notify_q)) == [2, "3"]
898+
assert literal_eval(await wait_until_done(notify_q)) == [3, "4"]
899+
assert literal_eval(await wait_until_done(notify_q)) == [4, "5"]

0 commit comments

Comments
 (0)