Config Entry migrations (#20888)

* Updated per review feedback.

* Fixed line length

* Review comments and lint error

* Fixed mypy typeing error

* Moved migration logic to setup

* Use new migration error state

* Fix bug and ignore mypy type error

* Removed SmartThings example and added unit tests.

* Fixed test comments.
This commit is contained in:
Andrew Sayre 2019-02-15 11:30:47 -06:00 committed by Paulus Schoutsen
parent 1130ccb325
commit 383813bfe6
3 changed files with 180 additions and 8 deletions

View File

@ -7,7 +7,11 @@ component.
During startup, Home Assistant will setup the entries during the normal setup 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 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 `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 ## Config Flows
@ -116,6 +120,7 @@ If the result of the step is to show a form, the user will be able to continue
the flow from the config panel. the flow from the config panel.
""" """
import logging import logging
import functools
import uuid import uuid
from typing import Set, Optional, List, Dict # noqa pylint: disable=unused-import from typing import Set, Optional, List, Dict # noqa pylint: disable=unused-import
@ -188,6 +193,8 @@ SAVE_DELAY = 1
ENTRY_STATE_LOADED = 'loaded' ENTRY_STATE_LOADED = 'loaded'
# There was an error while trying to set up this config entry # There was an error while trying to set up this config entry
ENTRY_STATE_SETUP_ERROR = 'setup_error' 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 # The config entry was not ready to be set up yet, but might be later
ENTRY_STATE_SETUP_RETRY = 'setup_retry' ENTRY_STATE_SETUP_RETRY = 'setup_retry'
# The config entry has not been loaded # The config entry has not been loaded
@ -256,6 +263,12 @@ class ConfigEntry:
if component is None: if component is None:
component = getattr(hass.components, self.domain) 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: try:
result = await component.async_setup_entry(hass, self) result = await component.async_setup_entry(hass, self)
@ -332,6 +345,45 @@ class ConfigEntry:
self.state = ENTRY_STATE_FAILED_UNLOAD self.state = ENTRY_STATE_FAILED_UNLOAD
return False 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): def as_dict(self):
"""Return dictionary version of this entry.""" """Return dictionary version of this entry."""
return { return {

View File

@ -451,7 +451,8 @@ class MockModule:
def __init__(self, domain=None, dependencies=None, setup=None, def __init__(self, domain=None, dependencies=None, setup=None,
requirements=None, config_schema=None, platform_schema=None, requirements=None, config_schema=None, platform_schema=None,
platform_schema_base=None, async_setup=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.""" """Initialize the mock module."""
self.DOMAIN = domain self.DOMAIN = domain
self.DEPENDENCIES = dependencies or [] self.DEPENDENCIES = dependencies or []
@ -482,6 +483,9 @@ class MockModule:
if async_unload_entry is not None: if async_unload_entry is not None:
self.async_unload_entry = async_unload_entry self.async_unload_entry = async_unload_entry
if async_migrate_entry is not None:
self.async_migrate_entry = async_migrate_entry
class MockPlatform: class MockPlatform:
"""Provide a fake platform.""" """Provide a fake platform."""
@ -602,7 +606,7 @@ class MockToggleDevice(entity.ToggleEntity):
class MockConfigEntry(config_entries.ConfigEntry): class MockConfigEntry(config_entries.ConfigEntry):
"""Helper for creating config entries that adds some defaults.""" """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', source=config_entries.SOURCE_USER, title='Mock Title',
state=None, state=None,
connection_class=config_entries.CONN_CLASS_UNKNOWN): connection_class=config_entries.CONN_CLASS_UNKNOWN):

View File

@ -15,6 +15,14 @@ from tests.common import (
MockPlatform, MockEntity) 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 @pytest.fixture
def manager(hass): def manager(hass):
"""Fixture of a loaded config manager.""" """Fixture of a loaded config manager."""
@ -25,10 +33,117 @@ def manager(hass):
return manager return manager
@asyncio.coroutine async def test_call_setup_entry(hass):
def test_call_setup_entry(hass):
"""Test we call <component>.setup_entry.""" """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,
async_migrate_entry=mock_migrate_entry))
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)) mock_setup_entry = MagicMock(return_value=mock_coro(True))
@ -36,9 +151,10 @@ def test_call_setup_entry(hass):
hass, 'comp', hass, 'comp',
MockModule('comp', async_setup_entry=mock_setup_entry)) MockModule('comp', async_setup_entry=mock_setup_entry))
result = yield from async_setup_component(hass, 'comp', {}) result = await async_setup_component(hass, 'comp', {})
assert result assert result
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 0
assert entry.state == config_entries.ENTRY_STATE_MIGRATION_ERROR
async def test_remove_entry(hass, manager): async def test_remove_entry(hass, manager):