Skip to content

Commit

Permalink
doc: Improve GUI and user manual about Remove & Retention (formally k…
Browse files Browse the repository at this point in the history
…nown as Auto-/Smart-Remove)

- Renaming terms "Auto-Remove" and "Smart-Remove" into "Remove & Retention" and "Retention Policy".
- The way this feature works is now more clearly illustrated in the GUI. Additionally, the user manual now contains a corresponding section, including examples.
- Labels and tooltips in the GUI are modified.

The behavior itself was not changed. Also no code refactoring was done to reduce the risk of introducing bugs.

Close #1976
Related to #1945
  • Loading branch information
buhtz authored Feb 2, 2025
1 parent d7a2563 commit 97d4dc6
Show file tree
Hide file tree
Showing 27 changed files with 1,419 additions and 938 deletions.
1 change: 1 addition & 0 deletions CHANGES
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions common/bitbase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
42 changes: 26 additions & 16 deletions common/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
45 changes: 31 additions & 14 deletions common/snapshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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]

Expand Down Expand Up @@ -2427,26 +2440,29 @@ 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
self.profileID = cfg.currentProfile()
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):
Expand Down Expand Up @@ -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
Expand Down
62 changes: 61 additions & 1 deletion common/test/test_config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# SPDX-FileCopyrightText: © 2016 Taylor Raack
# SPDX-FileCopyrightText: © 2025 Christian Buhtz <[email protected]>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
Expand All @@ -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()
Expand Down
Loading

0 comments on commit 97d4dc6

Please sign in to comment.