Add config flow to Folder Watcher (#105605)

* Add config flow to Folder Watcher

* Add tests config flow

* docstrings

* watcher is sync

* Fix strings

* Fix

* setup_entry issue

* ConfigFlowResult

* Review comments

* Review comment

* ruff

* new date
This commit is contained in:
G Johansson 2024-04-23 08:55:39 +02:00 committed by GitHub
parent 2fafdc64d5
commit 917f4136a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 430 additions and 15 deletions

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import logging import logging
import os import os
from typing import cast from typing import Any, cast
import voluptuous as vol import voluptuous as vol
from watchdog.events import ( from watchdog.events import (
@ -19,17 +19,17 @@ from watchdog.events import (
) )
from watchdog.observers import Observer from watchdog.observers import Observer
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant from homeassistant.core import Event, HomeAssistant
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import CONF_FOLDER, CONF_PATTERNS, DEFAULT_PATTERN, DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONF_FOLDER = "folder"
CONF_PATTERNS = "patterns"
DEFAULT_PATTERN = "*"
DOMAIN = "folder_watcher"
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {
@ -51,20 +51,62 @@ CONFIG_SCHEMA = vol.Schema(
) )
def setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the folder watcher.""" """Set up the folder watcher."""
conf = config[DOMAIN] if DOMAIN in config:
for watcher in conf: conf: list[dict[str, Any]] = config[DOMAIN]
path: str = watcher[CONF_FOLDER] for watcher in conf:
patterns: list[str] = watcher[CONF_PATTERNS] path: str = watcher[CONF_FOLDER]
if not hass.config.is_allowed_path(path): if not hass.config.is_allowed_path(path):
_LOGGER.error("Folder %s is not valid or allowed", path) async_create_issue(
return False hass,
Watcher(path, patterns, hass) DOMAIN,
f"import_failed_not_allowed_path_{path}",
is_fixable=False,
is_persistent=False,
severity=IssueSeverity.ERROR,
translation_key="import_failed_not_allowed_path",
translation_placeholders={
"path": path,
"config_variable": "allowlist_external_dirs",
},
)
continue
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=watcher
)
)
return True return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Folder watcher from a config entry."""
path: str = entry.options[CONF_FOLDER]
patterns: list[str] = entry.options[CONF_PATTERNS]
if not hass.config.is_allowed_path(path):
_LOGGER.error("Folder %s is not valid or allowed", path)
async_create_issue(
hass,
DOMAIN,
f"setup_not_allowed_path_{path}",
is_fixable=False,
is_persistent=False,
severity=IssueSeverity.ERROR,
translation_key="setup_not_allowed_path",
translation_placeholders={
"path": path,
"config_variable": "allowlist_external_dirs",
},
learn_more_url="https://www.home-assistant.io/docs/configuration/basic/#allowlist_external_dirs",
)
return False
await hass.async_add_executor_job(Watcher, path, patterns, hass)
return True
def create_event_handler(patterns: list[str], hass: HomeAssistant) -> EventHandler: def create_event_handler(patterns: list[str], hass: HomeAssistant) -> EventHandler:
"""Return the Watchdog EventHandler object.""" """Return the Watchdog EventHandler object."""

View File

@ -0,0 +1,116 @@
"""Adds config flow for Folder watcher."""
from __future__ import annotations
from collections.abc import Mapping
import os
from typing import Any
import voluptuous as vol
from homeassistant.components.homeassistant import DOMAIN as HOMEASSISTANT_DOMAIN
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.core import callback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaConfigFlowHandler,
SchemaFlowError,
SchemaFlowFormStep,
)
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TextSelector,
)
from .const import CONF_FOLDER, CONF_PATTERNS, DEFAULT_PATTERN, DOMAIN
async def validate_setup(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Check path is a folder."""
value: str = user_input[CONF_FOLDER]
dir_in = os.path.expanduser(str(value))
handler.parent_handler._async_abort_entries_match({CONF_FOLDER: value}) # pylint: disable=protected-access
if not os.path.isdir(dir_in):
raise SchemaFlowError("not_dir")
if not os.access(dir_in, os.R_OK):
raise SchemaFlowError("not_readable_dir")
if not handler.parent_handler.hass.config.is_allowed_path(value):
raise SchemaFlowError("not_allowed_dir")
return user_input
async def validate_import_setup(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Create issue on successful import."""
async_create_issue(
handler.parent_handler.hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2024.11.0",
is_fixable=False,
is_persistent=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Folder Watcher",
},
)
return user_input
OPTIONS_SCHEMA = vol.Schema(
{
vol.Optional(CONF_PATTERNS, default=[DEFAULT_PATTERN]): SelectSelector(
SelectSelectorConfig(
options=[DEFAULT_PATTERN],
multiple=True,
custom_value=True,
mode=SelectSelectorMode.DROPDOWN,
)
),
}
)
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_FOLDER): TextSelector(),
}
).extend(OPTIONS_SCHEMA.schema)
CONFIG_FLOW = {
"user": SchemaFlowFormStep(schema=DATA_SCHEMA, validate_user_input=validate_setup),
"import": SchemaFlowFormStep(
schema=DATA_SCHEMA, validate_user_input=validate_import_setup
),
}
OPTIONS_FLOW = {
"init": SchemaFlowFormStep(schema=OPTIONS_SCHEMA),
}
class FolderWatcherConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config flow for Folder Watcher."""
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
return f"Folder Watcher {options[CONF_FOLDER]}"
@callback
def async_create_entry(
self, data: Mapping[str, Any], **kwargs: Any
) -> ConfigFlowResult:
"""Finish config flow and create a config entry."""
self._async_abort_entries_match({CONF_FOLDER: data[CONF_FOLDER]})
return super().async_create_entry(data, **kwargs)

View File

@ -0,0 +1,6 @@
"""Constants for Folder watcher."""
CONF_FOLDER = "folder"
CONF_PATTERNS = "patterns"
DEFAULT_PATTERN = "*"
DOMAIN = "folder_watcher"

View File

@ -2,6 +2,7 @@
"domain": "folder_watcher", "domain": "folder_watcher",
"name": "Folder Watcher", "name": "Folder Watcher",
"codeowners": [], "codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/folder_watcher", "documentation": "https://www.home-assistant.io/integrations/folder_watcher",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["watchdog"], "loggers": ["watchdog"],

View File

@ -0,0 +1,46 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"not_dir": "Configured path is not a directory",
"not_readable_dir": "Configured path is not readable",
"not_allowed_dir": "Configured path is not in allowlist"
},
"step": {
"user": {
"data": {
"folder": "Path to the watched folder",
"patterns": "Pattern(s) to monitor"
},
"data_description": {
"folder": "Path needs to be from root, as example `/config`",
"patterns": "Example: `*.yaml` to only see yaml files"
}
}
}
},
"options": {
"step": {
"init": {
"data": {
"patterns": "[%key:component::folder_watcher::config::step::user::data::patterns%]"
},
"data_description": {
"patterns": "[%key:component::folder_watcher::config::step::user::data_description::patterns%]"
}
}
}
},
"issues": {
"import_failed_not_allowed_path": {
"title": "The Folder Watcher YAML configuration could not be imported",
"description": "Configuring Folder Watcher using YAML is being removed but your configuration could not be imported as the folder {path} is not in the configured allowlist.\n\nPlease add it to `{config_variable}` in config.yaml and restart Home Assistant to import it and fix this issue."
},
"setup_not_allowed_path": {
"title": "The Folder Watcher configuration for {path} could not start",
"description": "The path {path} is not accessible or not allowed to be accessed.\n\nPlease check the path is accessible and add it to `{config_variable}` in config.yaml and restart Home Assistant to fix this issue."
}
}
}

View File

@ -175,6 +175,7 @@ FLOWS = {
"flo", "flo",
"flume", "flume",
"flux_led", "flux_led",
"folder_watcher",
"forecast_solar", "forecast_solar",
"forked_daapd", "forked_daapd",
"foscam", "foscam",

View File

@ -1956,7 +1956,7 @@
"folder_watcher": { "folder_watcher": {
"name": "Folder Watcher", "name": "Folder Watcher",
"integration_type": "hub", "integration_type": "hub",
"config_flow": false, "config_flow": true,
"iot_class": "local_polling" "iot_class": "local_polling"
}, },
"foobot": { "foobot": {

View File

@ -0,0 +1,17 @@
"""Fixtures for Folder Watcher integration tests."""
from __future__ import annotations
from collections.abc import Generator
from unittest.mock import patch
import pytest
@pytest.fixture
def mock_setup_entry() -> Generator[None, None, None]:
"""Mock setting up a config entry."""
with patch(
"homeassistant.components.folder_watcher.async_setup_entry", return_value=True
):
yield

View File

@ -0,0 +1,186 @@
"""Test the Folder Watcher config flow."""
from pathlib import Path
from unittest.mock import patch
import pytest
from homeassistant import config_entries
from homeassistant.components.folder_watcher.const import (
CONF_FOLDER,
CONF_PATTERNS,
DOMAIN,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
async def test_form(hass: HomeAssistant, tmp_path: Path) -> None:
"""Test we get the form."""
path = tmp_path.as_posix()
hass.config.allowlist_external_dirs = {path}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_FOLDER: path},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == f"Folder Watcher {path}"
assert result["options"] == {CONF_FOLDER: path, CONF_PATTERNS: ["*"]}
async def test_form_not_allowed_path(hass: HomeAssistant, tmp_path: Path) -> None:
"""Test we handle not allowed path."""
path = tmp_path.as_posix()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_FOLDER: path},
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": "not_allowed_dir"}
hass.config.allowlist_external_dirs = {tmp_path}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_FOLDER: path},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == f"Folder Watcher {path}"
assert result["options"] == {CONF_FOLDER: path, CONF_PATTERNS: ["*"]}
async def test_form_not_directory(hass: HomeAssistant, tmp_path: Path) -> None:
"""Test we handle not a directory."""
path = tmp_path.as_posix()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_FOLDER: "not_a_directory"},
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": "not_dir"}
hass.config.allowlist_external_dirs = {path}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_FOLDER: path},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == f"Folder Watcher {path}"
assert result["options"] == {CONF_FOLDER: path, CONF_PATTERNS: ["*"]}
async def test_form_not_readable_dir(hass: HomeAssistant, tmp_path: Path) -> None:
"""Test we handle not able to read directory."""
path = tmp_path.as_posix()
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch("os.access", return_value=False):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_FOLDER: path},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": "not_readable_dir"}
hass.config.allowlist_external_dirs = {path}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_FOLDER: path},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == f"Folder Watcher {path}"
assert result["options"] == {CONF_FOLDER: path, CONF_PATTERNS: ["*"]}
async def test_form_already_configured(hass: HomeAssistant, tmp_path: Path) -> None:
"""Test we abort when entry is already configured."""
path = tmp_path.as_posix()
hass.config.allowlist_external_dirs = {path}
entry = MockConfigEntry(
domain=DOMAIN,
title=f"Folder Watcher {path}",
data={CONF_FOLDER: path},
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_FOLDER: path},
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_import(hass: HomeAssistant, tmp_path: Path) -> None:
"""Test import flow."""
path = tmp_path.as_posix()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={CONF_FOLDER: path, CONF_PATTERNS: ["*"]},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == f"Folder Watcher {path}"
assert result["options"] == {CONF_FOLDER: path, CONF_PATTERNS: ["*"]}
async def test_import_already_configured(hass: HomeAssistant, tmp_path: Path) -> None:
"""Test we abort import when entry is already configured."""
path = tmp_path.as_posix()
entry = MockConfigEntry(
domain=DOMAIN,
title=f"Folder Watcher {path}",
data={CONF_FOLDER: path},
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={CONF_FOLDER: path},
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"