diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index 321ed98bfa8..fb2927a58bb 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -5,10 +5,10 @@ from __future__ import annotations from collections.abc import AsyncIterator, Callable, Coroutine from functools import wraps import logging +from time import time from typing import Any, Concatenate from aiohttp import ClientTimeout -from aiowebdav2 import Property, PropertyRequest from aiowebdav2.exceptions import UnauthorizedError, WebDavError from propcache.api import cached_property @@ -28,9 +28,8 @@ from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN _LOGGER = logging.getLogger(__name__) -METADATA_VERSION = "1" BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200) -NAMESPACE = "https://home-assistant.io" +CACHE_TTL = 300 async def async_get_backup_agents( @@ -96,23 +95,6 @@ def suggested_filenames(backup: AgentBackup) -> tuple[str, str]: return f"{base_name}.tar", f"{base_name}.metadata.json" -def _is_current_metadata_version(properties: list[Property]) -> bool: - """Check if any property is of the current metadata version.""" - return any( - prop.value == METADATA_VERSION - for prop in properties - if prop.namespace == NAMESPACE and prop.name == "metadata_version" - ) - - -def _backup_id_from_properties(properties: list[Property]) -> str | None: - """Return the backup ID from properties.""" - for prop in properties: - if prop.namespace == NAMESPACE and prop.name == "backup_id": - return prop.value - return None - - class WebDavBackupAgent(BackupAgent): """Backup agent interface.""" @@ -126,6 +108,8 @@ class WebDavBackupAgent(BackupAgent): self._client = entry.runtime_data self.name = entry.title self.unique_id = entry.entry_id + self._cache_metadata_files: dict[str, AgentBackup] = {} + self._cache_expiration = time() @cached_property def _backup_path(self) -> str: @@ -182,27 +166,14 @@ class WebDavBackupAgent(BackupAgent): f"{self._backup_path}/{filename_meta}", ) - await self._client.set_property_batch( - f"{self._backup_path}/{filename_meta}", - [ - Property( - namespace=NAMESPACE, - name="backup_id", - value=backup.backup_id, - ), - Property( - namespace=NAMESPACE, - name="metadata_version", - value=METADATA_VERSION, - ), - ], - ) - _LOGGER.debug( "Uploaded metadata file for %s", f"{self._backup_path}/{filename_meta}", ) + # reset cache + self._cache_expiration = time() + @handle_backup_errors async def async_delete_backup( self, @@ -226,14 +197,13 @@ class WebDavBackupAgent(BackupAgent): backup_path, ) + # reset cache + self._cache_expiration = time() + @handle_backup_errors async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" - metadata_files = await self._list_metadata_files() - return [ - await self._download_metadata(metadata_file) - for metadata_file in metadata_files.values() - ] + return list((await self._list_cached_metadata_files()).values()) @handle_backup_errors async def async_get_backup( @@ -244,38 +214,35 @@ class WebDavBackupAgent(BackupAgent): """Return a backup.""" return await self._find_backup_by_id(backup_id) - async def _list_metadata_files(self) -> dict[str, str]: - """List metadata files.""" - files = await self._client.list_with_properties( - self._backup_path, - [ - PropertyRequest( - namespace=NAMESPACE, - name="metadata_version", - ), - PropertyRequest( - namespace=NAMESPACE, - name="backup_id", - ), - ], - ) - return { - backup_id: file_name - for file_name, properties in files.items() - if file_name.endswith(".json") and _is_current_metadata_version(properties) - if (backup_id := _backup_id_from_properties(properties)) - } + async def _list_cached_metadata_files(self) -> dict[str, AgentBackup]: + """List metadata files with a cache.""" + if time() <= self._cache_expiration: + return self._cache_metadata_files + + async def _download_metadata(path: str) -> AgentBackup: + """Download metadata file.""" + iterator = await self._client.download_iter(path) + metadata = await anext(iterator) + return AgentBackup.from_dict(json_loads_object(metadata)) + + async def _list_metadata_files() -> dict[str, AgentBackup]: + """List metadata files.""" + files = await self._client.list_files(self._backup_path) + return { + metadata_content.backup_id: metadata_content + for file_name in files + if file_name.endswith(".json") + if (metadata_content := await _download_metadata(file_name)) + } + + self._cache_metadata_files = await _list_metadata_files() + self._cache_expiration = time() + CACHE_TTL + return self._cache_metadata_files async def _find_backup_by_id(self, backup_id: str) -> AgentBackup: """Find a backup by its backup ID on remote.""" - metadata_files = await self._list_metadata_files() + metadata_files = await self._list_cached_metadata_files() if metadata_file := metadata_files.get(backup_id): - return await self._download_metadata(metadata_file) + return metadata_file raise BackupNotFound(f"Backup {backup_id} not found") - - async def _download_metadata(self, path: str) -> AgentBackup: - """Download metadata file.""" - iterator = await self._client.download_iter(path) - metadata = await anext(iterator) - return AgentBackup.from_dict(json_loads_object(metadata)) diff --git a/tests/components/webdav/conftest.py b/tests/components/webdav/conftest.py index 645e2111364..5fa972e5fae 100644 --- a/tests/components/webdav/conftest.py +++ b/tests/components/webdav/conftest.py @@ -9,7 +9,7 @@ import pytest from homeassistant.components.webdav.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME -from .const import BACKUP_METADATA, MOCK_LIST_WITH_PROPERTIES +from .const import BACKUP_METADATA, MOCK_LIST_FILES from tests.common import MockConfigEntry @@ -58,7 +58,7 @@ def mock_webdav_client() -> Generator[AsyncMock]: mock = mock_webdav_client.return_value mock.check.return_value = True mock.mkdir.return_value = True - mock.list_with_properties.return_value = MOCK_LIST_WITH_PROPERTIES + mock.list_files.return_value = MOCK_LIST_FILES mock.download_iter.side_effect = _download_mock mock.upload_iter.return_value = None mock.clean.return_value = None diff --git a/tests/components/webdav/const.py b/tests/components/webdav/const.py index 8d6b8ad67d7..0147826a777 100644 --- a/tests/components/webdav/const.py +++ b/tests/components/webdav/const.py @@ -1,7 +1,5 @@ """Constants for WebDAV tests.""" -from aiowebdav2 import Property - BACKUP_METADATA = { "addons": [], "backup_id": "23e64aec", @@ -16,18 +14,7 @@ BACKUP_METADATA = { "size": 34519040, } -MOCK_LIST_WITH_PROPERTIES = { - "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar": [], - "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json": [ - Property( - namespace="https://home-assistant.io", - name="backup_id", - value="23e64aec", - ), - Property( - namespace="https://home-assistant.io", - name="metadata_version", - value="1", - ), - ], -} +MOCK_LIST_FILES = [ + "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar", + "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json", +] diff --git a/tests/components/webdav/test_backup.py b/tests/components/webdav/test_backup.py index c20e73cc786..ca20467484f 100644 --- a/tests/components/webdav/test_backup.py +++ b/tests/components/webdav/test_backup.py @@ -6,7 +6,6 @@ from collections.abc import AsyncGenerator from io import StringIO from unittest.mock import Mock, patch -from aiowebdav2 import Property from aiowebdav2.exceptions import UnauthorizedError, WebDavError import pytest @@ -17,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component -from .const import BACKUP_METADATA, MOCK_LIST_WITH_PROPERTIES +from .const import BACKUP_METADATA from tests.common import AsyncMock, MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -184,7 +183,6 @@ async def test_agents_upload( assert resp.status == 201 assert webdav_client.upload_iter.call_count == 2 - assert webdav_client.set_property_batch.call_count == 1 async def test_agents_download( @@ -211,7 +209,7 @@ async def test_error_on_agents_download( """Test we get not found on a not existing backup on download.""" client = await hass_client() backup_id = BACKUP_METADATA["backup_id"] - webdav_client.list_with_properties.side_effect = [MOCK_LIST_WITH_PROPERTIES, {}] + webdav_client.list_files.return_value = [] resp = await client.get( f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" @@ -262,7 +260,7 @@ async def test_agents_delete_not_found_does_not_throw( webdav_client: AsyncMock, ) -> None: """Test agent delete backup.""" - webdav_client.list_with_properties.return_value = {} + webdav_client.list_files.return_value = {} client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -283,7 +281,7 @@ async def test_agents_backup_not_found( webdav_client: AsyncMock, ) -> None: """Test backup not found.""" - webdav_client.list_with_properties.return_value = [] + webdav_client.list_files.return_value = [] backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) @@ -300,7 +298,7 @@ async def test_raises_on_403( mock_config_entry: MockConfigEntry, ) -> None: """Test we raise on 403.""" - webdav_client.list_with_properties.side_effect = UnauthorizedError( + webdav_client.list_files.side_effect = UnauthorizedError( "https://webdav.example.com" ) backup_id = BACKUP_METADATA["backup_id"] @@ -324,30 +322,3 @@ async def test_listeners_get_cleaned_up(hass: HomeAssistant) -> None: remove_listener() assert hass.data.get(DATA_BACKUP_AGENT_LISTENERS) is None - - -async def test_metadata_misses_backup_id( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - webdav_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test getting a backup when metadata has backup id property.""" - MOCK_LIST_WITH_PROPERTIES[ - "/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json" - ] = [ - Property( - namespace="homeassistant", - name="metadata_version", - value="1", - ) - ] - webdav_client.list_with_properties.return_value = MOCK_LIST_WITH_PROPERTIES - - backup_id = BACKUP_METADATA["backup_id"] - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) - response = await client.receive_json() - - assert response["success"] - assert response["result"]["backup"] is None