Make WS command backup/generate send events (#130524)

* Make WS command backup/generate send events

* Update backup.create service
This commit is contained in:
Erik Montnemery 2024-11-13 16:16:49 +01:00 committed by GitHub
parent ac4cb52dbb
commit 093b16c723
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 199 additions and 85 deletions

View file

@ -32,7 +32,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_handle_create_service(call: ServiceCall) -> None: async def async_handle_create_service(call: ServiceCall) -> None:
"""Service handler for creating backups.""" """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) hass.services.async_register(DOMAIN, "create", async_handle_create_service)

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import abc import abc
import asyncio import asyncio
from collections.abc import Callable
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
import hashlib import hashlib
import io import io
@ -34,6 +35,13 @@ from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER
BUF_SIZE = 2**20 * 4 # 4MB BUF_SIZE = 2**20 * 4 # 4MB
@dataclass(slots=True)
class NewBackup:
"""New backup class."""
slug: str
@dataclass(slots=True) @dataclass(slots=True)
class Backup: class Backup:
"""Backup class.""" """Backup class."""
@ -49,6 +57,15 @@ class Backup:
return {**asdict(self), "path": self.path.as_posix()} 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): class BackupPlatformProtocol(Protocol):
"""Define the format that backup platforms can have.""" """Define the format that backup platforms can have."""
@ -65,7 +82,7 @@ class BaseBackupManager(abc.ABC):
def __init__(self, hass: HomeAssistant) -> None: def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the backup manager.""" """Initialize the backup manager."""
self.hass = hass self.hass = hass
self.backing_up = False self.backup_task: asyncio.Task | None = None
self.backups: dict[str, Backup] = {} self.backups: dict[str, Backup] = {}
self.loaded_platforms = False self.loaded_platforms = False
self.platforms: dict[str, BackupPlatformProtocol] = {} self.platforms: dict[str, BackupPlatformProtocol] = {}
@ -133,7 +150,12 @@ class BaseBackupManager(abc.ABC):
"""Restore a backup.""" """Restore a backup."""
@abc.abstractmethod @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.""" """Generate a backup."""
@abc.abstractmethod @abc.abstractmethod
@ -292,17 +314,36 @@ class BackupManager(BaseBackupManager):
await self.hass.async_add_executor_job(_move_and_cleanup) await self.hass.async_add_executor_job(_move_and_cleanup)
await self.load_backups() 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.""" """Generate a backup."""
if self.backing_up: if self.backup_task:
raise HomeAssistantError("Backup already in progress") 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: try:
self.backing_up = True
await self.async_pre_backup_actions() 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 = { backup_data = {
"slug": slug, "slug": slug,
@ -329,9 +370,12 @@ class BackupManager(BaseBackupManager):
if self.loaded_backups: if self.loaded_backups:
self.backups[slug] = backup self.backups[slug] = backup
LOGGER.debug("Generated new backup with slug %s", slug) LOGGER.debug("Generated new backup with slug %s", slug)
success = True
return backup return backup
finally: 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() await self.async_post_backup_actions()
def _mkdir_and_generate_backup_contents( def _mkdir_and_generate_backup_contents(

View file

@ -8,6 +8,7 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from .const import DATA_MANAGER, LOGGER from .const import DATA_MANAGER, LOGGER
from .manager import BackupProgress
@callback @callback
@ -40,7 +41,7 @@ async def handle_info(
msg["id"], msg["id"],
{ {
"backups": list(backups.values()), "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], msg: dict[str, Any],
) -> None: ) -> None:
"""Generate a backup.""" """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) connection.send_result(msg["id"], backup)
@ -127,7 +132,6 @@ async def handle_backup_start(
) -> None: ) -> None:
"""Backup start notification.""" """Backup start notification."""
manager = hass.data[DATA_MANAGER] manager = hass.data[DATA_MANAGER]
manager.backing_up = True
LOGGER.debug("Backup start notification") LOGGER.debug("Backup start notification")
try: try:
@ -149,7 +153,6 @@ async def handle_backup_end(
) -> None: ) -> None:
"""Backup end notification.""" """Backup end notification."""
manager = hass.data[DATA_MANAGER] manager = hass.data[DATA_MANAGER]
manager.backing_up = False
LOGGER.debug("Backup end notification") LOGGER.debug("Backup end notification")
try: try:

View 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

View file

@ -210,16 +210,23 @@
dict({ dict({
'id': 1, 'id': 1,
'result': dict({ 'result': dict({
'date': '1970-01-01T00:00:00.000Z', 'slug': '27f5c632',
'name': 'Test',
'path': 'abc123.tar',
'size': 0.0,
'slug': 'abc123',
}), }),
'success': True, 'success': True,
'type': 'result', '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] # name: test_info[with_hassio]
dict({ dict({
'error': dict({ 'error': dict({

View file

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path import asyncio
from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch
import aiohttp import aiohttp
@ -10,7 +10,10 @@ from multidict import CIMultiDict, CIMultiDictProxy
import pytest import pytest
from homeassistant.components.backup import BackupManager 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.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@ -20,59 +23,30 @@ from .common import TEST_BACKUP
from tests.common import MockPlatform, mock_platform 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.""" """Mock backup generator."""
def _mock_iterdir(path: Path) -> list[Path]: progress: list[BackupProgress] = []
if not path.name.endswith("testing_config"):
return []
return [
Path("test.txt"),
Path(".DS_Store"),
Path(".storage"),
]
with ( def on_progress(_progress: BackupProgress) -> None:
patch( """Mock progress callback."""
"homeassistant.components.backup.manager.SecureTarFile" progress.append(_progress)
) 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()
assert mocked_json_bytes.call_count == 1 assert manager.backup_task is None
backup_json_dict = mocked_json_bytes.call_args[0][0] await manager.async_create_backup(on_progress=on_progress)
assert isinstance(backup_json_dict, dict) assert manager.backup_task is not None
assert backup_json_dict["homeassistant"] == {"version": "2025.1.0"} assert progress == []
assert manager.backup_dir.as_posix() in str(
mocked_tarfile.call_args_list[0][0][0] 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( 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: async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None:
"""Test generate backup.""" """Test generate backup."""
event = asyncio.Event()
manager = BackupManager(hass) 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"): 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( async def test_async_create_backup(
hass: HomeAssistant, hass: HomeAssistant,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
mocked_json_bytes: Mock,
mocked_tarfile: Mock,
) -> None: ) -> None:
"""Test generate backup.""" """Test generate backup."""
manager = BackupManager(hass) manager = BackupManager(hass)
manager.loaded_backups = True 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 "Generated new backup with slug " in caplog.text
assert "Creating backup directory" 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.""" """Test exception in pre step."""
manager = BackupManager(hass) manager = BackupManager(hass)
manager.loaded_backups = True manager.loaded_backups = True
@ -264,10 +245,12 @@ async def test_exception_plaform_pre(hass: HomeAssistant) -> None:
) )
with pytest.raises(HomeAssistantError): 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.""" """Test exception in post step."""
manager = BackupManager(hass) manager = BackupManager(hass)
manager.loaded_backups = True manager.loaded_backups = True
@ -284,7 +267,7 @@ async def test_exception_plaform_post(hass: HomeAssistant) -> None:
) )
with pytest.raises(HomeAssistantError): 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( async def test_loading_platforms_when_running_async_pre_backup_actions(

View file

@ -2,6 +2,7 @@
from unittest.mock import patch from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
@ -115,29 +116,30 @@ async def test_remove(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"with_hassio", ("with_hassio", "number_of_messages"),
[ [
pytest.param(True, id="with_hassio"), pytest.param(True, 1, id="with_hassio"),
pytest.param(False, id="without_hassio"), pytest.param(False, 2, id="without_hassio"),
], ],
) )
@pytest.mark.usefixtures("mock_backup_generation")
async def test_generate( async def test_generate(
hass: HomeAssistant, hass: HomeAssistant,
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,
freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
with_hassio: bool, with_hassio: bool,
number_of_messages: int,
) -> None: ) -> None:
"""Test generating a backup.""" """Test generating a backup."""
await setup_backup_integration(hass, with_hassio=with_hassio) await setup_backup_integration(hass, with_hassio=with_hassio)
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
freezer.move_to("2024-11-13 12:01:00+01:00")
await hass.async_block_till_done() await hass.async_block_till_done()
with patch( await client.send_json_auto_id({"type": "backup/generate"})
"homeassistant.components.backup.manager.BackupManager.async_create_backup", for _ in range(number_of_messages):
return_value=TEST_BACKUP,
):
await client.send_json_auto_id({"type": "backup/generate"})
assert snapshot == await client.receive_json() assert snapshot == await client.receive_json()