Compare commits

...
Sign in to create a new pull request.

4 commits

Author SHA1 Message Date
Erik
9a3ebabf88 Remove WS command backup/upload 2024-11-14 08:36:36 +01:00
Martin Hjelmare
f99b319048
Rename backup sync agent to backup agent (#130575)
* Rename sync agent module to agent

* Rename BackupSyncAgent to BackupAgent

* Fix test typo

* Rename async_get_backup_sync_agents to async_get_backup_agents

* Rename and clean up remaining sync things

* Update kitchen sink

* Apply suggestions from code review

* Update test_manager.py

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
2024-11-14 02:00:49 +01:00
Martin Hjelmare
957ece747d
Make BackupSyncMetadata model a dataclass (#130555)
Make backup BackupSyncMetadata model a dataclass
2024-11-13 21:11:25 +01:00
Joakim Sørensen
325738829d
MVP implementation of Backup sync agents (#126122)
* init sync agent

* add syncing

* root import

* rename list to info and add sync state

* Add base backup class

* Revert unneded change

* adjust tests

* move to kitchen_sink

* split

* move

* Adjustments

* Adjustment

* update

* Tests

* Test unknown agent

* adjust

* Adjust for different test environments

* Change /info WS to contain a dictinary

* reorder

* Add websocket command to trigger sync from the supervisor

* cleanup

* Make mypy happier

---------

Co-authored-by: Erik <erik@montnemery.com>
2024-11-13 20:22:54 +01:00
12 changed files with 837 additions and 54 deletions

View file

@ -5,18 +5,25 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.typing import ConfigType
from .const import DATA_MANAGER, DOMAIN, LOGGER
from .agent import BackupAgent, UploadedBackup
from .const import DOMAIN, LOGGER
from .http import async_register_http_views
from .manager import BackupManager
from .models import BackupUploadMetadata
from .websocket import async_register_websocket_handlers
__all__ = [
"BackupAgent",
"BackupUploadMetadata",
"UploadedBackup",
]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Backup integration."""
backup_manager = BackupManager(hass)
hass.data[DATA_MANAGER] = backup_manager
hass.data[DOMAIN] = backup_manager = BackupManager(hass)
with_hassio = is_hassio(hass)

View file

@ -0,0 +1,73 @@
"""Backup agents for the Backup integration."""
from __future__ import annotations
import abc
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol
from homeassistant.core import HomeAssistant
from .models import BackupUploadMetadata, BaseBackup
@dataclass(slots=True)
class UploadedBackup(BaseBackup):
"""Uploaded backup class."""
id: str
class BackupAgent(abc.ABC):
"""Define the format that backup agents can have."""
def __init__(self, name: str) -> None:
"""Initialize the backup agent."""
self.name = name
@abc.abstractmethod
async def async_download_backup(
self,
*,
id: str,
path: Path,
**kwargs: Any,
) -> None:
"""Download a backup file.
The `id` parameter is the ID of the backup that was returned in async_list_backups.
The `path` parameter is the full file path to download the backup to.
"""
@abc.abstractmethod
async def async_upload_backup(
self,
*,
path: Path,
metadata: BackupUploadMetadata,
**kwargs: Any,
) -> None:
"""Upload a backup.
The `path` parameter is the full file path to the backup that should be uploaded.
The `metadata` parameter contains metadata about the backup that should be uploaded.
"""
@abc.abstractmethod
async def async_list_backups(self, **kwargs: Any) -> list[UploadedBackup]:
"""List backups."""
class BackupAgentPlatformProtocol(Protocol):
"""Define the format that backup platforms can have."""
async def async_get_backup_agents(
self,
*,
hass: HomeAssistant,
**kwargs: Any,
) -> list[BackupAgent]:
"""Register the backup agent."""

View file

@ -8,10 +8,11 @@ from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from .manager import BackupManager
from .manager import BaseBackupManager
from .models import BaseBackup
DOMAIN = "backup"
DATA_MANAGER: HassKey[BackupManager] = HassKey(DOMAIN)
DATA_MANAGER: HassKey[BaseBackupManager[BaseBackup]] = HassKey(DOMAIN)
LOGGER = getLogger(__package__)
EXCLUDE_FROM_BACKUP = [

View file

@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.util import slugify
from .const import DATA_MANAGER
from .manager import BackupManager
@callback
@ -39,7 +40,7 @@ class DownloadBackupView(HomeAssistantView):
if not request["hass_user"].is_admin:
return Response(status=HTTPStatus.UNAUTHORIZED)
manager = request.app[KEY_HASS].data[DATA_MANAGER]
manager = cast(BackupManager, request.app[KEY_HASS].data[DATA_MANAGER])
backup = await manager.async_get_backup(slug=slug)
if backup is None or not backup.path.exists():

View file

@ -16,10 +16,11 @@ import tarfile
from tarfile import TarError
from tempfile import TemporaryDirectory
import time
from typing import Any, Protocol, cast
from typing import Any, Generic, Protocol, cast
import aiohttp
from securetar import SecureTarFile, atomic_contents_add
from typing_extensions import TypeVar
from homeassistant.backup_restore import RESTORE_BACKUP_FILE
from homeassistant.const import __version__ as HAVERSION
@ -30,10 +31,14 @@ from homeassistant.helpers.json import json_bytes
from homeassistant.util import dt as dt_util
from homeassistant.util.json import json_loads_object
from .agent import BackupAgent, BackupAgentPlatformProtocol
from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER
from .models import BackupUploadMetadata, BaseBackup
BUF_SIZE = 2**20 * 4 # 4MB
_BackupT = TypeVar("_BackupT", bound=BaseBackup, default=BaseBackup)
@dataclass(slots=True)
class NewBackup:
@ -43,14 +48,10 @@ class NewBackup:
@dataclass(slots=True)
class Backup:
class Backup(BaseBackup):
"""Backup class."""
slug: str
name: str
date: str
path: Path
size: float
def as_dict(self) -> dict:
"""Return a dict representation of this backup."""
@ -76,19 +77,21 @@ class BackupPlatformProtocol(Protocol):
"""Perform operations after a backup finishes."""
class BaseBackupManager(abc.ABC):
class BaseBackupManager(abc.ABC, Generic[_BackupT]):
"""Define the format that backup managers can have."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the backup manager."""
self.hass = hass
self.backup_task: asyncio.Task | None = None
self.backups: dict[str, Backup] = {}
self.backups: dict[str, _BackupT] = {}
self.loaded_platforms = False
self.platforms: dict[str, BackupPlatformProtocol] = {}
self.backup_agents: dict[str, BackupAgent] = {}
self.syncing = False
@callback
def _add_platform(
def _add_platform_pre_post_handlers(
self,
hass: HomeAssistant,
integration_domain: str,
@ -98,13 +101,25 @@ class BaseBackupManager(abc.ABC):
if not hasattr(platform, "async_pre_backup") or not hasattr(
platform, "async_post_backup"
):
LOGGER.warning(
"%s does not implement required functions for the backup platform",
integration_domain,
)
return
self.platforms[integration_domain] = platform
async def _async_add_platform_agents(
self,
hass: HomeAssistant,
integration_domain: str,
platform: BackupAgentPlatformProtocol,
) -> None:
"""Add a platform to the backup manager."""
if not hasattr(platform, "async_get_backup_agents"):
return
agents = await platform.async_get_backup_agents(hass=hass)
self.backup_agents.update(
{f"{integration_domain}.{agent.name}": agent for agent in agents}
)
async def async_pre_backup_actions(self, **kwargs: Any) -> None:
"""Perform pre backup actions."""
if not self.loaded_platforms:
@ -139,10 +154,22 @@ class BaseBackupManager(abc.ABC):
async def load_platforms(self) -> None:
"""Load backup platforms."""
if self.loaded_platforms:
return
await integration_platform.async_process_integration_platforms(
self.hass, DOMAIN, self._add_platform, wait_for_platforms=True
self.hass,
DOMAIN,
self._add_platform_pre_post_handlers,
wait_for_platforms=True,
)
await integration_platform.async_process_integration_platforms(
self.hass,
DOMAIN,
self._async_add_platform_agents,
wait_for_platforms=True,
)
LOGGER.debug("Loaded %s platforms", len(self.platforms))
LOGGER.debug("Loaded %s agents", len(self.backup_agents))
self.loaded_platforms = True
@abc.abstractmethod
@ -159,14 +186,14 @@ class BaseBackupManager(abc.ABC):
"""Generate a backup."""
@abc.abstractmethod
async def async_get_backups(self, **kwargs: Any) -> dict[str, Backup]:
async def async_get_backups(self, **kwargs: Any) -> dict[str, _BackupT]:
"""Get backups.
Return a dictionary of Backup instances keyed by their slug.
"""
@abc.abstractmethod
async def async_get_backup(self, *, slug: str, **kwargs: Any) -> Backup | None:
async def async_get_backup(self, *, slug: str, **kwargs: Any) -> _BackupT | None:
"""Get a backup."""
@abc.abstractmethod
@ -182,8 +209,12 @@ class BaseBackupManager(abc.ABC):
) -> None:
"""Receive and store a backup file from upload."""
@abc.abstractmethod
async def async_upload_backup(self, *, slug: str, **kwargs: Any) -> None:
"""Upload a backup."""
class BackupManager(BaseBackupManager):
class BackupManager(BaseBackupManager[Backup]):
"""Backup manager for the Backup integration."""
def __init__(self, hass: HomeAssistant) -> None:
@ -192,10 +223,42 @@ class BackupManager(BaseBackupManager):
self.backup_dir = Path(hass.config.path("backups"))
self.loaded_backups = False
async def async_upload_backup(self, *, slug: str, **kwargs: Any) -> None:
"""Upload a backup."""
await self.load_platforms()
if not self.backup_agents:
return
if not (backup := await self.async_get_backup(slug=slug)):
return
self.syncing = True
sync_backup_results = await asyncio.gather(
*(
agent.async_upload_backup(
path=backup.path,
metadata=BackupUploadMetadata(
homeassistant=HAVERSION,
size=backup.size,
date=backup.date,
slug=backup.slug,
name=backup.name,
),
)
for agent in self.backup_agents.values()
),
return_exceptions=True,
)
for result in sync_backup_results:
if isinstance(result, Exception):
LOGGER.error("Error during backup upload - %s", result)
self.syncing = False
async def load_backups(self) -> None:
"""Load data of stored backup files."""
backups = await self.hass.async_add_executor_job(self._read_backups)
LOGGER.debug("Loaded %s backups", len(backups))
LOGGER.debug("Loaded %s local backups", len(backups))
self.backups = backups
self.loaded_backups = True

View file

@ -0,0 +1,28 @@
"""Models for the backup integration."""
from dataclasses import asdict, dataclass
@dataclass()
class BaseBackup:
"""Base backup class."""
date: str
slug: str
size: float
name: str
def as_dict(self) -> dict:
"""Return a dict representation of this backup."""
return asdict(self)
@dataclass()
class BackupUploadMetadata:
"""Backup upload metadata."""
date: str # The date the backup was created
slug: str # The slug of the backup
size: float # The size of the backup (in bytes)
name: str # The name of the backup
homeassistant: str # The version of Home Assistant that created the backup

View file

@ -1,5 +1,6 @@
"""Websocket commands for the Backup integration."""
from pathlib import Path
from typing import Any
import voluptuous as vol
@ -14,6 +15,10 @@ from .manager import BackupProgress
@callback
def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> None:
"""Register websocket commands."""
websocket_api.async_register_command(hass, backup_agents_download)
websocket_api.async_register_command(hass, backup_agents_info)
websocket_api.async_register_command(hass, backup_agents_list_backups)
if with_hassio:
websocket_api.async_register_command(hass, handle_backup_end)
websocket_api.async_register_command(hass, handle_backup_start)
@ -40,7 +45,7 @@ async def handle_info(
connection.send_result(
msg["id"],
{
"backups": list(backups.values()),
"backups": [b.as_dict() for b in backups.values()],
"backing_up": manager.backup_task is not None,
},
)
@ -162,3 +167,77 @@ async def handle_backup_end(
return
connection.send_result(msg["id"])
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "backup/agents/info"})
@websocket_api.async_response
async def backup_agents_info(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return backup agents info."""
manager = hass.data[DATA_MANAGER]
await manager.load_platforms()
connection.send_result(
msg["id"],
{
"agents": [{"id": agent_id} for agent_id in manager.backup_agents],
"syncing": manager.syncing,
},
)
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "backup/agents/list_backups"})
@websocket_api.async_response
async def backup_agents_list_backups(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return a list of uploaded backups."""
manager = hass.data[DATA_MANAGER]
backups: list[dict[str, Any]] = []
await manager.load_platforms()
for agent_id, agent in manager.backup_agents.items():
_listed_backups = await agent.async_list_backups()
backups.extend({**b.as_dict(), "agent_id": agent_id} for b in _listed_backups)
connection.send_result(msg["id"], backups)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "backup/agents/download",
vol.Required("agent"): str,
vol.Required("backup_id"): str,
vol.Required("slug"): str,
}
)
@websocket_api.async_response
async def backup_agents_download(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Download an uploaded backup."""
manager = hass.data[DATA_MANAGER]
await manager.load_platforms()
if not (agent := manager.backup_agents.get(msg["agent"])):
connection.send_error(
msg["id"], "unknown_agent", f"Agent {msg['agent']} not found"
)
return
try:
await agent.async_download_backup(
id=msg["backup_id"],
path=Path(hass.config.path("backup"), f"{msg['slug']}.tar"),
)
except Exception as err: # noqa: BLE001
connection.send_error(msg["id"], "backup_agents_download", str(err))
return
connection.send_result(msg["id"])

View file

@ -0,0 +1,74 @@
"""Backup platform for the kitchen_sink integration."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from uuid import uuid4
from homeassistant.components.backup import (
BackupAgent,
BackupUploadMetadata,
UploadedBackup,
)
from homeassistant.core import HomeAssistant
LOGGER = logging.getLogger(__name__)
async def async_get_backup_sync_agents(
hass: HomeAssistant,
) -> list[BackupAgent]:
"""Register the backup agents."""
return [KitchenSinkBackupAgent("syncer")]
class KitchenSinkBackupAgent(BackupAgent):
"""Kitchen sink backup agent."""
def __init__(self, name: str) -> None:
"""Initialize the kitchen sink backup sync agent."""
super().__init__(name)
self._uploads = [
UploadedBackup(
id="def456",
name="Kitchen sink syncer",
slug="abc123",
size=1234,
date="1970-01-01T00:00:00Z",
)
]
async def async_download_backup(
self,
*,
id: str,
path: Path,
**kwargs: Any,
) -> None:
"""Download a backup file."""
LOGGER.info("Downloading backup %s to %s", id, path)
async def async_upload_backup(
self,
*,
path: Path,
metadata: BackupUploadMetadata,
**kwargs: Any,
) -> None:
"""Upload a backup."""
LOGGER.info("Uploading backup %s %s", path.name, metadata)
self._uploads.append(
UploadedBackup(
id=uuid4().hex,
name=metadata.name,
slug=metadata.slug,
size=metadata.size,
date=metadata.date,
)
)
async def async_list_backups(self, **kwargs: Any) -> list[UploadedBackup]:
"""List synced backups."""
return self._uploads

View file

@ -3,10 +3,13 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import patch
from homeassistant.components.backup import DOMAIN
from homeassistant.components.backup.agent import BackupAgent, UploadedBackup
from homeassistant.components.backup.manager import Backup
from homeassistant.components.backup.models import BackupUploadMetadata
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component
@ -20,6 +23,40 @@ TEST_BACKUP = Backup(
)
class BackupAgentTest(BackupAgent):
"""Test backup agent."""
async def async_download_backup(
self,
*,
id: str,
path: Path,
**kwargs: Any,
) -> None:
"""Download a backup file."""
async def async_upload_backup(
self,
*,
path: Path,
metadata: BackupUploadMetadata,
**kwargs: Any,
) -> None:
"""Upload a backup."""
async def async_list_backups(self, **kwargs: Any) -> list[UploadedBackup]:
"""List backups."""
return [
UploadedBackup(
id="abc123",
name="Test",
slug="abc123",
size=13.37,
date="1970-01-01T00:00:00Z",
)
]
async def setup_backup_integration(
hass: HomeAssistant,
with_hassio: bool = False,

View file

@ -1,4 +1,106 @@
# serializer version: 1
# name: test_agents_download[with_hassio]
dict({
'id': 1,
'result': None,
'success': True,
'type': 'result',
})
# ---
# name: test_agents_download[without_hassio]
dict({
'id': 1,
'result': None,
'success': True,
'type': 'result',
})
# ---
# name: test_agents_download_exception
dict({
'error': dict({
'code': 'backup_agents_download',
'message': 'Boom',
}),
'id': 1,
'success': False,
'type': 'result',
})
# ---
# name: test_agents_download_unknown_agent
dict({
'error': dict({
'code': 'unknown_agent',
'message': 'Agent domain.test not found',
}),
'id': 1,
'success': False,
'type': 'result',
})
# ---
# name: test_agents_info[with_hassio]
dict({
'id': 1,
'result': dict({
'agents': list([
dict({
'id': 'domain.test',
}),
]),
'syncing': False,
}),
'success': True,
'type': 'result',
})
# ---
# name: test_agents_info[without_hassio]
dict({
'id': 1,
'result': dict({
'agents': list([
dict({
'id': 'domain.test',
}),
]),
'syncing': False,
}),
'success': True,
'type': 'result',
})
# ---
# name: test_agents_list_backups[with_hassio]
dict({
'id': 1,
'result': list([
dict({
'agent_id': 'domain.test',
'date': '1970-01-01T00:00:00Z',
'id': 'abc123',
'name': 'Test',
'size': 13.37,
'slug': 'abc123',
}),
]),
'success': True,
'type': 'result',
})
# ---
# name: test_agents_list_backups[without_hassio]
dict({
'id': 1,
'result': list([
dict({
'agent_id': 'domain.test',
'date': '1970-01-01T00:00:00Z',
'id': 'abc123',
'name': 'Test',
'size': 13.37,
'slug': 'abc123',
}),
]),
'success': True,
'type': 'result',
})
# ---
# name: test_backup_end[with_hassio-hass_access_token]
dict({
'error': dict({
@ -40,7 +142,7 @@
'type': 'result',
})
# ---
# name: test_backup_end_excepion[exception0]
# name: test_backup_end_exception[exception0]
dict({
'error': dict({
'code': 'post_backup_actions_failed',
@ -51,7 +153,7 @@
'type': 'result',
})
# ---
# name: test_backup_end_excepion[exception1]
# name: test_backup_end_exception[exception1]
dict({
'error': dict({
'code': 'post_backup_actions_failed',
@ -62,7 +164,7 @@
'type': 'result',
})
# ---
# name: test_backup_end_excepion[exception2]
# name: test_backup_end_exception[exception2]
dict({
'error': dict({
'code': 'post_backup_actions_failed',
@ -114,7 +216,7 @@
'type': 'result',
})
# ---
# name: test_backup_start_excepion[exception0]
# name: test_backup_start_exception[exception0]
dict({
'error': dict({
'code': 'pre_backup_actions_failed',
@ -125,7 +227,7 @@
'type': 'result',
})
# ---
# name: test_backup_start_excepion[exception1]
# name: test_backup_start_exception[exception1]
dict({
'error': dict({
'code': 'pre_backup_actions_failed',
@ -136,7 +238,7 @@
'type': 'result',
})
# ---
# name: test_backup_start_excepion[exception2]
# name: test_backup_start_exception[exception2]
dict({
'error': dict({
'code': 'pre_backup_actions_failed',

View file

@ -3,13 +3,15 @@
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch
import aiohttp
from multidict import CIMultiDict, CIMultiDictProxy
import pytest
from homeassistant.components.backup import BackupManager
from homeassistant.components.backup import BackupManager, BackupUploadMetadata
from homeassistant.components.backup.agent import BackupAgentPlatformProtocol
from homeassistant.components.backup.manager import (
BackupPlatformProtocol,
BackupProgress,
@ -18,7 +20,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component
from .common import TEST_BACKUP
from .common import TEST_BACKUP, BackupAgentTest
from tests.common import MockPlatform, mock_platform
@ -39,7 +41,7 @@ async def _mock_backup_generation(
assert manager.backup_task is not None
assert progress == []
await manager.backup_task
backup = await manager.backup_task
assert progress == [BackupProgress(done=True, stage=None, success=True)]
assert mocked_json_bytes.call_count == 1
@ -48,10 +50,12 @@ async def _mock_backup_generation(
assert backup_json_dict["homeassistant"] == {"version": "2025.1.0"}
assert manager.backup_dir.as_posix() in str(mocked_tarfile.call_args_list[0][0][0])
return backup
async def _setup_mock_domain(
hass: HomeAssistant,
platform: BackupPlatformProtocol | None = None,
platform: BackupPlatformProtocol | BackupAgentPlatformProtocol | None = None,
) -> None:
"""Set up a mock domain."""
mock_platform(hass, "some_domain.backup", platform or MockPlatform())
@ -174,6 +178,7 @@ async def test_async_create_backup(
assert "Generated new backup with slug " in caplog.text
assert "Creating backup directory" in caplog.text
assert "Loaded 0 platforms" in caplog.text
assert "Loaded 0 agents" in caplog.text
async def test_loading_platforms(
@ -191,6 +196,7 @@ async def test_loading_platforms(
Mock(
async_pre_backup=AsyncMock(),
async_post_backup=AsyncMock(),
async_get_backup_agents=AsyncMock(),
),
)
await manager.load_platforms()
@ -202,6 +208,32 @@ async def test_loading_platforms(
assert "Loaded 1 platforms" in caplog.text
async def test_loading_agents(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test loading backup agents."""
manager = BackupManager(hass)
assert not manager.loaded_platforms
assert not manager.platforms
await _setup_mock_domain(
hass,
Mock(
async_get_backup_agents=AsyncMock(return_value=[BackupAgentTest("test")]),
),
)
await manager.load_platforms()
await hass.async_block_till_done()
assert manager.loaded_platforms
assert len(manager.backup_agents) == 1
assert "Loaded 1 agents" in caplog.text
assert "some_domain.test" in manager.backup_agents
async def test_not_loading_bad_platforms(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
@ -220,10 +252,151 @@ async def test_not_loading_bad_platforms(
assert len(manager.platforms) == 0
assert "Loaded 0 platforms" in caplog.text
assert (
"some_domain does not implement required functions for the backup platform"
in caplog.text
@pytest.mark.usefixtures("mock_backup_generation")
async def test_syncing_backup(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
mocked_json_bytes: Mock,
mocked_tarfile: Mock,
) -> None:
"""Test syncing a backup."""
manager = BackupManager(hass)
await _setup_mock_domain(
hass,
Mock(
async_pre_backup=AsyncMock(),
async_post_backup=AsyncMock(),
async_get_backup_agents=AsyncMock(
return_value=[
BackupAgentTest("agent1"),
BackupAgentTest("agent2"),
]
),
),
)
await manager.load_platforms()
await hass.async_block_till_done()
backup = await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile)
with (
patch(
"homeassistant.components.backup.manager.BackupManager.async_get_backup",
return_value=backup,
),
patch.object(BackupAgentTest, "async_upload_backup") as mocked_upload,
patch(
"homeassistant.components.backup.manager.HAVERSION",
"2025.1.0",
),
):
await manager.async_upload_backup(slug=backup.slug)
assert mocked_upload.call_count == 2
first_call = mocked_upload.call_args_list[0]
assert first_call[1]["path"] == backup.path
assert first_call[1]["metadata"] == BackupUploadMetadata(
date=backup.date,
homeassistant="2025.1.0",
name=backup.name,
size=backup.size,
slug=backup.slug,
)
assert "Error during backup upload" not in caplog.text
@pytest.mark.usefixtures("mock_backup_generation")
async def test_syncing_backup_with_exception(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
mocked_json_bytes: Mock,
mocked_tarfile: Mock,
) -> None:
"""Test syncing a backup with exception."""
manager = BackupManager(hass)
class ModifiedBackupSyncAgentTest(BackupAgentTest):
async def async_upload_backup(self, **kwargs: Any) -> None:
raise HomeAssistantError("Test exception")
await _setup_mock_domain(
hass,
Mock(
async_pre_backup=AsyncMock(),
async_post_backup=AsyncMock(),
async_get_backup_agents=AsyncMock(
return_value=[
ModifiedBackupSyncAgentTest("agent1"),
ModifiedBackupSyncAgentTest("agent2"),
]
),
),
)
await manager.load_platforms()
await hass.async_block_till_done()
backup = await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile)
with (
patch(
"homeassistant.components.backup.manager.BackupManager.async_get_backup",
return_value=backup,
),
patch.object(
ModifiedBackupSyncAgentTest,
"async_upload_backup",
) as mocked_upload,
patch(
"homeassistant.components.backup.manager.HAVERSION",
"2025.1.0",
),
):
mocked_upload.side_effect = HomeAssistantError("Test exception")
await manager.async_upload_backup(slug=backup.slug)
assert mocked_upload.call_count == 2
first_call = mocked_upload.call_args_list[0]
assert first_call[1]["path"] == backup.path
assert first_call[1]["metadata"] == BackupUploadMetadata(
date=backup.date,
homeassistant="2025.1.0",
name=backup.name,
size=backup.size,
slug=backup.slug,
)
assert "Error during backup upload - Test exception" in caplog.text
@pytest.mark.usefixtures("mock_backup_generation")
async def test_syncing_backup_no_agents(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
mocked_json_bytes: Mock,
mocked_tarfile: Mock,
) -> None:
"""Test syncing a backup with no agents."""
manager = BackupManager(hass)
await _setup_mock_domain(
hass,
Mock(
async_pre_backup=AsyncMock(),
async_post_backup=AsyncMock(),
async_get_backup_agents=AsyncMock(return_value=[]),
),
)
await manager.load_platforms()
await hass.async_block_till_done()
backup = await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile)
with patch(
"homeassistant.components.backup.agent.BackupAgent.async_upload_backup"
) as mocked_async_upload_backup:
await manager.async_upload_backup(slug=backup.slug)
assert mocked_async_upload_backup.call_count == 0
async def test_exception_plaform_pre(
@ -241,6 +414,7 @@ async def test_exception_plaform_pre(
Mock(
async_pre_backup=_mock_step,
async_post_backup=AsyncMock(),
async_get_backup_agents=AsyncMock(),
),
)
@ -263,6 +437,7 @@ async def test_exception_plaform_post(
Mock(
async_pre_backup=AsyncMock(),
async_post_backup=_mock_step,
async_get_backup_agents=AsyncMock(),
),
)
@ -285,6 +460,7 @@ async def test_loading_platforms_when_running_async_pre_backup_actions(
Mock(
async_pre_backup=AsyncMock(),
async_post_backup=AsyncMock(),
async_get_backup_agents=AsyncMock(),
),
)
await manager.async_pre_backup_actions()
@ -310,6 +486,7 @@ async def test_loading_platforms_when_running_async_post_backup_actions(
Mock(
async_pre_backup=AsyncMock(),
async_post_backup=AsyncMock(),
async_get_backup_agents=AsyncMock(),
),
)
await manager.async_post_backup_actions()

View file

@ -1,16 +1,18 @@
"""Tests for the Backup integration."""
from unittest.mock import patch
from pathlib import Path
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.backup.manager import Backup
from homeassistant.components.backup.const import DATA_MANAGER
from homeassistant.components.backup.models import BaseBackup
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .common import TEST_BACKUP, setup_backup_integration
from .common import TEST_BACKUP, BackupAgentTest, setup_backup_integration
from tests.typing import WebSocketGenerator
@ -43,15 +45,23 @@ async def test_info(
"""Test getting backup info."""
await setup_backup_integration(hass, with_hassio=with_hassio)
hass.data[DATA_MANAGER].backups = {TEST_BACKUP.slug: TEST_BACKUP}
client = await hass_ws_client(hass)
await hass.async_block_till_done()
with patch(
"homeassistant.components.backup.manager.BackupManager.async_get_backups",
return_value={TEST_BACKUP.slug: TEST_BACKUP},
with (
patch(
"homeassistant.components.backup.manager.BackupManager.load_backups",
AsyncMock(),
),
patch(
"homeassistant.components.backup.manager.BackupManager.async_get_backups",
return_value={TEST_BACKUP.slug: TEST_BACKUP},
),
):
await client.send_json_auto_id({"type": "backup/info"})
assert snapshot == await client.receive_json()
assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
@ -73,7 +83,7 @@ async def test_details(
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
with_hassio: bool,
backup_content: Backup | None,
backup_content: BaseBackup | None,
) -> None:
"""Test getting backup info."""
await setup_backup_integration(hass, with_hassio=with_hassio)
@ -112,7 +122,7 @@ async def test_remove(
"homeassistant.components.backup.manager.BackupManager.async_remove_backup",
):
await client.send_json_auto_id({"type": "backup/remove", "slug": "abc123"})
assert snapshot == await client.receive_json()
assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
@ -140,7 +150,7 @@ async def test_generate(
await client.send_json_auto_id({"type": "backup/generate"})
for _ in range(number_of_messages):
assert snapshot == await client.receive_json()
assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
@ -199,7 +209,7 @@ async def test_backup_end(
"homeassistant.components.backup.manager.BackupManager.async_post_backup_actions",
):
await client.send_json_auto_id({"type": "backup/end"})
assert snapshot == await client.receive_json()
assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
@ -232,7 +242,7 @@ async def test_backup_start(
"homeassistant.components.backup.manager.BackupManager.async_pre_backup_actions",
):
await client.send_json_auto_id({"type": "backup/start"})
assert snapshot == await client.receive_json()
assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
@ -243,7 +253,7 @@ async def test_backup_start(
Exception("Boom"),
],
)
async def test_backup_end_excepion(
async def test_backup_end_exception(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
@ -261,7 +271,7 @@ async def test_backup_end_excepion(
side_effect=exception,
):
await client.send_json_auto_id({"type": "backup/end"})
assert snapshot == await client.receive_json()
assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
@ -272,7 +282,7 @@ async def test_backup_end_excepion(
Exception("Boom"),
],
)
async def test_backup_start_excepion(
async def test_backup_start_exception(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
@ -290,4 +300,135 @@ async def test_backup_start_excepion(
side_effect=exception,
):
await client.send_json_auto_id({"type": "backup/start"})
assert snapshot == await client.receive_json()
assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
"with_hassio",
[
pytest.param(True, id="with_hassio"),
pytest.param(False, id="without_hassio"),
],
)
async def test_agents_info(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
with_hassio: bool,
) -> None:
"""Test getting backup agents info."""
await setup_backup_integration(hass, with_hassio=with_hassio)
hass.data[DATA_MANAGER].backup_agents = {"domain.test": BackupAgentTest("test")}
client = await hass_ws_client(hass)
await hass.async_block_till_done()
await client.send_json_auto_id({"type": "backup/agents/info"})
assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
"with_hassio",
[
pytest.param(True, id="with_hassio"),
pytest.param(False, id="without_hassio"),
],
)
async def test_agents_list_backups(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
with_hassio: bool,
) -> None:
"""Test backup agents list backups details."""
await setup_backup_integration(hass, with_hassio=with_hassio)
hass.data[DATA_MANAGER].backup_agents = {"domain.test": BackupAgentTest("test")}
client = await hass_ws_client(hass)
await hass.async_block_till_done()
await client.send_json_auto_id({"type": "backup/agents/list_backups"})
assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
"with_hassio",
[
pytest.param(True, id="with_hassio"),
pytest.param(False, id="without_hassio"),
],
)
async def test_agents_download(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
with_hassio: bool,
) -> None:
"""Test WS command to start downloading a backup."""
await setup_backup_integration(hass, with_hassio=with_hassio)
hass.data[DATA_MANAGER].backup_agents = {"domain.test": BackupAgentTest("test")}
client = await hass_ws_client(hass)
await hass.async_block_till_done()
await client.send_json_auto_id(
{
"type": "backup/agents/download",
"slug": "abc123",
"agent": "domain.test",
"backup_id": "abc123",
}
)
with patch.object(BackupAgentTest, "async_download_backup") as download_mock:
assert await client.receive_json() == snapshot
assert download_mock.call_args[1] == {
"id": "abc123",
"path": Path(hass.config.path("backup"), "abc123.tar"),
}
async def test_agents_download_exception(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test WS command to start downloading a backup throwing an exception."""
await setup_backup_integration(hass)
hass.data[DATA_MANAGER].backup_agents = {"domain.test": BackupAgentTest("test")}
client = await hass_ws_client(hass)
await hass.async_block_till_done()
await client.send_json_auto_id(
{
"type": "backup/agents/download",
"slug": "abc123",
"agent": "domain.test",
"backup_id": "abc123",
}
)
with patch.object(BackupAgentTest, "async_download_backup") as download_mock:
download_mock.side_effect = Exception("Boom")
assert await client.receive_json() == snapshot
async def test_agents_download_unknown_agent(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test downloading a backup with an unknown agent."""
await setup_backup_integration(hass)
client = await hass_ws_client(hass)
await hass.async_block_till_done()
await client.send_json_auto_id(
{
"type": "backup/agents/download",
"slug": "abc123",
"agent": "domain.test",
"backup_id": "abc123",
}
)
assert await client.receive_json() == snapshot