1
0
mirror of https://github.com/home-assistant/core.git synced 2026-03-03 16:20:40 +00:00

File add read_file action with Response (#139216)

This commit is contained in:
Pete Sage
2025-09-23 16:25:56 -04:00
committed by GitHub
parent 3bc2ea7b5f
commit 60bf298ca6
16 changed files with 371 additions and 0 deletions

View File

@@ -9,6 +9,7 @@
"conversation",
"dhcp",
"energy",
"file",
"go2rtc",
"history",
"homeassistant_alerts",

View File

@@ -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."""

View File

@@ -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"

View File

@@ -0,0 +1,7 @@
{
"services": {
"read_file": {
"service": "mdi:file"
}
}
}

View File

@@ -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}

View File

@@ -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"

View File

@@ -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.)"
}
}
}
}
}

View File

@@ -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

View File

@@ -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()

View File

@@ -0,0 +1 @@
{ "key": "value", "key1": "value1" }

View File

@@ -0,0 +1 @@
{ "key": "value", "key1": value1 }

View File

@@ -0,0 +1,4 @@
test:
- element: "X"
- element: "Y"
unexpected: "Z"

View File

@@ -0,0 +1,5 @@
mylist:
- name: list_item_1
id: 1
- name: list_item_2
id: 2

View File

@@ -0,0 +1,4 @@
- name: list_item_1
id: 1
- name: list_item_2
id: 2

View File

@@ -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',
}),
]),
})
# ---

View File

@@ -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)