Config entry options (#18929)

Add support for options flow for config entries
This commit is contained in:
Robert Svensson 2019-02-22 17:59:43 +01:00 committed by GitHub
parent caa3b123ae
commit d9712027e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 365 additions and 24 deletions

View File

@ -17,6 +17,10 @@ async def async_setup(hass):
hass.http.register_view(
ConfigManagerFlowResourceView(hass.config_entries.flow))
hass.http.register_view(ConfigManagerAvailableFlowView)
hass.http.register_view(
OptionManagerFlowIndexView(hass.config_entries.options.flow))
hass.http.register_view(
OptionManagerFlowResourceView(hass.config_entries.options.flow))
return True
@ -45,8 +49,9 @@ class ConfigManagerEntryIndexView(HomeAssistantView):
name = 'api:config:config_entries:entry'
async def get(self, request):
"""List flows in progress."""
"""List available config entries."""
hass = request.app['hass']
return self.json([{
'entry_id': entry.entry_id,
'domain': entry.domain,
@ -54,6 +59,9 @@ class ConfigManagerEntryIndexView(HomeAssistantView):
'source': entry.source,
'state': entry.state,
'connection_class': entry.connection_class,
'supports_options': hasattr(
config_entries.HANDLERS[entry.domain],
'async_get_options_flow'),
} for entry in hass.config_entries.async_entries()])
@ -145,3 +153,48 @@ class ConfigManagerAvailableFlowView(HomeAssistantView):
async def get(self, request):
"""List available flow handlers."""
return self.json(config_entries.FLOWS)
class OptionManagerFlowIndexView(FlowManagerIndexView):
"""View to create option flows."""
url = '/api/config/config_entries/entry/option/flow'
name = 'api:config:config_entries:entry:resource:option:flow'
# pylint: disable=arguments-differ
async def post(self, request):
"""Handle a POST request.
handler in request is entry_id.
"""
if not request['hass_user'].is_admin:
raise Unauthorized(
perm_category=CAT_CONFIG_ENTRIES, permission='edit')
# pylint: disable=no-value-for-parameter
return await super().post(request)
class OptionManagerFlowResourceView(ConfigManagerFlowResourceView):
"""View to interact with the option flow manager."""
url = '/api/config/config_entries/options/flow/{flow_id}'
name = 'api:config:config_entries:options:flow:resource'
async def get(self, request, flow_id):
"""Get the current state of a data_entry_flow."""
if not request['hass_user'].is_admin:
raise Unauthorized(
perm_category=CAT_CONFIG_ENTRIES, permission='edit')
return await super().get(request, flow_id)
# pylint: disable=arguments-differ
async def post(self, request, flow_id):
"""Handle a POST request."""
if not request['hass_user'].is_admin:
raise Unauthorized(
perm_category=CAT_CONFIG_ENTRIES, permission='edit')
# pylint: disable=no-value-for-parameter
return await super().post(request, flow_id)

View File

@ -122,7 +122,8 @@ the flow from the config panel.
import logging
import functools
import uuid
from typing import Set, Optional, List, Dict # noqa pylint: disable=unused-import
from typing import Callable, Dict, List, Optional, Set # noqa pylint: disable=unused-import
import weakref
from homeassistant import data_entry_flow
from homeassistant.core import callback, HomeAssistant
@ -130,7 +131,6 @@ from homeassistant.exceptions import HomeAssistantError, ConfigEntryNotReady
from homeassistant.setup import async_setup_component, async_process_deps_reqs
from homeassistant.util.decorator import Registry
_LOGGER = logging.getLogger(__name__)
_UNDEF = object()
@ -223,12 +223,13 @@ CONN_CLASS_UNKNOWN = 'unknown'
class ConfigEntry:
"""Hold a configuration entry."""
__slots__ = ('entry_id', 'version', 'domain', 'title', 'data', 'source',
'connection_class', 'state', '_setup_lock',
'_async_cancel_retry_setup')
__slots__ = ('entry_id', 'version', 'domain', 'title', 'data', 'options',
'source', 'connection_class', 'state', '_setup_lock',
'update_listeners', '_async_cancel_retry_setup')
def __init__(self, version: str, domain: str, title: str, data: dict,
source: str, connection_class: str,
options: Optional[dict] = None,
entry_id: Optional[str] = None,
state: str = ENTRY_STATE_NOT_LOADED) -> None:
"""Initialize a config entry."""
@ -247,6 +248,9 @@ class ConfigEntry:
# Config data
self.data = data
# Entry options
self.options = options or {}
# Source of the configuration (user, discovery, cloud)
self.source = source
@ -256,6 +260,9 @@ class ConfigEntry:
# State of the entry (LOADED, NOT_LOADED)
self.state = state
# Listeners to call on update
self.update_listeners = [] # type: list
# Function to cancel a scheduled retry
self._async_cancel_retry_setup = None
@ -386,6 +393,18 @@ class ConfigEntry:
self.title, component.DOMAIN)
return False
def add_update_listener(self, listener: Callable) -> Callable:
"""Listen for when entry is updated.
Listener: Callback function(hass, entry)
Returns function to unlisten.
"""
weak_listener = weakref.ref(listener)
self.update_listeners.append(weak_listener)
return lambda: self.update_listeners.remove(weak_listener)
def as_dict(self):
"""Return dictionary version of this entry."""
return {
@ -394,6 +413,7 @@ class ConfigEntry:
'domain': self.domain,
'title': self.title,
'data': self.data,
'options': self.options,
'source': self.source,
'connection_class': self.connection_class,
}
@ -418,6 +438,7 @@ class ConfigEntries:
self.hass = hass
self.flow = data_entry_flow.FlowManager(
hass, self._async_create_flow, self._async_finish_flow)
self.options = OptionsFlowManager(hass)
self._hass_config = hass_config
self._entries = [] # type: List[ConfigEntry]
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
@ -435,6 +456,14 @@ class ConfigEntries:
return result
@callback
def async_get_entry(self, entry_id: str) -> Optional[ConfigEntry]:
"""Return entry with matching entry_id."""
for entry in self._entries:
if entry_id == entry.entry_id:
return entry
return None
@callback
def async_entries(self, domain: Optional[str] = None) -> List[ConfigEntry]:
"""Return all entries or entries for a specific domain."""
@ -492,14 +521,25 @@ class ConfigEntries:
title=entry['title'],
# New in 0.79
connection_class=entry.get('connection_class',
CONN_CLASS_UNKNOWN))
CONN_CLASS_UNKNOWN),
# New in 0.89
options=entry.get('options'))
for entry in config['entries']]
@callback
def async_update_entry(self, entry, *, data=_UNDEF):
def async_update_entry(self, entry, *, data=_UNDEF, options=_UNDEF):
"""Update a config entry."""
if data is not _UNDEF:
entry.data = data
if options is not _UNDEF:
entry.options = options
if data is not _UNDEF or options is not _UNDEF:
for listener_ref in entry.update_listeners:
listener = listener_ref()
self.hass.async_create_task(listener(self.hass, entry))
self._async_schedule_save()
async def async_forward_entry_setup(self, entry, component):
@ -549,6 +589,7 @@ class ConfigEntries:
domain=result['handler'],
title=result['title'],
data=result['data'],
options={},
source=flow.context['source'],
connection_class=flow.CONNECTION_CLASS,
)
@ -598,7 +639,7 @@ class ConfigEntries:
flow.init_step = source
return flow
def _async_schedule_save(self):
def _async_schedule_save(self) -> None:
"""Save the entity registry to a file."""
self._store.async_delay_save(self._data_to_save, SAVE_DELAY)
@ -631,3 +672,39 @@ class ConfigFlow(data_entry_flow.FlowHandler):
return [flw for flw in self.hass.config_entries.flow.async_progress()
if flw['handler'] == self.handler and
flw['flow_id'] != self.flow_id]
class OptionsFlowManager:
"""Flow to set options for a configuration entry."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the options manager."""
self.hass = hass
self.flow = data_entry_flow.FlowManager(
hass, self._async_create_flow, self._async_finish_flow)
async def _async_create_flow(self, entry_id, *, context, data):
"""Create an options flow for a config entry.
Entry_id and flow.handler is the same thing to map entry with flow.
"""
entry = self.hass.config_entries.async_get_entry(entry_id)
if entry is None:
return
flow = HANDLERS[entry.domain].async_get_options_flow(
entry.data, entry.options)
return flow
async def _async_finish_flow(self, flow, result):
"""Finish an options flow and update options for configuration entry.
Flow.handler and entry_id is the same thing to map flow with entry.
"""
entry = self.hass.config_entries.async_get_entry(flow.handler)
if entry is None:
return
self.hass.config_entries.async_update_entry(
entry, options=result['data'])
result['result'] = True
return result

