From 4f6f48247d7ba18fa7f42a5d8dbbea094dd674a6 Mon Sep 17 00:00:00 2001 From: furtest Date: Tue, 30 Dec 2025 17:54:47 +0100 Subject: [PATCH 01/10] Add classes for error handling --- src/unisync/errors.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/unisync/errors.py diff --git a/src/unisync/errors.py b/src/unisync/errors.py new file mode 100644 index 0000000..f1676f9 --- /dev/null +++ b/src/unisync/errors.py @@ -0,0 +1,5 @@ +class RemoteMountedError(BaseException): + pass + +class InvalidMountError(BaseException): + pass From 86a6c8acceaadc65b1f5bf2aec67a861d2559f9c Mon Sep 17 00:00:00 2001 From: furtest Date: Tue, 30 Dec 2025 17:56:03 +0100 Subject: [PATCH 02/10] Adds error handling for the paths --- src/unisync/synchroniser.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/unisync/synchroniser.py b/src/unisync/synchroniser.py index f1831d1..fc07e76 100644 --- a/src/unisync/synchroniser.py +++ b/src/unisync/synchroniser.py @@ -9,6 +9,8 @@ import logging from pathlib import Path +from errors import RemoteMountedError, InvalidMountError + logger = logging.getLogger(__name__) class Synchroniser: @@ -175,16 +177,24 @@ class Synchroniser: Mount the remote directory to make the local links work. This is achieved using sshfs. Raise: + - RemoteMountedError: The .data directory is already a mount point + - InvalidMountError: .data is either not a directory or not empty - subprocess.CalledProcessError: An error occured with sshfs """ + # Get the absolute path to the correct .data directory resolving symlinks + path_to_mount:PosixPath = Path(f"{self.local}/../.data").resolve() + if path_to_mount.is_mount(): + raise RemoteMountedError + # Check if it is an empty directory + if not path_to_mount.is_dir() or any(path_to_mount.iterdir()): + raise InvalidMountError command = [ "/usr/bin/sshfs", "-o", "ControlPath={self.control_path}", "-o", "ServerAliveInterval=15", "-p", str(self.remote_port), f"{self.remote_user}@{self.remote_ip}:{self.remote_dir}/.data", - # Get the absolute path to the correct .data directory resolving symlinks - str(Path(f"{self.local}/../.data").resolve()) + str(path_to_mount) ] completed_process = subprocess.run(command) completed_process.check_returncode() From f9001ecb9dbe752ef42bc7b1c6ab9cda9b1dd69f Mon Sep 17 00:00:00 2001 From: furtest Date: Wed, 31 Dec 2025 00:03:17 +0100 Subject: [PATCH 03/10] Adds usage of the paths manager --- src/unisync/main.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/unisync/main.py b/src/unisync/main.py index 98c3d59..e957c09 100644 --- a/src/unisync/main.py +++ b/src/unisync/main.py @@ -5,6 +5,8 @@ import os from argparser import create_argparser from config import RootsConfig, ServerConfig, Config, load_config from synchroniser import Synchroniser +from pathlib import Path, PosixPath +from paths import * def main(): parser = create_argparser() @@ -29,14 +31,19 @@ def main(): config.unison.values ) + paths_manager = PathsManager(Path(config.roots.local), config.other.cache_dir_path) + if synchroniser.create_ssh_master_connection() != 0: print("Connection failed quitting") return 1 print("Connected to the remote.") - synchroniser.sync_files(["salut"]) + #synchroniser.sync_files() + #synchroniser.update_links(background=False) + #synchroniser.mount_remote_dir() synchroniser.close_ssh_master_connection() + print(paths_manager.get_paths_to_sync()) From 48179034a7f5ea005eaca274a1b7d1126bbdd9d7 Mon Sep 17 00:00:00 2001 From: furtest Date: Wed, 31 Dec 2025 00:03:59 +0100 Subject: [PATCH 04/10] Adds prerequisites to README --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 2a2205b..57116c9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,19 @@ Unisync is a data synchronisation tool written in python and based on [unison](https://github.com/bcpierce00/unison). I couldn't find a tool to fulfill the requirements I had for a synchronisation tool so I am creating my own as a wrapper around unison. +# Prerequisite + +You need to have the following tools installed. + +Locally : +- unison +- sshfs +- nnn + +Remotely : +- unison +- cleanlinks and lndir (Should be in `xutils-dev` when using apt) + # Goal Unisync purpose is to keep personal data synchronised between multiple machines without needing to have all the data present an all the machines at the same time. For example you might not need to have your movies on your laptop but still want them on your desktop at home or you might want to keep your old pictures only on a server. From 27924013d9441147b1942aacebf706d5785b2e0d Mon Sep 17 00:00:00 2001 From: furtest Date: Thu, 1 Jan 2026 17:24:46 +0100 Subject: [PATCH 05/10] Bug fixes and small improvements Fix : - paths : true instead of True - paths : Path has no len convert to str first to get the number of characters Improvements : - Replace all PosixPath by Path --- src/unisync/config.py | 14 +++++++------- src/unisync/paths.py | 15 ++++++++------- src/unisync/synchroniser.py | 2 +- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/unisync/config.py b/src/unisync/config.py index 0677fb9..e615c50 100644 --- a/src/unisync/config.py +++ b/src/unisync/config.py @@ -3,7 +3,7 @@ from configparser import UNNAMED_SECTION from dataclasses import dataclass, field -from pathlib import Path, PosixPath +from pathlib import Path import ipaddress import configparser @@ -13,10 +13,10 @@ class ServerConfig: Dataclass keeping the config for connecting to the server """ user: str - sshargs: list[str] | None = field(default_factory=list) + sshargs: str = "" hostname: str = "" ip: str = "" - port: int = 22 + port: int | None = 22 def __post_init__(self): """ @@ -52,7 +52,7 @@ class OtherConfig: """ Dataclass keeping miscellanous configuration options """ - cache_dir_path: PosixPath = Path("~/.unisync").expanduser() + cache_dir_path: Path = Path("~/.unisync").expanduser() @dataclass class Config: @@ -82,9 +82,9 @@ def load_config(config_path:str) -> Config: server_config = ServerConfig( config.get(server_section, "user"), - config.get(server_section, "sshargs", fallback=None), - config.get(server_section, "hostname", fallback=None), - config.get(server_section, "ip", fallback=None), + config.get(server_section, "sshargs", fallback=""), + config.get(server_section, "hostname", fallback=""), + config.get(server_section, "ip", fallback=""), config.getint(server_section, "port", fallback=None) ) roots_config = RootsConfig( diff --git a/src/unisync/paths.py b/src/unisync/paths.py index c06a15f..6048c46 100644 --- a/src/unisync/paths.py +++ b/src/unisync/paths.py @@ -4,12 +4,13 @@ import os.path import subprocess +import sys -from pathlib import Path, PosixPath +from pathlib import Path class PathsManager: - def __init__(self, local_dir:PosixPath, cache_dir:PosixPath): + def __init__(self, local_dir:Path, cache_dir:Path): """ Creates a PathsManager with the necessary data Args: @@ -24,7 +25,7 @@ class PathsManager: raise ValueError("Invalid cache directory") self.cache_dir = cache_dir - self.paths_file:PosixPath = self.cache_dir / "paths" + self.paths_file:Path = self.cache_dir / "paths" if not self.paths_file.is_file(): raise ValueError("The paths file does not exist") @@ -48,7 +49,7 @@ class PathsManager: "-p", "-", self.local_dir ] - nnn_process = subprocess.Popen(command, stdout=subprocess.PIPE) + nnn_process:subprocess.Popen = subprocess.Popen(command, stdout=subprocess.PIPE) try: ret_code = nnn_process.wait(timeout=choice_timeout) except subprocess.TimeoutExpired as e: @@ -57,18 +58,18 @@ class PathsManager: if ret_code != 0: print("File selection failed", file=sys.stderr) - raise subprocess.CalledProcessError("File selection failed") + raise subprocess.CalledProcessError(1, "File selection failed") paths_list:list[str] = [] while (next_path := nnn_process.stdout.readline()) != b'': next_path = next_path.decode().strip() # Make the path relative to the top directory - next_path = next_path[len(self.local_dir):].lstrip("/") + next_path = next_path[len(str(self.local_dir)):].lstrip("/") paths_list.append(next_path) return paths_list def add_files_to_sync(self): - while true: + while True: try: paths = self.user_select_files() break diff --git a/src/unisync/synchroniser.py b/src/unisync/synchroniser.py index fc07e76..8ece7d9 100644 --- a/src/unisync/synchroniser.py +++ b/src/unisync/synchroniser.py @@ -182,7 +182,7 @@ class Synchroniser: - subprocess.CalledProcessError: An error occured with sshfs """ # Get the absolute path to the correct .data directory resolving symlinks - path_to_mount:PosixPath = Path(f"{self.local}/../.data").resolve() + path_to_mount:Path = Path(f"{self.local}/../.data").resolve() if path_to_mount.is_mount(): raise RemoteMountedError # Check if it is an empty directory From 5af5374f7755738e44f63e59d755f5996ea7e4b9 Mon Sep 17 00:00:00 2001 From: furtest Date: Thu, 1 Jan 2026 17:30:28 +0100 Subject: [PATCH 06/10] config : fallback to 22 instead of None The configparser fallback option was None set it to use 22 instead as None doesn't make sense --- src/unisync/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/unisync/config.py b/src/unisync/config.py index e615c50..89246b3 100644 --- a/src/unisync/config.py +++ b/src/unisync/config.py @@ -16,7 +16,7 @@ class ServerConfig: sshargs: str = "" hostname: str = "" ip: str = "" - port: int | None = 22 + port: int = 22 def __post_init__(self): """ @@ -85,7 +85,7 @@ def load_config(config_path:str) -> Config: config.get(server_section, "sshargs", fallback=""), config.get(server_section, "hostname", fallback=""), config.get(server_section, "ip", fallback=""), - config.getint(server_section, "port", fallback=None) + config.getint(server_section, "port", fallback=22) ) roots_config = RootsConfig( config.get(roots_section, "local"), From 89fa5bca70c44db9b9c9dbb8f3ad792227699350 Mon Sep 17 00:00:00 2001 From: furtest Date: Thu, 1 Jan 2026 18:51:45 +0100 Subject: [PATCH 07/10] paths : fixes write_new_paths writing of the file I was writing the file using 'w' instead of 'a' so the old paths were deleted use 'a'. --- src/unisync/paths.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/unisync/paths.py b/src/unisync/paths.py index 6048c46..6bb6269 100644 --- a/src/unisync/paths.py +++ b/src/unisync/paths.py @@ -106,7 +106,7 @@ class PathsManager: if not is_contained and new_path not in paths_to_add: paths_to_add.append(new_path) - with self.paths_file.open("w") as f: + with self.paths_file.open("a") as f: for p in paths_to_add: f.write(p + "\n") From bd65c623a48cd61b661e7609c073d7ab89407ad7 Mon Sep 17 00:00:00 2001 From: furtest Date: Thu, 1 Jan 2026 23:15:02 +0100 Subject: [PATCH 08/10] runners : Create runners file and basic runnners This adds runners.py, it contains a set of functions that peform all the various task that unisync can do (sync, add and mount for now). They are simple function that put together all the rest. --- src/unisync/runners.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/unisync/runners.py diff --git a/src/unisync/runners.py b/src/unisync/runners.py new file mode 100644 index 0000000..fe185f0 --- /dev/null +++ b/src/unisync/runners.py @@ -0,0 +1,32 @@ +from synchroniser import Synchroniser +from paths import PathsManager + + +def unisync_sync(synchroniser:Synchroniser, paths_manager:PathsManager): + if synchroniser.create_ssh_master_connection() != 0: + print("Connection failed quitting") + return 1 + print("Connected to the remote.") + + synchroniser.sync_files(paths_manager.get_paths_to_sync()) + synchroniser.update_links(background=False) + + # TODO check the config options + #synchroniser.mount_remote_dir() + + synchroniser.close_ssh_master_connection() + + +def unisync_add(synchroniser:Synchroniser, paths_manager:PathsManager): + if synchroniser.create_ssh_master_connection() != 0: + print("Connection failed quitting") + return 1 + print("Connected to the remote.") + + paths_manager.add_files_to_sync() + + synchroniser.close_ssh_master_connection() + + +def unisync_mount(synchroniser:Synchroniser, paths_manager:PathsManager): + synchroniser.mount_remote_dir() From 885050cf84a6b6c7954b580f5229e67e0d2407a8 Mon Sep 17 00:00:00 2001 From: furtest Date: Thu, 1 Jan 2026 23:17:33 +0100 Subject: [PATCH 09/10] argparser : adds subcommands to the argparser this adds subcommands to the argparser using subparsers, we also set a default value for func depending on which of the subcommands is selected. Also change the formatting of the epilog so it is on two lines. --- src/unisync/argparser.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/unisync/argparser.py b/src/unisync/argparser.py index dda0eb0..ba28700 100644 --- a/src/unisync/argparser.py +++ b/src/unisync/argparser.py @@ -3,20 +3,34 @@ import argparse -def create_argparser() -> argparse.ArgumentParser: +def create_argparser(sync_function, add_function, mount_function) -> argparse.ArgumentParser: + """ + Creates an argument parser to parse the command line arguments. + We use subparsers and set a default function for each to perform the correct action. + """ parser = argparse.ArgumentParser( prog='unisync', description='File synchronisation application', - epilog=""" - Copyright © 2025 Paul Retourné. - License GPLv3+: GNU GPL version 3 or later .""" + epilog="Copyright © 2025 Paul Retourné.\n" + "License GPLv3+: GNU GPL version 3 or later .", + formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument("local", nargs="?") parser.add_argument("remote", nargs="?") + parser.set_defaults(func=sync_function) remote_addr_group = parser.add_mutually_exclusive_group() remote_addr_group.add_argument("--ip") remote_addr_group.add_argument("--hostname") parser.add_argument("--config", help="Path to the configuration file", metavar="path_to_config") + + subparsers = parser.add_subparsers(help='Actions other than synchronisation') + + parser_add = subparsers.add_parser('add', help='Add files to be synchronised.') + parser_add.set_defaults(func=add_function) + + parser_mount = subparsers.add_parser('mount', help='Mount the remote.') + parser_mount.set_defaults(func=mount_function) + return parser From addbdb87dfa79d0f8dc94c740e837acbc4939fe5 Mon Sep 17 00:00:00 2001 From: furtest Date: Thu, 1 Jan 2026 23:19:57 +0100 Subject: [PATCH 10/10] main : adds subcommands, move to Path and improve Multiple changes to the main file, after this unisync becomes kind of usable. Add subcommands : this uses the 2 previous commits to add the subcommands to unisync it is now possible to sync, add and mount. pathlib : move from PosixPath to Path Remove unused imports Rename base_namespace to cli_args Add some comments and TODOs --- src/unisync/main.py | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/src/unisync/main.py b/src/unisync/main.py index e957c09..828aa3f 100644 --- a/src/unisync/main.py +++ b/src/unisync/main.py @@ -3,24 +3,29 @@ import os from argparser import create_argparser -from config import RootsConfig, ServerConfig, Config, load_config +from runners import unisync_sync, unisync_add, unisync_mount +from config import load_config from synchroniser import Synchroniser -from pathlib import Path, PosixPath +from pathlib import Path from paths import * def main(): - parser = create_argparser() - base_namespace = parser.parse_args() + parser = create_argparser(unisync_sync, unisync_add, unisync_mount) + cli_args = parser.parse_args() config_path = os.path.expanduser("~/.config/unisync/config.ini") - if base_namespace.config != None and os.path.isfile(base_namespace.config): - config = load_config(base_namespace.config) + # Check if --config is set + if cli_args.config != None and os.path.isfile(cli_args.config): + config = load_config(cli_args.config) elif os.path.isfile(config_path): config = load_config(config_path) else: - # TODO make the command line arguments work and override the config options + # TODO replace the next line with something to do if no config file is found + config = load_config(config_path) pass + # TODO make the command line arguments work and override the config options + synchroniser = Synchroniser( config.roots.remote, config.roots.local, @@ -33,18 +38,7 @@ def main(): paths_manager = PathsManager(Path(config.roots.local), config.other.cache_dir_path) - if synchroniser.create_ssh_master_connection() != 0: - print("Connection failed quitting") - return 1 - print("Connected to the remote.") - - #synchroniser.sync_files() - #synchroniser.update_links(background=False) - #synchroniser.mount_remote_dir() - - synchroniser.close_ssh_master_connection() - print(paths_manager.get_paths_to_sync()) - + cli_args.func(synchroniser, paths_manager) if __name__ == "__main__":