Add options flow to File (#120269)

* Add options flow to File

* Review comments
This commit is contained in:
G Johansson 2024-08-15 18:21:07 +02:00 committed by GitHub
parent e39bfeac08
commit 24a20c75eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 307 additions and 38 deletions

View file

@ -1,5 +1,8 @@
"""The file component."""
from copy import deepcopy
from typing import Any
from homeassistant.components.notify import migrate_notify_issue
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
@ -84,7 +87,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a file component entry."""
config = dict(entry.data)
config = {**entry.data, **entry.options}
filepath: str = config[CONF_FILE_PATH]
if filepath and not await hass.async_add_executor_job(
hass.config.is_allowed_path, filepath
@ -98,6 +101,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(
entry, [Platform(entry.data[CONF_PLATFORM])]
)
entry.async_on_unload(entry.add_update_listener(update_listener))
if entry.data[CONF_PLATFORM] == Platform.NOTIFY and CONF_NAME in entry.data:
# New notify entities are being setup through the config entry,
# but during the deprecation period we want to keep the legacy notify platform,
@ -121,3 +125,29 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return await hass.config_entries.async_unload_platforms(
entry, [entry.data[CONF_PLATFORM]]
)
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate config entry."""
if config_entry.version > 2:
# Downgraded from future
return False
if config_entry.version < 2:
# Move optional fields from data to options in config entry
data: dict[str, Any] = deepcopy(dict(config_entry.data))
options = {}
for key, value in config_entry.data.items():
if key not in (CONF_FILE_PATH, CONF_PLATFORM, CONF_NAME):
data.pop(key)
options[key] = value
hass.config_entries.async_update_entry(
config_entry, version=2, data=data, options=options
)
return True

View file

@ -1,11 +1,18 @@
"""Config flow for file integration."""
from copy import deepcopy
import os
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
OptionsFlowWithConfigEntry,
)
from homeassistant.const import (
CONF_FILE_PATH,
CONF_FILENAME,
@ -15,6 +22,7 @@ from homeassistant.const import (
CONF_VALUE_TEMPLATE,
Platform,
)
from homeassistant.core import callback
from homeassistant.helpers.selector import (
BooleanSelector,
BooleanSelectorConfig,
@ -31,27 +39,44 @@ BOOLEAN_SELECTOR = BooleanSelector(BooleanSelectorConfig())
TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig())
TEXT_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
FILE_FLOW_SCHEMAS = {
FILE_OPTIONS_SCHEMAS = {
Platform.SENSOR.value: vol.Schema(
{
vol.Required(CONF_FILE_PATH): TEXT_SELECTOR,
vol.Optional(CONF_VALUE_TEMPLATE): TEMPLATE_SELECTOR,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): TEXT_SELECTOR,
}
),
Platform.NOTIFY.value: vol.Schema(
{
vol.Required(CONF_FILE_PATH): TEXT_SELECTOR,
vol.Optional(CONF_TIMESTAMP, default=False): BOOLEAN_SELECTOR,
}
),
}
FILE_FLOW_SCHEMAS = {
Platform.SENSOR.value: vol.Schema(
{
vol.Required(CONF_FILE_PATH): TEXT_SELECTOR,
}
).extend(FILE_OPTIONS_SCHEMAS[Platform.SENSOR.value].schema),
Platform.NOTIFY.value: vol.Schema(
{
vol.Required(CONF_FILE_PATH): TEXT_SELECTOR,
}
).extend(FILE_OPTIONS_SCHEMAS[Platform.NOTIFY.value].schema),
}
class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a file config flow."""
VERSION = 1
VERSION = 2
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
"""Get the options flow for this handler."""
return FileOptionsFlowHandler(config_entry)
async def validate_file_path(self, file_path: str) -> bool:
"""Ensure the file path is valid."""
@ -80,7 +105,13 @@ class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN):
errors[CONF_FILE_PATH] = "not_allowed"
else:
title = f"{DEFAULT_NAME} [{user_input[CONF_FILE_PATH]}]"
return self.async_create_entry(data=user_input, title=title)
data = deepcopy(user_input)
options = {}
for key, value in user_input.items():
if key not in (CONF_FILE_PATH, CONF_PLATFORM, CONF_NAME):
data.pop(key)
options[key] = value
return self.async_create_entry(data=data, title=title, options=options)
return self.async_show_form(
step_id=platform, data_schema=FILE_FLOW_SCHEMAS[platform], errors=errors
@ -114,4 +145,29 @@ class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN):
else:
file_path = import_data[CONF_FILE_PATH]
title = f"{name} [{file_path}]"
return self.async_create_entry(title=title, data=import_data)
data = deepcopy(import_data)
options = {}
for key, value in import_data.items():
if key not in (CONF_FILE_PATH, CONF_PLATFORM, CONF_NAME):
data.pop(key)
options[key] = value
return self.async_create_entry(title=title, data=data, options=options)
class FileOptionsFlowHandler(OptionsFlowWithConfigEntry):
"""Handle File options."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage File options."""
if user_input:
return self.async_create_entry(data=user_input)
platform = self.config_entry.data[CONF_PLATFORM]
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
FILE_OPTIONS_SCHEMAS[platform], self.config_entry.options or {}
),
)

