From 033896480dd1f325df1d233efe6d1721be491a73 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Mon, 4 Aug 2025 08:19:33 -0400 Subject: [PATCH] Fix backup equal and add hash to objects with eq (#6059) * Fix backup equal and add hash to objects with eq * Add test for failed consolidate --- supervisor/backups/backup.py | 46 ++++++++++++++++-------------------- supervisor/mounts/mount.py | 6 ++++- tests/backups/test_backup.py | 27 +++++++++++++++++++++ 3 files changed, 52 insertions(+), 27 deletions(-) diff --git a/supervisor/backups/backup.py b/supervisor/backups/backup.py index 8ebb6c838..ee132cc3e 100644 --- a/supervisor/backups/backup.py +++ b/supervisor/backups/backup.py @@ -262,41 +262,35 @@ class Backup(JobGroup): def __eq__(self, other: Any) -> bool: """Return true if backups have same metadata.""" - if not isinstance(other, Backup): - return False + return isinstance(other, Backup) and self.slug == other.slug - # Compare all fields except ones about protection. Current encryption status does not affect equality - keys = self._data.keys() | other._data.keys() - for k in keys - IGNORED_COMPARISON_FIELDS: - if ( - k not in self._data - or k not in other._data - or self._data[k] != other._data[k] - ): - _LOGGER.info( - "Backup %s and %s not equal because %s field has different value: %s and %s", - self.slug, - other.slug, - k, - self._data.get(k), - other._data.get(k), - ) - return False - return True + def __hash__(self) -> int: + """Return hash of backup.""" + return hash(self.slug) def consolidate(self, backup: Self) -> None: """Consolidate two backups with same slug in different locations.""" - if self.slug != backup.slug: + if self != backup: raise ValueError( f"Backup {self.slug} and {backup.slug} are not the same backup" ) - if self != backup: - raise BackupInvalidError( - f"Backup in {backup.location} and {self.location} both have slug {self.slug} but are not the same!" - ) + + # Compare all fields except ones about protection. Current encryption status does not affect equality + other_data = backup._data # pylint: disable=protected-access + keys = self._data.keys() | other_data.keys() + for k in keys - IGNORED_COMPARISON_FIELDS: + if ( + k not in self._data + or k not in other_data + or self._data[k] != other_data[k] + ): + raise BackupInvalidError( + f"Cannot consolidate backups in {backup.location} and {self.location} with slug {self.slug} " + f"because field {k} has different values: {self._data.get(k)} and {other_data.get(k)}!", + _LOGGER.error, + ) # In case of conflict we always ignore the ones from the first one. But log them to let the user know - if conflict := { loc: val.path for loc, val in self.all_locations.items() diff --git a/supervisor/mounts/mount.py b/supervisor/mounts/mount.py index 1a4dade1d..8e3ab6fca 100644 --- a/supervisor/mounts/mount.py +++ b/supervisor/mounts/mount.py @@ -164,10 +164,14 @@ class Mount(CoreSysAttributes, ABC): """Return true if successfully mounted and available.""" return self.state == UnitActiveState.ACTIVE - def __eq__(self, other): + def __eq__(self, other: object) -> bool: """Return true if mounts are the same.""" return isinstance(other, Mount) and self.name == other.name + def __hash__(self) -> int: + """Return hash of mount.""" + return hash(self.name) + async def load(self) -> None: """Initialize object.""" # If there's no mount unit, mount it to make one diff --git a/tests/backups/test_backup.py b/tests/backups/test_backup.py index 33a2c1709..1afa3f3a7 100644 --- a/tests/backups/test_backup.py +++ b/tests/backups/test_backup.py @@ -185,6 +185,33 @@ async def test_consolidate( } +@pytest.mark.usefixtures("tmp_supervisor_data") +async def test_consolidate_failure(coresys: CoreSys, tmp_path: Path): + """Test consolidate with two backups that are not the same.""" + (mount_dir := coresys.config.path_mounts / "backup_test").mkdir() + tar1 = Path(copy(get_fixture_path("test_consolidate_unc.tar"), tmp_path)) + backup1 = Backup(coresys, tar1, "test", None) + await backup1.load() + + tar2 = Path(copy(get_fixture_path("backup_example.tar"), mount_dir)) + backup2 = Backup(coresys, tar2, "test", "backup_test") + await backup2.load() + + with pytest.raises( + ValueError, + match=f"Backup {backup1.slug} and {backup2.slug} are not the same backup", + ): + backup1.consolidate(backup2) + + # Force slugs to be the same to run the fields check + backup1._data["slug"] = backup2.slug # pylint: disable=protected-access + with pytest.raises( + BackupInvalidError, + match=f"Cannot consolidate backups in {backup2.location} and {backup1.location} with slug {backup1.slug}", + ): + backup1.consolidate(backup2) + + @pytest.mark.parametrize( ( "tarfile_side_effect",