Made dependency loading more robust

This commit is contained in:
Paulus Schoutsen 2014-11-28 15:34:42 -08:00
parent ce1a5de607
commit 5fe73cf33e
5 changed files with 238 additions and 99 deletions

View File

@ -13,12 +13,10 @@ import os
import configparser import configparser
import logging import logging
from collections import defaultdict from collections import defaultdict
from itertools import chain
import homeassistant import homeassistant
import homeassistant.loader as loader import homeassistant.loader as loader
import homeassistant.components as core_components import homeassistant.components as core_components
import homeassistant.components.group as group
# pylint: disable=too-many-branches, too-many-statements # pylint: disable=too-many-branches, too-many-statements
@ -33,112 +31,22 @@ def from_config_dict(config, hass=None):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
loader.prepare(hass)
# Make a copy because we are mutating it. # Make a copy because we are mutating it.
# Convert it to defaultdict so components can always have config dict # Convert it to defaultdict so components can always have config dict
config = defaultdict(dict, config) config = defaultdict(dict, config)
# List of loaded components # Filter out the common config section [homeassistant]
components = {} components = (key for key in config.keys() if key != homeassistant.DOMAIN)
# List of components to validate
to_validate = []
# List of validated components
validated = []
# List of components we are going to load
to_load = [key for key in config.keys() if key != homeassistant.DOMAIN]
loader.prepare(hass)
# Load required components
while to_load:
domain = to_load.pop()
component = loader.get_component(domain)
# if None it does not exist, error already thrown by get_component
if component is not None:
components[domain] = component
# Special treatment for GROUP, we want to load it as late as
# possible. We do this by loading it if all other to be loaded
# modules depend on it.
if component.DOMAIN == group.DOMAIN:
pass
# Components with no dependencies are valid
elif not component.DEPENDENCIES:
validated.append(domain)
# If dependencies we'll validate it later
else:
to_validate.append(domain)
# Make sure to load all dependencies that are not being loaded
for dependency in component.DEPENDENCIES:
if dependency not in chain(components.keys(), to_load):
to_load.append(dependency)
# Validate dependencies
group_added = False
while to_validate:
newly_validated = []
for domain in to_validate:
if all(domain in validated for domain
in components[domain].DEPENDENCIES):
newly_validated.append(domain)
# We validated new domains this iteration, add them to validated
if newly_validated:
# Add newly validated domains to validated
validated.extend(newly_validated)
# remove domains from to_validate
for domain in newly_validated:
to_validate.remove(domain)
newly_validated.clear()
# Nothing validated this iteration. Add group dependency and try again.
elif not group_added:
group_added = True
validated.append(group.DOMAIN)
# Group has already been added and we still can't validate all.
# Report missing deps as error and skip loading of these domains
else:
for domain in to_validate:
missing_deps = [dep for dep in components[domain].DEPENDENCIES
if dep not in validated]
logger.error(
"Could not validate all dependencies for %s: %s",
domain, ", ".join(missing_deps))
break
# Make sure we load groups if not in list yet.
if not group_added:
validated.append(group.DOMAIN)
if group.DOMAIN not in components:
components[group.DOMAIN] = \
loader.get_component(group.DOMAIN)
# Setup the components # Setup the components
if core_components.setup(hass, config): if core_components.setup(hass, config):
logger.info("Home Assistant core initialized") logger.info("Home Assistant core initialized")
for domain in validated: for domain in loader.load_order_components(components):
component = components[domain]
try: try:
if component.setup(hass, config): if loader.get_component(domain).setup(hass, config):
logger.info("component %s initialized", domain) logger.info("component %s initialized", domain)
else: else:
logger.error("component %s failed to initialize", domain) logger.error("component %s failed to initialize", domain)

View File

