Fix hassio.backup_partial AttributeError when folders are specified (#170312)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Agner
2026-05-11 12:10:37 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent dc9eba372a
commit 6d8b010245
2 changed files with 116 additions and 9 deletions
+36 -7
View File
@@ -7,6 +7,7 @@ from typing import Any
from aiohasupervisor import SupervisorClient, SupervisorError
from aiohasupervisor.models import (
Folder,
FullBackupOptions,
FullRestoreOptions,
PartialBackupOptions,
@@ -70,6 +71,31 @@ SERVICE_MOUNT_RELOAD = "mount_reload"
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
# Legacy alias used by the Supervisor API for the homeassistant flag, kept
# for backwards compatibility with existing automations.
LEGACY_FOLDER_HOMEASSISTANT = "homeassistant"
def _normalize_partial_options_data(data: dict[str, Any]) -> dict[str, Any]:
"""Map legacy aliases used by both partial backup and partial restore handlers."""
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
if ATTR_FOLDERS in data:
folders: set[Any] = set(data[ATTR_FOLDERS])
if LEGACY_FOLDER_HOMEASSISTANT in folders:
folders.discard(LEGACY_FOLDER_HOMEASSISTANT)
if data.get(ATTR_HOMEASSISTANT) is False:
raise ServiceValidationError(
f"{ATTR_HOMEASSISTANT}=False conflicts with the legacy "
f"{LEGACY_FOLDER_HOMEASSISTANT!r} entry in {ATTR_FOLDERS}"
)
data[ATTR_HOMEASSISTANT] = True
if folders:
data[ATTR_FOLDERS] = folders
else:
data.pop(ATTR_FOLDERS)
return data
def valid_addon(value: Any) -> str:
"""Validate value is a valid addon slug."""
@@ -113,7 +139,10 @@ SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
cv.ensure_list,
[vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))],
vol.Unique(),
vol.Coerce(set),
),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
@@ -136,7 +165,10 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
cv.ensure_list,
[vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))],
vol.Unique(),
vol.Coerce(set),
),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
@@ -343,9 +375,7 @@ def async_register_backup_restore_services(
service: ServiceCall,
) -> ServiceResponse:
"""Handler for create partial backup service. Returns the new backup's ID."""
data = service.data.copy()
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
data = _normalize_partial_options_data(service.data.copy())
options = PartialBackupOptions(**data)
try:
@@ -392,8 +422,7 @@ def async_register_backup_restore_services(
"""Handler for partial restore service."""
data = service.data.copy()
backup_slug = data.pop(ATTR_SLUG)
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
data = _normalize_partial_options_data(data)
options = PartialRestoreOptions(**data)
try:
+80 -2
View File
@@ -14,6 +14,7 @@ from aiohasupervisor.models import (
AddonStage,
AddonState,
CIFSMountResponse,
Folder,
FullBackupOptions,
HomeAssistantOptions,
InstalledAddon,
@@ -528,7 +529,7 @@ async def test_service_calls(
name="2021-11-13 03:48:00",
homeassistant=True,
addons={"test"},
folders={"ssl"},
folders={Folder.SSL},
password="123456",
)
)
@@ -552,7 +553,10 @@ async def test_service_calls(
supervisor_client.backups.partial_restore.assert_called_once_with(
"test",
PartialRestoreOptions(
homeassistant=False, addons={"test"}, folders={"ssl"}, password="123456"
homeassistant=False,
addons={"test"},
folders={Folder.SSL},
password="123456",
),
)
@@ -770,6 +774,80 @@ async def test_invalid_service_calls_folder_duplicates(hass: HomeAssistant) -> N
)
@pytest.mark.usefixtures("hassio_env")
async def test_partial_backup_legacy_homeassistant_folder(
hass: HomeAssistant, supervisor_client: AsyncMock
) -> None:
"""Test that the legacy "homeassistant" folder is translated to homeassistant=True."""
assert await async_setup_component(hass, "hassio", {})
supervisor_client.backups.partial_backup.return_value = NewBackup(
job_id=uuid4(), slug="partial"
)
await hass.services.async_call(
"hassio",
"backup_partial",
{"folders": ["homeassistant", "ssl"], "name": "test"},
blocking=True,
)
supervisor_client.backups.partial_backup.assert_called_once_with(
PartialBackupOptions(
name="test",
homeassistant=True,
folders={Folder.SSL},
)
)
@pytest.mark.usefixtures("hassio_env")
async def test_partial_restore_legacy_homeassistant_folder(
hass: HomeAssistant, supervisor_client: AsyncMock
) -> None:
"""Test that the legacy "homeassistant" folder is translated for restore too."""
assert await async_setup_component(hass, "hassio", {})
await hass.services.async_call(
"hassio",
"restore_partial",
{"slug": "test", "folders": ["homeassistant", "ssl"]},
blocking=True,
)
supervisor_client.backups.partial_restore.assert_called_once_with(
"test",
PartialRestoreOptions(
homeassistant=True,
folders={Folder.SSL},
),
)
@pytest.mark.usefixtures("hassio_env", "supervisor_client")
async def test_partial_backup_invalid_folder(hass: HomeAssistant) -> None:
"""Test that an unknown folder name is rejected."""
assert await async_setup_component(hass, "hassio", {})
with pytest.raises(Invalid, match="not a valid value"):
await hass.services.async_call(
"hassio", "backup_partial", {"folders": ["bogus"]}
)
@pytest.mark.usefixtures("hassio_env", "supervisor_client")
async def test_partial_backup_legacy_homeassistant_folder_conflict(
hass: HomeAssistant,
) -> None:
"""Reject combining homeassistant=False with the legacy "homeassistant" folder."""
assert await async_setup_component(hass, "hassio", {})
with pytest.raises(ServiceValidationError, match="conflicts"):
await hass.services.async_call(
"hassio",
"backup_partial",
{"homeassistant": False, "folders": ["homeassistant"]},
blocking=True,
)
@pytest.mark.usefixtures("addon_installed")
async def test_entry_load_and_unload(hass: HomeAssistant) -> None:
"""Test loading and unloading config entry."""