From 5baa98b79f1ed9b267cdf3d500b43a5826dc14b3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 27 Mar 2016 18:48:51 -0700 Subject: [PATCH 1/5] Add initial config validation --- homeassistant/__main__.py | 3 + homeassistant/bootstrap.py | 68 +++++++---- .../components/binary_sensor/__init__.py | 1 + homeassistant/components/camera/__init__.py | 1 + .../components/device_tracker/__init__.py | 4 +- .../components/garage_door/__init__.py | 2 +- homeassistant/components/group.py | 43 +++++-- homeassistant/components/light/__init__.py | 1 + homeassistant/components/lock/__init__.py | 2 +- .../components/media_player/__init__.py | 1 + homeassistant/components/notify/__init__.py | 6 +- .../components/rollershutter/__init__.py | 1 + homeassistant/components/sensor/__init__.py | 1 + homeassistant/components/switch/__init__.py | 2 +- .../components/thermostat/__init__.py | 1 + homeassistant/config.py | 32 ++++- homeassistant/helpers/__init__.py | 16 +-- homeassistant/helpers/config_validation.py | 65 ++++++++++ homeassistant/helpers/entity_component.py | 4 +- requirements_all.txt | 1 + setup.py | 1 + tests/components/test_group.py | 23 ++-- tests/helpers/test_config_validation.py | 114 ++++++++++++++++++ tests/test_config.py | 28 +++++ 24 files changed, 349 insertions(+), 72 deletions(-) create mode 100644 homeassistant/helpers/config_validation.py create mode 100644 tests/helpers/test_config_validation.py diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 5e21119d91f..6dbb609ec05 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -244,6 +244,9 @@ def setup_and_run_hass(config_dir, args, top_process=False): config_file, daemon=args.daemon, verbose=args.verbose, skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days) + if hass is None: + return + if args.open_ui: def open_browser(event): """Open the webinterface in a browser.""" diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index c6f1a75b8a6..6c4334e314f 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -8,6 +8,8 @@ import sys from collections import defaultdict from threading import RLock +import voluptuous as vol + import homeassistant.components as core_components import homeassistant.components.group as group import homeassistant.config as config_util @@ -20,7 +22,8 @@ from homeassistant.const import ( CONF_CUSTOMIZE, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_TEMPERATURE_UNIT, CONF_TIME_ZONE, EVENT_COMPONENT_LOADED, TEMP_CELCIUS, TEMP_FAHRENHEIT, __version__) -from homeassistant.helpers import event_decorators, service +from homeassistant.helpers import ( + event_decorators, service, config_per_platform) from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -72,7 +75,7 @@ def _handle_requirements(hass, component, name): def _setup_component(hass, domain, config): """Setup a component for Home Assistant.""" - # pylint: disable=too-many-return-statements + # pylint: disable=too-many-return-statements,too-many-branches if domain in hass.config.components: return True @@ -96,6 +99,25 @@ def _setup_component(hass, domain, config): domain, ", ".join(missing_deps)) return False + if hasattr(component, 'CONFIG_SCHEMA'): + try: + config = component.CONFIG_SCHEMA(config) + except vol.MultipleInvalid as ex: + _LOGGER.error('Invalid config for [%s]: %s', domain, ex) + return False + + if hasattr(component, 'PLATFORM_SCHEMA'): + platforms = [] + for _, platform in config_per_platform(config, domain): + try: + platforms.append(component.PLATFORM_SCHEMA(platform)) + except vol.MultipleInvalid as ex: + _LOGGER.error('Invalid platform config for [%s]: %s. %s', + domain, ex, platform) + return False + + config = {domain: platforms} + if not _handle_requirements(hass, component, domain): return False @@ -176,8 +198,14 @@ def from_config_dict(config, hass=None, config_dir=None, enable_log=True, hass.config.config_dir = config_dir mount_local_lib_path(config_dir) + try: + process_ha_core_config(hass, config_util.CORE_CONFIG_SCHEMA( + config.get(core.DOMAIN, {}))) + except vol.MultipleInvalid as ex: + _LOGGER.error('Invalid config for [homeassistant]: %s', ex) + return None + process_ha_config_upgrade(hass) - process_ha_core_config(hass, config.get(core.DOMAIN, {})) if enable_log: enable_logging(hass, verbose, daemon, log_rotate_days) @@ -336,40 +364,28 @@ def process_ha_core_config(hass, config): else: _LOGGER.error('Received invalid time zone %s', time_zone_str) - for key, attr, typ in ((CONF_LATITUDE, 'latitude', float), - (CONF_LONGITUDE, 'longitude', float), - (CONF_NAME, 'location_name', str)): + for key, attr in ((CONF_LATITUDE, 'latitude'), + (CONF_LONGITUDE, 'longitude'), + (CONF_NAME, 'location_name')): if key in config: - try: - setattr(hac, attr, typ(config[key])) - except ValueError: - _LOGGER.error('Received invalid %s value for %s: %s', - typ.__name__, key, attr) + setattr(hac, attr, config[key]) - set_time_zone(config.get(CONF_TIME_ZONE)) + if CONF_TIME_ZONE in config: + set_time_zone(config.get(CONF_TIME_ZONE)) - customize = config.get(CONF_CUSTOMIZE) - - if isinstance(customize, dict): - for entity_id, attrs in config.get(CONF_CUSTOMIZE, {}).items(): - if not isinstance(attrs, dict): - continue - Entity.overwrite_attribute(entity_id, attrs.keys(), attrs.values()) + for entity_id, attrs in config.get(CONF_CUSTOMIZE).items(): + Entity.overwrite_attribute(entity_id, attrs.keys(), attrs.values()) if CONF_TEMPERATURE_UNIT in config: - unit = config[CONF_TEMPERATURE_UNIT] - - if unit == 'C': - hac.temperature_unit = TEMP_CELCIUS - elif unit == 'F': - hac.temperature_unit = TEMP_FAHRENHEIT + hac.temperature_unit = config[CONF_TEMPERATURE_UNIT] # If we miss some of the needed values, auto detect them if None not in ( hac.latitude, hac.longitude, hac.temperature_unit, hac.time_zone): return - _LOGGER.info('Auto detecting location and temperature unit') + _LOGGER.warning('Incomplete core config. Auto detecting location and ' + 'temperature unit') info = loc_util.detect_location_info() diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 0fed26bd710..8d941166fdd 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -11,6 +11,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.const import (STATE_ON, STATE_OFF) from homeassistant.components import ( bloomsky, mysensors, zwave, vera, wemo, wink) +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa DOMAIN = 'binary_sensor' SCAN_INTERVAL = 30 diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index d48563d471f..c473f159f65 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.components import bloomsky from homeassistant.const import HTTP_OK, HTTP_NOT_FOUND, ATTR_ENTITY_ID +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa DOMAIN = 'camera' diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 4278d3db655..240ac532e5c 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -17,6 +17,7 @@ from homeassistant.config import load_yaml_config_file from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform from homeassistant.helpers.entity import Entity +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa import homeassistant.util as util import homeassistant.util.dt as dt_util @@ -129,8 +130,7 @@ def setup(hass, config): except Exception: # pylint: disable=broad-except _LOGGER.exception('Error setting up platform %s', p_type) - for p_type, p_config in \ - config_per_platform(config, DOMAIN, _LOGGER): + for p_type, p_config in config_per_platform(config, DOMAIN): setup_platform(p_type, p_config) def device_tracker_discovered(service, info): diff --git a/homeassistant/components/garage_door/__init__.py b/homeassistant/components/garage_door/__init__.py index aace95d8a05..736949e474e 100644 --- a/homeassistant/components/garage_door/__init__.py +++ b/homeassistant/components/garage_door/__init__.py @@ -10,7 +10,7 @@ import os from homeassistant.config import load_yaml_config_file from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import Entity - +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.const import ( STATE_CLOSED, STATE_OPEN, STATE_UNKNOWN, SERVICE_CLOSE, SERVICE_OPEN, ATTR_ENTITY_ID) diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py index 028222e0e76..392f6dbd92a 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -6,6 +6,8 @@ https://home-assistant.io/components/group/ """ import threading +import voluptuous as vol + import homeassistant.core as ha from homeassistant.const import ( ATTR_ENTITY_ID, CONF_ICON, CONF_NAME, STATE_CLOSED, STATE_HOME, @@ -14,6 +16,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import ( Entity, generate_entity_id, split_entity_id) from homeassistant.helpers.event import track_state_change +import homeassistant.helpers.config_validation as cv DOMAIN = 'group' @@ -26,6 +29,38 @@ ATTR_AUTO = 'auto' ATTR_ORDER = 'order' ATTR_VIEW = 'view' + +def _conf_preprocess(value): + """Preprocess alternative configuration formats.""" + if isinstance(value, (str, list)): + value = {CONF_ENTITIES: value} + + return value + +_SINGLE_GROUP_CONFIG = vol.Schema(vol.All(_conf_preprocess, { + vol.Required(CONF_ENTITIES): cv.entity_ids, + CONF_VIEW: bool, + CONF_NAME: str, + CONF_ICON: cv.icon, +})) + + +def _group_dict(value): + """Validate a dictionary of group definitions.""" + config = {} + for key, group in value.items(): + try: + config[key] = _SINGLE_GROUP_CONFIG(group) + except vol.MultipleInvalid as ex: + raise vol.Invalid('Group {} is invalid: {}'.format(key, ex)) + + return config + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(dict, _group_dict) +}, extra=True) + # List of ON/OFF state tuples for groupable states _GROUP_TYPES = [(STATE_ON, STATE_OFF), (STATE_HOME, STATE_NOT_HOME), (STATE_OPEN, STATE_CLOSED)] @@ -108,17 +143,11 @@ def get_entity_ids(hass, entity_id, domain_filter=None): def setup(hass, config): """Setup all groups found definded in the configuration.""" for object_id, conf in config.get(DOMAIN, {}).items(): - if not isinstance(conf, dict): - conf = {CONF_ENTITIES: conf} - name = conf.get(CONF_NAME, object_id) - entity_ids = conf.get(CONF_ENTITIES) + entity_ids = conf[CONF_ENTITIES] icon = conf.get(CONF_ICON) view = conf.get(CONF_VIEW) - if isinstance(entity_ids, str): - entity_ids = [ent.strip() for ent in entity_ids.split(",")] - Group(hass, name, entity_ids, icon=icon, view=view, object_id=object_id) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index fa3aaa80596..c7014fd05f6 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -17,6 +17,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID) from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa import homeassistant.util as util import homeassistant.util.color as color_util diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 6d21ae310d8..821e2e3da33 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -11,7 +11,7 @@ import os from homeassistant.config import load_yaml_config_file from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import Entity - +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.const import ( ATTR_CODE, ATTR_CODE_FORMAT, ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN, SERVICE_LOCK, SERVICE_UNLOCK) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index a0d654133de..a2f4b7c5283 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -11,6 +11,7 @@ from homeassistant.components import discovery from homeassistant.config import load_yaml_config_file from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.const import ( STATE_OFF, STATE_UNKNOWN, STATE_PLAYING, STATE_IDLE, ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index d142c5a29d1..f9b3a5b940b 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -10,8 +10,8 @@ import os import homeassistant.bootstrap as bootstrap from homeassistant.config import load_yaml_config_file -from homeassistant.helpers import config_per_platform -from homeassistant.helpers import template +from homeassistant.helpers import config_per_platform, template +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.const import CONF_NAME @@ -51,7 +51,7 @@ def setup(hass, config): descriptions = load_yaml_config_file( os.path.join(os.path.dirname(__file__), 'services.yaml')) - for platform, p_config in config_per_platform(config, DOMAIN, _LOGGER): + for platform, p_config in config_per_platform(config, DOMAIN): notify_implementation = bootstrap.prepare_setup_platform( hass, config, DOMAIN, platform) diff --git a/homeassistant/components/rollershutter/__init__.py b/homeassistant/components/rollershutter/__init__.py index 109302708b2..5c90aa99fe2 100644 --- a/homeassistant/components/rollershutter/__init__.py +++ b/homeassistant/components/rollershutter/__init__.py @@ -10,6 +10,7 @@ import logging from homeassistant.config import load_yaml_config_file from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import Entity +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.components import group from homeassistant.const import ( SERVICE_MOVE_UP, SERVICE_MOVE_DOWN, SERVICE_STOP, diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 48dee4e169b..3c625ae4a85 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -7,6 +7,7 @@ https://home-assistant.io/components/sensor/ import logging from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.components import ( wink, zwave, isy994, verisure, ecobee, tellduslive, mysensors, bloomsky, vera) diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index ac19a1a7639..d807668fb73 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -11,7 +11,7 @@ import os from homeassistant.config import load_yaml_config_file from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import ToggleEntity - +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.const import ( STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, ATTR_ENTITY_ID) diff --git a/homeassistant/components/thermostat/__init__.py b/homeassistant/components/thermostat/__init__.py index 9e128f95630..c6ddbeb0df4 100644 --- a/homeassistant/components/thermostat/__init__.py +++ b/homeassistant/components/thermostat/__init__.py @@ -13,6 +13,7 @@ from homeassistant.config import load_yaml_config_file import homeassistant.util as util from homeassistant.helpers.entity import Entity from homeassistant.helpers.temperature import convert +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.components import ecobee from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_ON, STATE_OFF, STATE_UNKNOWN, diff --git a/homeassistant/config.py b/homeassistant/config.py index f18ab0fe037..09f75ce6976 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -1,13 +1,18 @@ """Module to help with parsing and generating configuration files.""" import logging import os +from types import MappingProxyType + +import voluptuous as vol import homeassistant.util.location as loc_util from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_TEMPERATURE_UNIT, - CONF_TIME_ZONE) + CONF_TIME_ZONE, CONF_CUSTOMIZE) from homeassistant.exceptions import HomeAssistantError from homeassistant.util.yaml import load_yaml +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import valid_entity_id _LOGGER = logging.getLogger(__name__) @@ -37,6 +42,31 @@ DEFAULT_COMPONENTS = { } +def _valid_customize(value): + """Config validator for customize.""" + if not isinstance(value, dict): + raise vol.Invalid('Expected dictionary') + + for key, val in value.items(): + if not valid_entity_id(key): + raise vol.Invalid('Invalid entity ID: {}'.format(key)) + + if not isinstance(val, dict): + raise vol.Invalid('Value of {} is not a dictionary'.format(key)) + + return value + +CORE_CONFIG_SCHEMA = vol.Schema({ + CONF_NAME: vol.Coerce(str), + CONF_LATITUDE: cv.latitude, + CONF_LONGITUDE: cv.longitude, + CONF_TEMPERATURE_UNIT: cv.temperature_unit, + CONF_TIME_ZONE: cv.time_zone, + vol.Required(CONF_CUSTOMIZE, + default=MappingProxyType({})): _valid_customize, +}) + + def get_default_config_dir(): """Put together the default configuration directory based on OS.""" data_dir = os.getenv('APPDATA') if os.name == "nt" \ diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py index 07e14c260fc..12d15e2bcd4 100644 --- a/homeassistant/helpers/__init__.py +++ b/homeassistant/helpers/__init__.py @@ -29,30 +29,18 @@ def validate_config(config, items, logger): return not errors_found -def config_per_platform(config, domain, logger): +def config_per_platform(config, domain): """Generator to break a component config into different platforms. For example, will find 'switch', 'switch 2', 'switch 3', .. etc """ - config_key = domain - found = 1 - for config_key in extract_domain_configs(config, domain): platform_config = config[config_key] if not isinstance(platform_config, list): platform_config = [platform_config] for item in platform_config: - platform_type = item.get(CONF_PLATFORM) - - if platform_type is None: - logger.warning('No platform specified for %s', config_key) - continue - - yield platform_type, item - - found += 1 - config_key = "{} {}".format(domain, found) + yield item.get(CONF_PLATFORM), item def extract_domain_configs(config, domain): diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py new file mode 100644 index 00000000000..1b83b2ee879 --- /dev/null +++ b/homeassistant/helpers/config_validation.py @@ -0,0 +1,65 @@ +"""Helpers for config validation using voluptuous.""" +import voluptuous as vol + +from homeassistant.const import ( + CONF_PLATFORM, TEMP_CELCIUS, TEMP_FAHRENHEIT) +from homeassistant.helpers.entity import valid_entity_id +import homeassistant.util.dt as dt_util + +# pylint: disable=invalid-name + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): str, +}, extra=vol.ALLOW_EXTRA) + +latitude = vol.All(vol.Coerce(float), vol.Range(min=-90, max=90)) +longitude = vol.All(vol.Coerce(float), vol.Range(min=-180, max=180)) + + +def entity_id(value): + """Validate Entity ID.""" + if valid_entity_id(value): + return value + raise vol.Invalid('Entity ID {} does not match format .' + .format(value)) + + +def entity_ids(value): + """Validate Entity IDs.""" + if isinstance(value, str): + value = [ent_id.strip() for ent_id in value.split(',')] + + for ent_id in value: + entity_id(ent_id) + + return value + + +def icon(value): + """Validate icon.""" + value = str(value) + + if value.startswith('mdi:'): + return value + + raise vol.Invalid('Icons should start with prefix "mdi:"') + + +def temperature_unit(value): + """Validate and transform temperature unit.""" + if isinstance(value, str): + value = value.upper() + if value == 'C': + return TEMP_CELCIUS + elif value == 'F': + return TEMP_FAHRENHEIT + raise vol.Invalid('Invalid temperature unit. Expected: C or F') + + +def time_zone(value): + """Validate timezone.""" + if dt_util.get_time_zone(value) is not None: + return value + raise vol.Invalid( + 'Invalid time zone passed in. Valid options can be found here: ' + 'http://en.wikipedia.org/wiki/List_of_tz_database_time_zones') diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 46b9ef284b3..b1a18b1de37 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -49,9 +49,7 @@ class EntityComponent(object): self.config = config # Look in config for Domain, Domain 2, Domain 3 etc and load them - for p_type, p_config in \ - config_per_platform(config, self.domain, self.logger): - + for p_type, p_config in config_per_platform(config, self.domain): self._setup_platform(p_type, p_config) if self.discovery_platforms: diff --git a/requirements_all.txt b/requirements_all.txt index 8058062fac6..289ad2491ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -5,6 +5,7 @@ pytz>=2015.4 pip>=7.0.0 vincenty==0.1.3 jinja2>=2.8 +voluptuous==0.8.9 # homeassistant.components.isy994 PyISY==1.0.5 diff --git a/setup.py b/setup.py index 9cc79615c71..7ddc2ab588e 100755 --- a/setup.py +++ b/setup.py @@ -17,6 +17,7 @@ REQUIRES = [ 'pip>=7.0.0', 'vincenty==0.1.3', 'jinja2>=2.8', + 'voluptuous==0.8.9', ] setup( diff --git a/tests/components/test_group.py b/tests/components/test_group.py index 3b4f3d43c29..8526be9768d 100644 --- a/tests/components/test_group.py +++ b/tests/components/test_group.py @@ -2,6 +2,7 @@ # pylint: disable=protected-access,too-many-public-methods import unittest +from homeassistant.bootstrap import _setup_component from homeassistant.const import ( STATE_ON, STATE_OFF, STATE_HOME, STATE_UNKNOWN, ATTR_ICON, ATTR_HIDDEN, ATTR_ASSUMED_STATE, ) @@ -218,19 +219,15 @@ class TestComponentsGroup(unittest.TestCase): test_group = group.Group( self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False) - self.assertTrue( - group.setup( - self.hass, - { - group.DOMAIN: { - 'second_group': { - 'entities': 'light.Bowl, ' + test_group.entity_id, - 'icon': 'mdi:work', - 'view': True, - }, - 'test_group': 'hello.world,sensor.happy', - } - })) + _setup_component(self.hass, 'group', {'group': { + 'second_group': { + 'entities': 'light.Bowl, ' + test_group.entity_id, + 'icon': 'mdi:work', + 'view': True, + }, + 'test_group': 'hello.world,sensor.happy', + } + }) group_state = self.hass.states.get( group.ENTITY_ID_FORMAT.format('second_group')) diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py new file mode 100644 index 00000000000..25d0e7e28ff --- /dev/null +++ b/tests/helpers/test_config_validation.py @@ -0,0 +1,114 @@ +import pytest +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv + + +def test_latitude(): + """Test latitude validation.""" + schema = vol.Schema(cv.latitude) + + for value in ('invalid', None, -91, 91, '-91', '91', '123.01A'): + with pytest.raises(vol.MultipleInvalid): + schema(value) + + for value in ('-89', 89, '12.34'): + schema(value) + + +def test_longitude(): + """Test longitude validation.""" + schema = vol.Schema(cv.longitude) + + for value in ('invalid', None, -181, 181, '-181', '181', '123.01A'): + with pytest.raises(vol.MultipleInvalid): + schema(value) + + for value in ('-179', 179, '12.34'): + schema(value) + + +def test_icon(): + """Test icon validation.""" + schema = vol.Schema(cv.icon) + + for value in (False, 'work', 'icon:work'): + with pytest.raises(vol.MultipleInvalid): + schema(value) + + schema('mdi:work') + + +def test_platform_config(): + """Test platform config validation.""" + for value in ( + {'platform': 1}, + {}, + {'hello': 'world'}, + ): + with pytest.raises(vol.MultipleInvalid): + cv.PLATFORM_SCHEMA(value) + + for value in ( + {'platform': 'mqtt'}, + {'platform': 'mqtt', 'beer': 'yes'}, + ): + cv.PLATFORM_SCHEMA(value) + + +def test_entity_id(): + """Test entity ID validation.""" + schema = vol.Schema(cv.entity_id) + + with pytest.raises(vol.MultipleInvalid): + schema('invalid_entity') + + schema('sensor.light') + + +def test_entity_ids(): + """Test entity ID validation.""" + schema = vol.Schema(cv.entity_ids) + + for value in ( + 'invalid_entity', + 'sensor.light,sensor_invalid', + ['invalid_entity'], + ['sensor.light', 'sensor_invalid'], + ['sensor.light,sensor_invalid'], + ): + with pytest.raises(vol.MultipleInvalid): + schema(value) + + for value in ( + [], + ['sensor.light'], + 'sensor.light' + ): + schema(value) + + assert schema('sensor.light, light.kitchen ') == [ + 'sensor.light', 'light.kitchen' + ] + + +def test_temperature_unit(): + """Test temperature unit validation.""" + schema = vol.Schema(cv.temperature_unit) + + with pytest.raises(vol.MultipleInvalid): + schema('K') + + schema('C') + schema('F') + + +def test_time_zone(): + """Test time zone validation.""" + schema = vol.Schema(cv.time_zone) + + with pytest.raises(vol.MultipleInvalid): + schema('America/Do_Not_Exist') + + schema('America/Los_Angeles') + schema('UTC') diff --git a/tests/test_config.py b/tests/test_config.py index 8ce6369d6d2..8a5ec306b3b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,6 +4,9 @@ import unittest import unittest.mock as mock import os +import pytest +from voluptuous import MultipleInvalid + from homeassistant.core import DOMAIN, HomeAssistantError import homeassistant.config as config_util from homeassistant.const import ( @@ -138,3 +141,28 @@ class TestConfig(unittest.TestCase): config_util.create_default_config( os.path.join(CONFIG_DIR, 'non_existing_dir/'), False)) self.assertTrue(mock_print.called) + + def test_core_config_schema(self): + for value in ( + {'temperature_unit': 'K'}, + {'time_zone': 'non-exist'}, + {'latitude': '91'}, + {'longitude': -181}, + {'customize': 'bla'}, + {'customize': {'invalid_entity_id': {}}}, + {'customize': {'light.sensor': 100}}, + ): + with pytest.raises(MultipleInvalid): + config_util.CORE_CONFIG_SCHEMA(value) + + config_util.CORE_CONFIG_SCHEMA({ + 'name': 'Test name', + 'latitude': '-23.45', + 'longitude': '123.45', + 'temperature_unit': 'c', + 'customize': { + 'sensor.temperature': { + 'hidden': True, + }, + }, + }) From a35173a5ffbda3f1f602931ecd1367d0c40af766 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 28 Mar 2016 23:46:19 -0700 Subject: [PATCH 2/5] Surpress silly warnings --- homeassistant/bootstrap.py | 3 +-- homeassistant/components/conversation.py | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 6c4334e314f..96ffe47f4fc 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -290,8 +290,7 @@ def enable_logging(hass, verbose=False, daemon=False, log_rotate_days=None): } )) except ImportError: - _LOGGER.warning( - "Colorlog package not found, console coloring disabled") + pass # Log errors to a file if we have write access to file or config dir err_log_path = hass.config.path(ERROR_LOG_FILENAME) diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py index 74ba0648c74..11e018846c2 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/conversation/ """ import logging import re +import warnings from homeassistant import core from homeassistant.const import ( @@ -24,6 +25,7 @@ REQUIREMENTS = ['fuzzywuzzy==0.8.0'] def setup(hass, config): """Register the process service.""" + warnings.filterwarnings('ignore', module='fuzzywuzzy') from fuzzywuzzy import process as fuzzyExtract logger = logging.getLogger(__name__) From 25269cdb6b60ab560caefe3eac81c3f29bdbcf13 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 29 Mar 2016 00:17:53 -0700 Subject: [PATCH 3/5] Add tests for bootstrap config validation --- homeassistant/helpers/__init__.py | 3 +- tests/common.py | 10 +++- tests/test_bootstrap.py | 93 +++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py index 12d15e2bcd4..69777a0ccd4 100644 --- a/homeassistant/helpers/__init__.py +++ b/homeassistant/helpers/__init__.py @@ -40,7 +40,8 @@ def config_per_platform(config, domain): platform_config = [platform_config] for item in platform_config: - yield item.get(CONF_PLATFORM), item + platform = None if item is None else item.get(CONF_PLATFORM) + yield platform, item def extract_domain_configs(config, domain): diff --git a/tests/common.py b/tests/common.py index 91bfc07fbc0..cb7476be828 100644 --- a/tests/common.py +++ b/tests/common.py @@ -143,10 +143,18 @@ class MockHTTP(object): class MockModule(object): """Representation of a fake module.""" - def __init__(self, domain=None, dependencies=[], setup=None): + def __init__(self, domain=None, dependencies=[], setup=None, + config_schema=None, platform_schema=None): """Initialize the mock module.""" self.DOMAIN = domain self.DEPENDENCIES = dependencies + + if config_schema is not None: + self.CONFIG_SCHEMA = config_schema + + if platform_schema is not None: + self.PLATFORM_SCHEMA = platform_schema + # Setup a mock setup if none given. if setup is None: self.setup = lambda hass, config: True diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index fd2265db76e..c2426dbe968 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -4,11 +4,14 @@ import os import tempfile import unittest +import voluptuous as vol + from homeassistant import bootstrap, loader from homeassistant.const import (__version__, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_CUSTOMIZE) import homeassistant.util.dt as dt_util from homeassistant.helpers.entity import Entity +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from tests.common import get_test_home_assistant, MockModule @@ -121,3 +124,93 @@ class TestBootstrap(unittest.TestCase): bootstrap.setup_component(hass, 'comp_a') self.assertEqual(['comp_a'], hass.config.components) hass.stop() + + def test_validate_component_config(self): + """Test validating component configuration.""" + config_schema = vol.Schema({ + 'comp_conf': { + 'hello': str + } + }, required=True) + loader.set_component( + 'comp_conf', MockModule('comp_conf', config_schema=config_schema)) + + hass = get_test_home_assistant() + + assert not bootstrap._setup_component(hass, 'comp_conf', {}) + + assert not bootstrap._setup_component(hass, 'comp_conf', { + 'comp_conf': None + }) + + assert not bootstrap._setup_component(hass, 'comp_conf', { + 'comp_conf': {} + }) + + assert not bootstrap._setup_component(hass, 'comp_conf', { + 'comp_conf': { + 'hello': 'world', + 'invalid': 'extra', + } + }) + + assert bootstrap._setup_component(hass, 'comp_conf', { + 'comp_conf': { + 'hello': 'world', + } + }) + + hass.stop() + + def test_validate_platform_config(self): + """Test validating platform configuration.""" + platform_schema = PLATFORM_SCHEMA.extend({ + 'hello': str, + }, required=True) + loader.set_component( + 'platform_conf', + MockModule('platform_conf', platform_schema=platform_schema)) + + hass = get_test_home_assistant() + + assert not bootstrap._setup_component(hass, 'platform_conf', { + 'platform_conf': None + }) + + assert not bootstrap._setup_component(hass, 'platform_conf', { + 'platform_conf': {} + }) + + assert not bootstrap._setup_component(hass, 'platform_conf', { + 'platform_conf': { + 'hello': 'world', + 'invalid': 'extra', + } + }) + + assert not bootstrap._setup_component(hass, 'platform_conf', { + 'platform_conf': { + 'platform': 'whatever', + 'hello': 'world', + }, + + 'platform_conf 2': { + 'invalid': True + } + }) + + assert bootstrap._setup_component(hass, 'platform_conf', { + 'platform_conf': { + 'platform': 'whatever', + 'hello': 'world', + } + }) + + assert bootstrap._setup_component(hass, 'platform_conf', { + 'platform_conf': [{ + 'platform': 'whatever', + 'hello': 'world', + }] + }) + + hass.stop() From 8ceab5d4ba45335466de29e415c45c6266433e4e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 29 Mar 2016 22:50:38 -0700 Subject: [PATCH 4/5] Add extra tests --- tests/common.py | 3 +- tests/test_bootstrap.py | 184 +++++++++++++++++++++++++++++----------- 2 files changed, 138 insertions(+), 49 deletions(-) diff --git a/tests/common.py b/tests/common.py index cb7476be828..1774e6845b0 100644 --- a/tests/common.py +++ b/tests/common.py @@ -144,10 +144,11 @@ class MockModule(object): """Representation of a fake module.""" def __init__(self, domain=None, dependencies=[], setup=None, - config_schema=None, platform_schema=None): + requirements=[], config_schema=None, platform_schema=None): """Initialize the mock module.""" self.DOMAIN = domain self.DEPENDENCIES = dependencies + self.REQUIREMENTS = requirements if config_schema is not None: self.CONFIG_SCHEMA = config_schema diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index c2426dbe968..d6a72fdd86e 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -2,7 +2,8 @@ # pylint: disable=too-many-public-methods,protected-access import os import tempfile -import unittest +from unittest import mock +import threading import voluptuous as vol @@ -15,17 +16,29 @@ from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from tests.common import get_test_home_assistant, MockModule +ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE -class TestBootstrap(unittest.TestCase): + +class TestBootstrap: """Test the bootstrap utils.""" - def setUp(self): + def setup_method(self, method): """Setup the test.""" - self.orig_timezone = dt_util.DEFAULT_TIME_ZONE + if method == self.test_from_config_file: + return - def tearDown(self): + self.hass = get_test_home_assistant() + self.backup_cache = loader._COMPONENT_CACHE + + def teardown_method(self, method): """Clean up.""" - dt_util.DEFAULT_TIME_ZONE = self.orig_timezone + dt_util.DEFAULT_TIME_ZONE = ORIG_TIMEZONE + + if method == self.test_from_config_file: + return + + self.hass.stop() + loader._COMPONENT_CACHE = self.backup_cache def test_from_config_file(self): """Test with configuration file.""" @@ -39,8 +52,7 @@ class TestBootstrap(unittest.TestCase): components.append('group') - self.assertEqual(sorted(components), - sorted(hass.config.components)) + assert sorted(components) == sorted(hass.config.components) def test_remove_lib_on_upgrade(self): """Test removal of library on upgrade.""" @@ -57,13 +69,11 @@ class TestBootstrap(unittest.TestCase): with open(check_file, 'w'): pass - hass = get_test_home_assistant() - hass.config.config_dir = config_dir + self.hass.config.config_dir = config_dir - self.assertTrue(os.path.isfile(check_file)) - bootstrap.process_ha_config_upgrade(hass) - self.assertFalse(os.path.isfile(check_file)) - hass.stop() + assert os.path.isfile(check_file) + bootstrap.process_ha_config_upgrade(self.hass) + assert not os.path.isfile(check_file) def test_not_remove_lib_if_not_upgrade(self): """Test removal of library with no upgrade.""" @@ -80,13 +90,11 @@ class TestBootstrap(unittest.TestCase): with open(check_file, 'w'): pass - hass = get_test_home_assistant() - hass.config.config_dir = config_dir + self.hass.config.config_dir = config_dir - bootstrap.process_ha_config_upgrade(hass) + bootstrap.process_ha_config_upgrade(self.hass) - self.assertTrue(os.path.isfile(check_file)) - hass.stop() + assert os.path.isfile(check_file) def test_entity_customization(self): """Test entity customization through configuration.""" @@ -95,23 +103,19 @@ class TestBootstrap(unittest.TestCase): CONF_NAME: 'Test', CONF_CUSTOMIZE: {'test.test': {'hidden': True}}} - hass = get_test_home_assistant() - - bootstrap.process_ha_core_config(hass, config) + bootstrap.process_ha_core_config(self.hass, config) entity = Entity() entity.entity_id = 'test.test' - entity.hass = hass + entity.hass = self.hass entity.update_ha_state() - state = hass.states.get('test.test') + state = self.hass.states.get('test.test') - self.assertTrue(state.attributes['hidden']) - hass.stop() + assert state.attributes['hidden'] def test_handle_setup_circular_dependency(self): """Test the setup of circular dependencies.""" - hass = get_test_home_assistant() loader.set_component('comp_b', MockModule('comp_b', ['comp_a'])) def setup_a(hass, config): @@ -121,9 +125,8 @@ class TestBootstrap(unittest.TestCase): loader.set_component('comp_a', MockModule('comp_a', setup=setup_a)) - bootstrap.setup_component(hass, 'comp_a') - self.assertEqual(['comp_a'], hass.config.components) - hass.stop() + bootstrap.setup_component(self.hass, 'comp_a') + assert ['comp_a'] == self.hass.config.components def test_validate_component_config(self): """Test validating component configuration.""" @@ -135,33 +138,29 @@ class TestBootstrap(unittest.TestCase): loader.set_component( 'comp_conf', MockModule('comp_conf', config_schema=config_schema)) - hass = get_test_home_assistant() + assert not bootstrap._setup_component(self.hass, 'comp_conf', {}) - assert not bootstrap._setup_component(hass, 'comp_conf', {}) - - assert not bootstrap._setup_component(hass, 'comp_conf', { + assert not bootstrap._setup_component(self.hass, 'comp_conf', { 'comp_conf': None }) - assert not bootstrap._setup_component(hass, 'comp_conf', { + assert not bootstrap._setup_component(self.hass, 'comp_conf', { 'comp_conf': {} }) - assert not bootstrap._setup_component(hass, 'comp_conf', { + assert not bootstrap._setup_component(self.hass, 'comp_conf', { 'comp_conf': { 'hello': 'world', 'invalid': 'extra', } }) - assert bootstrap._setup_component(hass, 'comp_conf', { + assert bootstrap._setup_component(self.hass, 'comp_conf', { 'comp_conf': { 'hello': 'world', } }) - hass.stop() - def test_validate_platform_config(self): """Test validating platform configuration.""" platform_schema = PLATFORM_SCHEMA.extend({ @@ -171,24 +170,22 @@ class TestBootstrap(unittest.TestCase): 'platform_conf', MockModule('platform_conf', platform_schema=platform_schema)) - hass = get_test_home_assistant() - - assert not bootstrap._setup_component(hass, 'platform_conf', { + assert not bootstrap._setup_component(self.hass, 'platform_conf', { 'platform_conf': None }) - assert not bootstrap._setup_component(hass, 'platform_conf', { + assert not bootstrap._setup_component(self.hass, 'platform_conf', { 'platform_conf': {} }) - assert not bootstrap._setup_component(hass, 'platform_conf', { + assert not bootstrap._setup_component(self.hass, 'platform_conf', { 'platform_conf': { 'hello': 'world', 'invalid': 'extra', } }) - assert not bootstrap._setup_component(hass, 'platform_conf', { + assert not bootstrap._setup_component(self.hass, 'platform_conf', { 'platform_conf': { 'platform': 'whatever', 'hello': 'world', @@ -199,18 +196,109 @@ class TestBootstrap(unittest.TestCase): } }) - assert bootstrap._setup_component(hass, 'platform_conf', { + assert bootstrap._setup_component(self.hass, 'platform_conf', { 'platform_conf': { 'platform': 'whatever', 'hello': 'world', } }) - assert bootstrap._setup_component(hass, 'platform_conf', { + assert bootstrap._setup_component(self.hass, 'platform_conf', { 'platform_conf': [{ 'platform': 'whatever', 'hello': 'world', }] }) - hass.stop() + def test_component_not_found(self): + """setup_component should not crash if component doesn't exist.""" + assert not bootstrap.setup_component(self.hass, 'non_existing') + + def test_component_not_double_initialized(self): + """Test we do not setup a component twice.""" + + mock_setup = mock.MagicMock() + + loader.set_component('comp', MockModule('comp', setup=mock_setup)) + + assert bootstrap.setup_component(self.hass, 'comp') + assert mock_setup.called + + mock_setup.reset_mock() + + assert bootstrap.setup_component(self.hass, 'comp') + assert not mock_setup.called + + @mock.patch('homeassistant.util.package.install_package', + return_value=False) + def test_component_not_installed_if_requirement_fails(self, mock_install): + """Component setup should fail if requirement can't install.""" + loader.set_component( + 'comp', MockModule('comp', requirements=['package==0.0.1'])) + + assert not bootstrap.setup_component(self.hass, 'comp') + assert 'comp' not in self.hass.config.components + + def test_component_not_setup_twice_if_loaded_during_other_setup(self): + """ + Test component that gets setup while waiting for lock is not setup + twice. + """ + loader.set_component('comp', MockModule('comp')) + + result = [] + + def setup_component(): + result.append(bootstrap.setup_component(self.hass, 'comp')) + + with bootstrap._SETUP_LOCK: + thread = threading.Thread(target=setup_component) + thread.start() + self.hass.config.components.append('comp') + + thread.join() + + assert len(result) == 1 + assert result[0] + + def test_component_not_setup_missing_dependencies(self): + """Test we do not setup a component if not all dependencies loaded.""" + deps = ['non_existing'] + loader.set_component('comp', MockModule('comp', dependencies=deps)) + + assert not bootstrap._setup_component(self.hass, 'comp', None) + assert 'comp' not in self.hass.config.components + + self.hass.config.components.append('non_existing') + + assert bootstrap._setup_component(self.hass, 'comp', None) + + def test_component_failing_setup(self): + """Test component that fails setup.""" + loader.set_component( + 'comp', MockModule('comp', setup=lambda hass, config: False)) + + assert not bootstrap._setup_component(self.hass, 'comp', None) + assert 'comp' not in self.hass.config.components + + def test_component_exception_setup(self): + """Test component that raises exception during setup.""" + def exception_setup(hass, config): + """Setup that raises exception.""" + raise Exception('fail!') + + loader.set_component('comp', MockModule('comp', setup=exception_setup)) + + assert not bootstrap._setup_component(self.hass, 'comp', None) + assert 'comp' not in self.hass.config.components + + @mock.patch('homeassistant.bootstrap.process_ha_core_config') + def test_home_assistant_core_config_validation(self, mock_process): + """Test if we pass in wrong information for HA conf.""" + # Extensive HA conf validation testing is done in test_config.py + assert None is bootstrap.from_config_dict({ + 'homeassistant': { + 'latitude': 'some string' + } + }) + assert not mock_process.called From ac28228e6bcf5f7835edb8da52f7b5e30e5ff21d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 29 Mar 2016 22:51:33 -0700 Subject: [PATCH 5/5] Either validate component config or platform config --- homeassistant/bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 96ffe47f4fc..2545960dd75 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -106,7 +106,7 @@ def _setup_component(hass, domain, config): _LOGGER.error('Invalid config for [%s]: %s', domain, ex) return False - if hasattr(component, 'PLATFORM_SCHEMA'): + elif hasattr(component, 'PLATFORM_SCHEMA'): platforms = [] for _, platform in config_per_platform(config, domain): try: