mirror of
https://github.com/home-assistant/core.git
synced 2025-07-31 19:25:12 +02:00
Add file upload service to OneDrive (#139092)
* Add file upload service to OneDrive * fix * Add test * docstring * docstring * Fix capitalization in description text.
This commit is contained in:
@@ -19,12 +19,14 @@ from onedrive_personal_sdk.models.items import Item, ItemUpdate
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_FOLDER_ID, CONF_FOLDER_NAME, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .coordinator import (
|
||||
@@ -32,13 +34,20 @@ from .coordinator import (
|
||||
OneDriveRuntimeData,
|
||||
OneDriveUpdateCoordinator,
|
||||
)
|
||||
from .services import async_register_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the OneDrive integration."""
|
||||
async_register_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool:
|
||||
"""Set up OneDrive from a config entry."""
|
||||
client, get_access_token = await _get_onedrive_client(hass, entry)
|
||||
|
@@ -20,5 +20,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"upload": {
|
||||
"service": "mdi:cloud-upload"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,18 +1,13 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
action-setup: done
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have any custom actions.
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
|
131
homeassistant/components/onedrive/services.py
Normal file
131
homeassistant/components/onedrive/services.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""OneDrive services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from onedrive_personal_sdk.exceptions import OneDriveException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_FILENAME
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import OneDriveConfigEntry
|
||||
|
||||
CONF_CONFIG_ENTRY_ID = "config_entry_id"
|
||||
CONF_DESTINATION_FOLDER = "destination_folder"
|
||||
|
||||
UPLOAD_SERVICE = "upload"
|
||||
UPLOAD_SERVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_CONFIG_ENTRY_ID): cv.string,
|
||||
vol.Required(CONF_FILENAME): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Required(CONF_DESTINATION_FOLDER): cv.string,
|
||||
}
|
||||
)
|
||||
CONTENT_SIZE_LIMIT = 250 * 1024 * 1024
|
||||
|
||||
|
||||
def _read_file_contents(
|
||||
hass: HomeAssistant, filenames: list[str]
|
||||
) -> list[tuple[str, bytes]]:
|
||||
"""Return the mime types and file contents for each file."""
|
||||
results = []
|
||||
for filename in filenames:
|
||||
if not hass.config.is_allowed_path(filename):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_access_to_path",
|
||||
translation_placeholders={"filename": filename},
|
||||
)
|
||||
filename_path = Path(filename)
|
||||
if not filename_path.exists():
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="filename_does_not_exist",
|
||||
translation_placeholders={"filename": filename},
|
||||
)
|
||||
if filename_path.stat().st_size > CONTENT_SIZE_LIMIT:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="file_too_large",
|
||||
translation_placeholders={
|
||||
"filename": filename,
|
||||
"size": str(filename_path.stat().st_size),
|
||||
"limit": str(CONTENT_SIZE_LIMIT),
|
||||
},
|
||||
)
|
||||
results.append((filename_path.name, filename_path.read_bytes()))
|
||||
return results
|
||||
|
||||
|
||||
def async_register_services(hass: HomeAssistant) -> None:
|
||||
"""Register OneDrive services."""
|
||||
|
||||
async def async_handle_upload(call: ServiceCall) -> ServiceResponse:
|
||||
"""Generate content from text and optionally images."""
|
||||
config_entry: OneDriveConfigEntry | None = hass.config_entries.async_get_entry(
|
||||
call.data[CONF_CONFIG_ENTRY_ID]
|
||||
)
|
||||
if not config_entry:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="integration_not_found",
|
||||
translation_placeholders={"target": DOMAIN},
|
||||
)
|
||||
client = config_entry.runtime_data.client
|
||||
upload_tasks = []
|
||||
file_results = await hass.async_add_executor_job(
|
||||
_read_file_contents, hass, call.data[CONF_FILENAME]
|
||||
)
|
||||
|
||||
# make sure the destination folder exists
|
||||
try:
|
||||
folder_id = (await client.get_approot()).id
|
||||
for folder in (
|
||||
cast(str, call.data[CONF_DESTINATION_FOLDER]).strip("/").split("/")
|
||||
):
|
||||
folder_id = (await client.create_folder(folder_id, folder)).id
|
||||
except OneDriveException as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="create_folder_error",
|
||||
translation_placeholders={"message": str(err)},
|
||||
) from err
|
||||
|
||||
upload_tasks = [
|
||||
client.upload_file(folder_id, file_name, content)
|
||||
for file_name, content in file_results
|
||||
]
|
||||
try:
|
||||
upload_results = await asyncio.gather(*upload_tasks)
|
||||
except OneDriveException as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="upload_error",
|
||||
translation_placeholders={"message": str(err)},
|
||||
) from err
|
||||
|
||||
if call.return_response:
|
||||
return {"files": [asdict(item_result) for item_result in upload_results]}
|
||||
return None
|
||||
|
||||
if not hass.services.has_service(DOMAIN, UPLOAD_SERVICE):
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
UPLOAD_SERVICE,
|
||||
async_handle_upload,
|
||||
schema=UPLOAD_SERVICE_SCHEMA,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
15
homeassistant/components/onedrive/services.yaml
Normal file
15
homeassistant/components/onedrive/services.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
upload:
|
||||
fields:
|
||||
config_entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: onedrive
|
||||
filename:
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
destination_folder:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
@@ -90,6 +90,24 @@
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "Failed to update drive state"
|
||||
},
|
||||
"integration_not_found": {
|
||||
"message": "Integration \"{target}\" not found in registry."
|
||||
},
|
||||
"no_access_to_path": {
|
||||
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
|
||||
},
|
||||
"filename_does_not_exist": {
|
||||
"message": "`{filename}` does not exist"
|
||||
},
|
||||
"file_too_large": {
|
||||
"message": "`{filename}` is too large ({size} > {limit})"
|
||||
},
|
||||
"upload_error": {
|
||||
"message": "Failed to upload content: {message}"
|
||||
},
|
||||
"create_folder_error": {
|
||||
"message": "Failed to create folder: {message}"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
@@ -113,5 +131,27 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"upload": {
|
||||
"name": "Upload file",
|
||||
"description": "Uploads files to OneDrive.",
|
||||
"fields": {
|
||||
"config_entry_id": {
|
||||
"name": "Config entry ID",
|
||||
"description": "The config entry representing the OneDrive you want to upload to."
|
||||
},
|
||||
"filename": {
|
||||
"name": "Filename",
|
||||
"description": "Path to the file to upload.",
|
||||
"example": "/config/www/image.jpg"
|
||||
},
|
||||
"destination_folder": {
|
||||
"name": "Destination folder",
|
||||
"description": "Folder inside the Home Assistant app folder (Apps/Home Assistant) you want to upload the file to. Will be created if it does not exist.",
|
||||
"example": "photos/snapshots"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
280
tests/components/onedrive/test_services.py
Normal file
280
tests/components/onedrive/test_services.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""Tests for OneDrive services."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
from onedrive_personal_sdk.exceptions import OneDriveException
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.onedrive.const import DOMAIN
|
||||
from homeassistant.components.onedrive.services import (
|
||||
CONF_CONFIG_ENTRY_ID,
|
||||
CONF_DESTINATION_FOLDER,
|
||||
UPLOAD_SERVICE,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_FILENAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
TEST_FILENAME = "doorbell_snapshot.jpg"
|
||||
DESINATION_FOLDER = "TestFolder"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockUploadFile:
|
||||
"""Dataclass used to configure the test with a fake file behavior."""
|
||||
|
||||
content: bytes = b"image bytes"
|
||||
exists: bool = True
|
||||
is_allowed_path: bool = True
|
||||
size: int | None = None
|
||||
|
||||
|
||||
@pytest.fixture(name="upload_file")
|
||||
def upload_file_fixture() -> MockUploadFile:
|
||||
"""Fixture to set up test configuration with a fake file."""
|
||||
return MockUploadFile()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_upload_file(
|
||||
hass: HomeAssistant, upload_file: MockUploadFile
|
||||
) -> Generator[None]:
|
||||
"""Fixture that mocks out the file calls using the FakeFile fixture."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.onedrive.services.Path.read_bytes",
|
||||
return_value=upload_file.content,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.onedrive.services.Path.exists",
|
||||
return_value=upload_file.exists,
|
||||
),
|
||||
patch.object(
|
||||
hass.config, "is_allowed_path", return_value=upload_file.is_allowed_path
|
||||
),
|
||||
patch("pathlib.Path.stat") as mock_stat,
|
||||
):
|
||||
mock_stat.return_value = Mock()
|
||||
mock_stat.return_value.st_size = (
|
||||
upload_file.size if upload_file.size else len(upload_file.content)
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
async def test_upload_service(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test service call to upload content."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert hass.services.has_service(DOMAIN, "upload")
|
||||
|
||||
response = await hass.services.async_call(
|
||||
DOMAIN,
|
||||
UPLOAD_SERVICE,
|
||||
{
|
||||
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
|
||||
CONF_FILENAME: TEST_FILENAME,
|
||||
CONF_DESTINATION_FOLDER: DESINATION_FOLDER,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert response
|
||||
assert response["files"]
|
||||
assert cast(list[dict[str, Any]], response["files"])[0]["id"] == "id"
|
||||
|
||||
|
||||
async def test_upload_service_no_response(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test service call to upload content without response."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert hass.services.has_service(DOMAIN, "upload")
|
||||
|
||||
response = await hass.services.async_call(
|
||||
DOMAIN,
|
||||
UPLOAD_SERVICE,
|
||||
{
|
||||
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
|
||||
CONF_FILENAME: TEST_FILENAME,
|
||||
CONF_DESTINATION_FOLDER: DESINATION_FOLDER,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert response is None
|
||||
|
||||
|
||||
async def test_upload_service_config_entry_not_found(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test upload service call with a config entry that does not exist."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
with pytest.raises(HomeAssistantError, match="not found in registry"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
UPLOAD_SERVICE,
|
||||
{
|
||||
CONF_CONFIG_ENTRY_ID: "invalid-config-entry-id",
|
||||
CONF_FILENAME: TEST_FILENAME,
|
||||
CONF_DESTINATION_FOLDER: DESINATION_FOLDER,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_config_entry_not_loaded(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test upload service call with a config entry that is not loaded."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="not found in registry"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
UPLOAD_SERVICE,
|
||||
{
|
||||
CONF_CONFIG_ENTRY_ID: mock_config_entry.unique_id,
|
||||
CONF_FILENAME: TEST_FILENAME,
|
||||
CONF_DESTINATION_FOLDER: DESINATION_FOLDER,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("upload_file", [MockUploadFile(is_allowed_path=False)])
|
||||
async def test_path_is_not_allowed(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test upload service call with a filename path that is not allowed."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
with (
|
||||
pytest.raises(HomeAssistantError, match="no access to path"),
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
UPLOAD_SERVICE,
|
||||
{
|
||||
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
|
||||
CONF_FILENAME: TEST_FILENAME,
|
||||
CONF_DESTINATION_FOLDER: DESINATION_FOLDER,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("upload_file", [MockUploadFile(exists=False)])
|
||||
async def test_filename_does_not_exist(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test upload service call with a filename path that does not exist."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
with pytest.raises(HomeAssistantError, match="does not exist"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
UPLOAD_SERVICE,
|
||||
{
|
||||
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
|
||||
CONF_FILENAME: TEST_FILENAME,
|
||||
CONF_DESTINATION_FOLDER: DESINATION_FOLDER,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_upload_service_fails_upload(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_onedrive_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test service call to upload content."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
mock_onedrive_client.upload_file.side_effect = OneDriveException("error")
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="Failed to upload"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
UPLOAD_SERVICE,
|
||||
{
|
||||
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
|
||||
CONF_FILENAME: TEST_FILENAME,
|
||||
CONF_DESTINATION_FOLDER: DESINATION_FOLDER,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("upload_file", [MockUploadFile(size=260 * 1024 * 1024)])
|
||||
async def test_upload_size_limit(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test upload service call with a filename path that does not exist."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match=re.escape(f"`{TEST_FILENAME}` is too large (272629760 > 262144000)"),
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
UPLOAD_SERVICE,
|
||||
{
|
||||
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
|
||||
CONF_FILENAME: TEST_FILENAME,
|
||||
CONF_DESTINATION_FOLDER: DESINATION_FOLDER,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_create_album_failed(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_onedrive_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test service call when folder creation fails."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
assert hass.services.has_service(DOMAIN, "upload")
|
||||
|
||||
mock_onedrive_client.create_folder.side_effect = OneDriveException()
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="Failed to create folder"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
UPLOAD_SERVICE,
|
||||
{
|
||||
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
|
||||
CONF_FILENAME: TEST_FILENAME,
|
||||
CONF_DESTINATION_FOLDER: DESINATION_FOLDER,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
Reference in New Issue
Block a user