Add BaseBackupManager as a common interface for backup managers (#126611)

* Add BaseBackupManager as a common interface for backup managers

* Document the key

* Update homeassistant/components/backup/manager.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Joakim Sørensen 2024-10-15 12:31:12 +02:00 committed by GitHub
parent 78fce90178
commit a14cb13194
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 78 additions and 44 deletions

View file

@ -32,7 +32,7 @@ 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.generate_backup() await backup_manager.async_create_backup()
hass.services.async_register(DOMAIN, "create", async_handle_create_service) hass.services.async_register(DOMAIN, "create", async_handle_create_service)

View file

@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.util import slugify from homeassistant.util import slugify
from .const import DOMAIN from .const import DOMAIN
from .manager import BackupManager from .manager import BaseBackupManager
@callback @callback
@ -36,8 +36,8 @@ class DownloadBackupView(HomeAssistantView):
if not request["hass_user"].is_admin: if not request["hass_user"].is_admin:
return Response(status=HTTPStatus.UNAUTHORIZED) return Response(status=HTTPStatus.UNAUTHORIZED)
manager: BackupManager = request.app[KEY_HASS].data[DOMAIN] manager: BaseBackupManager = request.app[KEY_HASS].data[DOMAIN]
backup = await manager.get_backup(slug) backup = await manager.async_get_backup(slug=slug)
if backup is None or not backup.path.exists(): if backup is None or not backup.path.exists():
return Response(status=HTTPStatus.NOT_FOUND) return Response(status=HTTPStatus.NOT_FOUND)

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import abc
import asyncio import asyncio
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
import hashlib import hashlib
@ -53,15 +54,48 @@ class BackupPlatformProtocol(Protocol):
"""Perform operations after a backup finishes.""" """Perform operations after a backup finishes."""
class BackupManager: class BaseBackupManager(abc.ABC):
"""Backup manager for the Backup integration.""" """Define the format that backup managers can have."""
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.backup_dir = Path(hass.config.path("backups"))
self.backing_up = False
self.backups: dict[str, Backup] = {} self.backups: dict[str, Backup] = {}
self.backing_up = False
async def async_post_backup_actions(self, **kwargs: Any) -> None:
"""Post backup actions."""
async def async_pre_backup_actions(self, **kwargs: Any) -> None:
"""Pre backup actions."""
@abc.abstractmethod
async def async_create_backup(self, **kwargs: Any) -> Backup:
"""Generate a backup."""
@abc.abstractmethod
async def async_get_backups(self, **kwargs: Any) -> dict[str, Backup]:
"""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:
"""Get a backup."""
@abc.abstractmethod
async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None:
"""Remove a backup."""
class BackupManager(BaseBackupManager):
"""Backup manager for the Backup integration."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the backup manager."""
super().__init__(hass=hass)
self.backup_dir = Path(hass.config.path("backups"))
self.platforms: dict[str, BackupPlatformProtocol] = {} self.platforms: dict[str, BackupPlatformProtocol] = {}
self.loaded_backups = False self.loaded_backups = False
self.loaded_platforms = False self.loaded_platforms = False
@ -84,7 +118,7 @@ class BackupManager:
return return
self.platforms[integration_domain] = platform self.platforms[integration_domain] = platform
async def pre_backup_actions(self) -> None: async def async_pre_backup_actions(self, **kwargs: Any) -> None:
"""Perform pre backup actions.""" """Perform pre backup actions."""
if not self.loaded_platforms: if not self.loaded_platforms:
await self.load_platforms() await self.load_platforms()
@ -100,7 +134,7 @@ class BackupManager:
if isinstance(result, Exception): if isinstance(result, Exception):
raise result raise result
async def post_backup_actions(self) -> None: async def async_post_backup_actions(self, **kwargs: Any) -> None:
"""Perform post backup actions.""" """Perform post backup actions."""
if not self.loaded_platforms: if not self.loaded_platforms:
await self.load_platforms() await self.load_platforms()
@ -151,14 +185,14 @@ class BackupManager:
LOGGER.warning("Unable to read backup %s: %s", backup_path, err) LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
return backups return backups
async def get_backups(self) -> dict[str, Backup]: async def async_get_backups(self, **kwargs: Any) -> dict[str, Backup]:
"""Return backups.""" """Return backups."""
if not self.loaded_backups: if not self.loaded_backups:
await self.load_backups() await self.load_backups()
return self.backups return self.backups
async def get_backup(self, slug: str) -> Backup | None: async def async_get_backup(self, *, slug: str, **kwargs: Any) -> Backup | None:
"""Return a backup.""" """Return a backup."""
if not self.loaded_backups: if not self.loaded_backups:
await self.load_backups() await self.load_backups()
@ -180,23 +214,23 @@ class BackupManager:
return backup return backup
async def remove_backup(self, slug: str) -> None: async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None:
"""Remove a backup.""" """Remove a backup."""
if (backup := await self.get_backup(slug)) is None: if (backup := await self.async_get_backup(slug=slug)) is None:
return return
await self.hass.async_add_executor_job(backup.path.unlink, True) await self.hass.async_add_executor_job(backup.path.unlink, True)
LOGGER.debug("Removed backup located at %s", backup.path) LOGGER.debug("Removed backup located at %s", backup.path)
self.backups.pop(slug) self.backups.pop(slug)
async def generate_backup(self) -> Backup: async def async_create_backup(self, **kwargs: Any) -> Backup:
"""Generate a backup.""" """Generate a backup."""
if self.backing_up: if self.backing_up:
raise HomeAssistantError("Backup already in progress") raise HomeAssistantError("Backup already in progress")
try: try:
self.backing_up = True self.backing_up = True
await self.pre_backup_actions() await self.async_pre_backup_actions()
backup_name = f"Core {HAVERSION}" backup_name = f"Core {HAVERSION}"
date_str = dt_util.now().isoformat() date_str = dt_util.now().isoformat()
slug = _generate_slug(date_str, backup_name) slug = _generate_slug(date_str, backup_name)
@ -229,7 +263,7 @@ class BackupManager:
return backup return backup
finally: finally:
self.backing_up = False self.backing_up = False
await self.post_backup_actions() await self.async_post_backup_actions()
def _mkdir_and_generate_backup_contents( def _mkdir_and_generate_backup_contents(
self, self,

View file

@ -33,7 +33,7 @@ async def handle_info(
) -> None: ) -> None:
"""List all stored backups.""" """List all stored backups."""
manager = hass.data[DATA_MANAGER] manager = hass.data[DATA_MANAGER]
backups = await manager.get_backups() backups = await manager.async_get_backups()
connection.send_result( connection.send_result(
msg["id"], msg["id"],
{ {
@ -57,7 +57,7 @@ async def handle_remove(
msg: dict[str, Any], msg: dict[str, Any],
) -> None: ) -> None:
"""Remove a backup.""" """Remove a backup."""
await hass.data[DATA_MANAGER].remove_backup(msg["slug"]) await hass.data[DATA_MANAGER].async_remove_backup(slug=msg["slug"])
connection.send_result(msg["id"]) connection.send_result(msg["id"])
@ -70,7 +70,7 @@ 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].generate_backup() backup = await hass.data[DATA_MANAGER].async_create_backup()
connection.send_result(msg["id"], backup) connection.send_result(msg["id"], backup)
@ -88,7 +88,7 @@ async def handle_backup_start(
LOGGER.debug("Backup start notification") LOGGER.debug("Backup start notification")
try: try:
await manager.pre_backup_actions() await manager.async_pre_backup_actions()
except Exception as err: # noqa: BLE001 except Exception as err: # noqa: BLE001
connection.send_error(msg["id"], "pre_backup_actions_failed", str(err)) connection.send_error(msg["id"], "pre_backup_actions_failed", str(err))
return return
@ -110,7 +110,7 @@ async def handle_backup_end(
LOGGER.debug("Backup end notification") LOGGER.debug("Backup end notification")
try: try:
await manager.post_backup_actions() await manager.async_post_backup_actions()
except Exception as err: # noqa: BLE001 except Exception as err: # noqa: BLE001
connection.send_error(msg["id"], "post_backup_actions_failed", str(err)) connection.send_error(msg["id"], "post_backup_actions_failed", str(err))
return return

View file

@ -23,7 +23,7 @@ async def test_downloading_backup(
with ( with (
patch( patch(
"homeassistant.components.backup.manager.BackupManager.get_backup", "homeassistant.components.backup.manager.BackupManager.async_get_backup",
return_value=TEST_BACKUP, return_value=TEST_BACKUP,
), ),
patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.exists", return_value=True),

View file

@ -33,7 +33,7 @@ async def test_create_service(
await setup_backup_integration(hass) await setup_backup_integration(hass)
with patch( with patch(
"homeassistant.components.backup.manager.BackupManager.generate_backup", "homeassistant.components.backup.manager.BackupManager.async_create_backup",
) as generate_backup: ) as generate_backup:
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,

View file

@ -62,7 +62,7 @@ async def _mock_backup_generation(manager: BackupManager):
"2025.1.0", "2025.1.0",
), ),
): ):
await manager.generate_backup() await manager.async_create_backup()
assert mocked_json_bytes.call_count == 1 assert mocked_json_bytes.call_count == 1
backup_json_dict = mocked_json_bytes.call_args[0][0] backup_json_dict = mocked_json_bytes.call_args[0][0]
@ -108,7 +108,7 @@ async def test_load_backups(hass: HomeAssistant) -> None:
), ),
): ):
await manager.load_backups() await manager.load_backups()
backups = await manager.get_backups() backups = await manager.async_get_backups()
assert backups == {TEST_BACKUP.slug: TEST_BACKUP} assert backups == {TEST_BACKUP.slug: TEST_BACKUP}
@ -123,7 +123,7 @@ async def test_load_backups_with_exception(
patch("tarfile.open", side_effect=OSError("Test exception")), patch("tarfile.open", side_effect=OSError("Test exception")),
): ):
await manager.load_backups() await manager.load_backups()
backups = await manager.get_backups() backups = await manager.async_get_backups()
assert f"Unable to read backup {TEST_BACKUP.path}: Test exception" in caplog.text assert f"Unable to read backup {TEST_BACKUP.path}: Test exception" in caplog.text
assert backups == {} assert backups == {}
@ -138,7 +138,7 @@ async def test_removing_backup(
manager.loaded_backups = True manager.loaded_backups = True
with patch("pathlib.Path.exists", return_value=True): with patch("pathlib.Path.exists", return_value=True):
await manager.remove_backup(TEST_BACKUP.slug) await manager.async_remove_backup(slug=TEST_BACKUP.slug)
assert "Removed backup located at" in caplog.text assert "Removed backup located at" in caplog.text
@ -149,7 +149,7 @@ async def test_removing_non_existing_backup(
"""Test removing not existing backup.""" """Test removing not existing backup."""
manager = BackupManager(hass) manager = BackupManager(hass)
await manager.remove_backup("non_existing") await manager.async_remove_backup(slug="non_existing")
assert "Removed backup located at" not in caplog.text assert "Removed backup located at" not in caplog.text
@ -163,7 +163,7 @@ async def test_getting_backup_that_does_not_exist(
manager.loaded_backups = True manager.loaded_backups = True
with patch("pathlib.Path.exists", return_value=False): with patch("pathlib.Path.exists", return_value=False):
backup = await manager.get_backup(TEST_BACKUP.slug) backup = await manager.async_get_backup(slug=TEST_BACKUP.slug)
assert backup is None assert backup is None
assert ( assert (
@ -172,15 +172,15 @@ async def test_getting_backup_that_does_not_exist(
) in caplog.text ) in caplog.text
async def test_generate_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."""
manager = BackupManager(hass) manager = BackupManager(hass)
manager.backing_up = True manager.backing_up = True
with pytest.raises(HomeAssistantError, match="Backup already in progress"): with pytest.raises(HomeAssistantError, match="Backup already in progress"):
await manager.generate_backup() await manager.async_create_backup()
async def test_generate_backup( async def test_async_create_backup(
hass: HomeAssistant, hass: HomeAssistant,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
@ -285,7 +285,7 @@ async def test_exception_plaform_post(hass: HomeAssistant) -> None:
await _mock_backup_generation(manager) await _mock_backup_generation(manager)
async def test_loading_platforms_when_running_pre_backup_actions( async def test_loading_platforms_when_running_async_pre_backup_actions(
hass: HomeAssistant, hass: HomeAssistant,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
@ -302,7 +302,7 @@ async def test_loading_platforms_when_running_pre_backup_actions(
async_post_backup=AsyncMock(), async_post_backup=AsyncMock(),
), ),
) )
await manager.pre_backup_actions() await manager.async_pre_backup_actions()
assert manager.loaded_platforms assert manager.loaded_platforms
assert len(manager.platforms) == 1 assert len(manager.platforms) == 1
@ -310,7 +310,7 @@ async def test_loading_platforms_when_running_pre_backup_actions(
assert "Loaded 1 platforms" in caplog.text assert "Loaded 1 platforms" in caplog.text
async def test_loading_platforms_when_running_post_backup_actions( async def test_loading_platforms_when_running_async_post_backup_actions(
hass: HomeAssistant, hass: HomeAssistant,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
@ -327,7 +327,7 @@ async def test_loading_platforms_when_running_post_backup_actions(
async_post_backup=AsyncMock(), async_post_backup=AsyncMock(),
), ),
) )
await manager.post_backup_actions() await manager.async_post_backup_actions()
assert manager.loaded_platforms assert manager.loaded_platforms
assert len(manager.platforms) == 1 assert len(manager.platforms) == 1

View file

@ -45,7 +45,7 @@ async def test_info(
await hass.async_block_till_done() await hass.async_block_till_done()
with patch( with patch(
"homeassistant.components.backup.manager.BackupManager.get_backups", "homeassistant.components.backup.manager.BackupManager.async_get_backups",
return_value={TEST_BACKUP.slug: TEST_BACKUP}, return_value={TEST_BACKUP.slug: TEST_BACKUP},
): ):
await client.send_json_auto_id({"type": "backup/info"}) await client.send_json_auto_id({"type": "backup/info"})
@ -72,7 +72,7 @@ async def test_remove(
await hass.async_block_till_done() await hass.async_block_till_done()
with patch( with patch(
"homeassistant.components.backup.manager.BackupManager.remove_backup", "homeassistant.components.backup.manager.BackupManager.async_remove_backup",
): ):
await client.send_json_auto_id({"type": "backup/remove", "slug": "abc123"}) await client.send_json_auto_id({"type": "backup/remove", "slug": "abc123"})
assert snapshot == await client.receive_json() assert snapshot == await client.receive_json()
@ -98,7 +98,7 @@ async def test_generate(
await hass.async_block_till_done() await hass.async_block_till_done()
with patch( with patch(
"homeassistant.components.backup.manager.BackupManager.generate_backup", "homeassistant.components.backup.manager.BackupManager.async_create_backup",
return_value=TEST_BACKUP, return_value=TEST_BACKUP,
): ):
await client.send_json_auto_id({"type": "backup/generate"}) await client.send_json_auto_id({"type": "backup/generate"})
@ -132,7 +132,7 @@ async def test_backup_end(
await hass.async_block_till_done() await hass.async_block_till_done()
with patch( with patch(
"homeassistant.components.backup.manager.BackupManager.post_backup_actions", "homeassistant.components.backup.manager.BackupManager.async_post_backup_actions",
): ):
await client.send_json_auto_id({"type": "backup/end"}) await client.send_json_auto_id({"type": "backup/end"})
assert snapshot == await client.receive_json() assert snapshot == await client.receive_json()
@ -165,7 +165,7 @@ async def test_backup_start(
await hass.async_block_till_done() await hass.async_block_till_done()
with patch( with patch(
"homeassistant.components.backup.manager.BackupManager.pre_backup_actions", "homeassistant.components.backup.manager.BackupManager.async_pre_backup_actions",
): ):
await client.send_json_auto_id({"type": "backup/start"}) await client.send_json_auto_id({"type": "backup/start"})
assert snapshot == await client.receive_json() assert snapshot == await client.receive_json()
@ -193,7 +193,7 @@ async def test_backup_end_excepion(
await hass.async_block_till_done() await hass.async_block_till_done()
with patch( with patch(
"homeassistant.components.backup.manager.BackupManager.post_backup_actions", "homeassistant.components.backup.manager.BackupManager.async_post_backup_actions",
side_effect=exception, side_effect=exception,
): ):
await client.send_json_auto_id({"type": "backup/end"}) await client.send_json_auto_id({"type": "backup/end"})
@ -222,7 +222,7 @@ async def test_backup_start_excepion(
await hass.async_block_till_done() await hass.async_block_till_done()
with patch( with patch(
"homeassistant.components.backup.manager.BackupManager.pre_backup_actions", "homeassistant.components.backup.manager.BackupManager.async_pre_backup_actions",
side_effect=exception, side_effect=exception,
): ):
await client.send_json_auto_id({"type": "backup/start"}) await client.send_json_auto_id({"type": "backup/start"})