diff --git a/common/config.py b/common/config.py index bc36e89da..84733206d 100644 --- a/common/config.py +++ b/common/config.py @@ -929,26 +929,12 @@ def setKeepOnlyOneSnapshot(self, value, profile_id = None): def removeOldSnapshotsEnabled(self, profile_id = None): return self.profileBoolValue('snapshots.remove_old_snapshots.enabled', True, profile_id) - def removeOldSnapshotsDate(self, profile_id = None): + def removeOldSnapshotsDate(self, profile_id=None): enabled, value, unit = self.removeOldSnapshots(profile_id) if not enabled: return datetime.date(1, 1, 1) - if unit == self.DAY: - date = datetime.date.today() - date = date - datetime.timedelta(days = value) - return date - - if unit == self.WEEK: - date = datetime.date.today() - date = date - datetime.timedelta(days = date.weekday() + 7 * value) - return date - - if unit == self.YEAR: - date = datetime.date.today() - return date.replace(day = 1, year = date.year - value) - - return datetime.date(1, 1, 1) + return _remove_old_snapshots_date(value, unit) def setRemoveOldSnapshots(self, enabled, value, unit, profile_id = None): self.setProfileBoolValue('snapshots.remove_old_snapshots.enabled', enabled, profile_id) @@ -1644,3 +1630,27 @@ def _cron_cmd(self, profile_id): cmd = tools.which('nice') + ' -n19 ' + cmd return cmd + + +def _remove_old_snapshots_date(value, unit): + """Dev note (buhtz, 2025-01): The function exist to decople that code from + Config class and make it testable to investigate its behavior. + + See issue #1943 for further reading. + """ + if unit == Config.DAY: + date = datetime.date.today() + date = date - datetime.timedelta(days=value) + return date + + if unit == Config.WEEK: + date = datetime.date.today() + # Always beginning (Monday) of the week + date = date - datetime.timedelta(days=date.weekday() + 7 * value) + return date + + if unit == Config.YEAR: + date = datetime.date.today() + return date.replace(day=1, year=date.year - value) + + return datetime.date(1, 1, 1) diff --git a/common/snapshots.py b/common/snapshots.py index 3c262a7d5..825200d11 100644 --- a/common/snapshots.py +++ b/common/snapshots.py @@ -1860,7 +1860,6 @@ def freeSpace(self, now): """ snapshots = listSnapshots(self.config, reverse=False) if not snapshots: - logger.debug('No snapshots. Skip freeSpace', self) return last_snapshot = snapshots[-1] @@ -1870,7 +1869,8 @@ def freeSpace(self, now): self.setTakeSnapshotMessage(0, _('Removing old snapshots')) oldBackupId = SID(self.config.removeOldSnapshotsDate(), self.config) - logger.debug("Remove snapshots older than: {}".format(oldBackupId.withoutTag), self) + logger.debug("Remove snapshots older than: {}" + .format(oldBackupId.withoutTag), self) while True: if len(snapshots) <= 1: diff --git a/common/test/test_config.py b/common/test/test_config.py index 6dec1a17a..3e4daf635 100644 --- a/common/test/test_config.py +++ b/common/test/test_config.py @@ -1,4 +1,5 @@ # SPDX-FileCopyrightText: © 2016 Taylor Raack +# SPDX-FileCopyrightText: © 2025 Christian Buhtz # # SPDX-License-Identifier: GPL-2.0-or-later # @@ -10,11 +11,71 @@ import os import sys import getpass +import unittest +import datetime +from unittest.mock import patch from test import generic sys.path.append(os.path.join(os.path.dirname(__file__), '..')) +import config -class TestSshCommand(generic.SSHTestCase): +class RemoveOldSnapshotsDate(unittest.TestCase): + # pylint: disable=protected-access + def test_invalid_unit(self): + """1st January Year 1 on errors""" + unit = 99999 + value = 99 + + self.assertEqual( + config._remove_old_snapshots_date(value, unit), + datetime.date(1, 1, 1)) + + @patch('datetime.date', wraps=datetime.date) + def test_day(self, m): + """Three days""" + m.today.return_value = datetime.date(2025, 1, 10) + sut = config._remove_old_snapshots_date(3, config.Config.DAY) + self.assertEqual(sut, datetime.date(2025, 1, 7)) + + @patch('datetime.date', wraps=datetime.date) + def test_week_always_monday(self, m): + """Result is always a Monday""" + + # 1-53 weeks back + for weeks in range(1, 54): + start = datetime.date(2026, 1, 1) + + # Every day in the year + for count in range(366): + m.today.return_value = start - datetime.timedelta(days=count) + + sut = config._remove_old_snapshots_date( + weeks, config.Config.WEEK) + + # 0=Monday + self.assertEqual(sut.weekday(), 0, f'{sut=} {weeks=}') + + @patch('datetime.date', wraps=datetime.date) + def test_week_ignore_current(self, m): + """Current (incomplete) week is ignored.""" + for day in range(25, 32): # Monday (25th) to Sunday (31th) + m.today.return_value = datetime.date(2025, 8, day) + sut = config._remove_old_snapshots_date(2, config.Config.WEEK) + self.assertEqual( + sut, + datetime.date(2025, 8, 11) # Monday + ) + + @patch('datetime.date', wraps=datetime.date) + def test_year_ignore_current_month(self, m): + """Not years but 12 months are counted. But current month is + ignored.""" + m.today.return_value = datetime.date(2025, 7, 30) + sut = config._remove_old_snapshots_date(2, config.Config.YEAR) + self.assertEqual(sut, datetime.date(2023, 7, 1)) + + +class SshCommand(generic.SSHTestCase): @classmethod def setUpClass(cls): cls._user = getpass.getuser() diff --git a/common/test/test_snapshots_autoremove.py b/common/test/test_snapshots_autoremove.py index 00e623dbe..16e96a2d8 100644 --- a/common/test/test_snapshots_autoremove.py +++ b/common/test/test_snapshots_autoremove.py @@ -68,7 +68,7 @@ def create_SIDs(start_date: Union[date, datetime, list[date]], for d in the_dates: sids.append(snapshots.SID(dt2sidstr(d), cfg)) - return list(reversed(sids)) + return sorted(sids, reverse=True) class KeepFirst(pyfakefs_ut.TestCase): @@ -307,424 +307,445 @@ def test_simple(self): self.assertEqual(sut[7].date.date(), date(2024, 2, 19)) -# class OnePerWeek(pyfakefs_ut.TestCase): -# """Covering the smart remove setting 'Keep one snapshot per week for the -# last N weeks'. - -# That logic is implemented in 'Snapshots.smartRemoveList()' but not testable -# in isolation. So for a first shot we just duplicate that code in this -# tests (see self._org()). -# """ - -# def setUp(self): -# """Setup a fake filesystem.""" -# self.setUpPyfakefs(allow_root_user=False) - -# # cleanup() happens automatically -# self._temp_dir = TemporaryDirectory(prefix='bit.') -# # Workaround: tempfile and pathlib not compatible yet -# self.temp_path = Path(self._temp_dir.name) - -# self._config_fp = self._create_config_file(parent_path=self.temp_path) -# self.cfg = config.Config(str(self._config_fp)) - -# self.sn = snapshots.Snapshots(self.cfg) - -# def _create_config_file(self, parent_path): -# """Minimal config file""" -# # pylint: disable-next=R0801 -# cfg_content = inspect.cleandoc(''' -# config.version=6 -# profile1.snapshots.include.1.type=0 -# profile1.snapshots.include.1.value=rootpath/source -# profile1.snapshots.include.size=1 -# profile1.snapshots.no_on_battery=false -# profile1.snapshots.notify.enabled=true -# profile1.snapshots.path=rootpath/destination -# profile1.snapshots.path.host=test-host -# profile1.snapshots.path.profile=1 -# profile1.snapshots.path.user=test-user -# profile1.snapshots.preserve_acl=false -# profile1.snapshots.preserve_xattr=false -# profile1.snapshots.remove_old_snapshots.enabled=true -# profile1.snapshots.remove_old_snapshots.unit=80 -# profile1.snapshots.remove_old_snapshots.value=10 -# profile1.snapshots.rsync_options.enabled=false -# profile1.snapshots.rsync_options.value= -# profiles.version=1 -# ''') - -# # config file location -# config_fp = parent_path / 'config_path' / 'config' -# config_fp.parent.mkdir() -# config_fp.write_text(cfg_content, 'utf-8') - -# return config_fp - -# def _org(self, now, n_weeks, snapshots, keep_healthy=True): -# """Keep one per week for the last n_weeks weeks. - -# Copied and slightly refactored from inside -# 'Snapshots.smartRemoveList()'. -# """ -# print(f'\n_org() :: now={dt2str(now)} {n_weeks=}') -# keep = set() - -# # Sunday ??? (Sonntag) of previous week -# idx_date = now - timedelta(days=now.weekday() + 1) - -# print(f' for-loop... idx_date={dt2str(idx_date)}') -# for _ in range(0, n_weeks): - -# min_date = idx_date -# max_date = idx_date + timedelta(days=7) - -# print(f' from {dt2str(min_date)} to/before {dt2str(max_date)}') -# keep |= self.sn.smartRemoveKeepFirst( -# snapshots, -# min_date, -# max_date, -# keep_healthy=keep_healthy) -# print(f' {keep=}') - -# idx_date -= timedelta(days=7) -# print(f' new idx_date={dt2str(idx_date)}') -# print(' ...end loop') - -# return keep - -# def test_foobar(self): -# # start = date(2022, 1, 15) -# now = date(2024, 11, 26) -# # sids = create_SIDs(start, 9*7+3, self.cfg) -# sids = create_SIDs( -# [ -# date(2024, 11, 2), -# date(2024, 11, 9), -# date(2024, 11, 16), -# date(2024, 11, 23), -# # date(2024, 11, 25) -# ], -# None, -# self.cfg -# ) - -# weeks = 3 -# sut = self._org( -# # "Today" is Thursday 28th March -# now=now, -# # Keep the last week -# n_weeks=weeks, -# snapshots=sids) - -# print(f'\noldest snapshot: {sid2str(sids[0])}') -# for s in sorted(sut): -# print(f'keep: {sid2str(s)}') -# print(f'from/now: {dt2str(now)} {weeks=}') -# print(f'latest snapshot: {sid2str(sids[-1])}') - -# def test_sunday_last_week(self): -# """Keep sunday of the last week.""" -# # 9 backups: 18th (Monday) - 26th (Thursday) March 2024 -# sids = create_SIDs(date(2024, 3, 18), 9, self.cfg) - -# sut = self._org( -# # "Today" is Thursday 28th March -# now=date(2024, 3, 28), -# # Keep the last week -# n_weeks=1, -# snapshots=sids) - -# # only one kept -# self.assertTrue(len(sut), 1) -# # Sunday March 24th -# self.assertTrue(str(sut.pop()).startswith('20240324-')) - -# def test_three_weeks(self): -# """Keep sunday of the last 3 weeks and throw away the rest.""" - -# # 6 Weeks of backups (2024-02-18 - 2024-03-30) -# sids = create_SIDs(datetime(2024, 2, 18), 7*6, self.cfg) -# print(f'{str(sids[0])=} {str(sids[-1])=}') - -# sut = self._org( -# # "Today" is Thursday 28th March -# now=date(2024, 3, 28), -# # Keep the last week -# n_weeks=3, -# snapshots=sids) - -# # only one kept -# self.assertTrue(len(sut), 3) -# sut = sorted(sut) -# for s in sut: -# print(s) - - -# class ForLastNDays(pyfakefs_ut.TestCase): -# """Covering the smart remove setting 'Keep one per day for N days.'. - -# That logic is implemented in 'Snapshots.smartRemoveList()' but not testable -# in isolation. So for a first shot we just duplicate that code in this -# tests (see self._org()). -# """ - -# def setUp(self): -# """Setup a fake filesystem.""" -# self.setUpPyfakefs(allow_root_user=False) - -# # cleanup() happens automatically -# self._temp_dir = TemporaryDirectory(prefix='bit.') -# # Workaround: tempfile and pathlib not compatible yet -# self.temp_path = Path(self._temp_dir.name) - -# self._config_fp = self._create_config_file(parent_path=self.temp_path) -# self.cfg = config.Config(str(self._config_fp)) - -# self.sn = snapshots.Snapshots(self.cfg) - -# def _create_config_file(self, parent_path): -# """Minimal config file""" -# # pylint: disable-next=R0801 -# cfg_content = inspect.cleandoc(''' -# config.version=6 -# profile1.snapshots.include.1.type=0 -# profile1.snapshots.include.1.value=rootpath/source -# profile1.snapshots.include.size=1 -# profile1.snapshots.no_on_battery=false -# profile1.snapshots.notify.enabled=true -# profile1.snapshots.path=rootpath/destination -# profile1.snapshots.path.host=test-host -# profile1.snapshots.path.profile=1 -# profile1.snapshots.path.user=test-user -# profile1.snapshots.preserve_acl=false -# profile1.snapshots.preserve_xattr=false -# profile1.snapshots.remove_old_snapshots.enabled=true -# profile1.snapshots.remove_old_snapshots.unit=80 -# profile1.snapshots.remove_old_snapshots.value=10 -# profile1.snapshots.rsync_options.enabled=false -# profile1.snapshots.rsync_options.value= -# profiles.version=1 -# ''') - -# # config file location -# config_fp = parent_path / 'config_path' / 'config' -# config_fp.parent.mkdir() -# config_fp.write_text(cfg_content, 'utf-8') - -# return config_fp - -# def _org(self, now, n_days, snapshots): -# """Copied and slightly refactored from inside -# 'Snapshots.smartRemoveList()'. -# """ -# print(f'\n_org() :: now={dt2str(now)} {n_days=}') - -# keep = self.sn.smartRemoveKeepAll( -# snapshots, -# now - timedelta(days=n_days-1), -# now + timedelta(days=1)) - -# return keep - -# def test_foobar(self): -# sids = create_SIDs(datetime(2024, 2, 18), 10, self.cfg) -# sut = self._org(now=date(2024, 2, 27), -# n_days=3, -# snapshots=sids) - -# self.assertEqual(len(sut), 3) - -# sut = sorted(sut) - -# self.assertEqual(sut[0].date.date(), date(2024, 2, 25)) -# self.assertEqual(sut[1].date.date(), date(2024, 2, 26)) -# self.assertEqual(sut[2].date.date(), date(2024, 2, 27)) - - -# class OnePerMonth(pyfakefs_ut.TestCase): -# """Covering the smart remove setting 'Keep one snapshot per week for the -# last N weeks'. - -# That logic is implemented in 'Snapshots.smartRemoveList()' but not testable -# in isolation. So for a first shot we just duplicate that code in this -# tests (see self._org()). -# """ - -# def setUp(self): -# """Setup a fake filesystem.""" -# self.setUpPyfakefs(allow_root_user=False) - -# # cleanup() happens automatically -# self._temp_dir = TemporaryDirectory(prefix='bit.') -# # Workaround: tempfile and pathlib not compatible yet -# self.temp_path = Path(self._temp_dir.name) - -# self._config_fp = self._create_config_file(parent_path=self.temp_path) -# self.cfg = config.Config(str(self._config_fp)) - -# self.sn = snapshots.Snapshots(self.cfg) - -# def _create_config_file(self, parent_path): -# """Minimal config file""" -# # pylint: disable-next=R0801 -# cfg_content = inspect.cleandoc(''' -# config.version=6 -# profile1.snapshots.include.1.type=0 -# profile1.snapshots.include.1.value=rootpath/source -# profile1.snapshots.include.size=1 -# profile1.snapshots.no_on_battery=false -# profile1.snapshots.notify.enabled=true -# profile1.snapshots.path=rootpath/destination -# profile1.snapshots.path.host=test-host -# profile1.snapshots.path.profile=1 -# profile1.snapshots.path.user=test-user -# profile1.snapshots.preserve_acl=false -# profile1.snapshots.preserve_xattr=false -# profile1.snapshots.remove_old_snapshots.enabled=true -# profile1.snapshots.remove_old_snapshots.unit=80 -# profile1.snapshots.remove_old_snapshots.value=10 -# profile1.snapshots.rsync_options.enabled=false -# profile1.snapshots.rsync_options.value= -# profiles.version=1 -# ''') - -# # config file location -# config_fp = parent_path / 'config_path' / 'config' -# config_fp.parent.mkdir() -# config_fp.write_text(cfg_content, 'utf-8') - -# return config_fp - -# def _org(self, now, n_months, snapshots, keep_healthy=True): -# """Keep one per months for the last n_months weeks. - -# Copied and slightly refactored from inside -# 'Snapshots.smartRemoveList()'. -# """ -# print(f'\n_org() :: now={dt2str(now)} {n_months=}') -# keep = set() - -# d1 = date(now.year, now.month, 1) -# d2 = self.sn.incMonth(d1) - -# # each months -# for i in range(0, n_months): -# print(f'{i=} {d1=} {d2}') -# keep |= self.sn.smartRemoveKeepFirst( -# snapshots, d1, d2, keep_healthy=keep_healthy) -# d2 = d1 -# d1 = self.sn.decMonth(d1) - -# return keep - -# def test_foobarm(self): -# now = date(2024, 12, 16) -# # sids = create_SIDs(start, 9*7+3, self.cfg) -# sids = create_SIDs(date(2023, 10, 26), 500, self.cfg) - -# months = 3 -# sut = self._org( -# now=now, -# # Keep the last week -# n_months=months, -# snapshots=sids) - -# print(f'\noldest snapshot: {sid2str(sids[0])}') -# for s in sorted(sut): -# print(f'keep: {sid2str(s)}') -# print(f'from/now: {dt2str(now)} {months=}') -# print(f'latest snapshot: {sid2str(sids[-1])}') - - -# class OnePerYear(pyfakefs_ut.TestCase): -# """Covering the smart remove setting 'Keep one snapshot per year for all -# years.' - -# That logic is implemented in 'Snapshots.smartRemoveList()' but not testable -# in isolation. So for a first shot we just duplicate that code in this -# tests (see self._org()). -# """ - -# def setUp(self): -# """Setup a fake filesystem.""" -# self.setUpPyfakefs(allow_root_user=False) - -# # cleanup() happens automatically -# self._temp_dir = TemporaryDirectory(prefix='bit.') -# # Workaround: tempfile and pathlib not compatible yet -# self.temp_path = Path(self._temp_dir.name) - -# self._config_fp = self._create_config_file(parent_path=self.temp_path) -# self.cfg = config.Config(str(self._config_fp)) - -# self.sn = snapshots.Snapshots(self.cfg) - -# def _create_config_file(self, parent_path): -# """Minimal config file""" -# # pylint: disable-next=R0801 -# cfg_content = inspect.cleandoc(''' -# config.version=6 -# profile1.snapshots.include.1.type=0 -# profile1.snapshots.include.1.value=rootpath/source -# profile1.snapshots.include.size=1 -# profile1.snapshots.no_on_battery=false -# profile1.snapshots.notify.enabled=true -# profile1.snapshots.path=rootpath/destination -# profile1.snapshots.path.host=test-host -# profile1.snapshots.path.profile=1 -# profile1.snapshots.path.user=test-user -# profile1.snapshots.preserve_acl=false -# profile1.snapshots.preserve_xattr=false -# profile1.snapshots.remove_old_snapshots.enabled=true -# profile1.snapshots.remove_old_snapshots.unit=80 -# profile1.snapshots.remove_old_snapshots.value=10 -# profile1.snapshots.rsync_options.enabled=false -# profile1.snapshots.rsync_options.value= -# profiles.version=1 -# ''') - -# # config file location -# config_fp = parent_path / 'config_path' / 'config' -# config_fp.parent.mkdir() -# config_fp.write_text(cfg_content, 'utf-8') - -# return config_fp - -# def _org(self, now, snapshots, keep_healthy=True): -# """Keep one per year - -# Copied and slightly refactored from inside -# 'Snapshots.smartRemoveList()'. -# """ -# first_year = int(snapshots[-1].sid[:4]) - -# print(f'\n_org() :: now={dt2str(now)} {first_year=}') -# keep = set() - -# for i in range(first_year, now.year+1): -# keep |= self.sn.smartRemoveKeepFirst( -# snapshots, -# date(i, 1, 1), -# date(i+1, 1, 1), -# keep_healthy=keep_healthy) - -# return keep - -# def test_foobary(self): -# now = date(2024, 12, 16) -# # sids = create_SIDs(start, 9*7+3, self.cfg) -# sids = create_SIDs(date(2019, 10, 26), 365*6, self.cfg) - -# sut = self._org( -# now=now, -# snapshots=sids) - -# print(f'\noldest snapshot: {sid2str(sids[0])}') -# for s in sorted(sut): -# print(f'keep: {sid2str(s)}') -# print(f'from/now: {dt2str(now)}') -# print(f'latest snapshot: {sid2str(sids[-1])}') +class OnePerWeek(pyfakefs_ut.TestCase): + """Covering the smart remove setting 'Keep one snapshot per week for the + last N weeks'. + + That logic is implemented in 'Snapshots.smartRemoveList()' but not testable + in isolation. So for a first shot we just duplicate that code in this + tests (see self._org()). + """ + + def setUp(self): + """Setup a fake filesystem.""" + self.setUpPyfakefs(allow_root_user=False) + + # cleanup() happens automatically + self._temp_dir = TemporaryDirectory(prefix='bit.') + # Workaround: tempfile and pathlib not compatible yet + self.temp_path = Path(self._temp_dir.name) + + self._config_fp = self._create_config_file(parent_path=self.temp_path) + self.cfg = config.Config(str(self._config_fp)) + + self.sn = snapshots.Snapshots(self.cfg) + + def _create_config_file(self, parent_path): + """Minimal config file""" + # pylint: disable-next=R0801 + cfg_content = inspect.cleandoc(''' + config.version=6 + profile1.snapshots.include.1.type=0 + profile1.snapshots.include.1.value=rootpath/source + profile1.snapshots.include.size=1 + profile1.snapshots.no_on_battery=false + profile1.snapshots.notify.enabled=true + profile1.snapshots.path=rootpath/destination + profile1.snapshots.path.host=test-host + profile1.snapshots.path.profile=1 + profile1.snapshots.path.user=test-user + profile1.snapshots.preserve_acl=false + profile1.snapshots.preserve_xattr=false + profile1.snapshots.remove_old_snapshots.enabled=true + profile1.snapshots.remove_old_snapshots.unit=80 + profile1.snapshots.remove_old_snapshots.value=10 + profile1.snapshots.rsync_options.enabled=false + profile1.snapshots.rsync_options.value= + profiles.version=1 + ''') + + # config file location + config_fp = parent_path / 'config_path' / 'config' + config_fp.parent.mkdir() + config_fp.write_text(cfg_content, 'utf-8') + + return config_fp + + def _org(self, now, n_weeks, snapshots, keep_healthy=True): + """Keep one per week for the last n_weeks weeks. + + Copied and slightly refactored from inside + 'Snapshots.smartRemoveList()'. + """ + print(f'\n_org() :: now={dt2str(now)} {n_weeks=}') + keep = set() + + # Sunday ??? (Sonntag) of previous week + idx_date = now - timedelta(days=now.weekday() + 1) + + print(f' for-loop... idx_date={dt2str(idx_date)}') + for _ in range(0, n_weeks): + + min_date = idx_date + max_date = idx_date + timedelta(days=7) + + print(f' from {dt2str(min_date)} to/before {dt2str(max_date)}') + keep |= self.sn.smartRemoveKeepFirst( + snapshots, + min_date, + max_date, + keep_healthy=keep_healthy) + print(f' {keep=}') + + idx_date -= timedelta(days=7) + print(f' new idx_date={dt2str(idx_date)}') + print(' ...end loop') + + return keep + + def test_foobar(self): + # start = date(2022, 1, 15) + now = date(2024, 11, 26) + # sids = create_SIDs(start, 9*7+3, self.cfg) + sids = create_SIDs( + [ + date(2024, 11, 2), + date(2024, 11, 9), + date(2024, 11, 16), + date(2024, 11, 23), + # date(2024, 11, 25) + ], + None, + self.cfg + ) + + weeks = 3 + sut = self._org( + # "Today" is Thursday 28th March + now=now, + # Keep the last week + n_weeks=weeks, + snapshots=sids) + + print(f'\noldest snapshot: {sid2str(sids[0])}') + for s in sorted(sut): + print(f'keep: {sid2str(s)}') + print(f'from/now: {dt2str(now)} {weeks=}') + print(f'latest snapshot: {sid2str(sids[-1])}') + + def test_sunday_last_week(self): + """Keep sunday of the last week.""" + # 9 backups: 18th (Monday) - 26th (Thursday) March 2024 + sids = create_SIDs(date(2024, 3, 18), 9, self.cfg) + + sut = self._org( + # "Today" is Thursday 28th March + now=date(2024, 3, 28), + # Keep the last week + n_weeks=1, + snapshots=sids) + + # only one kept + self.assertTrue(len(sut), 1) + # Sunday March 24th + self.assertTrue(str(sut.pop()).startswith('20240324-')) + + def test_three_weeks(self): + """Keep sunday of the last 3 weeks and throw away the rest.""" + + # 6 Weeks of backups (2024-02-18 - 2024-03-30) + sids = create_SIDs(datetime(2024, 2, 18), 7*6, self.cfg) + print(f'{str(sids[0])=} {str(sids[-1])=}') + + sut = self._org( + # "Today" is Thursday 28th March + now=date(2024, 3, 28), + # Keep the last week + n_weeks=3, + snapshots=sids) + + # only one kept + self.assertTrue(len(sut), 3) + sut = sorted(sut) + for s in sut: + print(s) + + +class ForLastNDays(pyfakefs_ut.TestCase): + """Covering the smart remove setting 'Keep one per day for N days.'. + + That logic is implemented in 'Snapshots.smartRemoveList()' but not testable + in isolation. So for a first shot we just duplicate that code in this + tests (see self._org()). + """ + + def setUp(self): + """Setup a fake filesystem.""" + self.setUpPyfakefs(allow_root_user=False) + + # cleanup() happens automatically + self._temp_dir = TemporaryDirectory(prefix='bit.') + # Workaround: tempfile and pathlib not compatible yet + self.temp_path = Path(self._temp_dir.name) + + self._config_fp = self._create_config_file(parent_path=self.temp_path) + self.cfg = config.Config(str(self._config_fp)) + + self.sn = snapshots.Snapshots(self.cfg) + + def _create_config_file(self, parent_path): + """Minimal config file""" + # pylint: disable-next=R0801 + cfg_content = inspect.cleandoc(''' + config.version=6 + profile1.snapshots.include.1.type=0 + profile1.snapshots.include.1.value=rootpath/source + profile1.snapshots.include.size=1 + profile1.snapshots.no_on_battery=false + profile1.snapshots.notify.enabled=true + profile1.snapshots.path=rootpath/destination + profile1.snapshots.path.host=test-host + profile1.snapshots.path.profile=1 + profile1.snapshots.path.user=test-user + profile1.snapshots.preserve_acl=false + profile1.snapshots.preserve_xattr=false + profile1.snapshots.remove_old_snapshots.enabled=true + profile1.snapshots.remove_old_snapshots.unit=80 + profile1.snapshots.remove_old_snapshots.value=10 + profile1.snapshots.rsync_options.enabled=false + profile1.snapshots.rsync_options.value= + profiles.version=1 + ''') + + # config file location + config_fp = parent_path / 'config_path' / 'config' + config_fp.parent.mkdir() + config_fp.write_text(cfg_content, 'utf-8') + + return config_fp + + def _org(self, now, n_days, snapshots): + """Copied and slightly refactored from inside + 'Snapshots.smartRemoveList()'. + """ + print(f'\n_org() :: now={dt2str(now)} {n_days=}') + + keep = set() + d = now + for i in range(0, n_days): + keep |= self.sn.smartRemoveKeepFirst( + snapshots, + d, + d + timedelta(days=1), + keep_healthy=True) + d -= timedelta(days=1) + + # keep = self.sn.smartRemoveKeepAll( + # snapshots, + # now - timedelta(days=n_days-1), + # now + timedelta(days=1)) + + return list(keep) + + def test_foobar(self): + sids = create_SIDs( + [ + datetime(2024, 2, 18, 12, 30), + datetime(2024, 2, 18, 18, 47), + datetime(2024, 2, 18, 9, 15), + datetime(2024, 2, 16, 1, 7), + datetime(2024, 2, 17, 8, 4), + ], + None, + self.cfg + ) + + print('\nINPUT') + for s in sids: + print(s) + + sut = self._org(now=date(2024, 2, 18), + n_days=5, + snapshots=sids) + + print('\nRESULT') + for s in sut: + print(s) + + +class OnePerMonth(pyfakefs_ut.TestCase): + """Covering the smart remove setting 'Keep one snapshot per week for the + last N weeks'. + + That logic is implemented in 'Snapshots.smartRemoveList()' but not testable + in isolation. So for a first shot we just duplicate that code in this + tests (see self._org()). + """ + + def setUp(self): + """Setup a fake filesystem.""" + self.setUpPyfakefs(allow_root_user=False) + + # cleanup() happens automatically + self._temp_dir = TemporaryDirectory(prefix='bit.') + # Workaround: tempfile and pathlib not compatible yet + self.temp_path = Path(self._temp_dir.name) + + self._config_fp = self._create_config_file(parent_path=self.temp_path) + self.cfg = config.Config(str(self._config_fp)) + + self.sn = snapshots.Snapshots(self.cfg) + + def _create_config_file(self, parent_path): + """Minimal config file""" + # pylint: disable-next=R0801 + cfg_content = inspect.cleandoc(''' + config.version=6 + profile1.snapshots.include.1.type=0 + profile1.snapshots.include.1.value=rootpath/source + profile1.snapshots.include.size=1 + profile1.snapshots.no_on_battery=false + profile1.snapshots.notify.enabled=true + profile1.snapshots.path=rootpath/destination + profile1.snapshots.path.host=test-host + profile1.snapshots.path.profile=1 + profile1.snapshots.path.user=test-user + profile1.snapshots.preserve_acl=false + profile1.snapshots.preserve_xattr=false + profile1.snapshots.remove_old_snapshots.enabled=true + profile1.snapshots.remove_old_snapshots.unit=80 + profile1.snapshots.remove_old_snapshots.value=10 + profile1.snapshots.rsync_options.enabled=false + profile1.snapshots.rsync_options.value= + profiles.version=1 + ''') + + # config file location + config_fp = parent_path / 'config_path' / 'config' + config_fp.parent.mkdir() + config_fp.write_text(cfg_content, 'utf-8') + + return config_fp + + def _org(self, now, n_months, snapshots, keep_healthy=True): + """Keep one per months for the last n_months weeks. + + Copied and slightly refactored from inside + 'Snapshots.smartRemoveList()'. + """ + print(f'\n_org() :: now={dt2str(now)} {n_months=}') + keep = set() + + d1 = date(now.year, now.month, 1) + d2 = self.sn.incMonth(d1) + + # each months + for i in range(0, n_months): + print(f'{i=} {d1=} {d2}') + keep |= self.sn.smartRemoveKeepFirst( + snapshots, d1, d2, keep_healthy=keep_healthy) + d2 = d1 + d1 = self.sn.decMonth(d1) + + return keep + + def test_foobarm(self): + now = date(2024, 12, 16) + # sids = create_SIDs(start, 9*7+3, self.cfg) + sids = create_SIDs(date(2023, 10, 26), 500, self.cfg) + + months = 3 + sut = self._org( + now=now, + # Keep the last week + n_months=months, + snapshots=sids) + + print(f'\noldest snapshot: {sid2str(sids[0])}') + for s in sorted(sut): + print(f'keep: {sid2str(s)}') + print(f'from/now: {dt2str(now)} {months=}') + print(f'latest snapshot: {sid2str(sids[-1])}') + + +class OnePerYear(pyfakefs_ut.TestCase): + """Covering the smart remove setting 'Keep one snapshot per year for all + years.' + + That logic is implemented in 'Snapshots.smartRemoveList()' but not testable + in isolation. So for a first shot we just duplicate that code in this + tests (see self._org()). + """ + + def setUp(self): + """Setup a fake filesystem.""" + self.setUpPyfakefs(allow_root_user=False) + + # cleanup() happens automatically + self._temp_dir = TemporaryDirectory(prefix='bit.') + # Workaround: tempfile and pathlib not compatible yet + self.temp_path = Path(self._temp_dir.name) + + self._config_fp = self._create_config_file(parent_path=self.temp_path) + self.cfg = config.Config(str(self._config_fp)) + + self.sn = snapshots.Snapshots(self.cfg) + + def _create_config_file(self, parent_path): + """Minimal config file""" + # pylint: disable-next=R0801 + cfg_content = inspect.cleandoc(''' + config.version=6 + profile1.snapshots.include.1.type=0 + profile1.snapshots.include.1.value=rootpath/source + profile1.snapshots.include.size=1 + profile1.snapshots.no_on_battery=false + profile1.snapshots.notify.enabled=true + profile1.snapshots.path=rootpath/destination + profile1.snapshots.path.host=test-host + profile1.snapshots.path.profile=1 + profile1.snapshots.path.user=test-user + profile1.snapshots.preserve_acl=false + profile1.snapshots.preserve_xattr=false + profile1.snapshots.remove_old_snapshots.enabled=true + profile1.snapshots.remove_old_snapshots.unit=80 + profile1.snapshots.remove_old_snapshots.value=10 + profile1.snapshots.rsync_options.enabled=false + profile1.snapshots.rsync_options.value= + profiles.version=1 + ''') + + # config file location + config_fp = parent_path / 'config_path' / 'config' + config_fp.parent.mkdir() + config_fp.write_text(cfg_content, 'utf-8') + + return config_fp + + def _org(self, now, snapshots, keep_healthy=True): + """Keep one per year + + Copied and slightly refactored from inside + 'Snapshots.smartRemoveList()'. + """ + first_year = int(snapshots[-1].sid[:4]) + + print(f'\n_org() :: now={dt2str(now)} {first_year=}') + keep = set() + + for i in range(first_year, now.year+1): + keep |= self.sn.smartRemoveKeepFirst( + snapshots, + date(i, 1, 1), + date(i+1, 1, 1), + keep_healthy=keep_healthy) + + return keep + + def test_foobary(self): + now = date(2024, 12, 16) + # sids = create_SIDs(start, 9*7+3, self.cfg) + sids = create_SIDs(date(2019, 10, 26), 365*6, self.cfg) + + sut = self._org( + now=now, + snapshots=sids) + + print(f'\noldest snapshot: {sid2str(sids[0])}') + for s in sorted(sut): + print(f'keep: {sid2str(s)}') + print(f'from/now: {dt2str(now)}') + print(f'latest snapshot: {sid2str(sids[-1])}') class IncDecMonths(pyfakefs_ut.TestCase): diff --git a/doc/maintain/5_auto_smart_remove.md b/doc/maintain/5_auto_smart_remove.md index cb31b4ff5..2b96f3733 100644 --- a/doc/maintain/5_auto_smart_remove.md +++ b/doc/maintain/5_auto_smart_remove.md @@ -46,11 +46,21 @@ This is how it looks like currently: - In `smartRemoveList()` the direction of ordering of the initial snapshots list is of high relevance. -### Older than N years +### Older than N years/weeks/days - Happens in `Snapshots.freeSpace()` - Relevant also `self.config.removeOldSnapshotsDate()` - Backups removed immediately before executing any other rule. - Named snapshots ignored and kept. +- Days: + - Just days. Nothing special. +- Weeks: + - It is always Monday. + - Current (incomplete) week is ignored. +- Years: + - Not years but 12 months are counted. + - But current month is ignored. + - Example: Step 2 Years back beginning from 7th August 2025, will result in + 1th August 2023. ### Smart remove: Daily GUI wording: _Keep all snapshots for the last `N` day(s)._ @@ -60,6 +70,8 @@ Current behavior of the algorithm: * Reason was that not dates but snapshotIDS (included their tags, the last 3 digits) are used for comparison. * The bug is fixed. +* Keeps the latest/youngest backup of a day. +* Starts in current (not complete) day. ### Smart remove: Weekly GUI wording: _Keep one snapshot per week for the last `N` week(s)._ @@ -78,7 +90,6 @@ Current behavior of the algorithm: * [PR #1819](https://github.com/bit-team/backintime/pull/1819) - ### Smart remove: Monthly - GUI wording: _Keep one snapshot per months for the last `N` month(s)._ - Seems to use the current month, too. diff --git a/doc/manual/src/settings.md b/doc/manual/src/settings.md index 412a8537d..954dfbac3 100644 --- a/doc/manual/src/settings.md +++ b/doc/manual/src/settings.md @@ -118,7 +118,8 @@ You can choose between couple different schedules which will automatically start ![Settings - Exclude](_images/light/settings_exclude.png#only-light) ![Settings - Exclude](_images/dark/settings_exclude.png#only-dark) -## Auto-removal +## Remove & Retention +Also known as _Auto-remove_ In previous versions of _Back In Time_. ![Settings - Auto Remove](_images/light/settings_autoremove.png#only-light) ![Settings - Auto Remove](_images/dark/settings_autoremove.png#only-dark) diff --git a/qt/manageprofiles/__init__.py b/qt/manageprofiles/__init__.py index eb83ab40c..da78be7c5 100644 --- a/qt/manageprofiles/__init__.py +++ b/qt/manageprofiles/__init__.py @@ -11,6 +11,7 @@ # General Public License v2 (GPLv2). See LICENSES directory or go to # . import os +import re import copy from PyQt6.QtGui import QPalette, QBrush, QIcon from PyQt6.QtWidgets import (QDialog, @@ -37,7 +38,7 @@ import messagebox from statedata import StateData from manageprofiles.tab_general import GeneralTab -from manageprofiles.tab_auto_remove import AutoRemoveTab +from manageprofiles.tab_remove_retention import RemoveRetentionTab from manageprofiles.tab_options import OptionsTab from manageprofiles.tab_expert_options import ExpertOptionsTab from editusercallback import EditUserCallback @@ -246,9 +247,20 @@ def _add_tab(wdg: QWidget, label: str): self.cbExcludeBySize.stateChanged.connect(enabled) # TAB: Auto-remove - self._tab_auto_remove = AutoRemoveTab(self) - _add_tab(self._tab_auto_remove, _('&Auto-remove')) - + self._tab_retention = RemoveRetentionTab(self) + _add_tab(self._tab_retention, + # Mask the "&" character, so Qt does not interpret it as a + # shortcut indicator. Doing this via regex to prevent + # confusing our translators. hide this from + # our translators. + re.sub( + # "&" followed by whitespace + r'&(?=\s)', + # replace with this + '&&', + # act on that string + _('&Remove & Retention') + )) # TAB: Options self._tab_options = OptionsTab(self) _add_tab(self._tab_options, _('&Options')) @@ -415,7 +427,7 @@ def updateProfile(self): self._update_exclude_recommend_label() - self._tab_auto_remove.load_values() + self._tab_retention.load_values() self._tab_options.load_values() self._tab_expert_options.load_values() @@ -423,7 +435,7 @@ def saveProfile(self): # These tabs need to be stored before the Generals tab, because the # latter is doing some premount checking and need to know this settings # first. - self._tab_auto_remove.store_values() + self._tab_retention.store_values() self._tab_options.store_values() self._tab_expert_options.store_values() @@ -701,7 +713,7 @@ def slot_combo_modes_changed(self, *params): self.updateExcludeItems() - self._tab_auto_remove.update_items_state(enabled) + self._tab_retention.update_items_state(enabled) self._tab_expert_options.update_items_state(enabled) def updateExcludeItems(self): diff --git a/qt/manageprofiles/combobox.py b/qt/manageprofiles/combobox.py index 5cdf332ce..861a78567 100644 --- a/qt/manageprofiles/combobox.py +++ b/qt/manageprofiles/combobox.py @@ -6,6 +6,7 @@ # General Public License v2 (GPLv2). See file/folder LICENSE or go to # . """Module with an improved combo box widget.""" +from typing import Any from PyQt6.QtWidgets import QComboBox, QWidget @@ -45,11 +46,11 @@ def __init__(self, parent: QWidget, content_dict: dict): self.addItem(entry, userData=data) @property - def current_data(self): + def current_data(self) -> Any: """Data linked to the current selected entry.""" return self.itemData(self.currentIndex()) - def select_by_data(self, data): + def select_by_data(self, data: Any): """Select an entry in the combo box by its underlying data.""" for idx in range(self.count()): if self.itemData(idx) == data: diff --git a/qt/manageprofiles/spinboxunit.py b/qt/manageprofiles/spinboxunit.py new file mode 100644 index 000000000..69586195d --- /dev/null +++ b/qt/manageprofiles/spinboxunit.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: © 2024 Christian BUHTZ +# +# SPDX-License-Identifier: GPL-2.0-or-later +# +# This file is part of the program "Back In Time" which is released under GNU +# General Public License v2 (GPLv2). See file/folder LICENSE or go to +# . +"""Module with a widget combining a spinbox and a combobox.""" +from typing import Any +from PyQt6.QtWidgets import QSpinBox, QWidget, QHBoxLayout +from manageprofiles.combobox import BitComboBox + + +class SpinBoxWithUnit(QWidget): + """A combination of a `QspinBox` and `BitComboBox` (`QComboBox`). + """ + + def __init__(self, + parent: QWidget, + range_min_max: tuple[int, int], + content_dict: dict): + """ + Args: + parent: The parent widget. + range_min_max: ... + content_dict: The dictionary values used to display entries in the + combo box and the keys used as data. + """ + super().__init__(parent=parent) + + layout = QHBoxLayout(self) + + self._spin = QSpinBox(self) + self._spin.setRange(*range_min_max) + layout.addWidget(self._spin) + + self._combo = BitComboBox(self, content_dict) + layout.addWidget(self._combo) + + @property + def data_and_unit(self) -> tuple[int, Any]: + """Data linked to the current selected entry.""" + return (self._spin.value(), self._combo.current_data) + + def select_unit(self, data: Any): + """Select a unit entry in the combo box by its underlying data.""" + self._combo.select_by_data(data) + + def unit(self) -> Any: + return self._combo.current_data + + def value(self) -> int: + """Get value of spin box.""" + return self._spin.value() + + def set_value(self, val: int) -> None: + """Set value of spin box.""" + self._spin.setValue(val) diff --git a/qt/manageprofiles/tab_auto_remove.py b/qt/manageprofiles/tab_auto_remove.py deleted file mode 100644 index 3c7c1ff85..000000000 --- a/qt/manageprofiles/tab_auto_remove.py +++ /dev/null @@ -1,225 +0,0 @@ -# SPDX-FileCopyrightText: © 2008-2022 Oprea Dan -# SPDX-FileCopyrightText: © 2008-2022 Bart de Koning -# SPDX-FileCopyrightText: © 2008-2022 Richard Bailey -# SPDX-FileCopyrightText: © 2008-2022 Germar Reitze -# SPDX-FileCopyrightText: © 2008-2022 Taylor Raak -# SPDX-FileCopyrightText: © 2024 Christian BUHTZ -# -# SPDX-License-Identifier: GPL-2.0-or-later -# -# This file is part of the program "Back In Time" which is released under GNU -# General Public License v2 (GPLv2). See LICENSES directory or go to -# . -from PyQt6.QtWidgets import (QDialog, - QGridLayout, - QVBoxLayout, - QGroupBox, - QLabel, - QSpinBox, - QCheckBox) -import config -import qttools -from manageprofiles.combobox import BitComboBox -from manageprofiles.statebindcheckbox import StateBindCheckBox - - -class AutoRemoveTab(QDialog): - """The 'Auto-remove' tab in the Manage Profiles dialog.""" - - def __init__(self, parent): - super().__init__(parent=parent) - - self._parent_dialog = parent - - tab_layout = QVBoxLayout(self) - - # older than - self.spbRemoveOlder = QSpinBox(self) - self.spbRemoveOlder.setRange(1, 1000) - - REMOVE_OLD_BACKUP_UNITS = { - config.Config.DAY: _('Day(s)'), - config.Config.WEEK: _('Week(s)'), - config.Config.YEAR: _('Year(s)') - } - self.comboRemoveOlderUnit = BitComboBox(self, REMOVE_OLD_BACKUP_UNITS) - - self.cbRemoveOlder = StateBindCheckBox(_('Older than:'), self) - self.cbRemoveOlder.bind(self.spbRemoveOlder) - self.cbRemoveOlder.bind(self.comboRemoveOlderUnit) - - # free space less than - enabled, value, unit = self.config.minFreeSpace() - - self.spbFreeSpace = QSpinBox(self) - self.spbFreeSpace.setRange(1, 1000) - - MIN_FREE_SPACE_UNITS = { - config.Config.DISK_UNIT_MB: 'MiB', - config.Config.DISK_UNIT_GB: 'GiB' - } - self.comboFreeSpaceUnit = BitComboBox(self, MIN_FREE_SPACE_UNITS) - - self.cbFreeSpace = StateBindCheckBox(_('If free space is less than:'), self) - self.cbFreeSpace.bind(self.spbFreeSpace) - self.cbFreeSpace.bind(self.comboFreeSpaceUnit) - - # min free inodes - self.cbFreeInodes = QCheckBox(_('If free inodes is less than:'), self) - - self.spbFreeInodes = QSpinBox(self) - self.spbFreeInodes.setSuffix(' %') - self.spbFreeInodes.setSingleStep(1) - self.spbFreeInodes.setRange(0, 15) - - enabled = lambda state: self.spbFreeInodes.setEnabled(state) - enabled(False) - self.cbFreeInodes.stateChanged.connect(enabled) - - grid = QGridLayout() - tab_layout.addLayout(grid) - grid.addWidget(self.cbRemoveOlder, 0, 0) - grid.addWidget(self.spbRemoveOlder, 0, 1) - grid.addWidget(self.comboRemoveOlderUnit, 0, 2) - grid.addWidget(self.cbFreeSpace, 1, 0) - grid.addWidget(self.spbFreeSpace, 1, 1) - grid.addWidget(self.comboFreeSpaceUnit, 1, 2) - grid.addWidget(self.cbFreeInodes, 2, 0) - grid.addWidget(self.spbFreeInodes, 2, 1) - grid.setColumnStretch(3, 1) - - tab_layout.addSpacing(tab_layout.spacing()*2) - - # Smart removal: checkable GroupBox - self.cbSmartRemove = QGroupBox(_('Smart removal:'), self) - self.cbSmartRemove.setCheckable(True) - smlayout = QGridLayout() - smlayout.setColumnStretch(3, 1) - self.cbSmartRemove.setLayout(smlayout) - tab_layout.addWidget(self.cbSmartRemove) - - # Smart removal: the items... - self.cbSmartRemoveRunRemoteInBackground = QCheckBox( - _('Run in background on remote host.'), self) - qttools.set_wrapped_tooltip( - self.cbSmartRemoveRunRemoteInBackground, - ( - _('The smart remove procedure will run directly on the remote ' - 'machine, not locally. The commands "bash", "screen", and ' - '"flock" must be installed and available on the ' - 'remote machine.'), - _('If selected, Back In Time will first test the ' - 'remote machine.') - ) - ) - smlayout.addWidget(self.cbSmartRemoveRunRemoteInBackground, 0, 0, 1, 2) - - smlayout.addWidget( - QLabel(_('Keep all snapshots for the last'), self), 1, 0) - self.spbKeepAll = QSpinBox(self) - self.spbKeepAll.setRange(1, 10000) - smlayout.addWidget(self.spbKeepAll, 1, 1) - smlayout.addWidget(QLabel(_('day(s).'), self), 1, 2) - - smlayout.addWidget( - QLabel(_('Keep one snapshot per day for the last'), self), 2, 0) - self.spbKeepOnePerDay = QSpinBox(self) - self.spbKeepOnePerDay.setRange(1, 10000) - smlayout.addWidget(self.spbKeepOnePerDay, 2, 1) - smlayout.addWidget(QLabel(_('day(s).'), self), 2, 2) - - smlayout.addWidget( - QLabel(_('Keep one snapshot per week for the last'), self), 3, 0) - self.spbKeepOnePerWeek = QSpinBox(self) - self.spbKeepOnePerWeek.setRange(1, 10000) - smlayout.addWidget(self.spbKeepOnePerWeek, 3, 1) - smlayout.addWidget(QLabel(_('week(s).'), self), 3, 2) - - smlayout.addWidget( - QLabel(_('Keep one snapshot per month for the last'), self), 4, 0) - self.spbKeepOnePerMonth = QSpinBox(self) - self.spbKeepOnePerMonth.setRange(1, 1000) - smlayout.addWidget(self.spbKeepOnePerMonth, 4, 1) - smlayout.addWidget(QLabel(_('month(s).'), self), 4, 2) - - smlayout.addWidget( - QLabel(_('Keep one snapshot per year for all years.'), self), - 5, 0, 1, 3) - - # don't remove named snapshots - self.cbDontRemoveNamedSnapshots \ - = QCheckBox(_('Keep named snapshots.'), self) - self.cbDontRemoveNamedSnapshots.setToolTip( - _('Snapshots that, in addition to the usual timestamp, have been ' - 'given a name will not be deleted.')) - tab_layout.addWidget(self.cbDontRemoveNamedSnapshots) - - tab_layout.addStretch() - - @property - def config(self) -> config.Config: - return self._parent_dialog.config - - def load_values(self): - # remove old snapshots - enabled, value, unit = self.config.removeOldSnapshots() - self.cbRemoveOlder.setChecked(enabled) - self.spbRemoveOlder.setValue(value) - self.comboRemoveOlderUnit.select_by_data(unit) - - # min free space - enabled, value, unit = self.config.minFreeSpace() - self.cbFreeSpace.setChecked(enabled) - self.spbFreeSpace.setValue(value) - self.comboFreeSpaceUnit.select_by_data(unit) - - # min free inodes - self.cbFreeInodes.setChecked(self.config.minFreeInodesEnabled()) - self.spbFreeInodes.setValue(self.config.minFreeInodes()) - - # smart remove - smart_remove, keep_all, keep_one_per_day, keep_one_per_week, \ - keep_one_per_month = self.config.smartRemove() - self.cbSmartRemove.setChecked(smart_remove) - self.spbKeepAll.setValue(keep_all) - self.spbKeepOnePerDay.setValue(keep_one_per_day) - self.spbKeepOnePerWeek.setValue(keep_one_per_week) - self.spbKeepOnePerMonth.setValue(keep_one_per_month) - self.cbSmartRemoveRunRemoteInBackground.setChecked( - self.config.smartRemoveRunRemoteInBackground()) - - # don't remove named snapshots - self.cbDontRemoveNamedSnapshots.setChecked( - self.config.dontRemoveNamedSnapshots()) - - def store_values(self): - self.config.setRemoveOldSnapshots( - self.cbRemoveOlder.isChecked(), - self.spbRemoveOlder.value(), - self.comboRemoveOlderUnit.current_data - ) - - self.config.setMinFreeSpace( - self.cbFreeSpace.isChecked(), - self.spbFreeSpace.value(), - self.comboFreeSpaceUnit.current_data) - - self.config.setMinFreeInodes( - self.cbFreeInodes.isChecked(), - self.spbFreeInodes.value()) - - self.config.setDontRemoveNamedSnapshots( - self.cbDontRemoveNamedSnapshots.isChecked()) - - self.config.setSmartRemove( - self.cbSmartRemove.isChecked(), - self.spbKeepAll.value(), - self.spbKeepOnePerDay.value(), - self.spbKeepOnePerWeek.value(), - self.spbKeepOnePerMonth.value()) - - self.config.setSmartRemoveRunRemoteInBackground( - self.cbSmartRemoveRunRemoteInBackground.isChecked()) - - def update_items_state(self, enabled): - self.cbSmartRemoveRunRemoteInBackground.setVisible(enabled) diff --git a/qt/manageprofiles/tab_remove_retention.py b/qt/manageprofiles/tab_remove_retention.py new file mode 100644 index 000000000..50ca58743 --- /dev/null +++ b/qt/manageprofiles/tab_remove_retention.py @@ -0,0 +1,319 @@ +# SPDX-FileCopyrightText: © 2008-2022 Oprea Dan +# SPDX-FileCopyrightText: © 2008-2022 Bart de Koning +# SPDX-FileCopyrightText: © 2008-2022 Richard Bailey +# SPDX-FileCopyrightText: © 2008-2022 Germar Reitze +# SPDX-FileCopyrightText: © 2008-2022 Taylor Raak +# SPDX-FileCopyrightText: © 2024 Christian BUHTZ +# +# SPDX-License-Identifier: GPL-2.0-or-later +# +# This file is part of the program "Back In Time" which is released under GNU +# General Public License v2 (GPLv2). See LICENSES directory or go to +# . +from PyQt6.QtWidgets import (QDialog, + QGridLayout, + QVBoxLayout, + QHBoxLayout, + QGroupBox, + QLabel, + QSpinBox, + QStyle, + QCheckBox, + QToolTip, + QWidget) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QCursor +import config +import qttools +from manageprofiles.statebindcheckbox import StateBindCheckBox +from manageprofiles.spinboxunit import SpinBoxWithUnit + + +class RemoveRetentionTab(QDialog): + """The 'Remove & Retention' tab in the Manage Profiles dialog.""" + + _STRETCH_FX = (1, ) + + def __init__(self, parent): + super().__init__(parent=parent) + + self._parent_dialog = parent + + # Vertical main layout + self._tab_layout = QVBoxLayout(self) + self.setLayout(self._tab_layout) + + # Icon & Info label + self._label_rule_execute_order() + + # --- + self._tab_layout.addWidget(qttools.HLineWidget()) + + # Keep named backups + self.cbDontRemoveNamedSnapshots = self._checkbox_keep_named() + + # Remove older than N years/months/days + self._checkbox_remove_older, self._spinunit_remove_older \ + = self._remove_older_than() + + # Retention policy + self.cbSmartRemove, \ + self.cbSmartRemoveRunRemoteInBackground, \ + self.spbKeepAll, \ + self.spbKeepOnePerDay, \ + self.spbKeepOnePerWeek, \ + self.spbKeepOnePerMonth \ + = self._groupbox_retention_policy() + + # return spin_unit_space, spin_inodes + self._checkbox_space, \ + self._spin_unit_space, \ + self._checkbox_inodes, \ + self._spin_inodes \ + = self._remove_free_space_inodes() + + self._tab_layout.addStretch() + + @property + def config(self) -> config.Config: + return self._parent_dialog.config + + def load_values(self): + # don't remove named snapshots + self.cbDontRemoveNamedSnapshots.setChecked( + self.config.dontRemoveNamedSnapshots()) + + # remove old snapshots + enabled, value, unit = self.config.removeOldSnapshots() + self._checkbox_remove_older.setChecked(enabled) + self._spinunit_remove_older.set_value(value) + self._spinunit_remove_older.select_unit(unit) + + # smart remove + smart_remove, keep_all, keep_one_per_day, keep_one_per_week, \ + keep_one_per_month = self.config.smartRemove() + self.cbSmartRemove.setChecked(smart_remove) + self.spbKeepAll.setValue(keep_all) + self.spbKeepOnePerDay.setValue(keep_one_per_day) + self.spbKeepOnePerWeek.setValue(keep_one_per_week) + self.spbKeepOnePerMonth.setValue(keep_one_per_month) + self.cbSmartRemoveRunRemoteInBackground.setChecked( + self.config.smartRemoveRunRemoteInBackground()) + + # min free space + enabled, value, unit = self.config.minFreeSpace() + self._checkbox_space.setChecked(enabled) + self._spin_unit_space.set_value(value) + self._spin_unit_space.select_unit(unit) + + # min free inodes + self._checkbox_inodes.setChecked(self.config.minFreeInodesEnabled()) + self._spin_inodes.setValue(self.config.minFreeInodes()) + + def store_values(self): + self.config.setRemoveOldSnapshots( + self._checkbox_remove_older.isChecked(), + self._spinunit_remove_older.value(), + self._spinunit_remove_older.unit() + ) + + self.config.setDontRemoveNamedSnapshots( + self.cbDontRemoveNamedSnapshots.isChecked()) + + self.config.setSmartRemove( + self.cbSmartRemove.isChecked(), + self.spbKeepAll.value(), + self.spbKeepOnePerDay.value(), + self.spbKeepOnePerWeek.value(), + self.spbKeepOnePerMonth.value()) + + self.config.setSmartRemoveRunRemoteInBackground( + self.cbSmartRemoveRunRemoteInBackground.isChecked()) + + self.config.setMinFreeSpace( + self._spin_unit_space.isEnabled(), + self._spin_unit_space.value(), + self._spin_unit_space.unit()) + + self.config.setMinFreeInodes( + self._spin_inodes.isEnabled(), + self._spin_inodes.value()) + + def update_items_state(self, enabled): + self.cbSmartRemoveRunRemoteInBackground.setVisible(enabled) + + def _label_rule_execute_order(self) -> QWidget: + # Icon + icon = self.style().standardPixmap( + QStyle.StandardPixmap.SP_MessageBoxInformation) + icon = icon.scaled( + icon.width()*2, + icon.height()*2, + Qt.AspectRatioMode.KeepAspectRatio) + + icon_label = QLabel(self) + icon_label.setPixmap(icon) + icon_label.setFixedSize(icon.size()) + + # Info text + txt = _( + 'The rules below are processed from top to buttom. Later rules ' + 'override earlier ones and are not constrained by them. See the ' + '{manual} for details and examples.' + ).format( + manual='{}'.format( + _('user manual'))) + txt_label = QLabel(txt) + txt_label.setWordWrap(True) + txt_label.setOpenExternalLinks(True) + + # Show URL in tooltip without anoing http-protocol prefix. + txt_label.linkHovered.connect( + lambda url: QToolTip.showText( + QCursor.pos(), url.replace('https://', '')) + ) + + wdg = QWidget() + layout = QHBoxLayout(wdg) + layout.addWidget(icon_label) + layout.addWidget(txt_label) + + self._tab_layout.addWidget(wdg) + + def _checkbox_keep_named(self) -> QCheckBox: + cb = QCheckBox(_('Keep named snapshots.'), self) + cb.setToolTip( + _('Snapshots that, in addition to the usual timestamp, have been ' + 'given a name will not be deleted.')) + + self._tab_layout.addWidget(cb) + + return cb + + def _remove_older_than(self) -> QWidget: + layout = QHBoxLayout() + + # units + units = { + config.Config.DAY: _('Day(s)'), + config.Config.WEEK: _('Week(s)'), + config.Config.YEAR: _('Year(s)') + } + spin_unit = SpinBoxWithUnit(self, (1, 999), units) + + # checkbox + checkbox = StateBindCheckBox(_('Remove snapshots older than'), self) + checkbox.bind(spin_unit) + + layout.addWidget(checkbox) + layout.addWidget(spin_unit) + + layout.addStretch() + + self._tab_layout.addLayout(layout) + + return checkbox, spin_unit + + def _groupbox_retention_policy(self) -> tuple: + layout = QGridLayout() + layout.setColumnStretch(2, 1) + + checkbox_group = QGroupBox(_('Retention policy'), self) + checkbox_group.setCheckable(True) + checkbox_group.setLayout(layout) + + cb_in_background = QCheckBox( + _('Run in background on remote host.'), self) + qttools.set_wrapped_tooltip( + cb_in_background, + (_('The smart remove procedure will run directly on the remote ' + 'machine, not locally. The commands "bash", "screen", and ' + '"flock" must be installed and available on the ' + 'remote machine.'), + _('If selected, Back In Time will first test the ' + 'remote machine.'))) + layout.addWidget(cb_in_background, 0, 0, 1, 2) + + layout.addWidget( + QLabel(_('Keep all snapshots for the last'), self), 1, 0) + all_last_days = QSpinBox(self) + all_last_days.setRange(1, 999) + all_last_days.setSuffix(' ' + _('day(s).')) + # all_last_days.setAlignment(Qt.AlignmentFlag.AlignRight) + layout.addWidget(all_last_days, 1, 1) + + layout.addWidget( + QLabel(_('Keep the last snapshot for each day for ' + 'the last'), self), + 2, 0) + one_per_day = QSpinBox(self) + one_per_day.setRange(1, 999) + one_per_day.setSuffix(' ' + _('day(s).')) + # one_per_day.setAlignment(Qt.AlignmentFlag.AlignRight) + layout.addWidget(one_per_day, 2, 1) + + layout.addWidget(QLabel(_('Keep the last snapshot for each week for ' + 'the last'), self), 3, 0) + one_per_week = QSpinBox(self) + one_per_week.setRange(1, 999) + one_per_week.setSuffix(' ' + _('week(s).')) + # one_per_week.setAlignment(Qt.AlignmentFlag.AlignRight) + layout.addWidget(one_per_week, 3, 1) + + layout.addWidget(QLabel(_('Keep the last snapshot for each month for ' + 'the last'), self), 4, 0) + one_per_month = QSpinBox(self) + one_per_month.setRange(1, 999) + one_per_month.setSuffix(' ' + _('month(s).')) + # one_per_month.setAlignment(Qt.AlignmentFlag.AlignRight) + layout.addWidget(one_per_month, 4, 1) + + layout.addWidget(QLabel(_('Keep the last snapshot for each year for'), + self), 5, 0) + layout.addWidget(QLabel(_('all years.'), + self), 5, 1) # , Qt.AlignmentFlag.AlignRight) + + self._tab_layout.addWidget(checkbox_group) + + return (checkbox_group, cb_in_background, all_last_days, one_per_day, + one_per_week, one_per_month) + + def _remove_free_space_inodes(self) -> tuple: + # enabled, value, unit = self.config.minFreeSpace() + + # free space less than + MIN_FREE_SPACE_UNITS = { + config.Config.DISK_UNIT_MB: 'MiB', + config.Config.DISK_UNIT_GB: 'GiB' + } + spin_unit_space = SpinBoxWithUnit(self, (1, 99999), MIN_FREE_SPACE_UNITS) + + checkbox_space = StateBindCheckBox( + _('… the free space is less than'), self) + checkbox_space.bind(spin_unit_space) + + # min free inodes + checkbox_inodes = StateBindCheckBox( + _('… the free inodes are less than'), self) + + spin_inodes = QSpinBox(self) + spin_inodes.setSuffix(' %') + spin_inodes.setRange(0, 15) + + checkbox_inodes.bind(spin_inodes) + + # layout + groupbox = QGroupBox(_('Remmove oldest snapshots if …'), self) + grid = QGridLayout() + groupbox.setLayout(grid) + + grid.addWidget(checkbox_space, 1, 0) + grid.addWidget(spin_unit_space, 1, 1) + grid.addWidget(checkbox_inodes, 2, 0) + grid.addWidget(spin_inodes, 2, 1) + grid.setColumnStretch(0, 1) + grid.setColumnStretch(2, 2) + + self._tab_layout.addWidget(groupbox) + + return checkbox_space, spin_unit_space, checkbox_inodes, spin_inodes diff --git a/qt/qttools.py b/qt/qttools.py index 0d7bc1e87..9b96fae37 100644 --- a/qt/qttools.py +++ b/qt/qttools.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: © 2008-2022 Bart de Koning # SPDX-FileCopyrightText: © 2008-2022 Richard Bailey # SPDX-FileCopyrightText: © 2008-2022 Germar Reitze +# SPDX-FileCopyrightText: © 2024 Christian Buhtz # # SPDX-License-Identifier: GPL-2.0-or-later # @@ -34,7 +35,8 @@ QLocale, QLibraryInfo, QT_VERSION_STR) -from PyQt6.QtWidgets import (QWidget, +from PyQt6.QtWidgets import (QFrame, + QWidget, QFileDialog, QAbstractItemView, QListView, @@ -46,6 +48,7 @@ QTreeWidgetItem, QComboBox, QSystemTrayIcon) + from datetime import (datetime, date, timedelta) from calendar import monthrange from packaging.version import Version @@ -108,7 +111,7 @@ def can_render(string, widget): def set_wrapped_tooltip(widget: QWidget, tooltip: Union[str, Iterable[str]], - wrap_length: int=72): + wrap_length: int = 72): """Add a tooltip to the widget but insert line breaks when appropriated. If a list of strings is provided, each string is wrapped individually and @@ -132,7 +135,6 @@ def set_wrapped_tooltip(widget: QWidget, widget.setToolTip('\n'.join(result)) - def update_combo_profiles(config, combo_profiles, current_profile_id): """ Updates the combo box with profiles. @@ -704,3 +706,16 @@ def setCurrentProfileID(self, profileID): if self.itemData(i) == profileID: self.setCurrentIndex(i) break + + +class HLineWidget(QFrame): + """Just a horizontal line. + + It really is the case that even in the year 2025 with Qt6 there is no + dedicated widget class to draw a horizontal line. + """ + + def __init__(self): + super().__init__() + self.setFrameShape(QFrame.Shape.HLine) + self.setFrameShadow(QFrame.Shadow.Sunken)