From d87b1c953b201caa385fbc303e0ea1f663ea6368 Mon Sep 17 00:00:00 2001 From: Andreas Zweili Date: Sun, 28 Apr 2019 18:39:05 +0200 Subject: [PATCH 1/7] Make the archive label clearer --- borg_qt/static/UI/MainWindow.ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From ef189be7cf62e2d9d61fabcba8e32b3c5fb58264 Mon Sep 17 00:00:00 2001 From: Andreas Zweili Date: Sun, 28 Apr 2019 20:01:17 +0200 Subject: [PATCH 2/7] add a prune function --- borg_qt/borg_interface.py | 23 +++++- borg_qt/config.py | 35 ++++++++++ borg_qt/main_window.py | 22 ++++-- borg_qt/static/UI/Settings.ui | 127 +++++++++++++++++++++++++++++++++- docs/borg_qt.conf.example | 8 +++ requirements.txt | 1 - tests/conftest.py | 12 ++++ tests/test_borg.py | 10 +++ tests/test_config.py | 9 +++ 9 files changed, 238 insertions(+), 9 deletions(-) diff --git a/borg_qt/borg_interface.py b/borg_qt/borg_interface.py index 530944e..1d1f47b 100644 --- a/borg_qt/borg_interface.py +++ b/borg_qt/borg_interface.py @@ -101,7 +101,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) @@ -192,3 +192,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 9226f4d..4b40501 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 26091ba..5081223 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.""" @@ -125,12 +128,19 @@ class MainWindow(QMainWindow): 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) 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..49431fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ pyinstaller PyQt5 -borgbackup[fuse]==1.1.8 pytest 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 0d703c7..4cc390a 100644 --- a/tests/test_borg.py +++ b/tests/test_borg.py @@ -58,3 +58,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 fa2e5e0..8bc2352 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' From 582fe2914b63e15e31091acb8ca3ef640acac55e Mon Sep 17 00:00:00 2001 From: Andreas Zweili Date: Sun, 28 Apr 2019 20:33:32 +0200 Subject: [PATCH 3/7] correct dialog text --- borg_qt/main_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/borg_qt/main_window.py b/borg_qt/main_window.py index 5081223..5edb1b1 100644 --- a/borg_qt/main_window.py +++ b/borg_qt/main_window.py @@ -208,7 +208,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: From bf8d132620075a90a39813bf1c7ae14a53b454a1 Mon Sep 17 00:00:00 2001 From: Andreas Zweili Date: Sun, 28 Apr 2019 20:34:17 +0200 Subject: [PATCH 4/7] move the mount check to it's own method --- borg_qt/main_window.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/borg_qt/main_window.py b/borg_qt/main_window.py index 5edb1b1..34db17e 100644 --- a/borg_qt/main_window.py +++ b/borg_qt/main_window.py @@ -120,12 +120,8 @@ 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() backup_thread = borg.BackupThread([self.src_path], @@ -276,6 +272,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. From 2700a365373e8cebb5977aea0472eff34fec758f Mon Sep 17 00:00:00 2001 From: Andreas Zweili Date: Sun, 28 Apr 2019 20:34:35 +0200 Subject: [PATCH 5/7] add the mount check to the delete method --- borg_qt/main_window.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/borg_qt/main_window.py b/borg_qt/main_window.py index 34db17e..6bbadab 100644 --- a/borg_qt/main_window.py +++ b/borg_qt/main_window.py @@ -189,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: From e9987c927be0ee4af265a83e97df6e5a18fe7fee Mon Sep 17 00:00:00 2001 From: Andreas Zweili Date: Sun, 28 Apr 2019 20:40:46 +0200 Subject: [PATCH 6/7] add pytest to the README --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 51e4088..abf1cd7 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,10 @@ 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 ## Contributing From ca9ea4461c2f78e58ddd90eab12f3c0016a4adc4 Mon Sep 17 00:00:00 2001 From: Andreas Zweili Date: Sun, 12 May 2019 21:01:27 +0200 Subject: [PATCH 7/7] add pytest-cov plugin to the requirements --- README.md | 2 ++ requirements.txt | 1 + 2 files changed, 3 insertions(+) diff --git a/README.md b/README.md index abf1cd7..93244c5 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,8 @@ features here: [todos.md](docs/todos.md) - [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/requirements.txt b/requirements.txt index 49431fe..b03964d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ pyinstaller PyQt5 pytest +pytest-cov