diff --git a/README.md b/README.md index 0fe45b2..370c0d7 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,12 @@ features here: [todos.md](docs/todos.md) ## Used packages -- [PyQt5](https://pyqt.readthedocs.io/en/latest/) - the GUI framework -- [PyInstaller](https://pyinstaller.readthedocs.io/en/stable/) - used for - creating the binary +- [PyQt5](https://pyqt.readthedocs.io/en/latest/) - the GUI framework +- [PyInstaller](https://pyinstaller.readthedocs.io/en/stable/) - used for + creating the binary +- [pytest](https://docs.pytest.org/en/latest/) - used for testing +- [pytest-cov](https://pytest-cov.readthedocs.io/en/latest/) - used for + coverage analysis ## Contributing diff --git a/borg_qt/borg_interface.py b/borg_qt/borg_interface.py index d460f39..6d499e8 100644 --- a/borg_qt/borg_interface.py +++ b/borg_qt/borg_interface.py @@ -99,7 +99,7 @@ class BackupThread(BorgQtThread): self.command = ['borg', 'create', '--log-json', '--json', ('::' + self.prefix - + '{now:%Y-%m-%d_%H:%M:%S}')] + + '{now:%Y-%m-%d_%H:%M:%S,%f}')] self.command.extend(self.includes) if self.excludes: self.command.extend(self.excludes) @@ -190,3 +190,24 @@ class MountThread(BorgQtThread): def create_command(self): self.command = ['borg', 'mount', '--log-json', ('::' + self.archive_name), self.mount_path] + + +class PruneThread(BorgQtThread): + """Prunes the repository according to the given retention policy. + + Args: + policy (dict) the name of the archive to restore. + """ + def __init__(self, policy): + self.policy = self._process_policy(policy) + super().__init__() + + def create_command(self): + self.command = ['borg', 'prune', '--log-json'] + self.command.extend(self.policy) + + def _process_policy(self, raw_policy): + policy = [] + for key, value in raw_policy.items(): + policy.append('--keep-' + key + "=" + value) + return policy diff --git a/borg_qt/config.py b/borg_qt/config.py index 9317b85..dec0296 100644 --- a/borg_qt/config.py +++ b/borg_qt/config.py @@ -126,6 +126,14 @@ class Config(QDialog): def hide_help(self): return util.strtobool(self._return_single_option('hide_help')) + @property + def retention_policy_enabled(self): + return util.strtobool(self._return_single_option('retention_policy_enabled')) + + @property + def retention_policy(self): + return self._return_dict_option('retention_policy') + def _return_single_option(self, option): """Gets the provided option from the configparser object.""" if option in self.config['borgqt']: @@ -141,6 +149,14 @@ class Config(QDialog): else: return [] + def _return_dict_option(self, option): + """Reads the provided option from the configparser object and returns + it as a dict.""" + if option in self.config['borgqt']: + return json.loads(self.config['borgqt'][option]) + else: + return {} + def _get_path(self): """searches for the configuration file and returns its full path.""" home = os.environ['HOME'] @@ -313,6 +329,14 @@ class Config(QDialog): self.schedule_predefined_name, Qt.MatchFixedString) self.combo_schedule_predefined.setCurrentIndex(index) self.spin_schedule_date.setValue(self.schedule_date) + if self.retention_policy_enabled: + self.check_policy_enabled.setChecked(True) + policy = self.retention_policy + self.spin_policy_hourly.setValue(int(policy['hourly'])) + self.spin_policy_daily.setValue(int(policy['daily'])) + self.spin_policy_weekly.setValue(int(policy['weekly'])) + self.spin_policy_monthly.setValue(int(policy['monthly'])) + self.spin_policy_yearly.setValue(int(policy['yearly'])) def apply_options(self): """Writes the changed options back into the configparser object.""" @@ -357,6 +381,17 @@ class Config(QDialog): self.config['borgqt']['excludes'] = json.dumps(excludes, indent=4, sort_keys=True) + self.config['borgqt']['retention_policy_enabled'] = ( + str(self.check_policy_enabled.isChecked())) + retention_policy = {} + retention_policy['hourly'] = self.spin_policy_hourly.text() + retention_policy['daily'] = self.spin_policy_daily.text() + retention_policy['weekly'] = self.spin_policy_weekly.text() + retention_policy['monthly'] = self.spin_policy_monthly.text() + retention_policy['yearly'] = self.spin_policy_yearly.text() + self.config['borgqt']['retention_policy'] = json.dumps(retention_policy, + indent=4, + sort_keys=True) self._set_environment_variables() # create and enable the required systemd files diff --git a/borg_qt/main_window.py b/borg_qt/main_window.py index d53b5d3..0568156 100644 --- a/borg_qt/main_window.py +++ b/borg_qt/main_window.py @@ -99,10 +99,13 @@ class MainWindow(QMainWindow): def background_backup(self): self.config.read() self.config._set_environment_variables() - thread = borg.BackupThread(self.config.includes, + backup_thread = borg.BackupThread(self.config.includes, excludes=self.config.excludes, prefix=self.config.prefix) - thread.run() + backup_thread.run() + if self.config.retention_policy_enabled: + prune_thread = borg.PruneThread(self.config.retention_policy) + prune_thread.run() def get_selected_path(self, signal): """returns the path of the item selected in the file tree.""" @@ -117,20 +120,23 @@ class MainWindow(QMainWindow): def create_backup(self): """Creates a backup of the selected item in the treeview.""" - if self.mount_paths: - if self.yes_no("To create an archive you need to unmout all " - "archives. Do you want to continue?"): - self._umount_archives() - else: - return + if not self._check_mounts(): + return try: self._check_path() - thread = borg.BackupThread([self.src_path], + backup_thread = borg.BackupThread([self.src_path], excludes=self.config.excludes, prefix=self.config.prefix) - dialog = ProgressDialog(thread) - dialog.label_info.setText("Borg-Qt is currently creating an archive.") - dialog.exec_() + backup_dialog = ProgressDialog(backup_thread) + backup_dialog.label_info.setText("Borg-Qt is currently creating an" + " archive.") + backup_dialog.exec_() + if self.config.retention_policy_enabled: + prune_thread = borg.PruneThread(self.config.retention_policy) + prune_dialog = ProgressDialog(prune_thread) + prune_dialog.label_info.setText("Borg-Qt is currently pruning " + "the repository.") + prune_dialog.exec_() self.update_ui() except BorgException as e: show_error(e) @@ -183,6 +189,8 @@ class MainWindow(QMainWindow): def delete_backup(self): """Deletes the selected archive from the repository.""" + if not self._check_mounts(): + return try: archive_name = self.selected_archive except AttributeError: @@ -198,7 +206,7 @@ class MainWindow(QMainWindow): thread = borg.DeleteThread(archive_name) dialog = ProgressDialog(thread) dialog.label_info.setText( - "Borg-Qt is currently deleting a backup.") + "Borg-Qt is currently deleting an archive.") dialog.exec_() self.update_ui() except BorgException as e: @@ -266,6 +274,15 @@ class MainWindow(QMainWindow): # Opens the path in a file manager open_path(mount_path) + def _check_mounts(self): + if self.mount_paths: + if self.yes_no("To proceed you need to unmout all " + "archives. Do you want to continue?"): + self._umount_archives() + return True + else: + return False + def yes_no(self, question): """Simple yes/no dialog. diff --git a/borg_qt/static/UI/MainWindow.ui b/borg_qt/static/UI/MainWindow.ui index ca251c3..7d0c30e 100644 --- a/borg_qt/static/UI/MainWindow.ui +++ b/borg_qt/static/UI/MainWindow.ui @@ -56,7 +56,7 @@ - Archives + Archives in Repository: diff --git a/borg_qt/static/UI/Settings.ui b/borg_qt/static/UI/Settings.ui index 2c64089..bbf3246 100644 --- a/borg_qt/static/UI/Settings.ui +++ b/borg_qt/static/UI/Settings.ui @@ -32,7 +32,7 @@ false - + 10 @@ -438,6 +438,131 @@ + + + Retention Policy + + + + + 10 + 10 + 541 + 191 + + + + + + + + 16777215 + 30 + + + + Enable + + + false + + + false + + + + + + + + + Daily: + + + + + + + 0 + + + 7 + + + + + + + 0 + + + 4 + + + + + + + Hourly: + + + + + + + Weekly: + + + + + + + Monthly: + + + + + + + 0 + + + 12 + + + + + + + 0 + + + 24 + + + + + + + 0 + + + 1 + + + + + + + Yearly: + + + + + + + + diff --git a/docs/borg_qt.conf.example b/docs/borg_qt.conf.example index ad72be3..d4790b3 100644 --- a/docs/borg_qt.conf.example +++ b/docs/borg_qt.conf.example @@ -26,6 +26,14 @@ schedule_month = 0 schedule_time = 12:00:00 schedule_predefined_enabled = True schedule_predefined_name = hourly +retention_policy_enabled = False +retention_policy = { + "hourly": "24", + "daily": "7", + "weekly": "4", + "monthly": "12", + "yearly": "1" + } [borgqt] includes = [ diff --git a/requirements.txt b/requirements.txt index 1368bdd..b03964d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ pyinstaller PyQt5 -borgbackup[fuse]==1.1.8 pytest +pytest-cov diff --git a/tests/conftest.py b/tests/conftest.py index 1c2e03c..d76caf5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -67,3 +67,15 @@ def archives(repository): def target_path(tmpdir): yield str(tmpdir) remove_path(str(tmpdir)) + + +@pytest.fixture +def create_archive(): + def _create_archive(number_of_turns): + while number_of_turns > 0: + backup_thread = borg.BackupThread(['.']) + backup_thread.run() + number_of_turns -= 1 + list_thread = borg.ListThread() + return list_thread.run() + return _create_archive diff --git a/tests/test_borg.py b/tests/test_borg.py index 92d4c69..248b737 100644 --- a/tests/test_borg.py +++ b/tests/test_borg.py @@ -57,3 +57,13 @@ def test_mount(target_path, archives): assert os.path.exists( os.path.join(mount_path, os.path.realpath(__file__))) os.system('borg umount ' + mount_path) + + +def test_prune(repository, create_archive): + archive_list = create_archive(2) + thread = borg.PruneThread({'hourly': '1'}) + thread.run() + list_thread = borg.ListThread() + repo_archives = list_thread.run() + assert len(archive_list) > len(repo_archives) + diff --git a/tests/test_config.py b/tests/test_config.py index 98c6be5..db06162 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -121,3 +121,12 @@ def test_exclude_remove(form): form.config.list_exclude.setCurrentRow(0) form.config.remove_exclude() assert (counter >= form.config.list_exclude.count()) + + +def test_retention_config(form): + assert form.config.retention_policy_enabled == False + assert form.config.retention_policy['hourly'] == '24' + assert form.config.retention_policy['daily'] == '7' + assert form.config.retention_policy['weekly'] == '4' + assert form.config.retention_policy['monthly'] == '12' + assert form.config.retention_policy['yearly'] == '1'