From f5e455fc79bf83e272ef96c8e37ec71fd747ff8c Mon Sep 17 00:00:00 2001 From: furtest Date: Mon, 5 Jan 2026 17:17:41 +0100 Subject: [PATCH 01/11] config, defaults: add configuration for backups Add configuration options for creating backups during the synchronisation. --- src/unisync/config.py | 24 +++++++++++++++++++++++- src/unisync/defaults.py | 7 +++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/unisync/config.py b/src/unisync/config.py index 26e2738..d504df1 100644 --- a/src/unisync/config.py +++ b/src/unisync/config.py @@ -49,6 +49,18 @@ class UnisonConfig: bools: list values: dict +@dataclass +class BackupConfig: + """ + Configuration options relative to backing up the files. + """ + enabled: bool + selection: str + location: str + max_backups: int + backupsuffix: str + backupprefix: str + @dataclass class OtherConfig: """ @@ -64,6 +76,7 @@ class Config: server: ServerConfig roots: RootsConfig unison: UnisonConfig + backup: BackupConfig other: OtherConfig @@ -81,6 +94,7 @@ def load_config(config_path:str) -> Config: # Check if sections are provided server_section = "Server" if "Server" in config.sections() else UNNAMED_SECTION roots_section = "Roots" if "Roots" in config.sections() else UNNAMED_SECTION + backup_section = "Backup" other_section = "Other" if "Other" in config.sections() else UNNAMED_SECTION server_config = ServerConfig( @@ -94,6 +108,14 @@ def load_config(config_path:str) -> Config: config.get(roots_section, "local", fallback=DEFAULT_ROOTS_LOCAL), config.get(roots_section, "remote") ) + backup_config = BackupConfig( + config.getboolean(backup_section, "enabled", fallback=DEFAULT_BACKUP_ENABLED), + config.get(backup_section, "selection", fallback=DEFAULT_BACKUP_SELECTION), + config.get(backup_section, "loction", fallback=DEFAULT_BACKUP_LOC), + config.getint(backup_section, "max_backups", fallback=DEFAULT_BACKUP_MAX_BACKUPS), + config.get(backup_section, "backupsuffix", fallback=DEFAULT_BACKUP_BACKUPSUFFIX), + config.get(backup_section, "backupprefix", fallback=DEFAULT_BACKUP_BACKUPPREFIX) + ) other_config = OtherConfig( Path(config.get(other_section, "cache_dir_path", fallback=DEFAULT_MISC_CACHE_DIR_PATH)).expanduser() ) @@ -110,4 +132,4 @@ def load_config(config_path:str) -> Config: args_val[key] = val unison_config = UnisonConfig(args_bool, args_val) - return Config(server_config, roots_config, unison_config, other_config) + return Config(server_config, roots_config, unison_config, backup_config, other_config) diff --git a/src/unisync/defaults.py b/src/unisync/defaults.py index a00a3e7..ddc13c0 100644 --- a/src/unisync/defaults.py +++ b/src/unisync/defaults.py @@ -16,3 +16,10 @@ DEFAULT_ROOTS_LOCAL: str = str(Path("~/files").expanduser()) # DEFAULT_ROOTS_REMOTE: str = "" DEFAULT_MISC_CACHE_DIR_PATH: str = "~/.unisync" + +DEFAULT_BACKUP_ENABLED: bool = False +DEFAULT_BACKUP_SELECTION: str = "" +DEFAULT_BACKUP_LOC: str = "local" +DEFAULT_BACKUP_MAX_BACKUPS: int = 2 +DEFAULT_BACKUP_BACKUPSUFFIX: str = ".unison_backups/" +DEFAULT_BACKUP_BACKUPPREFIX: str = ".$VERSION.bak" From f6189325841103c2aaec7e2c9863011b5f1f158f Mon Sep 17 00:00:00 2001 From: furtest Date: Wed, 7 Jan 2026 23:27:48 +0100 Subject: [PATCH 02/11] synchroniser : add arbitrary synchronisation arguments Add the option to give arbitrary arguments to the unison call. These arguments must be passed as a list to sync and will be given to unison as is. This is a prerequisite for using the backup system of unison as the arguments for backup will only be given when synchronising the files and not the links. --- src/unisync/synchroniser.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/unisync/synchroniser.py b/src/unisync/synchroniser.py index 8a39e44..222fc7d 100644 --- a/src/unisync/synchroniser.py +++ b/src/unisync/synchroniser.py @@ -150,7 +150,9 @@ class Synchroniser: ) def sync(self, remote_root:str, local_root:str, - paths:list=[], ignore:list=[], force:bool=False) -> int: + paths:list=[], ignore:list=[], force:bool=False, + other:list=[] + ) -> int: """Performs the synchronisation by calling unison. Args: @@ -162,6 +164,12 @@ class Synchroniser: If you need to ignore some specific files use the arguments. force: Force all changes from remote to local. Used mostly when replacing a link by the file. + other: + Other arguments to add to unison. + These arguments will only be used for this sync which is not + the case for the ones in self.args_bool and self.args_value. + They will be added to the command as is no - in front. + For exemple backups are implemented using this argument. Returns: the unison return code see section 6.11 of the documentation @@ -192,6 +200,9 @@ class Synchroniser: command.append(remote_root) command.append("-batch") + for arg in other: + command.append(arg) + proc = subprocess.Popen(command) ret_code = proc.wait() return ret_code From 667c418f09f6ae0786f53b717d94ca8afc303f8e Mon Sep 17 00:00:00 2001 From: furtest Date: Wed, 7 Jan 2026 23:32:24 +0100 Subject: [PATCH 03/11] synchroniser : add backup to sync_files Adds the option to enable backup when synchronising. This is done in sync_files by passing the appropriate arguments to sync. For this we need to add an argument to sync_files as the backup configuration options are needed. The configuration options are imported from unisync.config.BackupConfig. Also import typing.cast to be able to narrow down a type. --- src/unisync/synchroniser.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/unisync/synchroniser.py b/src/unisync/synchroniser.py index 222fc7d..9646c06 100644 --- a/src/unisync/synchroniser.py +++ b/src/unisync/synchroniser.py @@ -14,8 +14,10 @@ import time import logging from pathlib import Path +from typing import cast from unisync.errors import RemoteMountedError, InvalidMountError +from unisync.config import BackupConfig logger = logging.getLogger(__name__) @@ -117,7 +119,9 @@ class Synchroniser: close = subprocess.Popen(command) return close.wait() - def sync_files(self, paths:list, force:bool=False) -> int: + def sync_files(self, paths:list, force:bool=False, + backup:BackupConfig | None = None + ) -> int: """Synchronises the files. Args: @@ -127,11 +131,35 @@ class Synchroniser: Returns: The return code of sync. """ + other = list() + if(backup != None and backup.enabled): + backup = cast(BackupConfig, backup) + + other.append("-backup") + if(backup.selection != ""): + other.append(backup.selection) + else: + other.append("Name *") + + other.append([ + "-backuploc", + backup.location, + "-maxbackups", + backup.max_backups, + "-backupsuffix", + backup.backupsuffix, + "-backupprefix", + backup.backupprefix, + "-ignore", + backup.backupprefix + ]) + return self.sync( f"ssh://{self.remote_user}@{self.remote_ip}/{self.remote_dir}/.data", self.local, paths=paths, - force=force + force=force, + other=other ) def sync_links(self, ignore:list) -> int: From 2ae9c38627aa18fdd116432cc9f761ac4dd7c01c Mon Sep 17 00:00:00 2001 From: furtest Date: Wed, 7 Jan 2026 23:35:26 +0100 Subject: [PATCH 04/11] tests : add some simple code to run a few tests --- tests/runners.py | 8 ++++++++ tests/test.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 tests/runners.py create mode 100644 tests/test.py diff --git a/tests/runners.py b/tests/runners.py new file mode 100644 index 0000000..11e162d --- /dev/null +++ b/tests/runners.py @@ -0,0 +1,8 @@ +# Copyright (C) 2026 Paul Retourné +# SPDX-License-Identifier: GPL-3.0-or-later + +from unisync.synchroniser import Synchroniser +from unisync.paths import PathsManager + +def unisync_test(synchroniser:Synchroniser, paths_manager:PathsManager): + print("Testing") diff --git a/tests/test.py b/tests/test.py new file mode 100644 index 0000000..6312859 --- /dev/null +++ b/tests/test.py @@ -0,0 +1,39 @@ +# Copyright (C) 2026 Paul Retourné +# SPDX-License-Identifier: GPL-3.0-or-later + +import os +from pathlib import Path + +from unisync.argparser import create_argparser +from unisync.runners import unisync_sync, unisync_add, unisync_mount +from unisync.config import load_config +from unisync.synchroniser import Synchroniser +from unisync.paths import * + +from runners import * + +def main(): + parser = create_argparser(unisync_test, unisync_add, unisync_mount) + cli_args = parser.parse_args() + + config_path = os.path.expanduser("./config.ini") + config = load_config(config_path) + + print(config) + + synchroniser = Synchroniser( + config.roots.remote, + config.roots.local, + config.server.user, + config.server.ip if config.server.ip != "" else config.server.hostname, + config.server.port, + config.unison.bools, + config.unison.values + ) + + paths_manager = PathsManager(Path(config.roots.local), config.other.cache_dir_path) + + cli_args.func(synchroniser, paths_manager) + +if __name__ == "__main__": + main() From 56da79f124be2ae9bb4af47f3d22c09df5046b8b Mon Sep 17 00:00:00 2001 From: furtest Date: Thu, 8 Jan 2026 13:53:37 +0100 Subject: [PATCH 05/11] runners, main : pass the config to the runners Some of the runners need the configuration to perform their task. So pass it to all of them and edit the call in main to reflect this change. --- src/unisync/main.py | 2 +- src/unisync/runners.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/unisync/main.py b/src/unisync/main.py index f77462d..0c69512 100644 --- a/src/unisync/main.py +++ b/src/unisync/main.py @@ -39,7 +39,7 @@ def main(): paths_manager = PathsManager(Path(config.roots.local), config.other.cache_dir_path) - cli_args.func(synchroniser, paths_manager) + cli_args.func(synchroniser, paths_manager, config) if __name__ == "__main__": diff --git a/src/unisync/runners.py b/src/unisync/runners.py index 5798941..ddaddf5 100644 --- a/src/unisync/runners.py +++ b/src/unisync/runners.py @@ -3,9 +3,9 @@ from unisync.synchroniser import Synchroniser from unisync.paths import PathsManager +from unisync.config import Config - -def unisync_sync(synchroniser:Synchroniser, paths_manager:PathsManager): +def unisync_sync(synchroniser:Synchroniser, paths_manager:PathsManager, config: Config): if synchroniser.create_ssh_master_connection() != 0: print("Connection failed quitting") return 1 @@ -21,7 +21,7 @@ def unisync_sync(synchroniser:Synchroniser, paths_manager:PathsManager): synchroniser.close_ssh_master_connection() -def unisync_add(synchroniser:Synchroniser, paths_manager:PathsManager): +def unisync_add(synchroniser:Synchroniser, paths_manager:PathsManager, config: Config): if synchroniser.create_ssh_master_connection() != 0: print("Connection failed quitting") return 1 @@ -32,5 +32,5 @@ def unisync_add(synchroniser:Synchroniser, paths_manager:PathsManager): synchroniser.close_ssh_master_connection() -def unisync_mount(synchroniser:Synchroniser, paths_manager:PathsManager): +def unisync_mount(synchroniser:Synchroniser, paths_manager:PathsManager, config: Config): synchroniser.mount_remote_dir() From aaa4a8f12c688d09270b8fd8c417c5c87f306cb3 Mon Sep 17 00:00:00 2001 From: furtest Date: Thu, 8 Jan 2026 14:06:36 +0100 Subject: [PATCH 06/11] runners : delete unused arguments Use the del keyword for unused functions arguments in runners. All the runners must have the same signature however some do not use all of the provided arguments so we delete them so the developement tools do not generate warnings. --- src/unisync/runners.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/unisync/runners.py b/src/unisync/runners.py index ddaddf5..af51ded 100644 --- a/src/unisync/runners.py +++ b/src/unisync/runners.py @@ -22,6 +22,7 @@ def unisync_sync(synchroniser:Synchroniser, paths_manager:PathsManager, config: def unisync_add(synchroniser:Synchroniser, paths_manager:PathsManager, config: Config): + del config # The function signature must be the same for all runners if synchroniser.create_ssh_master_connection() != 0: print("Connection failed quitting") return 1 @@ -33,4 +34,6 @@ def unisync_add(synchroniser:Synchroniser, paths_manager:PathsManager, config: C def unisync_mount(synchroniser:Synchroniser, paths_manager:PathsManager, config: Config): + del paths_manager # The function signature must be the same for all runners + del config # The function signature must be the same for all runners synchroniser.mount_remote_dir() From bb059904641a8e859256ad9ccca96c6feb5b12bb Mon Sep 17 00:00:00 2001 From: furtest Date: Thu, 8 Jan 2026 14:13:14 +0100 Subject: [PATCH 07/11] runners : pass config.backup to sync_files After adding the backup infrastructure to config and synchroniser the only thing left to do is pass the BackupConfig to sync_files. --- src/unisync/runners.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/unisync/runners.py b/src/unisync/runners.py index af51ded..e36704d 100644 --- a/src/unisync/runners.py +++ b/src/unisync/runners.py @@ -11,7 +11,7 @@ def unisync_sync(synchroniser:Synchroniser, paths_manager:PathsManager, config: return 1 print("Connected to the remote.") - synchroniser.sync_files(paths_manager.get_paths_to_sync()) + synchroniser.sync_files(paths_manager.get_paths_to_sync(), backup=config.backup) synchroniser.sync_links(paths_manager.get_paths_to_sync()) # TODO check the config options and do or don't do the following From c34d30a0061ab7764b3715e313fe2ec0639b39c3 Mon Sep 17 00:00:00 2001 From: furtest Date: Thu, 8 Jan 2026 14:19:03 +0100 Subject: [PATCH 08/11] defaults : switch prefix and suffix I mixed up the prefix and suffix, fix that --- src/unisync/defaults.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/unisync/defaults.py b/src/unisync/defaults.py index ddc13c0..55f0062 100644 --- a/src/unisync/defaults.py +++ b/src/unisync/defaults.py @@ -21,5 +21,5 @@ DEFAULT_BACKUP_ENABLED: bool = False DEFAULT_BACKUP_SELECTION: str = "" DEFAULT_BACKUP_LOC: str = "local" DEFAULT_BACKUP_MAX_BACKUPS: int = 2 -DEFAULT_BACKUP_BACKUPSUFFIX: str = ".unison_backups/" -DEFAULT_BACKUP_BACKUPPREFIX: str = ".$VERSION.bak" +DEFAULT_BACKUP_BACKUPSUFFIX: str = ".$VERSION.bak" +DEFAULT_BACKUP_BACKUPPREFIX: str = ".unison_backups/" From cf49ffb8e8ec10919d5ca95add5f013db0552b6f Mon Sep 17 00:00:00 2001 From: furtest Date: Fri, 9 Jan 2026 18:31:00 +0100 Subject: [PATCH 09/11] synchroniser : fix broken synchronisation Append was used instead of extend which made a list inside of a list instead of appending the content at the end fix that. Convert backup.maxbackups to str as needed for subprocess. --- src/unisync/synchroniser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/unisync/synchroniser.py b/src/unisync/synchroniser.py index 9646c06..f3a6c2c 100644 --- a/src/unisync/synchroniser.py +++ b/src/unisync/synchroniser.py @@ -141,11 +141,11 @@ class Synchroniser: else: other.append("Name *") - other.append([ + other.extend([ "-backuploc", backup.location, "-maxbackups", - backup.max_backups, + str(backup.max_backups), "-backupsuffix", backup.backupsuffix, "-backupprefix", From 5ec43f916633e4984f5c837dcb31e4f67fa27c7b Mon Sep 17 00:00:00 2001 From: furtest Date: Tue, 20 Jan 2026 10:33:13 +0100 Subject: [PATCH 10/11] synchroniser : move backup options to init Moves the backup options from sync_files to init. The options are needed in links (to ignore the backup folders) so it is way easier to have them as attributes. To do this we move everything related to backup into __init__. Also remove the option from the runner. --- src/unisync/runners.py | 2 +- src/unisync/synchroniser.py | 65 ++++++++++++++++++++----------------- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/src/unisync/runners.py b/src/unisync/runners.py index e36704d..af51ded 100644 --- a/src/unisync/runners.py +++ b/src/unisync/runners.py @@ -11,7 +11,7 @@ def unisync_sync(synchroniser:Synchroniser, paths_manager:PathsManager, config: return 1 print("Connected to the remote.") - synchroniser.sync_files(paths_manager.get_paths_to_sync(), backup=config.backup) + synchroniser.sync_files(paths_manager.get_paths_to_sync()) synchroniser.sync_links(paths_manager.get_paths_to_sync()) # TODO check the config options and do or don't do the following diff --git a/src/unisync/synchroniser.py b/src/unisync/synchroniser.py index f3a6c2c..7d0e912 100644 --- a/src/unisync/synchroniser.py +++ b/src/unisync/synchroniser.py @@ -49,8 +49,10 @@ class Synchroniser: Currently unused. """ - def __init__(self, remote:str, local:str, user:str, ip:str, - port:int=22, args_bool:list=[], args_value:dict={}, ssh_settings:dict={}): + def __init__(self, remote:str, local:str, user:str, ip:str, port:int=22, + args_bool:list=[], args_value:dict={}, ssh_settings:dict={}, + backup:BackupConfig | None = None + ): """Initialises an instance of Synchroniser. """ self.remote_dir:str = remote @@ -61,6 +63,34 @@ class Synchroniser: self.remote_user:str = user self.remote_ip:str = ip self.remote_port:int = port + self.files_extra:list = list() + self.links_extra:list = list() + + if(backup != None and backup.enabled): + backup = cast(BackupConfig, backup) + self.files_extra.append("-backup") + if(backup.selection != ""): + self.files_extra.append(backup.selection) + else: + self.files_extra.append("Name *") + + self.files_extra.extend([ + "-backuploc", + backup.location, + "-maxbackups", + str(backup.max_backups), + "-backupsuffix", + backup.backupsuffix, + "-backupprefix", + backup.backupprefix, + "-ignore", + f"Name {backup.backupprefix[:-1]}" + ]) + + self.links_extra.extend([ + "-ignore", + f"Name {backup.backupprefix[:-1]}" + ]) def create_ssh_master_connection(self, control_path:str="~/.ssh/control_%C", connection_timeout:int=60) -> int: """Creates an ssh master connection. @@ -119,9 +149,7 @@ class Synchroniser: close = subprocess.Popen(command) return close.wait() - def sync_files(self, paths:list, force:bool=False, - backup:BackupConfig | None = None - ) -> int: + def sync_files(self, paths:list, force:bool=False) -> int: """Synchronises the files. Args: @@ -131,35 +159,13 @@ class Synchroniser: Returns: The return code of sync. """ - other = list() - if(backup != None and backup.enabled): - backup = cast(BackupConfig, backup) - - other.append("-backup") - if(backup.selection != ""): - other.append(backup.selection) - else: - other.append("Name *") - - other.extend([ - "-backuploc", - backup.location, - "-maxbackups", - str(backup.max_backups), - "-backupsuffix", - backup.backupsuffix, - "-backupprefix", - backup.backupprefix, - "-ignore", - backup.backupprefix - ]) return self.sync( f"ssh://{self.remote_user}@{self.remote_ip}/{self.remote_dir}/.data", self.local, paths=paths, force=force, - other=other + other=self.files_extra ) def sync_links(self, ignore:list) -> int: @@ -174,7 +180,8 @@ class Synchroniser: return self.sync( f"ssh://{self.remote_user}@{self.remote_ip}/{self.remote_dir}/links", self.local, - ignore=ignore + ignore=ignore, + other=self.links_extra ) def sync(self, remote_root:str, local_root:str, From cf508eb94c35d123b86ebb9a83d4af442adb49ec Mon Sep 17 00:00:00 2001 From: furtest Date: Tue, 20 Jan 2026 10:48:44 +0100 Subject: [PATCH 11/11] main : pass the backup options to the synchroniser --- src/unisync/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/unisync/main.py b/src/unisync/main.py index 0c69512..741b8a3 100644 --- a/src/unisync/main.py +++ b/src/unisync/main.py @@ -34,7 +34,8 @@ def main(): config.server.ip if config.server.ip != "" else config.server.hostname, config.server.port, config.unison.bools, - config.unison.values + config.unison.values, + backup=config.backup ) paths_manager = PathsManager(Path(config.roots.local), config.other.cache_dir_path)