diff --git a/CHANGES b/CHANGES index ab77fa810..95423b5f7 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,7 @@ Back In Time Version 1.6.0-dev (development of upcoming release) +* Doc: Remove & Retention (formally known as Auto-/Smart-Remove) with improved GUI and user manual section (#2000) * Changed: Updated desktop entry files * Changed: Move several values from config file into new introduce state file ($XDG_STATE_HOME/backintime.json) * Fix: The width of the fourth column in files view is now saved diff --git a/common/bitbase.py b/common/bitbase.py index 46f770f6e..41d91a608 100644 --- a/common/bitbase.py +++ b/common/bitbase.py @@ -16,6 +16,7 @@ USER_MANUAL_ONLINE_URL = 'https://backintime.readthedocs.io' USER_MANUAL_LOCAL_PATH = Path('/') / 'usr' / 'share' / 'doc' / \ 'backintime-common' / 'manual' / 'index.html' +USER_MANUAL_LOCAL_AVAILABLE = USER_MANUAL_LOCAL_PATH.exists() class TimeUnit(Enum): diff --git a/common/config.py b/common/config.py index cfca761b7..b38bce6d5 100644 --- a/common/config.py +++ b/common/config.py @@ -943,26 +943,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) @@ -1659,3 +1645,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 4139b1a35..98874182d 100644 --- a/common/snapshots.py +++ b/common/snapshots.py @@ -1856,24 +1856,35 @@ def freeSpace(self, now): Args: now (datetime.datetime): Timestamp when takeSnapshot was started. """ + + # All existing snapshots, ordered from old to new. + # e.g. 2025-01-11 to 2025-01-19 snapshots = listSnapshots(self.config, reverse=False) + if not snapshots: - logger.debug('No snapshots. Skip freeSpace', self) return + logger.debug(f'Backups from {snapshots[0]} to {snapshots[-1]}.', self) + last_snapshot = snapshots[-1] - # Remove old backups + # Remove snapshots older than N years/weeks/days if self.config.removeOldSnapshotsEnabled(): self.setTakeSnapshotMessage(0, _('Removing old snapshots')) - oldBackupId = SID(self.config.removeOldSnapshotsDate(), self.config) - logger.debug("Remove snapshots older than: {}".format(oldBackupId.withoutTag), self) + # The oldest backup to keep. Others older than this are removed. + oldSID = SID(self.config.removeOldSnapshotsDate(), self.config) + oldBackupId = oldSID.withoutTag + + logger.debug(f'Remove snapshots older than: {oldBackupId}', self) while True: + # Keep min one backup if len(snapshots) <= 1: break - if snapshots[0] >= oldBackupId: + + # ... younger or same as ... + if snapshots[0].withoutTag >= oldBackupId: break if self.config.dontRemoveNamedSnapshots(): @@ -1882,7 +1893,9 @@ def freeSpace(self, now): continue msg = 'Remove snapshot {} because it is older than {}' - logger.debug(msg.format(snapshots[0].withoutTag, oldBackupId.withoutTag), self) + logger.debug(msg.format( + snapshots[0].withoutTag, oldBackupId), self) + self.remove(snapshots[0]) del snapshots[0] @@ -2427,11 +2440,11 @@ class SID: """ __cValidSID = re.compile(r'^\d{8}-\d{6}(?:-\d{3})?$') - INFO = 'info' - NAME = 'name' - FAILED = 'failed' + INFO = 'info' + NAME = 'name' + FAILED = 'failed' FILEINFO = 'fileinfo.bz2' - LOG = 'takesnapshot.log.bz2' + LOG = 'takesnapshot.log.bz2' def __init__(self, date, cfg): self.config = cfg @@ -2439,14 +2452,17 @@ def __init__(self, date, cfg): self.isRoot = False if isinstance(date, datetime.datetime): - self.sid = '-'.join((date.strftime('%Y%m%d-%H%M%S'), self.config.tag(self.profileID))) + self.sid = '-'.join((date.strftime('%Y%m%d-%H%M%S'), + self.config.tag(self.profileID))) # TODO: Don't use "date" as attribute name. Btw: It is not a date # but a datetime. self.date = date elif isinstance(date, datetime.date): - self.sid = '-'.join((date.strftime('%Y%m%d-000000'), self.config.tag(self.profileID))) - self.date = datetime.datetime.combine(date, datetime.datetime.min.time()) + self.sid = '-'.join((date.strftime('%Y%m%d-000000'), + self.config.tag(self.profileID))) + self.date = datetime.datetime.combine( + date, datetime.datetime.min.time()) elif isinstance(date, str): if self.__cValidSID.match(date): @@ -2756,7 +2772,8 @@ def lastChecked(self): return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(os.path.getatime(info))) return self.displayID - #using @property.setter would be confusing here as there is no value to give + # using @property.setter would be confusing here as there is no value to + # give def setLastChecked(self): """ Set info files atime to current time to indicate this snapshot was diff --git a/common/test/test_config.py b/common/test/test_config.py index 6dec1a17a..6df9f5527 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,70 @@ 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): + 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..22cc44b38 100644 --- a/common/test/test_snapshots_autoremove.py +++ b/common/test/test_snapshots_autoremove.py @@ -9,6 +9,17 @@ # 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 # . +"""Tests related to Remove & Retention, formally known as Auto- and +Smart-remove. + +About the current state of this test module: + + Most of the tests in this module are pseudo-tests. The do not test + productive code but surrogates (e.g. method name `_org()` in each class). + Because the productive code is in an untestable state and needs refactoring + or totally rewrite. This is on the projects todo list. See meta issue + #1945. +""" import os import sys import inspect @@ -17,7 +28,6 @@ from datetime import date, time, datetime, timedelta from pathlib import Path from tempfile import TemporaryDirectory -from test import generic import pyfakefs.fake_filesystem_unittest as pyfakefs_ut sys.path.append(os.path.join(os.path.dirname(__file__), '..')) import config # noqa: E402,RUF100 @@ -68,7 +78,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): @@ -145,6 +155,29 @@ def test_simple_one(self): self.assertTrue(str(sut).startswith('20220324-074231-')) + def test_min_included_max_not(self): + """Minimum date is included in range, but max date not""" + sids = create_SIDs( + [ + datetime(2022, 3, 5), + datetime(2022, 3, 3), + ], + None, + self.cfg) + + sut = self.sn.smartRemoveKeepFirst( + snapshots=sids, + min_date=date(2022, 3, 3), + max_date=date(2022, 3, 5), + ) + + self.assertEqual(len(sut), 1) + + sut = sut.pop() + + # the min_date is included + self.assertEqual(sut.date.date(), date(2022, 3, 3)) + def test_no_date_ordering(self): """Hit first in the list and ignoring its date ordering. @@ -171,6 +204,7 @@ def test_no_date_ordering(self): self.assertEqual(str(sut.pop()), '20160422-030324-123') def test_keep_first_range_outside(self): + """No SID inside the specified range""" sids = [] # April, 2016... for timestamp_string in ['20160424-215134-123', # …24th @@ -229,9 +263,10 @@ def test_ignore_unhealthy(self, mock_failed): self.assertEqual(str(sut.pop()), '20160422-020324-123') -class KeepAll(pyfakefs_ut.TestCase): +class KeepAllForLast(pyfakefs_ut.TestCase): """Test Snapshot.removeKeepAll(). + Keep all snapshots for the last N days. PyFakeFS is used here because of Config file dependency.""" def setUp(self): @@ -279,9 +314,52 @@ def _create_config_file(self, parent_path): return config_fp + def _org(self, snapshots, now, days_to_keep): + """Simulated production code. Refactoring is on the todo list.""" + + keep_all = days_to_keep + keep = self.sn.smartRemoveKeepAll( + snapshots, + now - timedelta(days=keep_all-1), + now + timedelta(days=1)) + + return sorted(keep, reverse=True) + + def test_border(self): + """The dates used in the user manual example. + + Here the current (just running incomplete) day is contained in the + calculation. + """ + sids = create_SIDs([ + datetime(2025, 4, 17, 22, 0), + datetime(2025, 4, 17, 18, 1), + datetime(2025, 4, 17, 12, 0), + datetime(2025, 4, 17, 4, 0), + datetime(2025, 4, 16, 8, 30), + datetime(2025, 4, 15, 16, 0), + datetime(2025, 4, 15, 0, 0), + datetime(2025, 4, 14, 23, 59), + datetime(2025, 4, 14, 9, 0), + ], + None, + self.cfg) + + sut = self._org( + snapshots=sids, + now=datetime(2025, 4, 17, 22, 00).date(), + days_to_keep=2) + + self.assertEqual(sut[0].date, datetime(2025, 4, 17, 22, 0)) + self.assertEqual(sut[1].date, datetime(2025, 4, 17, 18, 1)) + self.assertEqual(sut[2].date, datetime(2025, 4, 17, 12, 0)) + self.assertEqual(sut[3].date, datetime(2025, 4, 17, 4, 0)) + self.assertEqual(sut[4].date, datetime(2025, 4, 16, 8, 30)) + def test_simple(self): """Simple""" # 10th to 25th + sids = create_SIDs(datetime(2024, 2, 10), 15, self.cfg) # keep... @@ -307,424 +385,442 @@ 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 KeepOneForLastNDays(pyfakefs_ut.TestCase): + """Covering the smart remove setting 'Keep the last snapshot of each day + for the last 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()'. + """ + + keep = set() + d = now + for _ in range(0, n_days): + keep |= self.sn.smartRemoveKeepFirst( + snapshots, + d, + d + timedelta(days=1), + keep_healthy=True) + d -= timedelta(days=1) + + return sorted(keep, reverse=True) + + def test_doc_example(self): + sids = create_SIDs([ + datetime(2025, 4, 17, 22, 0), + datetime(2025, 4, 17, 4, 0), + datetime(2025, 4, 16, 8, 30), + datetime(2025, 4, 15, 16, 0), + datetime(2025, 4, 15, 0, 0), + datetime(2025, 4, 14, 23, 59), + datetime(2025, 4, 13, 19, 0), + datetime(2025, 4, 13, 7, 0), + datetime(2025, 4, 12, 18, 45), + datetime(2025, 4, 12, 18, 5), + datetime(2025, 4, 11, 9, 0), + ], + None, + self.cfg) + + sut = self._org( + now=date(2025, 4, 17), + n_days=5, + snapshots=sids) + + self.assertEqual(sut[0].date, datetime(2025, 4, 17, 22, 0)) + self.assertEqual(sut[1].date, datetime(2025, 4, 16, 8, 30)) + self.assertEqual(sut[2].date, datetime(2025, 4, 15, 16, 0)) + self.assertEqual(sut[3].date, datetime(2025, 4, 14, 23, 59)) + self.assertEqual(sut[4].date, datetime(2025, 4, 13, 19, 0)) + + +class KeepOneForLastNWeeks(pyfakefs_ut.TestCase): + """Covering the smart remove setting 'Keep the last snapshot for each 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()'. + """ + keep = set() + + # Sunday ??? (Sonntag) of previous week + idx_date = now - timedelta(days=now.weekday()) + + for _ in range(0, n_weeks): + + min_date = idx_date + max_date = idx_date + timedelta(days=7) + + keep |= self.sn.smartRemoveKeepFirst( + snapshots, + min_date, + max_date, + keep_healthy=keep_healthy) + + idx_date -= timedelta(days=7) + + return sorted(keep, reverse=True) + + def test_doc_example(self): + """Example used in manual""" + sids = create_SIDs( + [ + # 5 Weeks, each 3 days + datetime(2025, 4, 17, 22, 0), + datetime(2025, 4, 16, 4, 0), + datetime(2025, 4, 15, 14, 0), + datetime(2025, 4, 13, 22, 0), + datetime(2025, 4, 9, 4, 0), + datetime(2025, 4, 8, 14, 0), + datetime(2025, 4, 3, 22, 0), + datetime(2025, 4, 2, 4, 0), + datetime(2025, 4, 1, 14, 0), + datetime(2025, 3, 27, 22, 0), + datetime(2025, 3, 26, 4, 0), + datetime(2025, 3, 24, 14, 0), + datetime(2025, 3, 20, 22, 0), + datetime(2025, 3, 19, 4, 0), + datetime(2025, 3, 18, 14, 0) + ], + None, + self.cfg + ) + + sut = self._org( + now=date(2025, 4, 17), + n_weeks=4, + snapshots=sids) + + self.assertEqual(sut[0].date, datetime(2025, 4, 17, 22, 0)) + self.assertEqual(sut[1].date, datetime(2025, 4, 13, 22, 0)) + self.assertEqual(sut[2].date, datetime(2025, 4, 3, 22, 0)) + self.assertEqual(sut[3].date, datetime(2025, 3, 27, 22, 0)) + self.assertEqual(len(sut), 4) + + +class KeepOneForLastNMonths(pyfakefs_ut.TestCase): + """Covering the smart remove setting 'Keep the last snapshot for each month + for the last N months'. + + 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 months. + + Copied and slightly refactored from inside + 'Snapshots.smartRemoveList()'. + """ + keep = set() + + d1 = date(now.year, now.month, 1) + d2 = self.sn.incMonth(d1) + + # each months + for _ in range(0, n_months): + keep |= self.sn.smartRemoveKeepFirst( + snapshots, d1, d2, keep_healthy=keep_healthy) + d2 = d1 + d1 = self.sn.decMonth(d1) + + return sorted(keep, reverse=True) + + def test_doc_example(self): + sids = create_SIDs( + [ + # 10 months period + date(2025, 8, 18), + date(2025, 8, 6), + date(2025, 7, 31), + date(2025, 7, 1), + date(2025, 6, 30), + date(2025, 6, 1), + # gap of 2 months + date(2025, 3, 18), + date(2025, 2, 9), + date(2025, 1, 6), + date(2024, 12, 26), + date(2024, 11, 14), + ], + None, + self.cfg + ) + now = sids[0].date.date() + timedelta(days=5) + + months = 6 + sut = self._org( + now=now, + # Keep the last week + n_months=months, + snapshots=sids) + + expect = [ + date(2025, 8, 18), + date(2025, 7, 31), + date(2025, 6, 30), + date(2025, 3, 18), + ] + self.assertEqual(len(sut), len(expect)) + for idx, expect_date in enumerate(expect): + self.assertEqual(sut[idx].date.date(), expect_date) + + +class KeepOnePerYearForAllYears(pyfakefs_ut.TestCase): + """Covering the smart remove setting 'Keep the last snapshot for each 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]) + + 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 sorted(keep, reverse=True) + + def test_doc_example(self): + now = date(2024, 12, 16) + sids = create_SIDs( + [ + date(2024, 10, 26), + date(2024, 4, 13), + date(2023, 10, 26), + date(2023, 10, 8), + date(2023, 1, 1), + date(2022, 12, 31), + date(2022, 4, 13), + date(2020, 10, 26), + date(2020, 4, 13), + ], + None, + self.cfg + ) + + sut = self._org( + now=now, + snapshots=sids) + + expect = [ + date(2024, 10, 26), + date(2023, 10, 26), + date(2022, 12, 31), + date(2020, 10, 26), + ] + self.assertEqual(len(sut), len(expect)) + for idx, expect_date in enumerate(expect): + self.assertTrue(sut[idx].date.date(), expect_date) class IncDecMonths(pyfakefs_ut.TestCase): @@ -802,91 +898,3 @@ def test_dec_year(self): def test_dec_leap_months(self): sut = self.sn.decMonth(date(2020, 2, 29)) self.assertEqual(sut, date(2020, 1, 1)) - - -class OldOrg_SmartRemove(generic.SnapshotsTestCase): - """This is the old/original test case using real filesystem and to much - dependencies.""" - - def test_keep_all(self): - sid1 = snapshots.SID('20160424-215134-123', self.cfg) - sid2 = snapshots.SID('20160422-030324-123', self.cfg) - sid3 = snapshots.SID('20160422-020324-123', self.cfg) - sid4 = snapshots.SID('20160422-010324-123', self.cfg) - sid5 = snapshots.SID('20160421-013218-123', self.cfg) - sid6 = snapshots.SID('20160410-134327-123', self.cfg) - sids = [sid1, sid2, sid3, sid4, sid5, sid6] - - keep = self.sn.smartRemoveKeepAll(sids, - date(2016, 4, 20), - date(2016, 4, 23)) - self.assertSetEqual(keep, set((sid2, sid3, sid4, sid5))) - - keep = self.sn.smartRemoveKeepAll(sids, - date(2016, 4, 11), - date(2016, 4, 18)) - self.assertSetEqual(keep, set()) - - def test_smart_remove_list(self): - sid1 = snapshots.SID('20160424-215134-123', self.cfg) - sid2 = snapshots.SID('20160422-030324-123', self.cfg) - sid3 = snapshots.SID('20160422-020324-123', self.cfg) - sid4 = snapshots.SID('20160422-010324-123', self.cfg) - sid5 = snapshots.SID('20160421-033218-123', self.cfg) - sid6 = snapshots.SID('20160421-013218-123', self.cfg) - sid7 = snapshots.SID('20160420-013218-123', self.cfg) - sid8 = snapshots.SID('20160419-013218-123', self.cfg) - sid9 = snapshots.SID('20160419-003218-123', self.cfg) - sid10 = snapshots.SID('20160418-003218-123', self.cfg) - sid11 = snapshots.SID('20160417-033218-123', self.cfg) - sid12 = snapshots.SID('20160417-003218-123', self.cfg) - sid13 = snapshots.SID('20160416-134327-123', self.cfg) - sid14 = snapshots.SID('20160416-114327-123', self.cfg) - sid15 = snapshots.SID('20160415-134327-123', self.cfg) - sid16 = snapshots.SID('20160411-134327-123', self.cfg) - sid17 = snapshots.SID('20160410-134327-123', self.cfg) - sid18 = snapshots.SID('20160409-134327-123', self.cfg) - sid19 = snapshots.SID('20160407-134327-123', self.cfg) - sid20 = snapshots.SID('20160403-134327-123', self.cfg) - sid21 = snapshots.SID('20160402-134327-123', self.cfg) - sid22 = snapshots.SID('20160401-134327-123', self.cfg) - sid23 = snapshots.SID('20160331-134327-123', self.cfg) - sid24 = snapshots.SID('20160330-134327-123', self.cfg) - sid25 = snapshots.SID('20160323-133715-123', self.cfg) - sid26 = snapshots.SID('20160214-134327-123', self.cfg) - sid27 = snapshots.SID('20160205-134327-123', self.cfg) - sid28 = snapshots.SID('20160109-134327-123', self.cfg) - sid29 = snapshots.SID('20151224-134327-123', self.cfg) - sid30 = snapshots.SID('20150904-134327-123', self.cfg) - sid31 = snapshots.SID('20140904-134327-123', self.cfg) - - sids = [ sid1, sid2, sid3, sid4, sid5, sid6, sid7, sid8, sid9, - sid10, sid11, sid12, sid13, sid14, sid15, sid16, sid17, sid18, sid19, - sid20, sid21, sid22, sid23, sid24, sid25, sid26, sid27, sid28, sid29, - sid30, sid31] - for sid in sids: - sid.makeDirs() - now = datetime(2016, 4, 24, 21, 51, 34) - - del_snapshots = self.sn.smartRemoveList(now, - 3, #keep_all - 7, #keep_one_per_day - 5, #keep_one_per_week - 3 #keep_one_per_month - ) - self.assertListEqual(del_snapshots, [sid6, sid9, sid12, sid13, sid14, - sid15, sid16, sid18, sid19, sid21, - sid22, sid24, sid27, sid28, sid30]) - - # test failed snapshots - for sid in (sid5, sid8, sid11, sid12, sid20, sid21, sid22): - sid.failed = True - del_snapshots = self.sn.smartRemoveList(now, - 3, #keep_all - 7, #keep_one_per_day - 5, #keep_one_per_week - 3 #keep_one_per_month - ) - self.assertListEqual(del_snapshots, [sid5, sid8, sid11, sid12, sid14, - sid15, sid16, sid18, sid19, sid20, sid21, - sid22, sid24, sid27, sid28, sid30]) diff --git a/doc/maintain/5_auto_smart_remove.md b/doc/maintain/5_auto_smart_remove.md deleted file mode 100644 index cb31b4ff5..000000000 --- a/doc/maintain/5_auto_smart_remove.md +++ /dev/null @@ -1,144 +0,0 @@ - -# Auto- & Smart-Remove -## Table of contents -* [Introduction](#introduction) -* [What we know](#what-we-know) -* [How it could be](#how-it-could-be) - -# Introduction -The actual auto- and smart-remove behavior of BIT will be described in this -document. Don't take this as a regular user manual. The document will help to -decide how that feature can be revised. See -[Meta Issue #1945](https://github.com/bit-team/backintime/issues/1945) about -the background story. - -This is how it looks like currently: -![Aut-remove tab](https://translate.codeberg.org/media/screenshots/bit_manage_profiles_autoremove.gif) - -# What we know -## Location in code -* `common/snapshots.py` - * `Snapshots.freeSpace()` is the main entry for the overall logic. - * `Snapshots.smartRemoveList()` is called by `freeSpace()` and is the entry - for _Smart remove_ related rules. - -## Ordering and interference of the rules -1. Remove snapshots older than N years/weeks/days. -2. Smart-remove rules with calling `Snapshots.smartRemoveList`. - 1. Don't if there is only one backup left. - 2. Always keep the latest/youngest backup. - 3. Keep one per day for N days. - 4. Keep one per week for N weeks. - 5. keep one per month for N months. - 6. Keep one per year for all years. -3. Free space: Remove until there is enough. -4. Free inodes: Remove until there are enough. - -## Details -- In `smartRemoveList()` the direction of ordering of the initial snapshots - list is of high relevance. - -### Older than N years -- Happens in `Snapshots.freeSpace()` -- Relevant also `self.config.removeOldSnapshotsDate()` -- Backups removed immediately before executing any other rule. -- Named snapshots ignored and kept. - -### Smart remove: Daily -GUI wording: _Keep all snapshots for the last `N` day(s)._ - -Current behavior of the algorithm: -* Bug was that in some cases `N-1` days are kept. - * Reason was that not dates but snapshotIDS (included their tags, the last 3 - digits) are used for comparison. - * The bug is fixed. - -### Smart remove: Weekly -GUI wording: _Keep one snapshot per week for the last `N` week(s)._ - -Current behavior of the algorithm: -* A "week" is defined based on the weekdays Monday to Sunday. -* The first week BIT is looking into is the current week even if it is not - completed yet. E.g. today is Wednesday the 27th November, BIT will look - for existing backups starting with Sunday the 24th ending and including the - Saturday 30th November. -* If there is no backup in the current week found, that week is "lost" and - there will only be `N-1` backups in the resulting list of weekly backups. -* See - * [#1094](https://github.com/bit-team/backintime/issues/1094) - * [PR #1944](https://github.com/bit-team/backintime/pull/1944) - * [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. -- Keeps the oldest, so it is the 1th of each months. - -### Smart remove: One per year for all years -- s -### Free space -- Remove until enough free disc space (`self.config.minFreeSpaceMib()`). -- Immediately removed before executing any other rule. - -### Free inodes -- Remove until enough free inodes (`self.config.minFreeInodes()`) -- Immediately removed before executing any other rule. - -# How it could be -## Overview -The following does not reflect the real behavior. It is a draft and suggestion -for the auto-/smart-remove related behavior of BIT and how to implement it. - -## General -- Wording: Remove "Smart" and make everything "Auto remove". -- The rules should to be consistent in their behavior. - - Always keep the latest/newest element in the list (Sunday for weeks, 31th - for months, ...). - - Ignore the current running/incomplete time frame. -- Wording: Use the term "backup" instead of "snapshot". See Issue #1929. - -## Mockup -![Mockup](_images/autoremove_mockup.png) - -[autoremove_mockup.drawio](_images/autoremove_mockup.drawio) - -## Rules in details -For new wording see the mockup. - -1. Remove snapshots older than N years. - - No need for modification. -2. Smart-remove rules with calling `Snapshots.smartRemoveList`. - 1. Don't if there is only one backup left. - - No need for modification. - 2. Always keep the latest/youngest backup. - - No need for modification. - 3. Keep one per day for N days. - - Ignore "today", the current day. - - Keep the latest/newest backup per day. - 4. Keep one per week for N weeks. - - Define "week" as calendar element from Monday to Sunday. - - Ignore the current running and incomplete week. - - Keep the latest/newest backup per week. So it would be Sunday in most - cases if available. - 5. keep one per month for N months. - - Ignore the current running and incomplete month. - - Keep the latest/newset backup per months (30th/31th day of the months). - 6. Keep one per year for all years. - - Use the latest day of year. - - That implicit ignores the current running year. -3. Free space: Remove until there is enough. - - No need for modification. -4. Free inodes: Remove until there are enough. - - No need for modification. - -December 2024 diff --git a/doc/maintain/README.md b/doc/maintain/README.md index 286d510ca..84bcda29c 100644 --- a/doc/maintain/README.md +++ b/doc/maintain/README.md @@ -16,7 +16,6 @@ General Public License v2 (GPLv2). See directory LICENSES or go to - [How to setup openssh for unit tests](3_How_to_set_up_openssh_server_for_ssh_unit_tests.md) - [Usage of control files (locks, flocks, logs and others)](4_Control_files_usage_(locks_flocks_logs_and_others).md) - [How to prepare and publish a new BiT release](BiT_release_process.md) -- [Auto- & Smart-Remove](5_auto_smart_remove.md) Sept 2024 diff --git a/doc/manual/mkdocs.yml b/doc/manual/mkdocs.yml index 61fa14fb6..21a93227d 100644 --- a/doc/manual/mkdocs.yml +++ b/doc/manual/mkdocs.yml @@ -24,7 +24,7 @@ site_dir: html # sub directories. See https://github.com/mkdocs/mkdocs/discussions/3847 use_directory_urls: false -# site_url: https://backintime-docs.readthedocs.io/en/latest/ +site_url: https://backintime-docs.readthedocs.io/en/latest/ # site_description: Back In Time is a simple backup tool for Linux inspired by "FlyBack". # Repository information @@ -32,13 +32,14 @@ repo_name: backintime # repo_url: https://github.com/bit-team/backintime # Entrie labels determined by the the 1st level heading -nav: +nav: - 'index.md' - 'quick-start.md' - 'main-window.md' - 'settings.md' - 'snapshots-dialog.md' - 'log.md' + - 'remove_retention.md' - 'user-callback.md' - 'additional.md' @@ -47,7 +48,7 @@ markdown_extensions: # https://squidfunk.github.io/mkdocs-material/reference/admonitions/ - admonition - toc: - permalink: true + permalink: true - footnotes # Syntax highlightening in code blocks - pymdownx.highlight: @@ -117,4 +118,4 @@ extra: link: https://github.com/bit-team/backintime name: Source code repository at Microsoft GitHub -copyright: © Back In Time Team \ No newline at end of file +copyright: © Back In Time Team diff --git a/doc/manual/src/_images/rule_keep_all_for_n_days.png b/doc/manual/src/_images/rule_keep_all_for_n_days.png new file mode 100644 index 000000000..dfc55a076 Binary files /dev/null and b/doc/manual/src/_images/rule_keep_all_for_n_days.png differ diff --git a/doc/manual/src/_images/rule_keep_last_each_day_for_n_days.png b/doc/manual/src/_images/rule_keep_last_each_day_for_n_days.png new file mode 100644 index 000000000..eeac1a48b Binary files /dev/null and b/doc/manual/src/_images/rule_keep_last_each_day_for_n_days.png differ diff --git a/doc/manual/src/_images/rule_keep_last_each_month_for_n_months.png b/doc/manual/src/_images/rule_keep_last_each_month_for_n_months.png new file mode 100644 index 000000000..05b6cac75 Binary files /dev/null and b/doc/manual/src/_images/rule_keep_last_each_month_for_n_months.png differ diff --git a/doc/manual/src/_images/rule_keep_last_each_week_for_n_weeks.png b/doc/manual/src/_images/rule_keep_last_each_week_for_n_weeks.png new file mode 100644 index 000000000..bc41d8476 Binary files /dev/null and b/doc/manual/src/_images/rule_keep_last_each_week_for_n_weeks.png differ diff --git a/doc/manual/src/_images/rule_keep_last_each_year_for_all_years.png b/doc/manual/src/_images/rule_keep_last_each_year_for_all_years.png new file mode 100644 index 000000000..bd7ffdcd8 Binary files /dev/null and b/doc/manual/src/_images/rule_keep_last_each_year_for_all_years.png differ diff --git a/doc/manual/src/_images/rule_older_than_n_days.png b/doc/manual/src/_images/rule_older_than_n_days.png new file mode 100644 index 000000000..9a1549006 Binary files /dev/null and b/doc/manual/src/_images/rule_older_than_n_days.png differ diff --git a/doc/manual/src/_images/rule_older_than_n_weeks.png b/doc/manual/src/_images/rule_older_than_n_weeks.png new file mode 100644 index 000000000..72877b3df Binary files /dev/null and b/doc/manual/src/_images/rule_older_than_n_weeks.png differ diff --git a/doc/manual/src/_images/rule_older_than_n_years.png b/doc/manual/src/_images/rule_older_than_n_years.png new file mode 100644 index 000000000..368cd1286 Binary files /dev/null and b/doc/manual/src/_images/rule_older_than_n_years.png differ diff --git a/doc/manual/src/_images/tab_remove_retention.png b/doc/manual/src/_images/tab_remove_retention.png new file mode 100644 index 000000000..b9c3beacd Binary files /dev/null and b/doc/manual/src/_images/tab_remove_retention.png differ diff --git a/doc/manual/src/remove_retention.md b/doc/manual/src/remove_retention.md new file mode 100644 index 000000000..add1c9bbe --- /dev/null +++ b/doc/manual/src/remove_retention.md @@ -0,0 +1,185 @@ +# Remove & Retention + +## Overview +Snapshots can be automatically deleted or retained based on rules. +These rules allow for fine-grained management of the backup archive, +reducing storage space usage. The process runs at the end of every snapshot +run, if no new snapshot is created. + +!!! note + The feature was also known as _Auto-remove_ or _Smart Remove_ in earlier + versions of _Back In Time_ (prior to 1.6.0). + +![Dialog tab - Remove and Retention](_images/tab_remove_retention.png) + +Here is a brief overview of the rules available: + + +- **Keep the most recent snapshot**: The last (or freshest) snapshot will be retained. +- **Keep named snapshots**: All snapshots with a name are excluded from every + rule and never removed. This is the only one rule that can not be overruled + by other rules. +- **Remove snapshots older than `N` Days/Weeks/Years**: Snapshots older than + the specified time period are removed immediately. +- **Retention policy**: A batterie of rules about which snapshots to keep. The + rest will be removed immediately. + - **Keep all snapshots for the last `N` days** + - **Keep the last snapshot for each day/week/month for the last `N` days/weeks/months** + - **Keep the last snapshot for each year for all years** +- **Remove oldest snapshot if the free space is less than `N` GiB/MiB**: If the + threshold of free storage space is reached, the oldest snapshots will be + removed until enough storage space is available again. +- **Remove oldest snapshot if the free inodes are less than `N` %**: If the + threshold of free inodes is reached, the oldest snapshots will be + removed until enough inodes are available again. + +!!! warning + All rules are processed from top to bottom, as presented in the GUI or in + this manual. Later rules **do override** earlier ones and are **not + constrained** by them. The only exception is the first rule + *Keep named snapshots*. + +## Rules in details +### Keep the most recent snapshot +The most recently created snapshot, in other words the freshest one, will be +retained and not deleted by any of the configured rules. Despite it is present +in the graphical frontend, that behavior cannot be changed. + +### Keep named snapshots +Beside the timestamp regularly used to identify snapshots, it is possible to +attach a name to it. Those named snapshots are never touched by any other +rule. It is a guarantee that they won't be removed. See +[Main Window](main-window.md) for more details about named snapshots. + +### Remove snapshots older than … + +**Remove snapshots older than `N` Years** + +- Calculation is based on 12 months. +- Current months is ignored. +- _Example_: Older than two years, at date 2025-04-17, result in + removing backups before (or older than) 2023-04-01. +![Rule - Remove older than 2 years](_images/rule_older_than_n_years.png) + +**Remove snapshots older than `N` Weeks** + +- Calculation is based on calendar weeks with Monday as first day of a week. +- Current week is ignored. +- _Example_: Older than two weeks, at Friday 2025-08-29, result in removing + backups before (or older than) Monday 2025-08-11. + +![Rule - Remove older than 2 weeks](_images/rule_older_than_n_weeks.png) + +**Remove snapshots older than `N` Days** + +- Calculation is based on full days from 0:00 to 23:59. +- Current day is ignored. +- _Example_: Older than 3 days, at date 2025-01-10, result in removing backups + before (or older than) 2025-01-07. + +![Rule - Remove older than 3 days](_images/rule_older_than_n_days.png) + +### Retention policy +Snapshots are retained if they fit at least one of the the rules from the +retention policy. All other snapshots, not covered by the retention policy, +will be removed. + +The values specified are treated as a period rather than a count. For example, +imagine keeping the last snapshot of each month for the past six months, +including the current running months. However, only four of these six months +have snapshots. In this case, only four snapshots are retained. The period is +not extended further into the past to reach a total of six snapshots. See the +rules below for more illustrated examples. + +**Keep all snapshots for the last `N` days** + +- Calculation is based on full days from 0:00 to 23:59. +- Current day is considered. + +_Example_: + +![Rule - Keep all for the last 2 days](_images/rule_keep_all_for_n_days.png) + +**Keep the last snapshot for each day for the last `N` days** + +- Calculation is based on full days from 0:00 to 23:59. +- Current day is considered. + +_Example_: + +![Rule - Keep last for each day for the last 5 days](_images/rule_keep_last_each_day_for_n_days.png) + +**Keep the last snapshot for each week for the last `N` weeks** + +- Calculation is based on full calendar weeks starting from Monday. +- Current week is considered. + +_Example_: + +![Rule - Keep last for each week for the last 4 weeks](_images/rule_keep_last_each_week_for_n_weeks.png) + +**Keep the last snapshot for each month for the last `N` months** + +- Calculation is based on full calendar months. +- Current months is considered. + +_Example_: + +![Rule - Keep last for each months for the last 4 months](_images/rule_keep_last_each_months_for_n_months.png) + +**Keep the last snapshot for each year for all years** + +- Calculation is based on calendar years. +- Current year is considered. +- Despite it is present in the graphical frontend, that behavior cannot be + changed, if _Retention Policy_ is enabled. + +_Example_: + +![Rule - Keep last for each year for all years](_images/rule_keep_last_each_year_for_all_years.png) + +### Run in background mode on remote host +The remove command can be executed on the local machine or on a remote host via +SSH. The latter can save time and resources. + +## Interactions between and mutual constraints of the rules +All rules are applied and executed immediatily one by one and in the order as +presented in the GUI and here in the manual. This contain the potential of +confusing interactions between the rules. + +### Example: Three years and all years. +Imagine this two rules: + +1. Remove snapshots older than 3 years. +2. Keep last snapshot for each year for all years. + +We continue to assume that multiple backups per year have been available over +the past five years. + +Rule 2 in isolation would result in five retained backups, one for each of the +five existing years. But rule 1 will be executed beforehand. Rule 1 will remove +all snapshots from four and five years ago. + +### Example: Six months but less storage space +Imagine this two rules: + +1. Keep last snapshot for each months for 6 months. +2. Remove oldest snapshots if the free space is less than 100 GiB. + +The consequence of rule 1 is that six snapshots are kept, one for each +months. Additionally imagine some more snapshots because of the other keep rules +beforehand. This consumes so much storage space that there is only 80 GiB free +space left. This is less than the 100 GiB limit configured in rule 2. Because +of that the two oldest snapshots (of months five and six) will be +removed. After this 105 GiB storage space is available again and the rule +stops. The final consequence is that snapshots of four months are kept, instead +of six months as configured in rule 1. + diff --git a/doc/manual/src/settings.md b/doc/manual/src/settings.md index f0eae28b2..dab43f15e 100644 --- a/doc/manual/src/settings.md +++ b/doc/manual/src/settings.md @@ -209,7 +209,8 @@ schedules. You can use `crontab -l` to view them or `crontab -e` to edit. ![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/app.py b/qt/app.py index b26db10cc..d1d8cb8e3 100644 --- a/qt/app.py +++ b/qt/app.py @@ -32,7 +32,6 @@ tools.initiate_translation(None) import qttools import backintime -import bitbase import tools import logger import snapshots @@ -789,7 +788,7 @@ def _create_main_toolbar(self): toolbar.customContextMenuRequested.connect( lambda point: self._context_menu_button_style(point, toolbar)) - # Resore button styling for main toolbar + # Restore button styling for main toolbar toolbar.setToolButtonStyle( Qt.ToolButtonStyle(StateData().toolbar_button_style)) @@ -1417,10 +1416,7 @@ def btnAboutClicked(self): dlg.exec() def btn_help_user_manual(self): - if bitbase.USER_MANUAL_LOCAL_PATH.exists(): - self.openUrl(bitbase.USER_MANUAL_LOCAL_PATH.as_uri()) - else: - self.openUrl(bitbase.USER_MANUAL_ONLINE_URL) + qttools.open_user_manual() def btn_help_man_backintime(self): self.openManPage('backintime') 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..33a0ad02c --- /dev/null +++ b/qt/manageprofiles/tab_remove_retention.py @@ -0,0 +1,413 @@ +# 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 (QCheckBox, + QDialog, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QSpinBox, + QStyle, + 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._tab_layout = QGridLayout() + self.setLayout(self._tab_layout) + + # Keep most recent + self._label_keep_most_recent() + + # Keep named backups + self.cbDontRemoveNamedSnapshots = self._checkbox_keep_named() + + # --- + self._tab_layout.addWidget( + qttools.HLineWidget(), + # fromRow + self._tab_layout.rowCount(), + # fromColumn + 0, + # rowSpan, + 1, + # columnSpan + 3) + + # Icon & Info label + self._label_rule_execute_order() + + # --- + self._tab_layout.addWidget( + qttools.HLineWidget(), + # fromRow + self._tab_layout.rowCount(), + # fromColumn + 0, + # rowSpan, + 1, + # columnSpan + 3) + + # Remove older than N years/months/days + self._checkbox_remove_older, self._spinunit_remove_older \ + = self._remove_older_than() + row = self._tab_layout.rowCount() + self._tab_layout.addWidget(self._checkbox_remove_older, row, 0, 1, 2) + self._tab_layout.addWidget(self._spinunit_remove_older, row, 2) + + # 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.setColumnStretch(0, 2) + self._tab_layout.setColumnStretch(1, 1) + self._tab_layout.setColumnStretch(2, 0) + self._tab_layout.setRowStretch(self._tab_layout.rowCount(), 1) + + @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 following rules are processed from top to bottom. 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.linkActivated.connect(self.handle_link_activated) + + txt_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextBrowserInteraction) + + # Show URL in tooltip without anoing http-protocol prefix. + txt_label.linkHovered.connect( + lambda url: QToolTip.showText( + # QCursor.pos(), url.replace('https://', '')) + QCursor.pos(), _('Open user manual in browser.')) + ) + + wdg = QWidget() + layout = QHBoxLayout(wdg) + layout.addWidget(icon_label) + layout.addWidget(txt_label) + + self._tab_layout.addWidget(wdg, self._tab_layout.rowCount(), 0, 1, 3) + + def handle_link_activated(self, link): + qttools.open_user_manual() + + def _label_keep_most_recent(self) -> None: + cb = QCheckBox(_('Keep the most recent snapshot.'), self) + qttools.set_wrapped_tooltip( + cb, + ( + _('The last or freshest snapshot is kept under ' + 'all circumstances.'), + _('That behavior cannot be changed.') + ) + ) + + # Always enabled + cb.setChecked(True) + cb.nextCheckState = lambda: None + + # fromRow, fromColumn spanning rowSpan rows and columnSpan + self._tab_layout.addWidget(cb, self._tab_layout.rowCount(), 0, 1, 2) + + def _checkbox_keep_named(self) -> QCheckBox: + cb = QCheckBox(_('Keep named snapshots.'), self) + qttools.set_wrapped_tooltip( + cb, + _('Snapshots that have been given a name, in addition to the ' + 'usual timestamp, will be retained under all circumstances ' + 'and will not be removed.') + ) + + # fromRow, fromColumn spanning rowSpan rows and columnSpan + self._tab_layout.addWidget(cb, self._tab_layout.rowCount(), 0, 1, 2) + + return cb + + def _remove_older_than(self) -> QWidget: + # 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) + + # tooltip + tip = ( + f'{units[config.Config.DAY]}: ' + + _('Full days. Current day ignored.'), + f'{units[config.Config.WEEK]}: ' + + _('Calendar weeks with Monday as first day. ' + 'Current week ignored.'), + f'{units[config.Config.YEAR]}: ' + + _('12 months periods. Current months ignored.') + ) + + qttools.set_wrapped_tooltip(checkbox, tip) + qttools.set_wrapped_tooltip(spin_unit, tip) + + return checkbox, spin_unit + + def _groupbox_retention_policy(self) -> tuple: + layout = QGridLayout() + # col, fx + layout.setColumnStretch(0, 1) + layout.setColumnStretch(1, 0) + layout.setColumnStretch(2, 0) + + 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) + + tip = _('The days are counted starting from today.') + label = QLabel(_('Keep all snapshots for the last'), self) + qttools.set_wrapped_tooltip(label, tip) + layout.addWidget(label, 1, 0) + all_last_days = QSpinBox(self) + all_last_days.setRange(1, 999) + all_last_days.setSuffix(' ' + _('day(s).')) + qttools.set_wrapped_tooltip(all_last_days, tip) + # all_last_days.setAlignment(Qt.AlignmentFlag.AlignRight) + layout.addWidget(all_last_days, 1, 1) + + # tip = same as the previous label + label = QLabel( + _('Keep the last snapshot for each day for the last'), self) + qttools.set_wrapped_tooltip(label, tip) + layout.addWidget(label, 2, 0) + one_per_day = QSpinBox(self) + one_per_day.setRange(1, 999) + one_per_day.setSuffix(' ' + _('day(s).')) + qttools.set_wrapped_tooltip(one_per_day, tip) + # one_per_day.setAlignment(Qt.AlignmentFlag.AlignRight) + layout.addWidget(one_per_day, 2, 1) + + tip = _('The weeks are counted starting from the current running ' + 'week. A week starts on Monday.') + label = QLabel( + _('Keep the last snapshot for each week for the last'), self) + qttools.set_wrapped_tooltip(label, tip) + layout.addWidget(label, 3, 0) + one_per_week = QSpinBox(self) + one_per_week.setRange(1, 999) + one_per_week.setSuffix(' ' + _('week(s).')) + qttools.set_wrapped_tooltip(one_per_week, tip) + # one_per_week.setAlignment(Qt.AlignmentFlag.AlignRight) + layout.addWidget(one_per_week, 3, 1) + + tip = _('The months are counted as calendar months starting with ' + 'the current months.') + label = QLabel( + _('Keep the last snapshot for each month for the last'), self) + qttools.set_wrapped_tooltip(label, tip) + layout.addWidget(label, 4, 0) + one_per_month = QSpinBox(self) + one_per_month.setRange(1, 999) + one_per_month.setSuffix(' ' + _('month(s).')) + qttools.set_wrapped_tooltip(one_per_month, tip) + # one_per_month.setAlignment(Qt.AlignmentFlag.AlignRight) + layout.addWidget(one_per_month, 4, 1) + + tip = _('The years are counted as calendar years starting with ' + 'the current year.') + label = QLabel(_('Keep the last snapshot for each year for'), self) + layout.addWidget(label, 5, 0) + labeltwo = QLabel(_('all years.'), self) + layout.addWidget(labeltwo, 5, 1) + qttools.set_wrapped_tooltip([label, labeltwo], tip) + + self._tab_layout.addWidget( + checkbox_group, self._tab_layout.rowCount(), 0, 1, 3) + + 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(_('Remove oldest snapshots if …'), self) + grid = QGridLayout() + groupbox.setLayout(grid) + grid.setColumnStretch(0, 1) + grid.setColumnStretch(1, 0) + grid.setColumnStretch(2, 0) + + # wdg, row, col + grid.addWidget(checkbox_space, 0, 0, 1, 2) + grid.addWidget(spin_unit_space, 0, 2) + grid.addWidget(checkbox_inodes, 1, 0, 1, 2) + grid.addWidget(spin_inodes, 1, 2) + + self._tab_layout.addWidget( + groupbox, + self._tab_layout.rowCount(), + 0, 1, 3 + ) + + return checkbox_space, spin_unit_space, checkbox_inodes, spin_inodes diff --git a/qt/qttools.py b/qt/qttools.py index 0d7bc1e87..cdb702bb9 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 # @@ -22,9 +23,14 @@ """ import os import sys +import re import textwrap from typing import Union, Iterable -from PyQt6.QtGui import (QAction, QFont, QPalette, QIcon) +from PyQt6.QtGui import (QAction, + QDesktopServices, + QFont, + QIcon, + QPalette) from PyQt6.QtCore import (QDir, Qt, pyqtSlot, @@ -33,8 +39,10 @@ QTranslator, QLocale, QLibraryInfo, - QT_VERSION_STR) -from PyQt6.QtWidgets import (QWidget, + QT_VERSION_STR, + QUrl) +from PyQt6.QtWidgets import (QFrame, + QWidget, QFileDialog, QAbstractItemView, QListView, @@ -46,6 +54,7 @@ QTreeWidgetItem, QComboBox, QSystemTrayIcon) + from datetime import (datetime, date, timedelta) from calendar import monthrange from packaging.version import Version @@ -55,6 +64,7 @@ import snapshots # noqa: E402 import tools # noqa: E402 import logger # noqa: E402 +import bitbase import version @@ -106,31 +116,76 @@ def can_render(string, widget): # | Widget modification & creation | # |--------------------------------| -def set_wrapped_tooltip(widget: QWidget, +_REX_RICHTEXT = re.compile( + # begin of line + r'^' + # all characters, except a new line + r'[^\n]*' + # tag opening + r'<' + # every character (as tagname) except > + r'[^>]+' + # tag closing + r'>') + + +def might_be_richtext(txt: str) -> bool: + """Returns `True` if the text is rich text. + + Rich text is a subset of HTML used by Qt to allow text formatting. The + function checks if the first line (before the first `\n') does contain a + tag. A tag begins with with `<`, following by one or more characters and + close with `>`. + + Qt itself does use `Qt::mightBeRichText()` internally but this is not + available in PyQt for unknown reasons. + + Args: + txt: The text to check. + + Returns: + `True` if it looks like a rich text, otherwise `False`. + """ + return bool(_REX_RICHTEXT.match(txt)) + + +def set_wrapped_tooltip(widget: Union[QWidget, Iterable[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 then joined with a line break. Args: - widget: The widget to which a tooltip should be added. + widget: The widget or list of widgets to which a tooltip should be + added. tooltip: The tooltip as string or iterable of strings. wrap_length: Every line is at most this lengths. """ + + if isinstance(widget, Iterable): + for wdg in widget: + set_wrapped_tooltip(wdg, tooltip, wrap_length) + + return + # Always use tuple or list if isinstance(tooltip, str): tooltip = (tooltip, ) + # Richtext or plain text + newline = {True: '
', False: '\n'}[might_be_richtext(tooltip[0])] + result = [] + # Wrap each paragraph in itself for paragraph in tooltip: result.append('\n'.join( textwrap.wrap(paragraph, wrap_length) )) - widget.setToolTip('\n'.join(result)) - + # glue all together + widget.setToolTip(newline.join(result)) def update_combo_profiles(config, combo_profiles, current_profile_id): @@ -151,6 +206,25 @@ def update_combo_profiles(config, combo_profiles, current_profile_id): # | Misc / Uncatgorized | # |---------------------| +def user_manual_uri() -> str: + """Return the URI to the user manual. + + If available the local URI is used otherwise the online version is. + """ + uri = bitbase.USER_MANUAL_LOCAL_PATH.as_uri() \ + if bitbase.USER_MANUAL_LOCAL_AVAILABLE \ + else bitbase.USER_MANUAL_ONLINE_URL + + return uri + +def open_user_manual() -> None: + """Open the user manual in browser. + + If available the local manual is used otherwise the online version is + opened. + """ + QDesktopServices.openUrl(QUrl(user_manual_uri())) + class FileDialogShowHidden(QFileDialog): """File dialog able to display hidden files.""" @@ -704,3 +778,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)