View file

@ -5,7 +5,6 @@ from __future__ import annotations
from functools import partial
import logging
import os
from types import MappingProxyType
from typing import Any, TextIO
import voluptuous as vol
@ -109,7 +108,7 @@ async def async_setup_entry(
) -> None:
"""Set up notify entity."""
unique_id = entry.entry_id
async_add_entities([FileNotifyEntity(unique_id, entry.data)])
async_add_entities([FileNotifyEntity(unique_id, {**entry.data, **entry.options})])
class FileNotifyEntity(NotifyEntity):
@ -118,7 +117,7 @@ class FileNotifyEntity(NotifyEntity):
_attr_icon = FILE_ICON
_attr_supported_features = NotifyEntityFeature.TITLE
def __init__(self, unique_id: str, config: MappingProxyType[str, Any]) -> None:
def __init__(self, unique_id: str, config: dict[str, Any]) -> None:
"""Initialize the service."""
self._file_path: str = config[CONF_FILE_PATH]
self._add_timestamp: bool = config.get(CONF_TIMESTAMP, False)

View file

@ -60,14 +60,15 @@ async def async_setup_entry(
) -> None:
"""Set up the file sensor."""
config = dict(entry.data)
options = dict(entry.options)
file_path: str = config[CONF_FILE_PATH]
unique_id: str = entry.entry_id
name: str = config.get(CONF_NAME, DEFAULT_NAME)
unit: str | None = config.get(CONF_UNIT_OF_MEASUREMENT)
unit: str | None = options.get(CONF_UNIT_OF_MEASUREMENT)
value_template: Template | None = None
if CONF_VALUE_TEMPLATE in config:
value_template = Template(config[CONF_VALUE_TEMPLATE], hass)
if CONF_VALUE_TEMPLATE in options:
value_template = Template(options[CONF_VALUE_TEMPLATE], hass)
async_add_entities(
[FileSensor(unique_id, name, file_path, unit, value_template)], True

View file

@ -42,6 +42,22 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"options": {
"step": {
"init": {
"data": {
"value_template": "[%key:component::file::config::step::sensor::data::value_template%]",
"unit_of_measurement": "[%key:component::file::config::step::sensor::data::unit_of_measurement%]",
"timestamp": "[%key:component::file::config::step::notify::data::timestamp%]"
},
"data_description": {
"value_template": "[%key:component::file::config::step::sensor::data_description::value_template%]",
"unit_of_measurement": "[%key:component::file::config::step::sensor::data_description::unit_of_measurement%]",
"timestamp": "[%key:component::file::config::step::notify::data_description::timestamp%]"
}
}
}
},
"exceptions": {
"dir_not_allowed": {
"message": "Access to {filename} is not allowed."

View file

@ -7,6 +7,7 @@ import pytest
from homeassistant import config_entries
from homeassistant.components.file import DOMAIN
from homeassistant.const import CONF_UNIT_OF_MEASUREMENT
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@ -15,20 +16,22 @@ from tests.common import MockConfigEntry
MOCK_CONFIG_NOTIFY = {
"platform": "notify",
"file_path": "some_file",
"timestamp": True,
}
MOCK_OPTIONS_NOTIFY = {"timestamp": True}
MOCK_CONFIG_SENSOR = {
"platform": "sensor",
"file_path": "some/path",
"value_template": "{{ value | round(1) }}",
}
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
MOCK_OPTIONS_SENSOR = {"value_template": "{{ value | round(1) }}"}
@pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize(
("platform", "data"),
[("sensor", MOCK_CONFIG_SENSOR), ("notify", MOCK_CONFIG_NOTIFY)],
("platform", "data", "options"),
[
("sensor", MOCK_CONFIG_SENSOR, MOCK_OPTIONS_SENSOR),
("notify", MOCK_CONFIG_NOTIFY, MOCK_OPTIONS_NOTIFY),
],
)
async def test_form(
hass: HomeAssistant,
@ -36,6 +39,7 @@ async def test_form(
mock_is_allowed_path: bool,
platform: str,
data: dict[str, Any],
options: dict[str, Any],
) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
@ -50,7 +54,7 @@ async def test_form(
)
await hass.async_block_till_done()
user_input = dict(data)
user_input = {**data, **options}
user_input.pop("platform")
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=user_input
@ -59,12 +63,17 @@ async def test_form(
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["data"] == data
assert result2["options"] == options
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize(
("platform", "data"),
[("sensor", MOCK_CONFIG_SENSOR), ("notify", MOCK_CONFIG_NOTIFY)],
("platform", "data", "options"),
[
("sensor", MOCK_CONFIG_SENSOR, MOCK_OPTIONS_SENSOR),
("notify", MOCK_CONFIG_NOTIFY, MOCK_OPTIONS_NOTIFY),
],
)
async def test_already_configured(
hass: HomeAssistant,
@ -72,9 +81,10 @@ async def test_already_configured(
mock_is_allowed_path: bool,
platform: str,
data: dict[str, Any],
options: dict[str, Any],
) -> None:
"""Test aborting if the entry is already configured."""
entry = MockConfigEntry(domain=DOMAIN, data=data)
entry = MockConfigEntry(domain=DOMAIN, data=data, options=options)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
@ -91,7 +101,7 @@ async def test_already_configured(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == platform
user_input = dict(data)
user_input = {**data, **options}
user_input.pop("platform")
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@ -103,10 +113,14 @@ async def test_already_configured(
assert result2["reason"] == "already_configured"
@pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize("is_allowed", [False], ids=["not_allowed"])
@pytest.mark.parametrize(
("platform", "data"),
[("sensor", MOCK_CONFIG_SENSOR), ("notify", MOCK_CONFIG_NOTIFY)],
("platform", "data", "options"),
[
("sensor", MOCK_CONFIG_SENSOR, MOCK_OPTIONS_SENSOR),
("notify", MOCK_CONFIG_NOTIFY, MOCK_OPTIONS_NOTIFY),
],
)
async def test_not_allowed(
hass: HomeAssistant,
@ -114,6 +128,7 @@ async def test_not_allowed(
mock_is_allowed_path: bool,
platform: str,
data: dict[str, Any],
options: dict[str, Any],
) -> None:
"""Test aborting if the file path is not allowed."""
result = await hass.config_entries.flow.async_init(
@ -130,7 +145,7 @@ async def test_not_allowed(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == platform
user_input = dict(data)
user_input = {**data, **options}
user_input.pop("platform")
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@ -140,3 +155,49 @@ async def test_not_allowed(
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"file_path": "not_allowed"}
@pytest.mark.parametrize(
("platform", "data", "options", "new_options"),
[
(
"sensor",
MOCK_CONFIG_SENSOR,
MOCK_OPTIONS_SENSOR,
{CONF_UNIT_OF_MEASUREMENT: "mm"},
),
("notify", MOCK_CONFIG_NOTIFY, MOCK_OPTIONS_NOTIFY, {"timestamp": False}),
],
)
async def test_options_flow(
hass: HomeAssistant,
mock_is_allowed_path: bool,
platform: str,
data: dict[str, Any],
options: dict[str, Any],
new_options: dict[str, Any],
) -> None:
"""Test options config flow."""
entry = MockConfigEntry(domain=DOMAIN, data=data, options=options, version=2)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input=new_options,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == new_options
entry = hass.config_entries.async_get_entry(entry.entry_id)
assert entry.state is config_entries.ConfigEntryState.LOADED
assert entry.options == new_options

View file

@ -0,0 +1,65 @@
"""The tests for local file init."""
from unittest.mock import MagicMock, Mock, patch
from homeassistant.components.file import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, get_fixture_path
@patch("os.path.isfile", Mock(return_value=True))
@patch("os.access", Mock(return_value=True))
async def test_migration_to_version_2(
hass: HomeAssistant, mock_is_allowed_path: MagicMock
) -> None:
"""Test the File sensor with JSON entries."""
data = {
"platform": "sensor",
"name": "file2",
"file_path": get_fixture_path("file_value_template.txt", "file"),
"value_template": "{{ value_json.temperature }}",
}
entry = MockConfigEntry(
domain=DOMAIN,
version=1,
data=data,
title=f"test [{data['file_path']}]",
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is ConfigEntryState.LOADED
assert entry.version == 2
assert entry.data == {
"platform": "sensor",
"name": "file2",
"file_path": get_fixture_path("file_value_template.txt", "file"),
}
assert entry.options == {
"value_template": "{{ value_json.temperature }}",
}
@patch("os.path.isfile", Mock(return_value=True))
@patch("os.access", Mock(return_value=True))
async def test_migration_from_future_version(
hass: HomeAssistant, mock_is_allowed_path: MagicMock
) -> None:
"""Test the File sensor with JSON entries."""
data = {
"platform": "sensor",
"name": "file2",
"file_path": get_fixture_path("file_value_template.txt", "file"),
"value_template": "{{ value_json.temperature }}",
}
entry = MockConfigEntry(
domain=DOMAIN, version=3, data=data, title=f"test [{data['file_path']}]"
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is ConfigEntryState.MIGRATION_ERROR

View file

@ -174,7 +174,7 @@ async def test_legacy_notify_file_exception(
@pytest.mark.parametrize(
("timestamp", "data"),
("timestamp", "data", "options"),
[
(
False,
@ -182,6 +182,8 @@ async def test_legacy_notify_file_exception(
"name": "test",
"platform": "notify",
"file_path": "mock_file",
},
{
"timestamp": False,
},
),
@ -191,6 +193,8 @@ async def test_legacy_notify_file_exception(
"name": "test",
"platform": "notify",
"file_path": "mock_file",
},
{
"timestamp": True,
},
),
@ -203,6 +207,7 @@ async def test_legacy_notify_file_entry_only_setup(
timestamp: bool,
mock_is_allowed_path: MagicMock,
data: dict[str, Any],
options: dict[str, Any],
) -> None:
"""Test the legacy notify file output in entry only setup."""
filename = "mock_file"
@ -213,7 +218,11 @@ async def test_legacy_notify_file_entry_only_setup(
message = params["message"]
entry = MockConfigEntry(
domain=DOMAIN, data=data, title=f"test [{data['file_path']}]"
domain=DOMAIN,
data=data,
version=2,
options=options,
title=f"test [{data['file_path']}]",
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
@ -252,7 +261,7 @@ async def test_legacy_notify_file_entry_only_setup(
@pytest.mark.parametrize(
("is_allowed", "config"),
("is_allowed", "config", "options"),
[
(
False,
@ -260,6 +269,8 @@ async def test_legacy_notify_file_entry_only_setup(
"name": "test",
"platform": "notify",
"file_path": "mock_file",
},
{
"timestamp": False,
},
),
@ -271,10 +282,15 @@ async def test_legacy_notify_file_not_allowed(
caplog: pytest.LogCaptureFixture,
mock_is_allowed_path: MagicMock,
config: dict[str, Any],
options: dict[str, Any],
) -> None:
"""Test legacy notify file output not allowed."""
entry = MockConfigEntry(
domain=DOMAIN, data=config, title=f"test [{config['file_path']}]"
domain=DOMAIN,
data=config,
version=2,
options=options,
title=f"test [{config['file_path']}]",
)
entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(entry.entry_id)
@ -293,13 +309,15 @@ async def test_legacy_notify_file_not_allowed(
],
)
@pytest.mark.parametrize(
("data", "is_allowed"),
("data", "options", "is_allowed"),
[
(
{
"name": "test",
"platform": "notify",
"file_path": "mock_file",
},
{
"timestamp": False,
},
True,
@ -314,12 +332,17 @@ async def test_notify_file_write_access_failed(
service: str,
params: dict[str, Any],
data: dict[str, Any],
options: dict[str, Any],
) -> None:
"""Test the notify file fails."""
domain = notify.DOMAIN
entry = MockConfigEntry(
domain=DOMAIN, data=data, title=f"test [{data['file_path']}]"
domain=DOMAIN,
data=data,
version=2,
options=options,
title=f"test [{data['file_path']}]",
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)

View file

@ -47,7 +47,11 @@ async def test_file_value_entry_setup(
}
entry = MockConfigEntry(
domain=DOMAIN, data=data, title=f"test [{data['file_path']}]"
domain=DOMAIN,
data=data,
version=2,
options={},
title=f"test [{data['file_path']}]",
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
@ -66,11 +70,17 @@ async def test_file_value_template(
"platform": "sensor",
"name": "file2",
"file_path": get_fixture_path("file_value_template.txt", "file"),
}
options = {
"value_template": "{{ value_json.temperature }}",
}
entry = MockConfigEntry(
domain=DOMAIN, data=data, title=f"test [{data['file_path']}]"
domain=DOMAIN,
data=data,
version=2,
options=options,
title=f"test [{data['file_path']}]",
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
@ -90,7 +100,11 @@ async def test_file_empty(hass: HomeAssistant, mock_is_allowed_path: MagicMock)
}
entry = MockConfigEntry(
domain=DOMAIN, data=data, title=f"test [{data['file_path']}]"
domain=DOMAIN,
data=data,
version=2,
options={},
title=f"test [{data['file_path']}]",
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
@ -113,7 +127,11 @@ async def test_file_path_invalid(
}
entry = MockConfigEntry(
domain=DOMAIN, data=data, title=f"test [{data['file_path']}]"
domain=DOMAIN,
data=data,
version=2,
options={},
title=f"test [{data['file_path']}]",
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)