Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion homeassistant/config_entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
During startup, Home Assistant will setup the entries during the normal setup
of a component. It will first call the normal setup and then call the method
`async_setup_entry(hass, entry)` for each entry. The same method is called when
Home Assistant is running while a config entry is created.
Home Assistant is running while a config entry is created. If the version of
the config entry does not match that of the flow handler, setup will
call the method `async_migrate_entry(hass, entry)` with the expectation that
the entry be brought to the current version. Return `True` to indicate
migration was successful, otherwise `False`.

## Config Flows

Expand Down Expand Up @@ -116,6 +120,7 @@ async def async_step_discovery(info):
the flow from the config panel.
"""
import logging
import functools
import uuid
from typing import Set, Optional, List, Dict # noqa pylint: disable=unused-import

Expand Down Expand Up @@ -187,6 +192,8 @@ async def async_step_discovery(info):
ENTRY_STATE_LOADED = 'loaded'
# There was an error while trying to set up this config entry
ENTRY_STATE_SETUP_ERROR = 'setup_error'
# There was an error while trying to migrate the config entry to a new version
ENTRY_STATE_MIGRATION_ERROR = 'migration_error'
# The config entry was not ready to be set up yet, but might be later
ENTRY_STATE_SETUP_RETRY = 'setup_retry'
# The config entry has not been loaded
Expand Down Expand Up @@ -255,6 +262,12 @@ async def async_setup(
if component is None:
component = getattr(hass.components, self.domain)

# Perform migration
if component.DOMAIN == self.domain:
if not await self.async_migrate(hass):
self.state = ENTRY_STATE_MIGRATION_ERROR
return

try:
result = await component.async_setup_entry(hass, self)

Expand Down Expand Up @@ -331,6 +344,45 @@ async def async_unload(self, hass, *, component=None):
self.state = ENTRY_STATE_FAILED_UNLOAD
return False

async def async_migrate(self, hass: HomeAssistant) -> bool:
"""Migrate an entry.