View File

@ -609,13 +609,14 @@ class MockConfigEntry(config_entries.ConfigEntry):
def __init__(self, *, domain='test', data=None, version=1, entry_id=None,
source=config_entries.SOURCE_USER, title='Mock Title',
state=None,
state=None, options={},
connection_class=config_entries.CONN_CLASS_UNKNOWN):
"""Initialize a mock config entry."""
kwargs = {
'entry_id': entry_id or 'mock-id',
'domain': domain,
'data': data or {},
'options': options,
'version': version,
'title': title,
'connection_class': connection_class,

View File

@ -7,8 +7,9 @@ from unittest.mock import patch
import pytest
import voluptuous as vol
from homeassistant import config_entries as core_ce
from homeassistant import config_entries as core_ce, data_entry_flow
from homeassistant.config_entries import HANDLERS
from homeassistant.core import callback
from homeassistant.setup import async_setup_component
from homeassistant.components.config import config_entries
from homeassistant.loader import set_component
@ -30,25 +31,37 @@ def client(hass, hass_client):
yield hass.loop.run_until_complete(hass_client())
@asyncio.coroutine
def test_get_entries(hass, client):
async def test_get_entries(hass, client):
"""Test get entries."""
MockConfigEntry(
domain='comp',
title='Test 1',
source='bla',
connection_class=core_ce.CONN_CLASS_LOCAL_POLL,
domain='comp',
title='Test 1',
source='bla',
connection_class=core_ce.CONN_CLASS_LOCAL_POLL,
).add_to_hass(hass)
MockConfigEntry(
domain='comp2',
title='Test 2',
source='bla2',
state=core_ce.ENTRY_STATE_LOADED,
connection_class=core_ce.CONN_CLASS_ASSUMED,
domain='comp2',
title='Test 2',
source='bla2',
state=core_ce.ENTRY_STATE_LOADED,
connection_class=core_ce.CONN_CLASS_ASSUMED,
).add_to_hass(hass)
resp = yield from client.get('/api/config/config_entries/entry')
class CompConfigFlow:
@staticmethod
@callback
def async_get_options_flow(config, options):
pass
HANDLERS['comp'] = CompConfigFlow()
class Comp2ConfigFlow:
def __init__(self):
pass
HANDLERS['comp2'] = Comp2ConfigFlow()
resp = await client.get('/api/config/config_entries/entry')
assert resp.status == 200
data = yield from resp.json()
data = await resp.json()
for entry in data:
entry.pop('entry_id')
assert data == [
@ -58,6 +71,7 @@ def test_get_entries(hass, client):
'source': 'bla',
'state': 'not_loaded',
'connection_class': 'local_poll',
'supports_options': True,
},
{
'domain': 'comp2',
@ -65,6 +79,7 @@ def test_get_entries(hass, client):
'source': 'bla2',
'state': 'loaded',
'connection_class': 'assumed',
'supports_options': False,
},
]
@ -467,3 +482,136 @@ async def test_get_progress_flow_unauth(hass, client, hass_admin_user):
'/api/config/config_entries/flow/{}'.format(data['flow_id']))
assert resp2.status == 401
async def test_options_flow(hass, client):
"""Test we can change options."""
class TestFlow(core_ce.ConfigFlow):
@staticmethod
@callback
def async_get_options_flow(config, options):
class OptionsFlowHandler(data_entry_flow.FlowHandler):
def __init__(self, config, options):
self.config = config
self.options = options
async def async_step_init(self, user_input=None):
schema = OrderedDict()
schema[vol.Required('enabled')] = bool
return self.async_show_form(
step_id='user',
data_schema=schema,
description_placeholders={
'enabled': 'Set to true to be true',
}
)
return OptionsFlowHandler(config, options)
MockConfigEntry(
domain='test',
entry_id='test1',
source='bla',
connection_class=core_ce.CONN_CLASS_LOCAL_POLL,
).add_to_hass(hass)
entry = hass.config_entries._entries[0]
with patch.dict(HANDLERS, {'test': TestFlow}):
url = '/api/config/config_entries/entry/option/flow'
resp = await client.post(url, json={'handler': entry.entry_id})
assert resp.status == 200
data = await resp.json()
data.pop('flow_id')
assert data == {
'type': 'form',
'handler': 'test1',
'step_id': 'user',
'data_schema': [
{
'name': 'enabled',
'required': True,
'type': 'boolean'
},
],
'description_placeholders': {
'enabled': 'Set to true to be true',
},
'errors': None
}
async def test_two_step_options_flow(hass, client):
"""Test we can finish a two step options flow."""
set_component(
hass, 'test',
MockModule('test', async_setup_entry=mock_coro_func(True)))
class TestFlow(core_ce.ConfigFlow):
@staticmethod
@callback
def async_get_options_flow(config, options):
class OptionsFlowHandler(data_entry_flow.FlowHandler):
def __init__(self, config, options):
self.config = config
self.options = options
async def async_step_init(self, user_input=None):
return self.async_show_form(
step_id='finish',
data_schema=vol.Schema({
'enabled': bool
})
)
async def async_step_finish(self, user_input=None):
return self.async_create_entry(
title='Enable disable',
data=user_input
)
return OptionsFlowHandler(config, options)
MockConfigEntry(
domain='test',
entry_id='test1',
source='bla',
connection_class=core_ce.CONN_CLASS_LOCAL_POLL,
).add_to_hass(hass)
entry = hass.config_entries._entries[0]
with patch.dict(HANDLERS, {'test': TestFlow}):
url = '/api/config/config_entries/entry/option/flow'
resp = await client.post(url, json={'handler': entry.entry_id})
assert resp.status == 200
data = await resp.json()
flow_id = data.pop('flow_id')
assert data == {
'type': 'form',
'handler': 'test1',
'step_id': 'finish',
'data_schema': [
{
'name': 'enabled',
'type': 'boolean'
}
],
'description_placeholders': None,
'errors': None
}
with patch.dict(HANDLERS, {'test': TestFlow}):
resp = await client.post(
'/api/config/config_entries/options/flow/{}'.format(flow_id),
json={'enabled': True})
assert resp.status == 200
data = await resp.json()
data.pop('flow_id')
assert data == {
'handler': 'test1',
'type': 'create_entry',
'title': 'Enable disable',
'version': 1,
'description': None,
'description_placeholders': None,
}

