diff --git a/homeassistant/components/folder_watcher/__init__.py b/homeassistant/components/folder_watcher/__init__.py index d111fe03c5c..3f0b9e8f6da 100644 --- a/homeassistant/components/folder_watcher/__init__.py +++ b/homeassistant/components/folder_watcher/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging import os -from typing import cast +from typing import Any, cast import voluptuous as vol from watchdog.events import ( @@ -19,17 +19,17 @@ from watchdog.events import ( ) 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.core import Event, HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType +from .const import CONF_FOLDER, CONF_PATTERNS, DEFAULT_PATTERN, DOMAIN + _LOGGER = logging.getLogger(__name__) -CONF_FOLDER = "folder" -CONF_PATTERNS = "patterns" -DEFAULT_PATTERN = "*" -DOMAIN = "folder_watcher" 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.""" - conf = config[DOMAIN] - for watcher in conf: - path: str = watcher[CONF_FOLDER] - patterns: list[str] = watcher[CONF_PATTERNS] - if not hass.config.is_allowed_path(path): - _LOGGER.error("Folder %s is not valid or allowed", path) - return False - Watcher(path, patterns, hass) + if DOMAIN in config: + conf: list[dict[str, Any]] = config[DOMAIN] + for watcher in conf: + path: str = watcher[CONF_FOLDER] + if not hass.config.is_allowed_path(path): + async_create_issue( + 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 +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: """Return the Watchdog EventHandler object.""" diff --git a/homeassistant/components/folder_watcher/config_flow.py b/homeassistant/components/folder_watcher/config_flow.py new file mode 100644 index 00000000000..50d198df3c3 --- /dev/null +++ b/homeassistant/components/folder_watcher/config_flow.py @@ -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) diff --git a/homeassistant/components/folder_watcher/const.py b/homeassistant/components/folder_watcher/const.py new file mode 100644 index 00000000000..22dae3b9164 --- /dev/null +++ b/homeassistant/components/folder_watcher/const.py @@ -0,0 +1,6 @@ +"""Constants for Folder watcher.""" + +CONF_FOLDER = "folder" +CONF_PATTERNS = "patterns" +DEFAULT_PATTERN = "*" +DOMAIN = "folder_watcher" diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json index 96decd0b8cf..7b471e08fcc 100644 --- a/homeassistant/components/folder_watcher/manifest.json +++ b/homeassistant/components/folder_watcher/manifest.json @@ -2,6 +2,7 @@ "domain": "folder_watcher", "name": "Folder Watcher", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/folder_watcher", "iot_class": "local_polling", "loggers": ["watchdog"], diff --git a/homeassistant/components/folder_watcher/strings.json b/homeassistant/components/folder_watcher/strings.json new file mode 100644 index 00000000000..bd1742b8ce3 --- /dev/null +++ b/homeassistant/components/folder_watcher/strings.json @@ -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." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e5d5f37ad5a..6f6ce237904 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -175,6 +175,7 @@ FLOWS = { "flo", "flume", "flux_led", + "folder_watcher", "forecast_solar", "forked_daapd", "foscam", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 0ee796d5376..e6a103989d1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1956,7 +1956,7 @@ "folder_watcher": { "name": "Folder Watcher", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "foobot": { diff --git a/tests/components/folder_watcher/conftest.py b/tests/components/folder_watcher/conftest.py new file mode 100644 index 00000000000..06c0a41d49c --- /dev/null +++ b/tests/components/folder_watcher/conftest.py @@ -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 diff --git a/tests/components/folder_watcher/test_config_flow.py b/tests/components/folder_watcher/test_config_flow.py new file mode 100644 index 00000000000..745059717fb --- /dev/null +++ b/tests/components/folder_watcher/test_config_flow.py @@ -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"