@ -19,6 +19,10 @@ import pkgutil
import importlib import importlib
import logging import logging
from homeassistant.util import OrderedSet
PREPARED = False
# List of available components # List of available components
AVAILABLE_COMPONENTS = [] AVAILABLE_COMPONENTS = []
@ -30,6 +34,8 @@ _LOGGER = logging.getLogger(__name__)
def prepare(hass): def prepare(hass):
""" Prepares the loading of components. """ """ Prepares the loading of components. """
global PREPARED # pylint: disable=global-statement
# Load the built-in components # Load the built-in components
import homeassistant.components as components import homeassistant.components as components
@ -62,9 +68,13 @@ def prepare(hass):
AVAILABLE_COMPONENTS.append( AVAILABLE_COMPONENTS.append(
'custom_components.{}'.format(fil[0:-3])) 'custom_components.{}'.format(fil[0:-3]))
PREPARED = True
def set_component(comp_name, component): def set_component(comp_name, component):
""" Sets a component in the cache. """ """ Sets a component in the cache. """
_check_prepared()
_COMPONENT_CACHE[comp_name] = component _COMPONENT_CACHE[comp_name] = component
@ -76,6 +86,8 @@ def get_component(comp_name):
if comp_name in _COMPONENT_CACHE: if comp_name in _COMPONENT_CACHE:
return _COMPONENT_CACHE[comp_name] return _COMPONENT_CACHE[comp_name]
_check_prepared()
# If we ie. try to load custom_components.switch.wemo but the parent # If we ie. try to load custom_components.switch.wemo but the parent
# custom_components.switch does not exist, importing it will trigger # custom_components.switch does not exist, importing it will trigger
# an exception because it will try to import the parent. # an exception because it will try to import the parent.
@ -125,3 +137,89 @@ def get_component(comp_name):
_LOGGER.error("Unable to find component %s", comp_name) _LOGGER.error("Unable to find component %s", comp_name)
return None return None
def load_order_components(components):
"""
Takes in a list of components we want to load:
- filters out components we cannot load
- filters out components that have invalid/circular dependencies
- Will ensure that all components that do not directly depend on
the group component will be loaded before the group component.
- returns an OrderedSet load order.
"""
_check_prepared()
group = get_component('group')
load_order = OrderedSet()
# Sort the list of modules on if they depend on group component or not.
# We do this because the components that do not depend on the group
# component usually set up states that the group component requires to be
# created before it can group them.
# This does not matter in the future if we can setup groups without the
# states existing yet.
for comp_load_order in sorted((load_order_component(component)
for component in components),
# Test if group component exists in case
# above get_component call had an error.
key=lambda order:
group and group.DOMAIN in order):
load_order.update(comp_load_order)
return load_order
def load_order_component(comp_name):
"""
Returns an OrderedSet of components in the correct order of loading.
Raises HomeAssistantError if a circular dependency is detected.
Returns an empty list if component could not be loaded.
"""
return _load_order_component(comp_name, OrderedSet(), set())
def _load_order_component(comp_name, load_order, loading):
""" Recursive function to get load order of components. """
component = get_component(comp_name)
# if None it does not exist, error already thrown by get_component
if component is None:
return OrderedSet()
loading.add(comp_name)
for dependency in component.DEPENDENCIES:
# Check not already loaded
if dependency not in load_order:
# If we are already loading it, we have a circular dependency
if dependency in loading:
_LOGGER.error('Circular dependency detected: %s -> %s',
comp_name, dependency)
return OrderedSet()
dep_load_order = _load_order_component(
dependency, load_order, loading)
# length == 0 means error loading dependency or children
if len(dep_load_order) == 0:
_LOGGER.error('Error loading %s dependency: %s',
comp_name, dependency)
return OrderedSet()
load_order.update(dep_load_order)
load_order.add(comp_name)
loading.remove(comp_name)
return load_order
def _check_prepared():
""" Issues a warning if loader.prepare() has never been called. """
if not PREPARED:
_LOGGER.warning((
"You did not call loader.prepare() yet. "
"Certain functionality might not be working."))

View File

