mirror of
https://github.com/home-assistant/core.git
synced 2026-03-03 08:10:36 +00:00
File add read_file action with Response (#139216)
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
"conversation",
|
||||
"dhcp",
|
||||
"energy",
|
||||
"file",
|
||||
"go2rtc",
|
||||
"history",
|
||||
"homeassistant_alerts",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
7
homeassistant/components/file/icons.json
Normal file
7
homeassistant/components/file/icons.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"services": {
|
||||
"read_file": {
|
||||
"service": "mdi:file"
|
||||
}
|
||||
}
|
||||
}
|
||||
88
homeassistant/components/file/services.py
Normal file
88
homeassistant/components/file/services.py
Normal 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}
|
||||
14
homeassistant/components/file/services.yaml
Normal file
14
homeassistant/components/file/services.yaml
Normal 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"
|
||||
@@ -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.)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
1
tests/components/file/fixtures/file_read.json
Normal file
1
tests/components/file/fixtures/file_read.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "key": "value", "key1": "value1" }
|
||||
1
tests/components/file/fixtures/file_read.not_json
Normal file
1
tests/components/file/fixtures/file_read.not_json
Normal file
@@ -0,0 +1 @@
|
||||
{ "key": "value", "key1": value1 }
|
||||
4
tests/components/file/fixtures/file_read.not_yaml
Normal file
4
tests/components/file/fixtures/file_read.not_yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
test:
|
||||
- element: "X"
|
||||
- element: "Y"
|
||||
unexpected: "Z"
|
||||
5
tests/components/file/fixtures/file_read.yaml
Normal file
5
tests/components/file/fixtures/file_read.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
mylist:
|
||||
- name: list_item_1
|
||||
id: 1
|
||||
- name: list_item_2
|
||||
id: 2
|
||||
4
tests/components/file/fixtures/file_read_list.yaml
Normal file
4
tests/components/file/fixtures/file_read_list.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
- name: list_item_1
|
||||
id: 1
|
||||
- name: list_item_2
|
||||
id: 2
|
||||
39
tests/components/file/snapshots/test_services.ambr
Normal file
39
tests/components/file/snapshots/test_services.ambr
Normal 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',
|
||||
}),
|
||||
]),
|
||||
})
|
||||
# ---
|
||||
147
tests/components/file/test_services.py
Normal file
147
tests/components/file/test_services.py
Normal 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)
|
||||
Reference in New Issue
Block a user