diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py new file mode 100644 index 00000000000..72346a23922 --- /dev/null +++ b/homeassistant/components/python_script.py @@ -0,0 +1,86 @@ +"""Component to allow running Python scripts.""" +import glob +import os +import logging + +import voluptuous as vol + +DOMAIN = 'python_script' +REQUIREMENTS = ['restrictedpython==4.0a2'] +FOLDER = 'python_scripts' +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema(dict) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Initialize the python_script component.""" + path = hass.config.path(FOLDER) + + if not os.path.isdir(path): + _LOGGER.warning('Folder %s not found in config folder', FOLDER) + return False + + def service_handler(call): + """Handle python script service calls.""" + filename = '{}.py'.format(call.service) + with open(hass.config.path(FOLDER, filename)) as fil: + execute(hass, filename, fil.read(), call.data) + + for fil in glob.iglob(os.path.join(path, '*.py')): + name = os.path.splitext(os.path.basename(fil))[0] + hass.services.register(DOMAIN, name, service_handler) + + return True + + +def execute(hass, filename, source, data): + """Execute a script.""" + from RestrictedPython import compile_restricted_exec + from RestrictedPython.Guards import safe_builtins, full_write_guard + + compiled = compile_restricted_exec(source, filename=filename) + + if compiled.errors: + _LOGGER.error('Error loading script %s: %s', filename, + ', '.join(compiled.errors)) + return + + if compiled.warnings: + _LOGGER.warning('Warning loading script %s: %s', filename, + ', '.join(compiled.warnings)) + + restricted_globals = { + '__builtins__': safe_builtins, + '_print_': StubPrinter, + '_getattr_': getattr, + '_write_': full_write_guard, + } + local = { + 'hass': hass, + 'data': data, + 'logger': logging.getLogger('{}.{}'.format(__name__, filename)) + } + + try: + _LOGGER.info('Executing %s: %s', filename, data) + # pylint: disable=exec-used + exec(compiled.code, restricted_globals, local) + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception('Error executing script %s: %s', filename, err) + + +class StubPrinter: + """Class to handle printing inside scripts.""" + + def __init__(self, _getattr_): + """Initialize our printer.""" + pass + + def _call_print(self, *objects, **kwargs): + """Print text.""" + # pylint: disable=no-self-use + _LOGGER.warning( + "Don't use print() inside scripts. Use logger.info() instead.") diff --git a/homeassistant/core.py b/homeassistant/core.py index 8f6c7df1f21..5a9b185372e 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -113,7 +113,13 @@ class HomeAssistant(object): else: self.loop = loop or asyncio.get_event_loop() - self.executor = ThreadPoolExecutor(max_workers=EXECUTOR_POOL_SIZE) + executor_opts = { + 'max_workers': EXECUTOR_POOL_SIZE + } + if sys.version_info[:2] >= (3, 6): + executor_opts['thread_name_prefix'] = 'SyncWorker' + + self.executor = ThreadPoolExecutor(**executor_opts) self.loop.set_default_executor(self.executor) self.loop.set_exception_handler(async_loop_exception_handler) self._pending_tasks = [] diff --git a/requirements_all.txt b/requirements_all.txt index b3df7f59b48..7e33e2cb730 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -752,6 +752,9 @@ radiotherm==1.2 # homeassistant.components.raspihats # raspihats==2.2.1 +# homeassistant.components.python_script +restrictedpython==4.0a2 + # homeassistant.components.rflink rflink==0.0.34 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e70a6c5be3..21cfb74380f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -100,6 +100,9 @@ python-forecastio==1.3.5 # homeassistant.components.notify.html5 pywebpush==1.0.4 +# homeassistant.components.python_script +restrictedpython==4.0a2 + # homeassistant.components.rflink rflink==0.0.34 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 614411fbde2..d25c1f88780 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -65,6 +65,7 @@ TEST_REQUIREMENTS = ( 'gTTS-token', 'pywebpush', 'PyJWT', + 'restrictedpython', ) IGNORE_PACKAGES = ( diff --git a/tests/components/test_python_script.py b/tests/components/test_python_script.py new file mode 100644 index 00000000000..31ee24587e0 --- /dev/null +++ b/tests/components/test_python_script.py @@ -0,0 +1,123 @@ +"""Test the python_script component.""" +import asyncio +import logging +from unittest.mock import patch, mock_open + +from homeassistant.setup import async_setup_component +from homeassistant.components.python_script import execute + + +@asyncio.coroutine +def test_setup(hass): + """Test we can discover scripts.""" + scripts = [ + '/some/config/dir/python_scripts/hello.py', + '/some/config/dir/python_scripts/world_beer.py' + ] + with patch('homeassistant.components.python_script.os.path.isdir', + return_value=True), \ + patch('homeassistant.components.python_script.glob.iglob', + return_value=scripts): + res = yield from async_setup_component(hass, 'python_script', {}) + + assert res + assert hass.services.has_service('python_script', 'hello') + assert hass.services.has_service('python_script', 'world_beer') + + with patch('homeassistant.components.python_script.open', + mock_open(read_data='fake source'), create=True), \ + patch('homeassistant.components.python_script.execute') as mock_ex: + yield from hass.services.async_call( + 'python_script', 'hello', {'some': 'data'}, blocking=True) + + assert len(mock_ex.mock_calls) == 1 + hass, script, source, data = mock_ex.mock_calls[0][1] + + assert hass is hass + assert script == 'hello.py' + assert source == 'fake source' + assert data == {'some': 'data'} + + +@asyncio.coroutine +def test_setup_fails_on_no_dir(hass, caplog): + """Test we fail setup when no dir found.""" + with patch('homeassistant.components.python_script.os.path.isdir', + return_value=False): + res = yield from async_setup_component(hass, 'python_script', {}) + + assert not res + assert 'Folder python_scripts not found in config folder' in caplog.text + + +@asyncio.coroutine +def test_execute_with_data(hass, caplog): + """Test executing a script.""" + caplog.set_level(logging.WARNING) + source = """ +hass.states.set('test.entity', data.get('name', 'not set')) + """ + + hass.async_add_job(execute, hass, 'test.py', source, {'name': 'paulus'}) + yield from hass.async_block_till_done() + + assert hass.states.is_state('test.entity', 'paulus') + + # No errors logged = good + assert caplog.text == '' + + +@asyncio.coroutine +def test_execute_warns_print(hass, caplog): + """Test print triggers warning.""" + caplog.set_level(logging.WARNING) + source = """ +print("This triggers warning.") + """ + + hass.async_add_job(execute, hass, 'test.py', source, {}) + yield from hass.async_block_till_done() + + assert "Don't use print() inside scripts." in caplog.text + + +@asyncio.coroutine +def test_execute_logging(hass, caplog): + """Test logging works.""" + caplog.set_level(logging.INFO) + source = """ +logger.info('Logging from inside script') + """ + + hass.async_add_job(execute, hass, 'test.py', source, {}) + yield from hass.async_block_till_done() + + assert "Logging from inside script" in caplog.text + + +@asyncio.coroutine +def test_execute_compile_error(hass, caplog): + """Test compile error logs error.""" + caplog.set_level(logging.ERROR) + source = """ +this is not valid Python + """ + + hass.async_add_job(execute, hass, 'test.py', source, {}) + yield from hass.async_block_till_done() + + assert "Error loading script test.py" in caplog.text + + +@asyncio.coroutine +def test_execute_runtime_error(hass, caplog): + """Test compile error logs error.""" + caplog.set_level(logging.ERROR) + source = """ +raise Exception('boom') + """ + + hass.async_add_job(execute, hass, 'test.py', source, {}) + yield from hass.async_block_till_done() + + assert "Error executing script test.py" in caplog.text