mirror of
https://github.com/home-assistant/core.git
synced 2025-04-26 10:17:51 +00:00
Make shell_command async (#10741)
* Make shell_command async Use `asyncio.subprocess` instead of `subprocess` to make the `shell_command` component async. Was able to migrate over existing component and tests without too many drastic changes. Retrieving stdout and stderr paves the way for possibly using these in future feature enhancements. * Remove trailing comma * Fix lint errors * Try to get rid of syntaxerror * Ignore spurious pylint error
This commit is contained in:
parent
b03c024f74
commit
61cddaa441
@ -4,15 +4,17 @@ Exposes regular shell commands as services.
|
|||||||
For more details about this platform, please refer to the documentation at
|
For more details about this platform, please refer to the documentation at
|
||||||
https://home-assistant.io/components/shell_command/
|
https://home-assistant.io/components/shell_command/
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
|
||||||
import shlex
|
import shlex
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.helpers import template
|
|
||||||
from homeassistant.exceptions import TemplateError
|
from homeassistant.exceptions import TemplateError
|
||||||
import homeassistant.helpers.config_validation as cv
|
from homeassistant.core import ServiceCall
|
||||||
|
from homeassistant.helpers import config_validation as cv, template
|
||||||
|
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
|
||||||
|
|
||||||
|
|
||||||
DOMAIN = 'shell_command'
|
DOMAIN = 'shell_command'
|
||||||
|
|
||||||
@ -25,15 +27,17 @@ CONFIG_SCHEMA = vol.Schema({
|
|||||||
}, extra=vol.ALLOW_EXTRA)
|
}, extra=vol.ALLOW_EXTRA)
|
||||||
|
|
||||||
|
|
||||||
def setup(hass, config):
|
@asyncio.coroutine
|
||||||
|
def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
|
||||||
"""Set up the shell_command component."""
|
"""Set up the shell_command component."""
|
||||||
conf = config.get(DOMAIN, {})
|
conf = config.get(DOMAIN, {})
|
||||||
|
|
||||||
cache = {}
|
cache = {}
|
||||||
|
|
||||||
def service_handler(call):
|
@asyncio.coroutine
|
||||||
|
def async_service_handler(service: ServiceCall) -> None:
|
||||||
"""Execute a shell command service."""
|
"""Execute a shell command service."""
|
||||||
cmd = conf[call.service]
|
cmd = conf[service.service]
|
||||||
|
|
||||||
if cmd in cache:
|
if cmd in cache:
|
||||||
prog, args, args_compiled = cache[cmd]
|
prog, args, args_compiled = cache[cmd]
|
||||||
@ -49,7 +53,7 @@ def setup(hass, config):
|
|||||||
|
|
||||||
if args_compiled:
|
if args_compiled:
|
||||||
try:
|
try:
|
||||||
rendered_args = args_compiled.render(call.data)
|
rendered_args = args_compiled.async_render(service.data)
|
||||||
except TemplateError as ex:
|
except TemplateError as ex:
|
||||||
_LOGGER.exception("Error rendering command template: %s", ex)
|
_LOGGER.exception("Error rendering command template: %s", ex)
|
||||||
return
|
return
|
||||||
@ -58,19 +62,34 @@ def setup(hass, config):
|
|||||||
|
|
||||||
if rendered_args == args:
|
if rendered_args == args:
|
||||||
# No template used. default behavior
|
# No template used. default behavior
|
||||||
shell = True
|
|
||||||
else:
|
|
||||||
# Template used. Break into list and use shell=False for security
|
|
||||||
cmd = [prog] + shlex.split(rendered_args)
|
|
||||||
shell = False
|
|
||||||
|
|
||||||
try:
|
# pylint: disable=no-member
|
||||||
subprocess.call(cmd, shell=shell,
|
create_process = asyncio.subprocess.create_subprocess_shell(
|
||||||
stdout=subprocess.DEVNULL,
|
cmd,
|
||||||
stderr=subprocess.DEVNULL)
|
loop=hass.loop,
|
||||||
except subprocess.SubprocessError:
|
stdin=None,
|
||||||
_LOGGER.exception("Error running command: %s", cmd)
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL)
|
||||||
|
else:
|
||||||
|
# Template used. Break into list and use create_subprocess_exec
|
||||||
|
# (which uses shell=False) for security
|
||||||
|
shlexed_cmd = [prog] + shlex.split(rendered_args)
|
||||||
|
|
||||||
|
# pylint: disable=no-member
|
||||||
|
create_process = asyncio.subprocess.create_subprocess_exec(
|
||||||
|
*shlexed_cmd,
|
||||||
|
loop=hass.loop,
|
||||||
|
stdin=None,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL)
|
||||||
|
|
||||||
|
process = yield from create_process
|
||||||
|
yield from process.communicate()
|
||||||
|
|
||||||
|
if process.returncode != 0:
|
||||||
|
_LOGGER.exception("Error running command: `%s`, return code: %s",
|
||||||
|
cmd, process.returncode)
|
||||||
|
|
||||||
for name in conf.keys():
|
for name in conf.keys():
|
||||||
hass.services.register(DOMAIN, name, service_handler)
|
hass.services.async_register(DOMAIN, name, async_service_handler)
|
||||||
return True
|
return True
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
"""The tests for the Shell command component."""
|
"""The tests for the Shell command component."""
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from typing import Tuple
|
||||||
from subprocess import SubprocessError
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from homeassistant.setup import setup_component
|
from homeassistant.setup import setup_component
|
||||||
from homeassistant.components import shell_command
|
from homeassistant.components import shell_command
|
||||||
@ -11,12 +12,35 @@ from homeassistant.components import shell_command
|
|||||||
from tests.common import get_test_home_assistant
|
from tests.common import get_test_home_assistant
|
||||||
|
|
||||||
|
|
||||||
|
@asyncio.coroutine
|
||||||
|
def mock_process_creator(error: bool = False) -> asyncio.coroutine:
|
||||||
|
"""Mock a coroutine that creates a process when yielded."""
|
||||||
|
@asyncio.coroutine
|
||||||
|
def communicate() -> Tuple[bytes, bytes]:
|
||||||
|
"""Mock a coroutine that runs a process when yielded.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
a tuple of (stdout, stderr).
|
||||||
|
"""
|
||||||
|
return b"I am stdout", b"I am stderr"
|
||||||
|
|
||||||
|
mock_process = Mock()
|
||||||
|
mock_process.communicate = communicate
|
||||||
|
mock_process.returncode = int(error)
|
||||||
|
return mock_process
|
||||||
|
|
||||||
|
|
||||||
class TestShellCommand(unittest.TestCase):
|
class TestShellCommand(unittest.TestCase):
|
||||||
"""Test the Shell command component."""
|
"""Test the shell_command component."""
|
||||||
|
|
||||||
def setUp(self): # pylint: disable=invalid-name
|
def setUp(self): # pylint: disable=invalid-name
|
||||||
"""Setup things to be run when tests are started."""
|
"""Setup things to be run when tests are started.
|
||||||
|
|
||||||
|
Also seems to require a child watcher attached to the loop when run
|
||||||
|
from pytest.
|
||||||
|
"""
|
||||||
self.hass = get_test_home_assistant()
|
self.hass = get_test_home_assistant()
|
||||||
|
asyncio.get_child_watcher().attach_loop(self.hass.loop)
|
||||||
|
|
||||||
def tearDown(self): # pylint: disable=invalid-name
|
def tearDown(self): # pylint: disable=invalid-name
|
||||||
"""Stop everything that was started."""
|
"""Stop everything that was started."""
|
||||||
@ -26,84 +50,101 @@ class TestShellCommand(unittest.TestCase):
|
|||||||
"""Test if able to call a configured service."""
|
"""Test if able to call a configured service."""
|
||||||
with tempfile.TemporaryDirectory() as tempdirname:
|
with tempfile.TemporaryDirectory() as tempdirname:
|
||||||
path = os.path.join(tempdirname, 'called.txt')
|
path = os.path.join(tempdirname, 'called.txt')
|
||||||
assert setup_component(self.hass, shell_command.DOMAIN, {
|
assert setup_component(
|
||||||
|
self.hass,
|
||||||
|
shell_command.DOMAIN, {
|
||||||
shell_command.DOMAIN: {
|
shell_command.DOMAIN: {
|
||||||
'test_service': "date > {}".format(path)
|
'test_service': "date > {}".format(path)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
self.hass.services.call('shell_command', 'test_service',
|
self.hass.services.call('shell_command', 'test_service',
|
||||||
blocking=True)
|
blocking=True)
|
||||||
self.hass.block_till_done()
|
self.hass.block_till_done()
|
||||||
|
|
||||||
self.assertTrue(os.path.isfile(path))
|
self.assertTrue(os.path.isfile(path))
|
||||||
|
|
||||||
def test_config_not_dict(self):
|
def test_config_not_dict(self):
|
||||||
"""Test if config is not a dict."""
|
"""Test that setup fails if config is not a dict."""
|
||||||
assert not setup_component(self.hass, shell_command.DOMAIN, {
|
self.assertFalse(
|
||||||
|
setup_component(self.hass, shell_command.DOMAIN, {
|
||||||
shell_command.DOMAIN: ['some', 'weird', 'list']
|
shell_command.DOMAIN: ['some', 'weird', 'list']
|
||||||
})
|
}))
|
||||||
|
|
||||||
def test_config_not_valid_service_names(self):
|
def test_config_not_valid_service_names(self):
|
||||||
"""Test if config contains invalid service names."""
|
"""Test that setup fails if config contains invalid service names."""
|
||||||
assert not setup_component(self.hass, shell_command.DOMAIN, {
|
self.assertFalse(
|
||||||
|
setup_component(self.hass, shell_command.DOMAIN, {
|
||||||
shell_command.DOMAIN: {
|
shell_command.DOMAIN: {
|
||||||
'this is invalid because space': 'touch bla.txt'
|
'this is invalid because space': 'touch bla.txt'
|
||||||
}
|
}
|
||||||
})
|
}))
|
||||||
|
|
||||||
@patch('homeassistant.components.shell_command.subprocess.call')
|
@patch('homeassistant.components.shell_command.asyncio.subprocess'
|
||||||
|
'.create_subprocess_shell')
|
||||||
def test_template_render_no_template(self, mock_call):
|
def test_template_render_no_template(self, mock_call):
|
||||||
"""Ensure shell_commands without templates get rendered properly."""
|
"""Ensure shell_commands without templates get rendered properly."""
|
||||||
assert setup_component(self.hass, shell_command.DOMAIN, {
|
mock_call.return_value = mock_process_creator(error=False)
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
setup_component(
|
||||||
|
self.hass,
|
||||||
|
shell_command.DOMAIN, {
|
||||||
shell_command.DOMAIN: {
|
shell_command.DOMAIN: {
|
||||||
'test_service': "ls /bin"
|
'test_service': "ls /bin"
|
||||||
}
|
}
|
||||||
})
|
}))
|
||||||
|
|
||||||
self.hass.services.call('shell_command', 'test_service',
|
self.hass.services.call('shell_command', 'test_service',
|
||||||
blocking=True)
|
blocking=True)
|
||||||
|
|
||||||
|
self.hass.block_till_done()
|
||||||
cmd = mock_call.mock_calls[0][1][0]
|
cmd = mock_call.mock_calls[0][1][0]
|
||||||
shell = mock_call.mock_calls[0][2]['shell']
|
|
||||||
|
|
||||||
assert 'ls /bin' == cmd
|
self.assertEqual(1, mock_call.call_count)
|
||||||
assert shell
|
self.assertEqual('ls /bin', cmd)
|
||||||
|
|
||||||
@patch('homeassistant.components.shell_command.subprocess.call')
|
@patch('homeassistant.components.shell_command.asyncio.subprocess'
|
||||||
|
'.create_subprocess_exec')
|
||||||
def test_template_render(self, mock_call):
|
def test_template_render(self, mock_call):
|
||||||
"""Ensure shell_commands without templates get rendered properly."""
|
"""Ensure shell_commands with templates get rendered properly."""
|
||||||
self.hass.states.set('sensor.test_state', 'Works')
|
self.hass.states.set('sensor.test_state', 'Works')
|
||||||
assert setup_component(self.hass, shell_command.DOMAIN, {
|
self.assertTrue(
|
||||||
|
setup_component(self.hass, shell_command.DOMAIN, {
|
||||||
shell_command.DOMAIN: {
|
shell_command.DOMAIN: {
|
||||||
'test_service': "ls /bin {{ states.sensor.test_state.state }}"
|
'test_service': ("ls /bin {{ states.sensor"
|
||||||
|
".test_state.state }}")
|
||||||
}
|
}
|
||||||
})
|
}))
|
||||||
|
|
||||||
self.hass.services.call('shell_command', 'test_service',
|
self.hass.services.call('shell_command', 'test_service',
|
||||||
blocking=True)
|
blocking=True)
|
||||||
|
|
||||||
cmd = mock_call.mock_calls[0][1][0]
|
self.hass.block_till_done()
|
||||||
shell = mock_call.mock_calls[0][2]['shell']
|
cmd = mock_call.mock_calls[0][1]
|
||||||
|
|
||||||
assert ['ls', '/bin', 'Works'] == cmd
|
self.assertEqual(1, mock_call.call_count)
|
||||||
assert not shell
|
self.assertEqual(('ls', '/bin', 'Works'), cmd)
|
||||||
|
|
||||||
@patch('homeassistant.components.shell_command.subprocess.call',
|
@patch('homeassistant.components.shell_command.asyncio.subprocess'
|
||||||
side_effect=SubprocessError)
|
'.create_subprocess_shell')
|
||||||
@patch('homeassistant.components.shell_command._LOGGER.error')
|
@patch('homeassistant.components.shell_command._LOGGER.error')
|
||||||
def test_subprocess_raising_error(self, mock_call, mock_error):
|
def test_subprocess_error(self, mock_error, mock_call):
|
||||||
"""Test subprocess."""
|
"""Test subprocess that returns an error."""
|
||||||
|
mock_call.return_value = mock_process_creator(error=True)
|
||||||
with tempfile.TemporaryDirectory() as tempdirname:
|
with tempfile.TemporaryDirectory() as tempdirname:
|
||||||
path = os.path.join(tempdirname, 'called.txt')
|
path = os.path.join(tempdirname, 'called.txt')
|
||||||
assert setup_component(self.hass, shell_command.DOMAIN, {
|
self.assertTrue(
|
||||||
|
setup_component(self.hass, shell_command.DOMAIN, {
|
||||||
shell_command.DOMAIN: {
|
shell_command.DOMAIN: {
|
||||||
'test_service': "touch {}".format(path)
|
'test_service': "touch {}".format(path)
|
||||||
}
|
}
|
||||||
})
|
}))
|
||||||
|
|
||||||
self.hass.services.call('shell_command', 'test_service',
|
self.hass.services.call('shell_command', 'test_service',
|
||||||
blocking=True)
|
blocking=True)
|
||||||
|
|
||||||
self.assertFalse(os.path.isfile(path))
|
self.hass.block_till_done()
|
||||||
|
self.assertEqual(1, mock_call.call_count)
|
||||||
self.assertEqual(1, mock_error.call_count)
|
self.assertEqual(1, mock_error.call_count)
|
||||||
|
self.assertFalse(os.path.isfile(path))
|
||||||
|
Loading…
x
Reference in New Issue
Block a user