View File

@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
import pytest
from homeassistant import config_entries, loader, data_entry_flow
from homeassistant.core import callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.setup import async_setup_component
from homeassistant.util import dt
@ -544,6 +545,31 @@ async def test_updating_entry_data(manager):
}
async def test_update_entry_options_and_trigger_listener(hass, manager):
"""Test that we can update entry options and trigger listener."""
entry = MockConfigEntry(
domain='test',
options={'first': True},
)
entry.add_to_manager(manager)
async def update_listener(hass, entry):
"""Test function."""
assert entry.options == {
'second': True
}
entry.add_update_listener(update_listener)
manager.async_update_entry(entry, options={
'second': True
})
assert entry.options == {
'second': True
}
async def test_setup_raise_not_ready(hass, caplog):
"""Test a setup raising not ready."""
entry = MockConfigEntry(domain='test')
@ -588,3 +614,39 @@ async def test_setup_retrying_during_unload(hass):
assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED
assert len(mock_call.return_value.mock_calls) == 1
async def test_entry_options(hass, manager):
"""Test that we can set options on an entry."""
entry = MockConfigEntry(
domain='test',
data={'first': True},
options=None
)
entry.add_to_manager(manager)
class TestFlow:
@staticmethod
@callback
def async_get_options_flow(config, options):
class OptionsFlowHandler(data_entry_flow.FlowHandler):
def __init__(self, config, options):
pass
return OptionsFlowHandler(config, options)
config_entries.HANDLERS['test'] = TestFlow()
flow = await manager.options._async_create_flow(
entry.entry_id, context={'source': 'test'}, data=None)
flow.handler = entry.entry_id # Used to keep reference to config entry
await manager.options._async_finish_flow(
flow, {'data': {'second': True}})
assert entry.data == {
'first': True
}
assert entry.options == {
'second': True
}