From 08581a99e364879c22e931b1e21c8fc07bfe23f5 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Thu, 3 Oct 2024 18:56:21 +0000 Subject: [PATCH 1/3] Add backups APIs to client library --- aiohasupervisor/backups.py | 101 +++++++++++ aiohasupervisor/models/__init__.py | 30 ++++ aiohasupervisor/models/backups.py | 169 ++++++++++++++++++ aiohasupervisor/root.py | 7 + tests/fixtures/backup_background.json | 4 + tests/fixtures/backup_foreground.json | 4 + tests/fixtures/backup_info.json | 32 ++++ tests/fixtures/backup_restore.json | 4 + tests/fixtures/backups_info.json | 47 +++++ tests/fixtures/backups_list.json | 46 +++++ tests/test_backups.py | 240 ++++++++++++++++++++++++++ 11 files changed, 684 insertions(+) create mode 100644 aiohasupervisor/backups.py create mode 100644 aiohasupervisor/models/backups.py create mode 100644 tests/fixtures/backup_background.json create mode 100644 tests/fixtures/backup_foreground.json create mode 100644 tests/fixtures/backup_info.json create mode 100644 tests/fixtures/backup_restore.json create mode 100644 tests/fixtures/backups_info.json create mode 100644 tests/fixtures/backups_list.json create mode 100644 tests/test_backups.py diff --git a/aiohasupervisor/backups.py b/aiohasupervisor/backups.py new file mode 100644 index 0000000..4e5e354 --- /dev/null +++ b/aiohasupervisor/backups.py @@ -0,0 +1,101 @@ +"""Backups client for supervisor.""" + +from .client import _SupervisorComponentClient +from .const import ResponseType +from .models.backups import ( + Backup, + BackupComplete, + BackupJob, + BackupList, + BackupsInfo, + BackupsOptions, + FreezeOptions, + FullBackupOptions, + FullRestoreOptions, + NewBackup, + PartialBackupOptions, + PartialRestoreOptions, +) + + +class BackupsClient(_SupervisorComponentClient): + """Handles backups access in Supervisor.""" + + async def list(self) -> list[Backup]: + """List backups.""" + result = await self._client.get("backups") + return BackupList.from_dict(result.data).backups + + async def info(self) -> BackupsInfo: + """Get backups info.""" + result = await self._client.get("backups/info") + return BackupsInfo.from_dict(result.data) + + async def options(self, options: BackupsOptions) -> None: + """Set options for backups.""" + await self._client.post("backups/options", json=options.to_dict()) + + async def reload(self) -> None: + """Reload backups cache.""" + await self._client.post("backups/reload") + + async def freeze(self, options: FreezeOptions | None = None) -> None: + """Start a freeze for external snapshot process.""" + await self._client.post( + "backups/freeze", json=options.to_dict() if options else None + ) + + async def thaw(self) -> None: + """Thaw an active freeze when external snapshot process ends.""" + await self._client.post("backups/thaw") + + async def full_backup(self, options: FullBackupOptions | None = None) -> NewBackup: + """Create a new full backup.""" + result = await self._client.post( + "backups/new/full", + json=options.to_dict() if options else None, + response_type=ResponseType.JSON, + ) + return NewBackup.from_dict(result.data) + + async def partial_backup(self, options: PartialBackupOptions) -> NewBackup: + """Create a new partial backup.""" + result = await self._client.post( + "backups/new/partial", + json=options.to_dict(), + response_type=ResponseType.JSON, + ) + return NewBackup.from_dict(result.data) + + async def backup_info(self, backup: str) -> BackupComplete: + """Get backup details.""" + result = await self._client.get(f"backups/{backup}/info") + return BackupComplete.from_dict(result.data) + + async def remove_backup(self, backup: str) -> None: + """Remove a backup.""" + await self._client.delete(f"backups/{backup}") + + async def full_restore( + self, backup: str, options: FullRestoreOptions | None = None + ) -> BackupJob: + """Start full restore from backup.""" + result = await self._client.post( + f"backups/{backup}/restore/full", + json=options.to_dict() if options else None, + response_type=ResponseType.JSON, + ) + return BackupJob.from_dict(result.data) + + async def partial_restore( + self, backup: str, options: PartialRestoreOptions + ) -> BackupJob: + """Start partial restore from backup.""" + result = await self._client.post( + f"backups/{backup}/restore/partial", + json=options.to_dict(), + response_type=ResponseType.JSON, + ) + return BackupJob.from_dict(result.data) + + # Omitted for now - Upload and download backup diff --git a/aiohasupervisor/models/__init__.py b/aiohasupervisor/models/__init__.py index 396a1b2..8c4b89b 100644 --- a/aiohasupervisor/models/__init__.py +++ b/aiohasupervisor/models/__init__.py @@ -46,6 +46,22 @@ YellowInfo, YellowOptions, ) +from aiohasupervisor.models.backups import ( + Backup, + BackupAddon, + BackupComplete, + BackupContent, + BackupJob, + BackupsInfo, + BackupsOptions, + BackupType, + Folder, + FullBackupOptions, + FullRestoreOptions, + NewBackup, + PartialBackupOptions, + PartialRestoreOptions, +) from aiohasupervisor.models.resolution import ( Check, CheckOptions, @@ -138,4 +154,18 @@ "GreenOptions", "YellowInfo", "YellowOptions", + "Backup", + "BackupAddon", + "BackupComplete", + "BackupContent", + "BackupJob", + "BackupsInfo", + "BackupsOptions", + "BackupType", + "Folder", + "FullBackupOptions", + "FullRestoreOptions", + "NewBackup", + "PartialBackupOptions", + "PartialRestoreOptions", ] diff --git a/aiohasupervisor/models/backups.py b/aiohasupervisor/models/backups.py new file mode 100644 index 0000000..66f2a2c --- /dev/null +++ b/aiohasupervisor/models/backups.py @@ -0,0 +1,169 @@ +"""Models for Supervisor backups.""" + +from abc import ABC +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum + +from .base import Request, ResponseData + +# --- ENUMS ---- + + +class BackupType(StrEnum): + """BackupType type.""" + + FULL = "full" + PARTIAL = "partial" + + +class Folder(StrEnum): + """Folder type.""" + + SHARE = "share" + ADDONS = "addons/local" + SSL = "ssl" + MEDIA = "media" + + +# --- OBJECTS ---- + + +@dataclass(frozen=True, slots=True) +class BackupContent(ResponseData): + """BackupContent model.""" + + homeassistant: bool + addons: list[str] + folders: list[str] + + +@dataclass(frozen=True) +class BackupBaseFields(ABC): + """BackupBaseFields ABC type.""" + + slug: str + name: str + date: datetime + type: BackupType + size: float + location: str | None + protected: bool + compressed: bool + + +@dataclass(frozen=True, slots=True) +class Backup(BackupBaseFields, ResponseData): + """Backup model.""" + + content: BackupContent + + +@dataclass(frozen=True, slots=True) +class BackupAddon(ResponseData): + """BackupAddon model.""" + + slug: str + name: str + version: str + size: float + + +@dataclass(frozen=True, slots=True) +class BackupComplete(BackupBaseFields, ResponseData): + """BackupComplete model.""" + + supervisor_version: str + homeassistant: str + addons: list[BackupAddon] + repositories: list[str] + folders: list[str] + homeassistant_exclude_database: bool | None + + +@dataclass(frozen=True, slots=True) +class BackupList(ResponseData): + """BackupList model.""" + + backups: list[Backup] + + +@dataclass(frozen=True, slots=True) +class BackupsInfo(BackupList): + """BackupsInfo model.""" + + days_until_stale: int + + +@dataclass(frozen=True, slots=True) +class BackupsOptions(Request): + """BackupsOptions model.""" + + days_until_stale: int + + +@dataclass(frozen=True, slots=True) +class FreezeOptions(Request): + """FreezeOptions model.""" + + timeout: int + + +@dataclass(frozen=True) +class PartialBackupRestoreOptions(ABC): # noqa: B024 + """PartialBackupRestoreOptions ABC type.""" + + addons: set[str] | None = None + folders: set[Folder] | None = None + homeassistant: bool | None = None + + def __post_init__(self) -> None: + """Validate at least one thing to backup/restore is included.""" + if not any((self.addons, self.folders, self.homeassistant)): + raise TypeError( + "At least one of addons, folders, or homeassistant must have a value" + ) + + +@dataclass(frozen=True, slots=True) +class FullBackupOptions(Request): + """FullBackupOptions model.""" + + name: str | None = None + password: str | None = None + compressed: bool | None = None + location: str | None = None + homeassistant_exclude_database: bool | None = None + background: bool | None = None + + +@dataclass(frozen=True, slots=True) +class PartialBackupOptions(FullBackupOptions, PartialBackupRestoreOptions): + """PartialBackupOptions model.""" + + +@dataclass(frozen=True, slots=True) +class BackupJob(ResponseData): + """BackupJob model.""" + + job_id: str + + +@dataclass(frozen=True, slots=True) +class NewBackup(BackupJob): + """NewBackup model.""" + + slug: str | None = None + + +@dataclass(frozen=True, slots=True) +class FullRestoreOptions(Request): + """FullRestoreOptions model.""" + + password: str | None = None + background: bool | None = None + + +@dataclass(frozen=True, slots=True) +class PartialRestoreOptions(FullRestoreOptions, PartialBackupRestoreOptions): + """PartialRestoreOptions model.""" diff --git a/aiohasupervisor/root.py b/aiohasupervisor/root.py index acf8d5b..c905212 100644 --- a/aiohasupervisor/root.py +++ b/aiohasupervisor/root.py @@ -5,6 +5,7 @@ from aiohttp import ClientSession from .addons import AddonsClient +from .backups import BackupsClient from .client import _SupervisorClient from .homeassistant import HomeAssistantClient from .models.root import AvailableUpdate, AvailableUpdates, RootInfo @@ -28,6 +29,7 @@ def __init__( self._client = _SupervisorClient(api_host, token, request_timeout, session) self._addons = AddonsClient(self._client) self._os = OSClient(self._client) + self._backups = BackupsClient(self._client) self._resolution = ResolutionClient(self._client) self._store = StoreClient(self._client) self._supervisor = SupervisorManagementClient(self._client) @@ -48,6 +50,11 @@ def os(self) -> OSClient: """Get OS component client.""" return self._os + @property + def backups(self) -> BackupsClient: + """Get backups component client.""" + return self._backups + @property def resolution(self) -> ResolutionClient: """Get resolution center component client.""" diff --git a/tests/fixtures/backup_background.json b/tests/fixtures/backup_background.json new file mode 100644 index 0000000..3d18934 --- /dev/null +++ b/tests/fixtures/backup_background.json @@ -0,0 +1,4 @@ +{ + "result": "ok", + "data": { "job_id": "dc9dbc16f6ad4de592ffa72c807ca2bf" } +} diff --git a/tests/fixtures/backup_foreground.json b/tests/fixtures/backup_foreground.json new file mode 100644 index 0000000..b417eb3 --- /dev/null +++ b/tests/fixtures/backup_foreground.json @@ -0,0 +1,4 @@ +{ + "result": "ok", + "data": { "job_id": "dc9dbc16f6ad4de592ffa72c807ca2bf", "slug": "9ecf0028" } +} diff --git a/tests/fixtures/backup_info.json b/tests/fixtures/backup_info.json new file mode 100644 index 0000000..7811e8e --- /dev/null +++ b/tests/fixtures/backup_info.json @@ -0,0 +1,32 @@ +{ + "result": "ok", + "data": { + "slug": "69558789", + "type": "partial", + "name": "addon_core_mosquitto_6.4.0", + "date": "2024-05-31T00:00:00.000000+00:00", + "size": 0.01, + "compressed": true, + "protected": false, + "supervisor_version": "2024.05.0", + "homeassistant": null, + "location": null, + "addons": [ + { + "slug": "core_mosquitto", + "name": "Mosquitto broker", + "version": "6.4.0", + "size": 0.0 + } + ], + "repositories": [ + "core", + "local", + "https://github.com/music-assistant/home-assistant-addon", + "https://github.com/esphome/home-assistant-addon", + "https://github.com/hassio-addons/repository" + ], + "folders": [], + "homeassistant_exclude_database": null + } +} diff --git a/tests/fixtures/backup_restore.json b/tests/fixtures/backup_restore.json new file mode 100644 index 0000000..3d18934 --- /dev/null +++ b/tests/fixtures/backup_restore.json @@ -0,0 +1,4 @@ +{ + "result": "ok", + "data": { "job_id": "dc9dbc16f6ad4de592ffa72c807ca2bf" } +} diff --git a/tests/fixtures/backups_info.json b/tests/fixtures/backups_info.json new file mode 100644 index 0000000..ee9bbba --- /dev/null +++ b/tests/fixtures/backups_info.json @@ -0,0 +1,47 @@ +{ + "result": "ok", + "data": { + "backups": [ + { + "slug": "58bc7491", + "name": "Full Backup 2024-04-06 00:05:39", + "date": "2024-04-06T07:05:40.000000+00:00", + "type": "full", + "size": 828.81, + "location": null, + "protected": false, + "compressed": true, + "content": { + "homeassistant": true, + "addons": [ + "core_matter_server", + "core_samba", + "core_ssh", + "a0d7b954_vscode", + "core_configurator", + "core_mosquitto", + "d5369777_music_assistant_beta", + "cebe7a76_hassio_google_drive_backup" + ], + "folders": ["share", "addons/local", "ssl", "media"] + } + }, + { + "slug": "69558789", + "name": "addon_core_mosquitto_6.4.0", + "date": "2024-05-31T20:48:03.838030+00:00", + "type": "partial", + "size": 0.01, + "location": null, + "protected": false, + "compressed": true, + "content": { + "homeassistant": false, + "addons": ["core_mosquitto"], + "folders": [] + } + } + ], + "days_until_stale": 30 + } +} diff --git a/tests/fixtures/backups_list.json b/tests/fixtures/backups_list.json new file mode 100644 index 0000000..4792494 --- /dev/null +++ b/tests/fixtures/backups_list.json @@ -0,0 +1,46 @@ +{ + "result": "ok", + "data": { + "backups": [ + { + "slug": "58bc7491", + "name": "Full Backup 2024-04-06 00:05:39", + "date": "2024-04-06T07:05:40.000000+00:00", + "type": "full", + "size": 828.81, + "location": null, + "protected": false, + "compressed": true, + "content": { + "homeassistant": true, + "addons": [ + "core_matter_server", + "core_samba", + "core_ssh", + "a0d7b954_vscode", + "core_configurator", + "core_mosquitto", + "d5369777_music_assistant_beta", + "cebe7a76_hassio_google_drive_backup" + ], + "folders": ["share", "addons/local", "ssl", "media"] + } + }, + { + "slug": "69558789", + "name": "addon_core_mosquitto_6.4.0", + "date": "2024-05-31T20:48:03.838030+00:00", + "type": "partial", + "size": 0.01, + "location": null, + "protected": false, + "compressed": true, + "content": { + "homeassistant": false, + "addons": ["core_mosquitto"], + "folders": [] + } + } + ] + } +} diff --git a/tests/test_backups.py b/tests/test_backups.py new file mode 100644 index 0000000..d87f2a1 --- /dev/null +++ b/tests/test_backups.py @@ -0,0 +1,240 @@ +"""Test backups supervisor client.""" + +from datetime import UTC, datetime +from typing import Any + +from aioresponses import CallbackResult, aioresponses +import pytest +from yarl import URL + +from aiohasupervisor import SupervisorClient +from aiohasupervisor.models import ( + BackupsOptions, + Folder, + FullBackupOptions, + PartialBackupOptions, + PartialRestoreOptions, +) + +from . import load_fixture +from .const import SUPERVISOR_URL + + +async def test_backups_list( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test backups list API.""" + responses.get( + f"{SUPERVISOR_URL}/backups", status=200, body=load_fixture("backups_list.json") + ) + backups = await supervisor_client.backups.list() + assert backups[0].slug == "58bc7491" + assert backups[0].type == "full" + assert backups[0].date == datetime(2024, 4, 6, 7, 5, 40, 0, UTC) + assert backups[0].compressed is True + assert backups[0].content.homeassistant is True + assert backups[0].content.folders == ["share", "addons/local", "ssl", "media"] + assert backups[1].slug == "69558789" + assert backups[1].type == "partial" + + +async def test_backups_info( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test backups info API.""" + responses.get( + f"{SUPERVISOR_URL}/backups/info", + status=200, + body=load_fixture("backups_info.json"), + ) + info = await supervisor_client.backups.info() + assert info.backups[0].slug == "58bc7491" + assert info.backups[1].slug == "69558789" + assert info.days_until_stale == 30 + + +async def test_backups_options( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test backups options API.""" + responses.post(f"{SUPERVISOR_URL}/backups/options", status=200) + assert ( + await supervisor_client.backups.options(BackupsOptions(days_until_stale=10)) + is None + ) + assert responses.requests.keys() == { + ("POST", URL(f"{SUPERVISOR_URL}/backups/options")) + } + + +async def test_backups_reload( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test backups reload API.""" + responses.post(f"{SUPERVISOR_URL}/backups/reload", status=200) + assert await supervisor_client.backups.reload() is None + assert responses.requests.keys() == { + ("POST", URL(f"{SUPERVISOR_URL}/backups/reload")) + } + + +async def test_backups_freeze( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test backups freeze API.""" + responses.post(f"{SUPERVISOR_URL}/backups/freeze", status=200) + assert await supervisor_client.backups.freeze() is None + assert responses.requests.keys() == { + ("POST", URL(f"{SUPERVISOR_URL}/backups/freeze")) + } + + +async def test_backups_thaw( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test backups thaw API.""" + responses.post(f"{SUPERVISOR_URL}/backups/thaw", status=200) + assert await supervisor_client.backups.thaw() is None + assert responses.requests.keys() == { + ("POST", URL(f"{SUPERVISOR_URL}/backups/thaw")) + } + + +async def test_partial_backup_options() -> None: + """Test partial backup options.""" + assert PartialBackupOptions(name="good", addons={"a"}) + assert PartialBackupOptions(name="good", folders={Folder.SSL}) + assert PartialBackupOptions(name="good", homeassistant=True) + with pytest.raises(TypeError): + PartialBackupOptions(name="bad") + + +async def test_partial_restore_options() -> None: + """Test partial restore options.""" + assert PartialRestoreOptions(addons={"a"}) + assert PartialRestoreOptions(folders={Folder.SSL}) + assert PartialRestoreOptions(homeassistant=True) + with pytest.raises(TypeError): + PartialRestoreOptions(background=True) + + +def backup_callback(url: str, **kwargs: dict[str, Any]) -> CallbackResult: # noqa: ARG001 + """Return response based on whether backup was in background or not.""" + if kwargs["json"]["background"]: + fixture = "backup_background.json" + else: + fixture = "backup_foreground.json" + return CallbackResult(status=200, body=load_fixture(fixture)) + + +@pytest.mark.parametrize( + ("background", "slug"), + [(True, None), (False, "9ecf0028")], +) +async def test_backups_full_backup( + responses: aioresponses, + supervisor_client: SupervisorClient, + background: bool, # noqa: FBT001 + slug: str | None, +) -> None: + """Test backups full backup API.""" + responses.post( + f"{SUPERVISOR_URL}/backups/new/full", + callback=backup_callback, + ) + result = await supervisor_client.backups.full_backup( + FullBackupOptions(name="test", background=background) + ) + assert result.job_id == "dc9dbc16f6ad4de592ffa72c807ca2bf" + assert result.slug == slug + + +@pytest.mark.parametrize( + ("background", "slug"), + [(True, None), (False, "9ecf0028")], +) +async def test_backups_partial_backup( + responses: aioresponses, + supervisor_client: SupervisorClient, + background: bool, # noqa: FBT001 + slug: str | None, +) -> None: + """Test backups full backup API.""" + responses.post( + f"{SUPERVISOR_URL}/backups/new/partial", + callback=backup_callback, + ) + result = await supervisor_client.backups.partial_backup( + PartialBackupOptions(name="test", background=background, addons={"core_ssh"}) + ) + assert result.job_id == "dc9dbc16f6ad4de592ffa72c807ca2bf" + assert result.slug == slug + + +async def test_backup_info( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test backup info API.""" + responses.get( + f"{SUPERVISOR_URL}/backups/69558789/info", + status=200, + body=load_fixture("backup_info.json"), + ) + result = await supervisor_client.backups.backup_info("69558789") + assert result.slug == "69558789" + assert result.type == "partial" + assert result.date == datetime(2024, 5, 31, 0, 0, 0, 0, UTC) + assert result.size == 0.01 + assert result.compressed is True + assert result.addons[0].slug == "core_mosquitto" + assert result.addons[0].name == "Mosquitto broker" + assert result.addons[0].version == "6.4.0" + assert result.addons[0].size == 0 + assert result.repositories == [ + "core", + "local", + "https://github.com/music-assistant/home-assistant-addon", + "https://github.com/esphome/home-assistant-addon", + "https://github.com/hassio-addons/repository", + ] + assert result.folders == [] + assert result.homeassistant_exclude_database is None + + +async def test_remove_backup( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test remove backup API.""" + responses.delete(f"{SUPERVISOR_URL}/backups/abc123", status=200) + assert await supervisor_client.backups.remove_backup("abc123") is None + assert responses.requests.keys() == { + ("DELETE", URL(f"{SUPERVISOR_URL}/backups/abc123")) + } + + +async def test_full_restore( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test full restore API.""" + responses.post( + f"{SUPERVISOR_URL}/backups/abc123/restore/full", + status=200, + body=load_fixture("backup_restore.json"), + ) + result = await supervisor_client.backups.full_restore("abc123") + assert result.job_id == "dc9dbc16f6ad4de592ffa72c807ca2bf" + + +async def test_partial_restore( + responses: aioresponses, supervisor_client: SupervisorClient +) -> None: + """Test partial restore API.""" + responses.post( + f"{SUPERVISOR_URL}/backups/abc123/restore/partial", + status=200, + body=load_fixture("backup_restore.json"), + ) + result = await supervisor_client.backups.partial_restore( + "abc123", PartialRestoreOptions(addons={"core_ssh"}) + ) + assert result.job_id == "dc9dbc16f6ad4de592ffa72c807ca2bf" From 34acdc94596e64aafb8acc8d7a460c1b93f7ec69 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Wed, 9 Oct 2024 03:19:58 +0000 Subject: [PATCH 2/3] Add tests of options --- aiohasupervisor/models/__init__.py | 34 ++++++++++++++++-------------- tests/test_backups.py | 24 +++++++++++++-------- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/aiohasupervisor/models/__init__.py b/aiohasupervisor/models/__init__.py index 8c4b89b..bf97016 100644 --- a/aiohasupervisor/models/__init__.py +++ b/aiohasupervisor/models/__init__.py @@ -23,6 +23,23 @@ StoreInfo, SupervisorRole, ) +from aiohasupervisor.models.backups import ( + Backup, + BackupAddon, + BackupComplete, + BackupContent, + BackupJob, + BackupsInfo, + BackupsOptions, + BackupType, + Folder, + FreezeOptions, + FullBackupOptions, + FullRestoreOptions, + NewBackup, + PartialBackupOptions, + PartialRestoreOptions, +) from aiohasupervisor.models.homeassistant import ( HomeAssistantInfo, HomeAssistantOptions, @@ -46,22 +63,6 @@ YellowInfo, YellowOptions, ) -from aiohasupervisor.models.backups import ( - Backup, - BackupAddon, - BackupComplete, - BackupContent, - BackupJob, - BackupsInfo, - BackupsOptions, - BackupType, - Folder, - FullBackupOptions, - FullRestoreOptions, - NewBackup, - PartialBackupOptions, - PartialRestoreOptions, -) from aiohasupervisor.models.resolution import ( Check, CheckOptions, @@ -163,6 +164,7 @@ "BackupsOptions", "BackupType", "Folder", + "FreezeOptions", "FullBackupOptions", "FullRestoreOptions", "NewBackup", diff --git a/tests/test_backups.py b/tests/test_backups.py index d87f2a1..c34ee4e 100644 --- a/tests/test_backups.py +++ b/tests/test_backups.py @@ -11,6 +11,7 @@ from aiohasupervisor.models import ( BackupsOptions, Folder, + FreezeOptions, FullBackupOptions, PartialBackupOptions, PartialRestoreOptions, @@ -78,12 +79,15 @@ async def test_backups_reload( } +@pytest.mark.parametrize("options", [None, FreezeOptions(timeout=1000)]) async def test_backups_freeze( - responses: aioresponses, supervisor_client: SupervisorClient + responses: aioresponses, + supervisor_client: SupervisorClient, + options: FreezeOptions | None, ) -> None: """Test backups freeze API.""" responses.post(f"{SUPERVISOR_URL}/backups/freeze", status=200) - assert await supervisor_client.backups.freeze() is None + assert await supervisor_client.backups.freeze(options) is None assert responses.requests.keys() == { ("POST", URL(f"{SUPERVISOR_URL}/backups/freeze")) } @@ -120,7 +124,7 @@ async def test_partial_restore_options() -> None: def backup_callback(url: str, **kwargs: dict[str, Any]) -> CallbackResult: # noqa: ARG001 """Return response based on whether backup was in background or not.""" - if kwargs["json"]["background"]: + if kwargs["json"] and kwargs["json"]["background"]: fixture = "backup_background.json" else: fixture = "backup_foreground.json" @@ -128,13 +132,17 @@ def backup_callback(url: str, **kwargs: dict[str, Any]) -> CallbackResult: # no @pytest.mark.parametrize( - ("background", "slug"), - [(True, None), (False, "9ecf0028")], + ("options", "slug"), + [ + (FullBackupOptions(name="Test", background=True), None), + (FullBackupOptions(name="Test", background=False), "9ecf0028"), + (None, "9ecf0028"), + ], ) async def test_backups_full_backup( responses: aioresponses, supervisor_client: SupervisorClient, - background: bool, # noqa: FBT001 + options: FullBackupOptions | None, slug: str | None, ) -> None: """Test backups full backup API.""" @@ -142,9 +150,7 @@ async def test_backups_full_backup( f"{SUPERVISOR_URL}/backups/new/full", callback=backup_callback, ) - result = await supervisor_client.backups.full_backup( - FullBackupOptions(name="test", background=background) - ) + result = await supervisor_client.backups.full_backup(options) assert result.job_id == "dc9dbc16f6ad4de592ffa72c807ca2bf" assert result.slug == slug From 4f1d5cc7f245cd64d3d82be5688c99ff4dafc6e2 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Wed, 9 Oct 2024 03:26:28 +0000 Subject: [PATCH 3/3] ValueError not TypeError in post_init --- aiohasupervisor/models/backups.py | 2 +- aiohasupervisor/models/base.py | 2 +- tests/test_backups.py | 10 ++++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/aiohasupervisor/models/backups.py b/aiohasupervisor/models/backups.py index 66f2a2c..51d5640 100644 --- a/aiohasupervisor/models/backups.py +++ b/aiohasupervisor/models/backups.py @@ -120,7 +120,7 @@ class PartialBackupRestoreOptions(ABC): # noqa: B024 def __post_init__(self) -> None: """Validate at least one thing to backup/restore is included.""" if not any((self.addons, self.folders, self.homeassistant)): - raise TypeError( + raise ValueError( "At least one of addons, folders, or homeassistant must have a value" ) diff --git a/aiohasupervisor/models/base.py b/aiohasupervisor/models/base.py index 00fc677..0cb0d22 100644 --- a/aiohasupervisor/models/base.py +++ b/aiohasupervisor/models/base.py @@ -58,7 +58,7 @@ class Options(ABC, DataClassDictMixin): def __post_init__(self) -> None: """Validate at least one field is present.""" if not self.to_dict(): - raise TypeError("At least one field must have a value") + raise ValueError("At least one field must have a value") class Config(RequestConfig): """Mashumaro config.""" diff --git a/tests/test_backups.py b/tests/test_backups.py index c34ee4e..78bf93b 100644 --- a/tests/test_backups.py +++ b/tests/test_backups.py @@ -109,7 +109,10 @@ async def test_partial_backup_options() -> None: assert PartialBackupOptions(name="good", addons={"a"}) assert PartialBackupOptions(name="good", folders={Folder.SSL}) assert PartialBackupOptions(name="good", homeassistant=True) - with pytest.raises(TypeError): + with pytest.raises( + ValueError, + match="At least one of addons, folders, or homeassistant must have a value", + ): PartialBackupOptions(name="bad") @@ -118,7 +121,10 @@ async def test_partial_restore_options() -> None: assert PartialRestoreOptions(addons={"a"}) assert PartialRestoreOptions(folders={Folder.SSL}) assert PartialRestoreOptions(homeassistant=True) - with pytest.raises(TypeError): + with pytest.raises( + ValueError, + match="At least one of addons, folders, or homeassistant must have a value", + ): PartialRestoreOptions(background=True)