@ -4,6 +4,8 @@ homeassistant.util
Helper methods for various modules. Helper methods for various modules.
""" """
import collections
from itertools import chain
import threading import threading
import queue import queue
import datetime import datetime
@ -176,6 +178,75 @@ class OrderedEnum(enum.Enum):
return NotImplemented return NotImplemented
class OrderedSet(collections.MutableSet):
""" Ordered set taken from http://code.activestate.com/recipes/576694/ """
def __init__(self, iterable=None):
self.end = end = []
end += [None, end, end] # sentinel node for doubly linked list
self.map = {} # key --> [key, prev, next]
if iterable is not None:
self |= iterable
def __len__(self):
return len(self.map)
def __contains__(self, key):
return key in self.map
def add(self, key):
""" Add an element to the set. """
if key not in self.map:
end = self.end
curr = end[1]
curr[2] = end[1] = self.map[key] = [key, curr, end]
def discard(self, key):
""" Discard an element from the set. """
if key in self.map:
key, prev_item, next_item = self.map.pop(key)
prev_item[2] = next_item
next_item[1] = prev_item
def __iter__(self):
end = self.end
curr = end[2]
while curr is not end:
yield curr[0]
curr = curr[2]
def __reversed__(self):
end = self.end
curr = end[1]
while curr is not end:
yield curr[0]
curr = curr[1]
def pop(self, last=True): # pylint: disable=arguments-differ
""" Pops element of the beginning of the set.
Set last=True to pop from the back. """
if not self:
raise KeyError('set is empty')
key = self.end[1][0] if last else self.end[2][0]
self.discard(key)
return key
def update(self, *args):
""" Add elements from args to the set. """
for item in chain(*args):
self.add(item)
def __repr__(self):
if not self:
return '%s()' % (self.__class__.__name__,)
return '%s(%r)' % (self.__class__.__name__, list(self))
def __eq__(self, other):
if isinstance(other, OrderedSet):
return len(self) == len(other) and list(self) == list(other)
return set(self) == set(other)
def validate_config(config, items, logger): def validate_config(config, items, logger):
""" """
Validates if all items are available in the configuration. Validates if all items are available in the configuration.

View File

@ -28,3 +28,13 @@ def mock_service(hass, domain, service):
domain, service, lambda call: calls.append(call)) domain, service, lambda call: calls.append(call))
return calls return calls
class MockModule(object):
""" Provides a fake module. """
def __init__(self, domain, dependencies=[], setup=None):
self.DOMAIN = domain
self.DEPENDENCIES = dependencies
# Setup a mock setup if none given.
self.setup = lambda hass, config: False if setup is None else setup

View File

@ -6,13 +6,14 @@ Provides tests to verify that we can load components.
""" """
# pylint: disable=too-many-public-methods,protected-access # pylint: disable=too-many-public-methods,protected-access
import unittest import unittest
from collections import namedtuple
import homeassistant as ha import homeassistant as ha
import homeassistant.loader as loader import homeassistant.loader as loader
import homeassistant.components.http as http import homeassistant.components.http as http
import mock_toggledevice_platform import mock_toggledevice_platform
from helper import get_test_home_assistant from helper import get_test_home_assistant, MockModule
class TestLoader(unittest.TestCase): class TestLoader(unittest.TestCase):
@ -37,3 +38,54 @@ class TestLoader(unittest.TestCase):
self.assertEqual(http, loader.get_component('http')) self.assertEqual(http, loader.get_component('http'))
self.assertIsNotNone(loader.get_component('custom_one')) self.assertIsNotNone(loader.get_component('custom_one'))
def test_load_order_component(self):
""" Test if we can get the proper load order of components. """
loader.set_component('mod1', MockModule('mod1'))
loader.set_component('mod2', MockModule('mod2', ['mod1']))
loader.set_component('mod3', MockModule('mod3', ['mod2']))
self.assertEqual(
['mod1', 'mod2', 'mod3'], loader.load_order_component('mod3'))
# Create circular dependency
loader.set_component('mod1', MockModule('mod1', ['mod3']))
self.assertEqual([], loader.load_order_component('mod3'))
# Depend on non-existing component
loader.set_component('mod1', MockModule('mod1', ['nonexisting']))
self.assertEqual([], loader.load_order_component('mod1'))
# Try to get load order for non-existing component
self.assertEqual([], loader.load_order_component('mod1'))
def test_load_order_components(self):
loader.set_component('mod1', MockModule('mod1', ['group']))
loader.set_component('mod2', MockModule('mod2', ['mod1', 'sun']))
loader.set_component('mod3', MockModule('mod3', ['mod2']))
loader.set_component('mod4', MockModule('mod4', ['group']))
self.assertEqual(
['group', 'mod4', 'mod1', 'sun', 'mod2', 'mod3'],
loader.load_order_components(['mod4', 'mod3', 'mod2']))
loader.set_component('mod1', MockModule('mod1'))
loader.set_component('mod2', MockModule('mod2', ['group']))
self.assertEqual(
['mod1', 'group', 'mod2'],
loader.load_order_components(['mod2', 'mod1']))
# Add a non existing one
self.assertEqual(
['mod1', 'group', 'mod2'],
loader.load_order_components(['mod2', 'nonexisting', 'mod1']))
# Depend on a non existing one
loader.set_component('mod1', MockModule('mod1', ['nonexisting']))
self.assertEqual(
['group', 'mod2'],
loader.load_order_components(['mod2', 'mod1']))