diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index 3d845066251..7aa037ac047 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -9,6 +9,7 @@ "conversation", "dhcp", "energy", + "file", "go2rtc", "history", "homeassistant_alerts", diff --git a/homeassistant/components/file/__init__.py b/homeassistant/components/file/__init__.py index 59a08715b8e..8f49fb09775 100644 --- a/homeassistant/components/file/__init__.py +++ b/homeassistant/components/file/__init__.py @@ -7,11 +7,22 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_FILE_PATH, CONF_NAME, CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN +from .services import async_register_services PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the file component.""" + async_register_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a file component entry.""" diff --git a/homeassistant/components/file/const.py b/homeassistant/components/file/const.py index 0fa9f8a421b..2504610bf5a 100644 --- a/homeassistant/components/file/const.py +++ b/homeassistant/components/file/const.py @@ -6,3 +6,7 @@ CONF_TIMESTAMP = "timestamp" DEFAULT_NAME = "File" FILE_ICON = "mdi:file" + +SERVICE_READ_FILE = "read_file" +ATTR_FILE_NAME = "file_name" +ATTR_FILE_ENCODING = "file_encoding" diff --git a/homeassistant/components/file/icons.json b/homeassistant/components/file/icons.json new file mode 100644 index 00000000000..826048974cc --- /dev/null +++ b/homeassistant/components/file/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "read_file": { + "service": "mdi:file" + } + } +} diff --git a/homeassistant/components/file/services.py b/homeassistant/components/file/services.py new file mode 100644 index 00000000000..3db7bb2c922 --- /dev/null +++ b/homeassistant/components/file/services.py @@ -0,0 +1,88 @@ +"""File Service calls.""" + +from collections.abc import Callable +import json + +import voluptuous as vol +import yaml + +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv + +from .const import ATTR_FILE_ENCODING, ATTR_FILE_NAME, DOMAIN, SERVICE_READ_FILE + + +def async_register_services(hass: HomeAssistant) -> None: + """Register services for File integration.""" + + if not hass.services.has_service(DOMAIN, SERVICE_READ_FILE): + hass.services.async_register( + DOMAIN, + SERVICE_READ_FILE, + read_file, + schema=vol.Schema( + { + vol.Required(ATTR_FILE_NAME): cv.string, + vol.Required(ATTR_FILE_ENCODING): cv.string, + } + ), + supports_response=SupportsResponse.ONLY, + ) + + +ENCODING_LOADERS: dict[str, tuple[Callable, type[Exception]]] = { + "json": (json.loads, json.JSONDecodeError), + "yaml": (yaml.safe_load, yaml.YAMLError), +} + + +def read_file(call: ServiceCall) -> dict: + """Handle read_file service call.""" + file_name = call.data[ATTR_FILE_NAME] + file_encoding = call.data[ATTR_FILE_ENCODING].lower() + + if not call.hass.config.is_allowed_path(file_name): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_access_to_path", + translation_placeholders={"filename": file_name}, + ) + + if file_encoding not in ENCODING_LOADERS: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unsupported_file_encoding", + translation_placeholders={ + "filename": file_name, + "encoding": file_encoding, + }, + ) + + try: + with open(file_name, encoding="utf-8") as file: + file_content = file.read() + except FileNotFoundError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="file_not_found", + translation_placeholders={"filename": file_name}, + ) from err + except OSError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="file_read_error", + translation_placeholders={"filename": file_name}, + ) from err + + loader, error_type = ENCODING_LOADERS[file_encoding] + try: + data = loader(file_content) + except error_type as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="file_decoding", + translation_placeholders={"filename": file_name, "encoding": file_encoding}, + ) from err + + return {"data": data} diff --git a/homeassistant/components/file/services.yaml b/homeassistant/components/file/services.yaml new file mode 100644 index 00000000000..18dafe88205 --- /dev/null +++ b/homeassistant/components/file/services.yaml @@ -0,0 +1,14 @@ +# Describes the format for available file services +read_file: + fields: + file_name: + example: "www/my_file.json" + selector: + text: + file_encoding: + example: "JSON" + selector: + select: + options: + - "JSON" + - "YAML" diff --git a/homeassistant/components/file/strings.json b/homeassistant/components/file/strings.json index 02f8c42755b..66666b3dd7d 100644 --- a/homeassistant/components/file/strings.json +++ b/homeassistant/components/file/strings.json @@ -64,6 +64,37 @@ }, "write_access_failed": { "message": "Write access to {filename} failed: {exc}." + }, + "no_access_to_path": { + "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" + }, + "unsupported_file_encoding": { + "message": "Cannot read {filename}, unsupported file encoding {encoding}." + }, + "file_decoding": { + "message": "Cannot read file {filename} as {encoding}." + }, + "file_not_found": { + "message": "File {filename} not found." + }, + "file_read_error": { + "message": "Error reading {filename}." + } + }, + "services": { + "read_file": { + "name": "Read file", + "description": "Reads a file and returns the contents.", + "fields": { + "file_name": { + "name": "File name", + "description": "Name of the file to read." + }, + "file_encoding": { + "name": "File encoding", + "description": "Encoding of the file (JSON, YAML.)" + } + } } } } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index facfb507fb5..227b9e3b918 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -31,6 +31,7 @@ ciso8601==2.3.3 cronsim==2.6 cryptography==45.0.7 dbus-fast==2.44.3 +file-read-backwards==2.0.0 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 diff --git a/tests/components/file/conftest.py b/tests/components/file/conftest.py index 5345a0d38d0..2e167310111 100644 --- a/tests/components/file/conftest.py +++ b/tests/components/file/conftest.py @@ -5,7 +5,9 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from homeassistant.components.file import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component @pytest.fixture @@ -30,3 +32,14 @@ def mock_is_allowed_path(hass: HomeAssistant, is_allowed: bool) -> Generator[Mag hass.config, "is_allowed_path", return_value=is_allowed ) as allowed_path_mock: yield allowed_path_mock + + +@pytest.fixture +async def setup_ha_file_integration(hass: HomeAssistant): + """Set up Home Assistant and load File integration.""" + await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {}}, + ) + await hass.async_block_till_done() diff --git a/tests/components/file/fixtures/file_read.json b/tests/components/file/fixtures/file_read.json new file mode 100644 index 00000000000..5f745331620 --- /dev/null +++ b/tests/components/file/fixtures/file_read.json @@ -0,0 +1 @@ +{ "key": "value", "key1": "value1" } diff --git a/tests/components/file/fixtures/file_read.not_json b/tests/components/file/fixtures/file_read.not_json new file mode 100644 index 00000000000..07967a9afa2 --- /dev/null +++ b/tests/components/file/fixtures/file_read.not_json @@ -0,0 +1 @@ +{ "key": "value", "key1": value1 } diff --git a/tests/components/file/fixtures/file_read.not_yaml b/tests/components/file/fixtures/file_read.not_yaml new file mode 100644 index 00000000000..a7e5ad397dc --- /dev/null +++ b/tests/components/file/fixtures/file_read.not_yaml @@ -0,0 +1,4 @@ +test: + - element: "X" + - element: "Y" + unexpected: "Z" diff --git a/tests/components/file/fixtures/file_read.yaml b/tests/components/file/fixtures/file_read.yaml new file mode 100644 index 00000000000..cb2a2c9b1f9 --- /dev/null +++ b/tests/components/file/fixtures/file_read.yaml @@ -0,0 +1,5 @@ +mylist: + - name: list_item_1 + id: 1 + - name: list_item_2 + id: 2 diff --git a/tests/components/file/fixtures/file_read_list.yaml b/tests/components/file/fixtures/file_read_list.yaml new file mode 100644 index 00000000000..3e4271b3941 --- /dev/null +++ b/tests/components/file/fixtures/file_read_list.yaml @@ -0,0 +1,4 @@ +- name: list_item_1 + id: 1 +- name: list_item_2 + id: 2 diff --git a/tests/components/file/snapshots/test_services.ambr b/tests/components/file/snapshots/test_services.ambr new file mode 100644 index 00000000000..daa7c3990fa --- /dev/null +++ b/tests/components/file/snapshots/test_services.ambr @@ -0,0 +1,39 @@ +# serializer version: 1 +# name: test_read_file[tests/components/file/fixtures/file_read.json-json] + dict({ + 'data': dict({ + 'key': 'value', + 'key1': 'value1', + }), + }) +# --- +# name: test_read_file[tests/components/file/fixtures/file_read.yaml-yaml] + dict({ + 'data': dict({ + 'mylist': list([ + dict({ + 'id': 1, + 'name': 'list_item_1', + }), + dict({ + 'id': 2, + 'name': 'list_item_2', + }), + ]), + }), + }) +# --- +# name: test_read_file[tests/components/file/fixtures/file_read_list.yaml-yaml] + dict({ + 'data': list([ + dict({ + 'id': 1, + 'name': 'list_item_1', + }), + dict({ + 'id': 2, + 'name': 'list_item_2', + }), + ]), + }) +# --- diff --git a/tests/components/file/test_services.py b/tests/components/file/test_services.py new file mode 100644 index 00000000000..9b7198b9967 --- /dev/null +++ b/tests/components/file/test_services.py @@ -0,0 +1,147 @@ +"""The tests for the notify file platform.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.file import DOMAIN +from homeassistant.components.file.services import ( + ATTR_FILE_ENCODING, + ATTR_FILE_NAME, + SERVICE_READ_FILE, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + + +@pytest.mark.parametrize( + ("file_name", "file_encoding"), + [ + ("tests/components/file/fixtures/file_read.json", "json"), + ("tests/components/file/fixtures/file_read.yaml", "yaml"), + ("tests/components/file/fixtures/file_read_list.yaml", "yaml"), + ], +) +async def test_read_file( + hass: HomeAssistant, + mock_is_allowed_path: MagicMock, + setup_ha_file_integration, + file_name: str, + file_encoding: str, + snapshot: SnapshotAssertion, +) -> None: + """Test reading files in supported formats.""" + result = await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: file_encoding, + }, + blocking=True, + return_response=True, + ) + assert result == snapshot + + +async def test_read_file_disallowed_path( + hass: HomeAssistant, + setup_ha_file_integration, +) -> None: + """Test reading in a disallowed path generates error.""" + file_name = "tests/components/file/fixtures/file_read.json" + + with pytest.raises(ServiceValidationError) as sve: + await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: "json", + }, + blocking=True, + return_response=True, + ) + assert file_name in str(sve.value) + assert sve.value.translation_key == "no_access_to_path" + assert sve.value.translation_domain == DOMAIN + + +async def test_read_file_bad_encoding_option( + hass: HomeAssistant, + mock_is_allowed_path: MagicMock, + setup_ha_file_integration, +) -> None: + """Test handling error if an invalid encoding is specified.""" + file_name = "tests/components/file/fixtures/file_read.json" + + with pytest.raises(ServiceValidationError) as sve: + await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: "invalid", + }, + blocking=True, + return_response=True, + ) + assert file_name in str(sve.value) + assert "invalid" in str(sve.value) + assert sve.value.translation_key == "unsupported_file_encoding" + assert sve.value.translation_domain == DOMAIN + + +@pytest.mark.parametrize( + ("file_name", "file_encoding"), + [ + ("tests/components/file/fixtures/file_read.not_json", "json"), + ("tests/components/file/fixtures/file_read.not_yaml", "yaml"), + ], +) +async def test_read_file_decoding_error( + hass: HomeAssistant, + mock_is_allowed_path: MagicMock, + setup_ha_file_integration, + file_name: str, + file_encoding: str, +) -> None: + """Test decoding errors are handled correctly.""" + with pytest.raises(HomeAssistantError) as hae: + await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: file_encoding, + }, + blocking=True, + return_response=True, + ) + assert file_name in str(hae.value) + assert file_encoding in str(hae.value) + assert hae.value.translation_key == "file_decoding" + assert hae.value.translation_domain == DOMAIN + + +async def test_read_file_dne( + hass: HomeAssistant, + mock_is_allowed_path: MagicMock, + setup_ha_file_integration, +) -> None: + """Test handling error if file does not exist.""" + file_name = "tests/components/file/fixtures/file_dne.yaml" + + with pytest.raises(HomeAssistantError) as hae: + _ = await hass.services.async_call( + DOMAIN, + SERVICE_READ_FILE, + { + ATTR_FILE_NAME: file_name, + ATTR_FILE_ENCODING: "yaml", + }, + blocking=True, + return_response=True, + ) + assert file_name in str(hae.value)