Returns True if config entry is up-to-date or has been migrated.
"""
handler = HANDLERS.get(self.domain)
if handler is None:
_LOGGER.error("Flow handler not found for entry %s for %s",
self.title, self.domain)
return False
# Handler may be a partial
while isinstance(handler, functools.partial):
handler = handler.func

if self.version == handler.VERSION:
return True

component = getattr(hass.components, self.domain)
supports_migrate = hasattr(component, 'async_migrate_entry')
if not supports_migrate:
_LOGGER.error("Migration handler not found for entry %s for %s",
self.title, self.domain)
return False

try:
result = await component.async_migrate_entry(hass, self)
if not isinstance(result, bool):
_LOGGER.error('%s.async_migrate_entry did not return boolean',
self.domain)
return False
if result:
# pylint: disable=protected-access
hass.config_entries._async_schedule_save() # type: ignore
return result
except Exception: # pylint: disable=broad-except
_LOGGER.exception('Error migrating entry %s for %s',
self.title, component.DOMAIN)
return False

def as_dict(self):
"""Return dictionary version of this entry."""
return {
Expand Down
8 changes: 6 additions & 2 deletions tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,8 @@ class MockModule:
def __init__(self, domain=None, dependencies=None, setup=None,
requirements=None, config_schema=None, platform_schema=None,
platform_schema_base=None, async_setup=None,
async_setup_entry=None, async_unload_entry=None):
async_setup_entry=None, async_unload_entry=None,
async_migrate_entry=None):
"""Initialize the mock module."""
self.DOMAIN = domain
self.DEPENDENCIES = dependencies or []
Expand Down Expand Up @@ -482,6 +483,9 @@ def __init__(self, domain=None, dependencies=None, setup=None,
if async_unload_entry is not None:
self.async_unload_entry = async_unload_entry

if async_migrate_entry is not None:
self.async_migrate_entry = async_migrate_entry


class MockPlatform:
"""Provide a fake platform."""
Expand Down Expand Up @@ -602,7 +606,7 @@ def last_call(self, method=None):
class MockConfigEntry(config_entries.ConfigEntry):
"""Helper for creating config entries that adds some defaults."""

def __init__(self, *, domain='test', data=None, version=0, entry_id=None,
def __init__(self, *, domain='test', data=None, version=1, entry_id=None,
source=config_entries.SOURCE_USER, title='Mock Title',
state=None,
connection_class=config_entries.CONN_CLASS_UNKNOWN):
Expand Down
126 changes: 121 additions & 5 deletions tests/test_config_entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
MockPlatform, MockEntity)


@config_entries.HANDLERS.register('test')
@config_entries.HANDLERS.register('comp')
class MockFlowHandler(config_entries.ConfigFlow):
"""Define a mock flow handler."""

VERSION = 1


@pytest.fixture
def manager(hass):
"""Fixture of a loaded config manager."""
Expand All @@ -25,20 +33,128 @@ def manager(hass):
return manager


@asyncio.coroutine
def test_call_setup_entry(hass):
async def test_call_setup_entry(hass):
"""Test we call <component>.setup_entry."""
MockConfigEntry(domain='comp').add_to_hass(hass)
entry = MockConfigEntry(domain='comp')
entry.add_to_hass(hass)

mock_setup_entry = MagicMock(return_value=mock_coro(True))
mock_migrate_entry = MagicMock(return_value=mock_coro(True))

loader.set_component(
hass, 'comp',
MockModule('comp', async_setup_entry=mock_setup_entry))
MockModule('comp', async_setup_entry=mock_setup_entry,
async_migrate_entry=mock_migrate_entry))

result = yield from async_setup_component(hass, 'comp', {})
result = await async_setup_component(hass, 'comp', {})
assert result
assert len(mock_migrate_entry.mock_calls) == 0
assert len(mock_setup_entry.mock_calls) == 1
assert entry.state == config_entries.ENTRY_STATE_LOADED


async def test_call_async_migrate_entry(hass):
"""Test we call <component>.async_migrate_entry when version mismatch."""
entry = MockConfigEntry(domain='comp')
entry.version = 2
entry.add_to_hass(hass)

mock_migrate_entry = MagicMock(return_value=mock_coro(True))
mock_setup_entry = MagicMock(return_value=mock_coro(True))

loader.set_component(
hass, 'comp',
MockModule('comp', async_setup_entry=mock_setup_entry,
async_migrate_entry=mock_migrate_entry))

result = await async_setup_component(hass, 'comp', {})
assert result
assert len(mock_migrate_entry.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
assert entry.state == config_entries.ENTRY_STATE_LOADED


async def test_call_async_migrate_entry_failure_false(hass):
"""Test migration fails if returns false."""
entry = MockConfigEntry(domain='comp')
entry.version = 2
entry.add_to_hass(hass)

mock_migrate_entry = MagicMock(return_value=mock_coro(False))
mock_setup_entry = MagicMock(return_value=mock_coro(True))

loader.set_component(
hass, 'comp',
MockModule('comp', async_setup_entry=mock_setup_entry,
async_migrate_entry=mock_migrate_entry))

result = await async_setup_component(hass, 'comp', {})
assert result
assert len(mock_migrate_entry.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 0
assert entry.state == config_entries.ENTRY_STATE_MIGRATION_ERROR


async def test_call_async_migrate_entry_failure_exception(hass):
"""Test migration fails if exception raised."""
entry = MockConfigEntry(domain='comp')
entry.version = 2
entry.add_to_hass(hass)

mock_migrate_entry = MagicMock(
return_value=mock_coro(exception=Exception))
mock_setup_entry = MagicMock(return_value=mock_coro(True))

loader.set_component(
hass, 'comp',
MockModule('comp', async_setup_entry=mock_setup_entry,
async_migrate_entry=mock_migrate_entry))

result = await async_setup_component(hass, 'comp', {})
assert result
assert len(mock_migrate_entry.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 0
assert entry.state == config_entries.ENTRY_STATE_MIGRATION_ERROR


async def test_call_async_migrate_entry_failure_not_bool(hass):
"""Test migration fails if boolean not returned."""
entry = MockConfigEntry(domain='comp')
entry.version = 2
entry.add_to_hass(hass)

mock_migrate_entry = MagicMock(
return_value=mock_coro())
mock_setup_entry = MagicMock(return_value=mock_coro(True))

loader.set_component(
hass, 'comp',
MockModule('comp', async_setup_entry=mock_setup_entry,
async_migrate_entry=mock_migrate_entry))

result = await async_setup_component(hass, 'comp', {})
assert result
assert len(mock_migrate_entry.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 0
assert entry.state == config_entries.ENTRY_STATE_MIGRATION_ERROR


async def test_call_async_migrate_entry_failure_not_supported(hass):
"""Test migration fails if async_migrate_entry not implemented."""
entry = MockConfigEntry(domain='comp')
entry.version = 2
entry.add_to_hass(hass)

mock_setup_entry = MagicMock(return_value=mock_coro(True))

loader.set_component(
hass, 'comp',
MockModule('comp', async_setup_entry=mock_setup_entry))

result = await async_setup_component(hass, 'comp', {})
assert result
assert len(mock_setup_entry.mock_calls) == 0
assert entry.state == config_entries.ENTRY_STATE_MIGRATION_ERROR


async def test_remove_entry(hass, manager):
Expand Down