From 028656f67b0a34299619688154fba8197336786f Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Fri, 6 Nov 2020 09:51:56 -0500 Subject: [PATCH 1/5] add support for service blocking --- custom_components/pyscript/function.py | 36 ++++++++++++++++---------- custom_components/pyscript/state.py | 17 +++++++----- docs/reference.rst | 12 +++++++-- 3 files changed, 44 insertions(+), 21 deletions(-) diff --git a/custom_components/pyscript/function.py b/custom_components/pyscript/function.py index 021c368..6df5012 100644 --- a/custom_components/pyscript/function.py +++ b/custom_components/pyscript/function.py @@ -190,13 +190,18 @@ def service_has_service(cls, domain, name): async def service_call(cls, domain, name, **kwargs): """Implement service.call().""" curr_task = asyncio.current_task() - if "context" in kwargs and isinstance(kwargs["context"], Context): - context = kwargs["context"] - del kwargs["context"] - else: - context = cls.task2context.get(curr_task, None) - - await cls.hass.services.async_call(domain, name, kwargs, context=context) + hass_args = {} + for keyword, typ, default in [ + ("context", Context, cls.task2context.get(curr_task, None)), + ("blocking", bool, None), + ("limit", float, None), + ]: + if keyword in kwargs and isinstance(kwargs[keyword], typ): + hass_args[keyword] = kwargs.pop(keyword) + elif default: + hass_args[keyword] = default + + await cls.hass.services.async_call(domain, name, kwargs, **hass_args) @classmethod async def service_completions(cls, root): @@ -255,15 +260,20 @@ def get(cls, name): def service_call_factory(domain, service): async def service_call(*args, **kwargs): curr_task = asyncio.current_task() - if "context" in kwargs and isinstance(kwargs["context"], Context): - context = kwargs["context"] - del kwargs["context"] - else: - context = cls.task2context.get(curr_task, None) + hass_args = {} + for keyword, typ, default in [ + ("context", Context, cls.task2context.get(curr_task, None)), + ("blocking", bool, None), + ("limit", float, None), + ]: + if keyword in kwargs and isinstance(kwargs[keyword], typ): + hass_args[keyword] = kwargs.pop(keyword) + elif default: + hass_args[keyword] = default if len(args) != 0: raise (TypeError, f"service {domain}.{service} takes no positional arguments") - await cls.hass.services.async_call(domain, service, kwargs, context=context) + await cls.hass.services.async_call(domain, service, kwargs, **hass_args) return service_call diff --git a/custom_components/pyscript/state.py b/custom_components/pyscript/state.py index e808b47..26685da 100644 --- a/custom_components/pyscript/state.py +++ b/custom_components/pyscript/state.py @@ -258,11 +258,16 @@ async def get(cls, var_name): def service_call_factory(domain, service, entity_id, params): async def service_call(*args, **kwargs): curr_task = asyncio.current_task() - if "context" in kwargs and isinstance(kwargs["context"], Context): - context = kwargs["context"] - del kwargs["context"] - else: - context = Function.task2context.get(curr_task, None) + hass_args = {} + for keyword, typ, default in [ + ("context", Context, Function.task2context.get(curr_task, None)), + ("blocking", bool, None), + ("limit", float, None), + ]: + if keyword in kwargs and isinstance(kwargs[keyword], typ): + hass_args[keyword] = kwargs.pop(keyword) + elif default: + hass_args[keyword] = default kwargs["entity_id"] = entity_id if len(args) == 1 and len(params) == 1: @@ -273,7 +278,7 @@ async def service_call(*args, **kwargs): kwargs[param_name] = args[0] elif len(args) != 0: raise TypeError(f"service {domain}.{service} takes no positional arguments") - await cls.hass.services.async_call(domain, service, kwargs, context=context) + await cls.hass.services.async_call(domain, service, kwargs, **hass_args) return service_call diff --git a/docs/reference.rst b/docs/reference.rst index 9140ad7..e7e4aa4 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -210,6 +210,11 @@ called by: service.call("myservice", "flash_light", light_name="front", light_color="red") +When making a service call, either using the ``service.call`` function or the service name as the +function, you can optionally pass the keyword argument ``blocking=True`` if you would like to wait +for the service to finish execution before continuing execution in your function. You can also +specify a timeout for a blocking service call using the ``limit=`` parameters. + Firing events ------------- @@ -691,8 +696,11 @@ or ``float()``). Attributes keep their native type. Service Calls ^^^^^^^^^^^^^ -``service.call(domain, name, **kwargs)`` - calls the service ``domain.name`` with the given keyword arguments as parameters. +``service.call(domain, name, blocking=False, limit=10, **kwargs)`` + calls the service ``domain.name`` with the given keyword arguments as parameters. If ``blocking`` + is ``True``, pyscript will wait for the service to finish executing before continuing the current + routine, or will wait a maximum of the number of seconds specified in the `limit` keyword + argument. ``service.has_service(domain, name)`` returns whether the service ``domain.name`` exists. From 936ac3b9b383f888655ff7d90c51d7aeea91d38b Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Fri, 6 Nov 2020 13:38:59 -0500 Subject: [PATCH 2/5] fix type check for kwargs and add function tests --- custom_components/pyscript/function.py | 17 +++++------ custom_components/pyscript/state.py | 8 +++--- tests/test_function.py | 39 ++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/custom_components/pyscript/function.py b/custom_components/pyscript/function.py index 6df5012..ea98308 100644 --- a/custom_components/pyscript/function.py +++ b/custom_components/pyscript/function.py @@ -192,11 +192,11 @@ async def service_call(cls, domain, name, **kwargs): curr_task = asyncio.current_task() hass_args = {} for keyword, typ, default in [ - ("context", Context, cls.task2context.get(curr_task, None)), - ("blocking", bool, None), - ("limit", float, None), + ("context", [Context], cls.task2context.get(curr_task, None)), + ("blocking", [bool], None), + ("limit", [float, int], None), ]: - if keyword in kwargs and isinstance(kwargs[keyword], typ): + if keyword in kwargs and type(kwargs[keyword]) in typ: hass_args[keyword] = kwargs.pop(keyword) elif default: hass_args[keyword] = default @@ -262,17 +262,18 @@ async def service_call(*args, **kwargs): curr_task = asyncio.current_task() hass_args = {} for keyword, typ, default in [ - ("context", Context, cls.task2context.get(curr_task, None)), - ("blocking", bool, None), - ("limit", float, None), + ("context", [Context], cls.task2context.get(curr_task, None)), + ("blocking", [bool], None), + ("limit", [float, int], None), ]: - if keyword in kwargs and isinstance(kwargs[keyword], typ): + if keyword in kwargs and type(kwargs[keyword]) in typ: hass_args[keyword] = kwargs.pop(keyword) elif default: hass_args[keyword] = default if len(args) != 0: raise (TypeError, f"service {domain}.{service} takes no positional arguments") + await cls.hass.services.async_call(domain, service, kwargs, **hass_args) return service_call diff --git a/custom_components/pyscript/state.py b/custom_components/pyscript/state.py index 26685da..2e9be67 100644 --- a/custom_components/pyscript/state.py +++ b/custom_components/pyscript/state.py @@ -260,11 +260,11 @@ async def service_call(*args, **kwargs): curr_task = asyncio.current_task() hass_args = {} for keyword, typ, default in [ - ("context", Context, Function.task2context.get(curr_task, None)), - ("blocking", bool, None), - ("limit", float, None), + ("context", [Context], Function.task2context.get(curr_task, None)), + ("blocking", [bool], None), + ("limit", [float, int], None), ]: - if keyword in kwargs and isinstance(kwargs[keyword], typ): + if keyword in kwargs and type(kwargs[keyword]) in typ: hass_args[keyword] = kwargs.pop(keyword) elif default: hass_args[keyword] = default diff --git a/tests/test_function.py b/tests/test_function.py index 8ec3fea..09d40a9 100644 --- a/tests/test_function.py +++ b/tests/test_function.py @@ -12,6 +12,7 @@ from pytest_homeassistant_custom_component.async_mock import MagicMock, Mock, mock_open, patch from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED +from homeassistant.core import Context from homeassistant.setup import async_setup_component @@ -804,3 +805,41 @@ def set_add(entity_id=None, val1=None, val2=None): assert literal_eval(await wait_until_done(notify_q)) == [4, "pyscript.var1", "32"] assert literal_eval(await wait_until_done(notify_q)) == [5, "pyscript.var1", "50", "HomeAssistant"] assert "TypeError: service pyscript.set_add takes no positional arguments" in caplog.text + + +async def test_service_call_params(hass): + """Test that hass params get set properly on service calls.""" + Function.init(hass) + with patch.object(Function.hass.services, "async_call") as call, patch.object( + Function, "service_has_service", return_value=True + ): + await Function.service_call( + "test", "test", context=Context(id="test"), blocking=True, limit=1, other_service_data="test" + ) + assert call.called + assert call.call_args[0] == ("test", "test", {"other_service_data": "test"}) + assert call.call_args[1] == {"context": Context(id="test"), "blocking": True, "limit": 1} + call.reset_mock() + + await Function.service_call( + "test", "test", context=Context(id="test"), blocking=False, other_service_data="test" + ) + assert call.called + assert call.call_args[0] == ("test", "test", {"other_service_data": "test"}) + assert call.call_args[1] == {"context": Context(id="test"), "blocking": False} + call.reset_mock() + + await Function.get("test.test")( + context=Context(id="test"), blocking=True, limit=1, other_service_data="test" + ) + assert call.called + assert call.call_args[0] == ("test", "test", {"other_service_data": "test"}) + assert call.call_args[1] == {"context": Context(id="test"), "blocking": True, "limit": 1} + call.reset_mock() + + await Function.get("test.test")( + context=Context(id="test"), blocking=False, other_service_data="test" + ) + assert call.called + assert call.call_args[0] == ("test", "test", {"other_service_data": "test"}) + assert call.call_args[1] == {"context": Context(id="test"), "blocking": False} From 44c11295b7db6cdea9e96552ebbb634e495e6c5a Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Fri, 6 Nov 2020 14:38:28 -0500 Subject: [PATCH 3/5] add tests for changes to state module --- tests/test_state.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/test_state.py diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..c52fd66 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,43 @@ +"""Test pyscripts test module.""" +from custom_components.pyscript.state import State +from pytest_homeassistant_custom_component.async_mock import patch + +from homeassistant.core import Context +from homeassistant.helpers.state import State as HassState + + +async def test_service_call(hass): + """Test calling a service using the entity_id as a property.""" + with patch( + "custom_components.pyscript.state.async_get_all_descriptions", + return_value={ + "test": { + "test": {"description": None, "fields": {"entity_id": "blah", "other_service_data": "blah"}} + } + }, + ), patch.object(hass.states, "get", return_value=HassState("test.entity", "True")), patch.object( + hass.services, "async_call" + ) as call: + State.init(hass) + await State.get_service_params() + + func = await State.get("test.entity.test") + await func(context=Context(id="test"), blocking=True, limit=1, other_service_data="test") + assert call.called + assert call.call_args[0] == ( + "test", + "test", + {"other_service_data": "test", "entity_id": "test.entity"}, + ) + assert call.call_args[1] == {"context": Context(id="test"), "blocking": True, "limit": 1} + call.reset_mock() + + func = await State.get("test.entity.test") + await func(context=Context(id="test"), blocking=False, other_service_data="test") + assert call.called + assert call.call_args[0] == ( + "test", + "test", + {"other_service_data": "test", "entity_id": "test.entity"}, + ) + assert call.call_args[1] == {"context": Context(id="test"), "blocking": False} From 5c79bf78b104b77f970c3e30ddc3d81bcb2ad430 Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Fri, 6 Nov 2020 14:55:35 -0500 Subject: [PATCH 4/5] add reaper_stop to test_service_call_params so that tests don't break --- tests/test_function.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_function.py b/tests/test_function.py index 09d40a9..1fca0b1 100644 --- a/tests/test_function.py +++ b/tests/test_function.py @@ -809,10 +809,10 @@ def set_add(entity_id=None, val1=None, val2=None): async def test_service_call_params(hass): """Test that hass params get set properly on service calls.""" - Function.init(hass) - with patch.object(Function.hass.services, "async_call") as call, patch.object( + with patch.object(hass.services, "async_call") as call, patch.object( Function, "service_has_service", return_value=True ): + Function.init(hass) await Function.service_call( "test", "test", context=Context(id="test"), blocking=True, limit=1, other_service_data="test" ) @@ -843,3 +843,6 @@ async def test_service_call_params(hass): assert call.called assert call.call_args[0] == ("test", "test", {"other_service_data": "test"}) assert call.call_args[1] == {"context": Context(id="test"), "blocking": False} + + # Stop all tasks to avoid conflicts with other tests + await Function.reaper_stop() From 1dfab0f3fc512e2669c77ff38e55cc91c7809dfd Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Fri, 6 Nov 2020 16:17:45 -0500 Subject: [PATCH 5/5] add new feature entry --- docs/new_features.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/new_features.rst b/docs/new_features.rst index 2afa9cd..985fddd 100644 --- a/docs/new_features.rst +++ b/docs/new_features.rst @@ -54,6 +54,9 @@ The new features since 0.32 in master include: messages get displayed in VSCode. One benign but unresolved bug with VSCode is that when you connect to the pyscript kernel, VSCode starts a second pyscript Jupyter kernel, before shutting that second one down. +- Service calls now accept ``blocking`` and ``limit`` parameters. The default behavior for a service call is + to run it in the background, but using ``blocking=True`` will force a task to wait up to ``limit`` seconds + for the service call to finish executing before continuing. Contributed by @raman325 (#85) The bug fixes since 0.32 in master include: