Make WS command backup/generate send events (#130524)
* Make WS command backup/generate send events * Update backup.create service
This commit is contained in:
parent
ac4cb52dbb
commit
093b16c723
7 changed files with 199 additions and 85 deletions
|
@ -32,7 +32,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||
|
||||
async def async_handle_create_service(call: ServiceCall) -> None:
|
||||
"""Service handler for creating backups."""
|
||||
await backup_manager.async_create_backup()
|
||||
await backup_manager.async_create_backup(on_progress=None)
|
||||
if backup_task := backup_manager.backup_task:
|
||||
await backup_task
|
||||
|
||||
hass.services.async_register(DOMAIN, "create", async_handle_create_service)
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import abc
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import io
|
||||
|
@ -34,6 +35,13 @@ from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER
|
|||
BUF_SIZE = 2**20 * 4 # 4MB
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NewBackup:
|
||||
"""New backup class."""
|
||||
|
||||
slug: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Backup:
|
||||
"""Backup class."""
|
||||
|
@ -49,6 +57,15 @@ class Backup:
|
|||
return {**asdict(self), "path": self.path.as_posix()}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BackupProgress:
|
||||
"""Backup progress class."""
|
||||
|
||||
done: bool
|
||||
stage: str | None
|
||||
success: bool | None
|
||||
|
||||
|
||||
class BackupPlatformProtocol(Protocol):
|
||||
"""Define the format that backup platforms can have."""
|
||||
|
||||
|
@ -65,7 +82,7 @@ class BaseBackupManager(abc.ABC):
|
|||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the backup manager."""
|
||||
self.hass = hass
|
||||
self.backing_up = False
|
||||
self.backup_task: asyncio.Task | None = None
|
||||
self.backups: dict[str, Backup] = {}
|
||||
self.loaded_platforms = False
|
||||
self.platforms: dict[str, BackupPlatformProtocol] = {}
|
||||
|
@ -133,7 +150,12 @@ class BaseBackupManager(abc.ABC):
|
|||
"""Restore a backup."""
|
||||
|
||||
@abc.abstractmethod
|
||||
async def async_create_backup(self, **kwargs: Any) -> Backup:
|
||||
async def async_create_backup(
|
||||
self,
|
||||
*,
|
||||
on_progress: Callable[[BackupProgress], None] | None,
|
||||
**kwargs: Any,
|
||||
) -> NewBackup:
|
||||
"""Generate a backup."""
|
||||
|
||||
@abc.abstractmethod
|
||||
|
@ -292,17 +314,36 @@ class BackupManager(BaseBackupManager):
|
|||
await self.hass.async_add_executor_job(_move_and_cleanup)
|
||||
await self.load_backups()
|
||||
|
||||
async def async_create_backup(self, **kwargs: Any) -> Backup:
|
||||
async def async_create_backup(
|
||||
self,
|
||||
*,
|
||||
on_progress: Callable[[BackupProgress], None] | None,
|
||||
**kwargs: Any,
|
||||
) -> NewBackup:
|
||||
"""Generate a backup."""
|
||||
if self.backing_up:
|
||||
if self.backup_task:
|
||||
raise HomeAssistantError("Backup already in progress")
|
||||
backup_name = f"Core {HAVERSION}"
|
||||
date_str = dt_util.now().isoformat()
|
||||
slug = _generate_slug(date_str, backup_name)
|
||||
self.backup_task = self.hass.async_create_task(
|
||||
self._async_create_backup(backup_name, date_str, slug, on_progress),
|
||||
name="backup_manager_create_backup",
|
||||
eager_start=False, # To ensure the task is not started before we return
|
||||
)
|
||||
return NewBackup(slug=slug)
|
||||
|
||||
async def _async_create_backup(
|
||||
self,
|
||||
backup_name: str,
|
||||
date_str: str,
|
||||
slug: str,
|
||||
on_progress: Callable[[BackupProgress], None] | None,
|
||||
) -> Backup:
|
||||
"""Generate a backup."""
|
||||
success = False
|
||||
try:
|
||||
self.backing_up = True
|
||||
await self.async_pre_backup_actions()
|
||||
backup_name = f"Core {HAVERSION}"
|
||||
date_str = dt_util.now().isoformat()
|
||||
slug = _generate_slug(date_str, backup_name)
|
||||
|
||||
backup_data = {
|
||||
"slug": slug,
|
||||
|
@ -329,9 +370,12 @@ class BackupManager(BaseBackupManager):
|
|||
if self.loaded_backups:
|
||||
self.backups[slug] = backup
|
||||
LOGGER.debug("Generated new backup with slug %s", slug)
|
||||
success = True
|
||||
return backup
|
||||
finally:
|
||||
self.backing_up = False
|
||||
if on_progress:
|
||||
on_progress(BackupProgress(done=True, stage=None, success=success))
|
||||
self.backup_task = None
|
||||
await self.async_post_backup_actions()
|
||||
|
||||
def _mkdir_and_generate_backup_contents(
|
||||
|
|
|
@ -8,6 +8,7 @@ from homeassistant.components import websocket_api
|
|||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import DATA_MANAGER, LOGGER
|
||||
from .manager import BackupProgress
|
||||
|
||||
|
||||
@callback
|
||||
|
@ -40,7 +41,7 @@ async def handle_info(
|
|||
msg["id"],
|
||||
{
|
||||
"backups": list(backups.values()),
|
||||
"backing_up": manager.backing_up,
|
||||
"backing_up": manager.backup_task is not None,
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -113,7 +114,11 @@ async def handle_create(
|
|||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Generate a backup."""
|
||||
backup = await hass.data[DATA_MANAGER].async_create_backup()
|
||||
|
||||
def on_progress(progress: BackupProgress) -> None:
|
||||
connection.send_message(websocket_api.event_message(msg["id"], progress))
|
||||
|
||||
backup = await hass.data[DATA_MANAGER].async_create_backup(on_progress=on_progress)
|
||||
connection.send_result(msg["id"], backup)
|
||||
|
||||
|
||||
|
@ -127,7 +132,6 @@ async def handle_backup_start(
|
|||
) -> None:
|
||||
"""Backup start notification."""
|
||||
manager = hass.data[DATA_MANAGER]
|
||||
manager.backing_up = True
|
||||
LOGGER.debug("Backup start notification")
|
||||
|
||||
try:
|
||||
|
@ -149,7 +153,6 @@ async def handle_backup_end(
|
|||
) -> None:
|
||||
"""Backup end notification."""
|
||||
manager = hass.data[DATA_MANAGER]
|
||||
manager.backing_up = False
|
||||
LOGGER.debug("Backup end notification")
|
||||
|
||||
try:
|
||||
|
|
73
tests/components/backup/conftest.py
Normal file
73
tests/components/backup/conftest.py
Normal file
|
@ -0,0 +1,73 @@
|
|||
"""Test fixtures for the Backup integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
@pytest.fixture(name="mocked_json_bytes")
|
||||
def mocked_json_bytes_fixture() -> Generator[Mock]:
|
||||
"""Mock json_bytes."""
|
||||
with patch(
|
||||
"homeassistant.components.backup.manager.json_bytes",
|
||||
return_value=b"{}", # Empty JSON
|
||||
) as mocked_json_bytes:
|
||||
yield mocked_json_bytes
|
||||
|
||||
|
||||
@pytest.fixture(name="mocked_tarfile")
|
||||
def mocked_tarfile_fixture() -> Generator[Mock]:
|
||||
"""Mock tarfile."""
|
||||
with patch(
|
||||
"homeassistant.components.backup.manager.SecureTarFile"
|
||||
) as mocked_tarfile:
|
||||
yield mocked_tarfile
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_backup_generation")
|
||||
def mock_backup_generation_fixture(
|
||||
hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock
|
||||
) -> Generator[None]:
|
||||
"""Mock backup generator."""
|
||||
|
||||
def _mock_iterdir(path: Path) -> list[Path]:
|
||||
if not path.name.endswith("testing_config"):
|
||||
return []
|
||||
return [
|
||||
Path("test.txt"),
|
||||
Path(".DS_Store"),
|
||||
Path(".storage"),
|
||||
]
|
||||
|
||||
with (
|
||||
patch("pathlib.Path.iterdir", _mock_iterdir),
|
||||
patch("pathlib.Path.stat", MagicMock(st_size=123)),
|
||||
patch("pathlib.Path.is_file", lambda x: x.name != ".storage"),
|
||||
patch(
|
||||
"pathlib.Path.is_dir",
|
||||
lambda x: x.name == ".storage",
|
||||
),
|
||||
patch(
|
||||
"pathlib.Path.exists",
|
||||
lambda x: x != Path(hass.config.path("backups")),
|
||||
),
|
||||
patch(
|
||||
"pathlib.Path.is_symlink",
|
||||
lambda _: False,
|
||||
),
|
||||
patch(
|
||||
"pathlib.Path.mkdir",
|
||||
MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.backup.manager.HAVERSION",
|
||||
"2025.1.0",
|
||||
),
|
||||
):
|
||||
yield
|
|
@ -210,16 +210,23 @@
|
|||
dict({
|
||||
'id': 1,
|
||||
'result': dict({
|
||||
'date': '1970-01-01T00:00:00.000Z',
|
||||
'name': 'Test',
|
||||
'path': 'abc123.tar',
|
||||
'size': 0.0,
|
||||
'slug': 'abc123',
|
||||
'slug': '27f5c632',
|
||||
}),
|
||||
'success': True,
|
||||
'type': 'result',
|
||||
})
|
||||
# ---
|
||||
# name: test_generate[without_hassio].1
|
||||
dict({
|
||||
'event': dict({
|
||||
'done': True,
|
||||
'stage': None,
|
||||
'success': True,
|
||||
}),
|
||||
'id': 1,
|
||||
'type': 'event',
|
||||
})
|
||||
# ---
|
||||
# name: test_info[with_hassio]
|
||||
dict({
|
||||
'error': dict({
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch
|
||||
|
||||
import aiohttp
|
||||
|
@ -10,7 +10,10 @@ from multidict import CIMultiDict, CIMultiDictProxy
|
|||
import pytest
|
||||
|
||||
from homeassistant.components.backup import BackupManager
|
||||
from homeassistant.components.backup.manager import BackupPlatformProtocol
|
||||
from homeassistant.components.backup.manager import (
|
||||
BackupPlatformProtocol,
|
||||
BackupProgress,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
@ -20,59 +23,30 @@ from .common import TEST_BACKUP
|
|||
from tests.common import MockPlatform, mock_platform
|
||||
|
||||
|
||||
async def _mock_backup_generation(manager: BackupManager):
|
||||
async def _mock_backup_generation(
|
||||
manager: BackupManager, mocked_json_bytes: Mock, mocked_tarfile: Mock
|
||||
) -> None:
|
||||
"""Mock backup generator."""
|
||||
|
||||
def _mock_iterdir(path: Path) -> list[Path]:
|
||||
if not path.name.endswith("testing_config"):
|
||||
return []
|
||||
return [
|
||||
Path("test.txt"),
|
||||
Path(".DS_Store"),
|
||||
Path(".storage"),
|
||||
]
|
||||
progress: list[BackupProgress] = []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.backup.manager.SecureTarFile"
|
||||
) as mocked_tarfile,
|
||||
patch("pathlib.Path.iterdir", _mock_iterdir),
|
||||
patch("pathlib.Path.stat", MagicMock(st_size=123)),
|
||||
patch("pathlib.Path.is_file", lambda x: x.name != ".storage"),
|
||||
patch(
|
||||
"pathlib.Path.is_dir",
|
||||
lambda x: x.name == ".storage",
|
||||
),
|
||||
patch(
|
||||
"pathlib.Path.exists",
|
||||
lambda x: x != manager.backup_dir,
|
||||
),
|
||||
patch(
|
||||
"pathlib.Path.is_symlink",
|
||||
lambda _: False,
|
||||
),
|
||||
patch(
|
||||
"pathlib.Path.mkdir",
|
||||
MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.backup.manager.json_bytes",
|
||||
return_value=b"{}", # Empty JSON
|
||||
) as mocked_json_bytes,
|
||||
patch(
|
||||
"homeassistant.components.backup.manager.HAVERSION",
|
||||
"2025.1.0",
|
||||
),
|
||||
):
|
||||
await manager.async_create_backup()
|
||||
def on_progress(_progress: BackupProgress) -> None:
|
||||
"""Mock progress callback."""
|
||||
progress.append(_progress)
|
||||
|
||||
assert mocked_json_bytes.call_count == 1
|
||||
backup_json_dict = mocked_json_bytes.call_args[0][0]
|
||||
assert isinstance(backup_json_dict, dict)
|
||||
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]
|
||||
)
|
||||
assert manager.backup_task is None
|
||||
await manager.async_create_backup(on_progress=on_progress)
|
||||
assert manager.backup_task is not None
|
||||
assert progress == []
|
||||
|
||||
await manager.backup_task
|
||||
assert progress == [BackupProgress(done=True, stage=None, success=True)]
|
||||
|
||||
assert mocked_json_bytes.call_count == 1
|
||||
backup_json_dict = mocked_json_bytes.call_args[0][0]
|
||||
assert isinstance(backup_json_dict, dict)
|
||||
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])
|
||||
|
||||
|
||||
async def _setup_mock_domain(
|
||||
|
@ -176,21 +150,26 @@ async def test_getting_backup_that_does_not_exist(
|
|||
|
||||
async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None:
|
||||
"""Test generate backup."""
|
||||
event = asyncio.Event()
|
||||
manager = BackupManager(hass)
|
||||
manager.backing_up = True
|
||||
manager.backup_task = hass.async_create_task(event.wait())
|
||||
with pytest.raises(HomeAssistantError, match="Backup already in progress"):
|
||||
await manager.async_create_backup()
|
||||
await manager.async_create_backup(on_progress=None)
|
||||
event.set()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_backup_generation")
|
||||
async def test_async_create_backup(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mocked_json_bytes: Mock,
|
||||
mocked_tarfile: Mock,
|
||||
) -> None:
|
||||
"""Test generate backup."""
|
||||
manager = BackupManager(hass)
|
||||
manager.loaded_backups = True
|
||||
|
||||
await _mock_backup_generation(manager)
|
||||
await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile)
|
||||
|
||||
assert "Generated new backup with slug " in caplog.text
|
||||
assert "Creating backup directory" in caplog.text
|
||||
|
@ -247,7 +226,9 @@ async def test_not_loading_bad_platforms(
|
|||
)
|
||||
|
||||
|
||||
async def test_exception_plaform_pre(hass: HomeAssistant) -> None:
|
||||
async def test_exception_plaform_pre(
|
||||
hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock
|
||||
) -> None:
|
||||
"""Test exception in pre step."""
|
||||
manager = BackupManager(hass)
|
||||
manager.loaded_backups = True
|
||||
|
@ -264,10 +245,12 @@ async def test_exception_plaform_pre(hass: HomeAssistant) -> None:
|
|||
)
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await _mock_backup_generation(manager)
|
||||
await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile)
|
||||
|
||||
|
||||
async def test_exception_plaform_post(hass: HomeAssistant) -> None:
|
||||
async def test_exception_plaform_post(
|
||||
hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock
|
||||
) -> None:
|
||||
"""Test exception in post step."""
|
||||
manager = BackupManager(hass)
|
||||
manager.loaded_backups = True
|
||||
|
@ -284,7 +267,7 @@ async def test_exception_plaform_post(hass: HomeAssistant) -> None:
|
|||
)
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await _mock_backup_generation(manager)
|
||||
await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile)
|
||||
|
||||
|
||||
async def test_loading_platforms_when_running_async_pre_backup_actions(
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from unittest.mock import patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from syrupy import SnapshotAssertion
|
||||
|
||||
|
@ -115,29 +116,30 @@ async def test_remove(
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"with_hassio",
|
||||
("with_hassio", "number_of_messages"),
|
||||
[
|
||||
pytest.param(True, id="with_hassio"),
|
||||
pytest.param(False, id="without_hassio"),
|
||||
pytest.param(True, 1, id="with_hassio"),
|
||||
pytest.param(False, 2, id="without_hassio"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_backup_generation")
|
||||
async def test_generate(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
snapshot: SnapshotAssertion,
|
||||
with_hassio: bool,
|
||||
number_of_messages: int,
|
||||
) -> None:
|
||||
"""Test generating a backup."""
|
||||
await setup_backup_integration(hass, with_hassio=with_hassio)
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
freezer.move_to("2024-11-13 12:01:00+01:00")
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.backup.manager.BackupManager.async_create_backup",
|
||||
return_value=TEST_BACKUP,
|
||||
):
|
||||
await client.send_json_auto_id({"type": "backup/generate"})
|
||||
await client.send_json_auto_id({"type": "backup/generate"})
|
||||
for _ in range(number_of_messages):
|
||||
assert snapshot == await client.receive_json()
|
||||
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue