1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-08 17:49:37 +01:00

Add delete service action to OneDrive integration (#168064)

Co-authored-by: Josef Zweck <josef@zweck.dev>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Leonardo Rivera
2026-05-04 15:17:41 -03:00
committed by GitHub
parent 5858db1cda
commit 2846dcc035
5 changed files with 431 additions and 15 deletions
@@ -22,6 +22,9 @@
}
},
"services": {
"delete": {
"service": "mdi:cloud-remove"
},
"upload": {
"service": "mdi:cloud-upload"
}
+88 -2
View File
@@ -2,7 +2,7 @@
import asyncio
from dataclasses import asdict
from pathlib import Path
from pathlib import Path, PurePosixPath
from typing import cast
from onedrive_personal_sdk.exceptions import OneDriveException
@@ -19,11 +19,12 @@ from homeassistant.core import (
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
from .const import CONF_DELETE_PERMANENTLY, DOMAIN
from .coordinator import OneDriveConfigEntry
CONF_CONFIG_ENTRY_ID = "config_entry_id"
CONF_DESTINATION_FOLDER = "destination_folder"
CONF_DESTINATION_PATH = "destination_path"
UPLOAD_SERVICE = "upload"
UPLOAD_SERVICE_SCHEMA = vol.Schema(
@@ -33,6 +34,17 @@ UPLOAD_SERVICE_SCHEMA = vol.Schema(
vol.Required(CONF_DESTINATION_FOLDER): cv.string,
}
)
DELETE_SERVICE = "delete"
DELETE_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(CONF_CONFIG_ENTRY_ID): cv.string,
vol.Required(CONF_DESTINATION_PATH): vol.All(
cv.ensure_list, vol.Length(min=1), [cv.string]
),
}
)
CONTENT_SIZE_LIMIT = 250 * 1024 * 1024
@@ -76,6 +88,29 @@ def _read_file_contents(
return results
def _raise_invalid_destination_path(destination_path: str) -> None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_destination_path",
translation_placeholders={"destination_path": destination_path},
)
def _validate_destination_path(destination_path: str) -> str:
"""Validate and normalize a remote destination path.
Returns the normalized path or raises HomeAssistantError.
"""
normalized = destination_path.strip("/")
if not normalized:
_raise_invalid_destination_path(destination_path)
parts = PurePosixPath(normalized).parts
for part in parts:
if part == ".." or ":" in part:
_raise_invalid_destination_path(destination_path)
return str(PurePosixPath(normalized))
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register OneDrive services."""
@@ -122,6 +157,50 @@ def async_setup_services(hass: HomeAssistant) -> None:
return {"files": [asdict(item_result) for item_result in upload_results]}
return None
async def async_handle_delete(call: ServiceCall) -> None:
"""Delete one or more files from OneDrive."""
config_entry: OneDriveConfigEntry = service.async_get_config_entry(
hass, DOMAIN, call.data[CONF_CONFIG_ENTRY_ID]
)
client = config_entry.runtime_data.client
delete_permanently = config_entry.options.get(CONF_DELETE_PERMANENTLY, False)
file_paths = [
_validate_destination_path(p)
for p in cast(list[str], call.data[CONF_DESTINATION_PATH])
]
try:
approot_id = (await client.get_approot()).id
except OneDriveException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="connection_error",
) from err
results = await asyncio.gather(
*[
client.delete_drive_item(
f"{approot_id}:/{file_path}:", delete_permanently
)
for file_path in file_paths
],
return_exceptions=True,
)
failures: list[tuple[str, OneDriveException]] = []
for file_path, result in zip(file_paths, results, strict=True):
if isinstance(result, OneDriveException):
failures.append((file_path, result))
if failures:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="delete_error",
translation_placeholders={
"paths": ", ".join(f"`{path}`" for path, _ in failures)
},
) from ExceptionGroup(
"OneDrive delete errors", [err for _, err in failures]
)
hass.services.async_register(
DOMAIN,
UPLOAD_SERVICE,
@@ -130,3 +209,10 @@ def async_setup_services(hass: HomeAssistant) -> None:
supports_response=SupportsResponse.OPTIONAL,
description_placeholders={"example_image_path": "/config/www/image.jpg"},
)
hass.services.async_register(
DOMAIN,
DELETE_SERVICE,
async_handle_delete,
schema=DELETE_SERVICE_SCHEMA,
)
@@ -14,3 +14,16 @@ upload:
required: true
selector:
text:
delete:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: onedrive
destination_path:
required: true
selector:
text:
multiple: true
+25 -1
View File
@@ -90,9 +90,15 @@
"authentication_failed": {
"message": "Authentication failed"
},
"connection_error": {
"message": "[%key:component::onedrive::config::abort::connection_error%]"
},
"create_folder_error": {
"message": "Failed to create folder: {message}"
},
"delete_error": {
"message": "Failed to delete from OneDrive: {paths}"
},
"failed_to_get_folder": {
"message": "Failed to get {folder} folder"
},
@@ -105,6 +111,9 @@
"filenames_do_not_exist": {
"message": "The following files do not exist: {filenames}"
},
"invalid_destination_path": {
"message": "Invalid destination path `{destination_path}`: must be non-empty, must not contain `:` or `..` path segments"
},
"no_access_to_path": {
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
},
@@ -142,6 +151,21 @@
}
},
"services": {
"delete": {
"description": "Deletes one or more files from OneDrive.",
"fields": {
"config_entry_id": {
"description": "The config entry representing the OneDrive you want to delete from.",
"name": "Config entry ID"
},
"destination_path": {
"description": "One or more paths to files inside the OneDrive app folder (Apps/Home Assistant) to delete.",
"example": "[\"photos/snapshots/image.jpg\", \"photos/snapshots/image2.jpg\"]",
"name": "Destination paths"
}
},
"name": "Delete files"
},
"upload": {
"description": "Uploads one or more files to OneDrive.",
"fields": {
@@ -150,7 +174,7 @@
"name": "Config entry ID"
},
"destination_folder": {
"description": "Folder inside the Home Assistant app folder (Apps/Home Assistant) you want to upload the files to. Will be created if it does not exist.",
"description": "Folder inside the OneDrive app folder (Apps/Home Assistant) you want to upload the files to. Will be created if it does not exist.",
"example": "photos/snapshots",
"name": "Destination folder"
},
+302 -12
View File
@@ -8,11 +8,14 @@ from unittest.mock import MagicMock, Mock, patch
from onedrive_personal_sdk.exceptions import OneDriveException
import pytest
import voluptuous as vol
from homeassistant.components.onedrive.const import DOMAIN
from homeassistant.components.onedrive.const import CONF_DELETE_PERMANENTLY, DOMAIN
from homeassistant.components.onedrive.services import (
CONF_CONFIG_ENTRY_ID,
CONF_DESTINATION_FOLDER,
CONF_DESTINATION_PATH,
DELETE_SERVICE,
UPLOAD_SERVICE,
)
from homeassistant.config_entries import ConfigEntryState
@@ -25,7 +28,8 @@ from . import setup_integration
from tests.common import MockConfigEntry
TEST_FILENAME = "doorbell_snapshot.jpg"
DESINATION_FOLDER = "TestFolder"
TEST_DESTINATION_PATH = "photos/snapshots/image.jpg"
DESTINATION_FOLDER = "TestFolder"
@dataclass
@@ -83,7 +87,7 @@ async def test_upload_service(
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_DESTINATION_FOLDER: DESINATION_FOLDER,
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
return_response=True,
@@ -109,7 +113,7 @@ async def test_upload_service_no_response(
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_DESTINATION_FOLDER: DESINATION_FOLDER,
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
)
@@ -130,7 +134,7 @@ async def test_upload_service_config_entry_not_found(
{
CONF_CONFIG_ENTRY_ID: "invalid-config-entry-id",
CONF_FILENAME: TEST_FILENAME,
CONF_DESTINATION_FOLDER: DESINATION_FOLDER,
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
return_response=True,
@@ -156,7 +160,7 @@ async def test_config_entry_not_loaded(
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_DESTINATION_FOLDER: DESINATION_FOLDER,
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
return_response=True,
@@ -180,7 +184,7 @@ async def test_path_is_not_allowed(
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_DESTINATION_FOLDER: DESINATION_FOLDER,
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
return_response=True,
@@ -201,7 +205,7 @@ async def test_filename_does_not_exist(
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_DESTINATION_FOLDER: DESINATION_FOLDER,
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
return_response=True,
@@ -225,7 +229,7 @@ async def test_multiple_filenames_do_not_exist(
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_FILENAME: [TEST_FILENAME, second_filename],
CONF_DESTINATION_FOLDER: DESINATION_FOLDER,
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
return_response=True,
@@ -251,7 +255,7 @@ async def test_upload_service_fails_upload(
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_DESTINATION_FOLDER: DESINATION_FOLDER,
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
return_response=True,
@@ -275,7 +279,7 @@ async def test_upload_size_limit(
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_DESTINATION_FOLDER: DESINATION_FOLDER,
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
return_response=True,
@@ -300,8 +304,294 @@ async def test_create_album_failed(
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_DESTINATION_FOLDER: DESINATION_FOLDER,
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
return_response=True,
)
async def test_delete_service_config_entry_not_found(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test delete service call with a config entry that does not exist."""
await setup_integration(hass, mock_config_entry)
with pytest.raises(ServiceValidationError) as err:
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: "invalid-config-entry-id",
CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH],
},
blocking=True,
)
assert err.value.translation_key == "service_config_entry_not_found"
async def test_delete_service_config_entry_not_loaded(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test delete 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(ServiceValidationError) as err:
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH],
},
blocking=True,
)
assert err.value.translation_key == "service_config_entry_not_loaded"
async def test_delete_service(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
) -> None:
"""Test delete service call removes the remote file."""
await setup_integration(hass, mock_config_entry)
assert hass.services.has_service(DOMAIN, DELETE_SERVICE)
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH],
},
blocking=True,
)
mock_onedrive_client.delete_drive_item.assert_called_once()
call_args = mock_onedrive_client.delete_drive_item.call_args
assert call_args.args[0] == f"id:/{TEST_DESTINATION_PATH}:"
assert call_args.args[1] is False
async def test_delete_service_delete_permanently(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
) -> None:
"""Test delete service passes delete_permanently=True when option is set."""
await setup_integration(hass, mock_config_entry)
hass.config_entries.async_update_entry(
mock_config_entry, options={CONF_DELETE_PERMANENTLY: True}
)
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH],
},
blocking=True,
)
call_args = mock_onedrive_client.delete_drive_item.call_args
assert call_args.args[0] == f"id:/{TEST_DESTINATION_PATH}:"
assert call_args.args[1] is True
async def test_delete_service_multiple_files(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
) -> None:
"""Test delete service removes multiple remote files in parallel."""
await setup_integration(hass, mock_config_entry)
second_path = "photos/snapshots/image2.jpg"
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH, second_path],
},
blocking=True,
)
assert mock_onedrive_client.delete_drive_item.call_count == 2
called_paths = {
c.args[0] for c in mock_onedrive_client.delete_drive_item.call_args_list
}
assert called_paths == {
f"id:/{TEST_DESTINATION_PATH}:",
f"id:/{second_path}:",
}
async def test_delete_service_fails(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
) -> None:
"""Test delete service raises HomeAssistantError on OneDriveException."""
await setup_integration(hass, mock_config_entry)
mock_onedrive_client.delete_drive_item.side_effect = OneDriveException("api error")
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH],
},
blocking=True,
)
assert exc_info.value.translation_key == "delete_error"
assert TEST_DESTINATION_PATH in exc_info.value.translation_placeholders["paths"]
async def test_delete_service_multiple_files_all_fail(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
) -> None:
"""Test delete service aggregates errors from multiple failed deletions."""
await setup_integration(hass, mock_config_entry)
second_path = "photos/snapshots/image2.jpg"
mock_onedrive_client.delete_drive_item.side_effect = [
OneDriveException("error one"),
OneDriveException("error two"),
]
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH, second_path],
},
blocking=True,
)
assert mock_onedrive_client.delete_drive_item.call_count == 2
assert isinstance(exc_info.value.__cause__, ExceptionGroup)
assert len(exc_info.value.__cause__.exceptions) == 2
assert TEST_DESTINATION_PATH in exc_info.value.translation_placeholders["paths"]
assert second_path in exc_info.value.translation_placeholders["paths"]
async def test_delete_service_multiple_files_partial_failure(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
) -> None:
"""Test delete service attempts all deletions before raising on partial failure."""
await setup_integration(hass, mock_config_entry)
second_path = "photos/snapshots/image2.jpg"
mock_onedrive_client.delete_drive_item.side_effect = [
None,
OneDriveException("error two"),
]
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH, second_path],
},
blocking=True,
)
assert mock_onedrive_client.delete_drive_item.call_count == 2
called_paths = {
c.args[0] for c in mock_onedrive_client.delete_drive_item.call_args_list
}
assert called_paths == {
f"id:/{TEST_DESTINATION_PATH}:",
f"id:/{second_path}:",
}
assert exc_info.value.translation_key == "delete_error"
assert second_path in exc_info.value.translation_placeholders["paths"]
async def test_delete_service_get_approot_fails(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
) -> None:
"""Test delete service raises HomeAssistantError when get_approot fails."""
await setup_integration(hass, mock_config_entry)
mock_onedrive_client.get_approot.side_effect = OneDriveException("network error")
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH],
},
blocking=True,
)
assert exc_info.value.translation_key == "connection_error"
async def test_delete_empty_destination_path(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test delete service raises when destination_path is an empty list."""
await setup_integration(hass, mock_config_entry)
with pytest.raises(vol.Invalid):
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: [],
},
blocking=True,
)
@pytest.mark.parametrize(
"bad_path",
[
"",
"/",
"//",
"photos/../secrets",
"photos/file:name.jpg",
"../escape",
],
)
async def test_delete_invalid_destination_path(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
bad_path: str,
) -> None:
"""Test delete service raises HomeAssistantError for invalid destination paths."""
await setup_integration(hass, mock_config_entry)
with pytest.raises(HomeAssistantError, match="Invalid destination path"):
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: bad_path,
},
blocking=True,
)