Merge branch 'dev'

Got to a state that seems stable enough to go into main
This commit is contained in:
2026-01-01 23:26:10 +01:00
8 changed files with 111 additions and 35 deletions

View File

@@ -1,6 +1,19 @@
Unisync is a data synchronisation tool written in python and based on [unison](https://github.com/bcpierce00/unison). 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. 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 # 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. 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.

View File

@@ -3,20 +3,34 @@
import argparse 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( parser = argparse.ArgumentParser(
prog='unisync', prog='unisync',
description='File synchronisation application', description='File synchronisation application',
epilog=""" epilog="Copyright © 2025 Paul Retourné.\n"
Copyright © 2025 Paul Retourné. "License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>.",
License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>.""" formatter_class=argparse.RawDescriptionHelpFormatter
) )
parser.add_argument("local", nargs="?") parser.add_argument("local", nargs="?")
parser.add_argument("remote", nargs="?") parser.add_argument("remote", nargs="?")
parser.set_defaults(func=sync_function)
remote_addr_group = parser.add_mutually_exclusive_group() remote_addr_group = parser.add_mutually_exclusive_group()
remote_addr_group.add_argument("--ip") remote_addr_group.add_argument("--ip")
remote_addr_group.add_argument("--hostname") remote_addr_group.add_argument("--hostname")
parser.add_argument("--config", help="Path to the configuration file", metavar="path_to_config") 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 return parser

View File

@@ -3,7 +3,7 @@
from configparser import UNNAMED_SECTION from configparser import UNNAMED_SECTION
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path, PosixPath from pathlib import Path
import ipaddress import ipaddress
import configparser import configparser
@@ -13,7 +13,7 @@ class ServerConfig:
Dataclass keeping the config for connecting to the server Dataclass keeping the config for connecting to the server
""" """
user: str user: str
sshargs: list[str] | None = field(default_factory=list) sshargs: str = ""
hostname: str = "" hostname: str = ""
ip: str = "" ip: str = ""
port: int = 22 port: int = 22
@@ -52,7 +52,7 @@ class OtherConfig:
""" """
Dataclass keeping miscellanous configuration options Dataclass keeping miscellanous configuration options
""" """
cache_dir_path: PosixPath = Path("~/.unisync").expanduser() cache_dir_path: Path = Path("~/.unisync").expanduser()
@dataclass @dataclass
class Config: class Config:
@@ -82,10 +82,10 @@ def load_config(config_path:str) -> Config:
server_config = ServerConfig( server_config = ServerConfig(
config.get(server_section, "user"), config.get(server_section, "user"),
config.get(server_section, "sshargs", fallback=None), config.get(server_section, "sshargs", fallback=""),
config.get(server_section, "hostname", fallback=None), config.get(server_section, "hostname", fallback=""),
config.get(server_section, "ip", fallback=None), config.get(server_section, "ip", fallback=""),
config.getint(server_section, "port", fallback=None) config.getint(server_section, "port", fallback=22)
) )
roots_config = RootsConfig( roots_config = RootsConfig(
config.get(roots_section, "local"), config.get(roots_section, "local"),

5
src/unisync/errors.py Normal file
View File

@@ -0,0 +1,5 @@
class RemoteMountedError(BaseException):
pass
class InvalidMountError(BaseException):
pass

View File

@@ -3,22 +3,29 @@
import os import os
from argparser import create_argparser 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 synchroniser import Synchroniser
from pathlib import Path
from paths import *
def main(): def main():
parser = create_argparser() parser = create_argparser(unisync_sync, unisync_add, unisync_mount)
base_namespace = parser.parse_args() cli_args = parser.parse_args()
config_path = os.path.expanduser("~/.config/unisync/config.ini") config_path = os.path.expanduser("~/.config/unisync/config.ini")
if base_namespace.config != None and os.path.isfile(base_namespace.config): # Check if --config is set
config = load_config(base_namespace.config) if cli_args.config != None and os.path.isfile(cli_args.config):
config = load_config(cli_args.config)
elif os.path.isfile(config_path): elif os.path.isfile(config_path):
config = load_config(config_path) config = load_config(config_path)
else: 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 pass
# TODO make the command line arguments work and override the config options
synchroniser = Synchroniser( synchroniser = Synchroniser(
config.roots.remote, config.roots.remote,
config.roots.local, config.roots.local,
@@ -29,15 +36,9 @@ def main():
config.unison.values config.unison.values
) )
if synchroniser.create_ssh_master_connection() != 0: paths_manager = PathsManager(Path(config.roots.local), config.other.cache_dir_path)
print("Connection failed quitting")
return 1
print("Connected to the remote.")
synchroniser.sync_files(["salut"])
synchroniser.close_ssh_master_connection()
cli_args.func(synchroniser, paths_manager)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -4,12 +4,13 @@
import os.path import os.path
import subprocess import subprocess
import sys
from pathlib import Path, PosixPath from pathlib import Path
class PathsManager: 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 Creates a PathsManager with the necessary data
Args: Args:
@@ -24,7 +25,7 @@ class PathsManager:
raise ValueError("Invalid cache directory") raise ValueError("Invalid cache directory")
self.cache_dir = cache_dir 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(): if not self.paths_file.is_file():
raise ValueError("The paths file does not exist") raise ValueError("The paths file does not exist")
@@ -48,7 +49,7 @@ class PathsManager:
"-p", "-", "-p", "-",
self.local_dir self.local_dir
] ]
nnn_process = subprocess.Popen(command, stdout=subprocess.PIPE) nnn_process:subprocess.Popen = subprocess.Popen(command, stdout=subprocess.PIPE)
try: try:
ret_code = nnn_process.wait(timeout=choice_timeout) ret_code = nnn_process.wait(timeout=choice_timeout)
except subprocess.TimeoutExpired as e: except subprocess.TimeoutExpired as e:
@@ -57,18 +58,18 @@ class PathsManager:
if ret_code != 0: if ret_code != 0:
print("File selection failed", file=sys.stderr) print("File selection failed", file=sys.stderr)
raise subprocess.CalledProcessError("File selection failed") raise subprocess.CalledProcessError(1, "File selection failed")
paths_list:list[str] = [] paths_list:list[str] = []
while (next_path := nnn_process.stdout.readline()) != b'': while (next_path := nnn_process.stdout.readline()) != b'':
next_path = next_path.decode().strip() next_path = next_path.decode().strip()
# Make the path relative to the top directory # 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) paths_list.append(next_path)
return paths_list return paths_list
def add_files_to_sync(self): def add_files_to_sync(self):
while true: while True:
try: try:
paths = self.user_select_files() paths = self.user_select_files()
break break
@@ -105,7 +106,7 @@ class PathsManager:
if not is_contained and new_path not in paths_to_add: if not is_contained and new_path not in paths_to_add:
paths_to_add.append(new_path) 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: for p in paths_to_add:
f.write(p + "\n") f.write(p + "\n")

32
src/unisync/runners.py Normal file
View File

@@ -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()

View File

@@ -9,6 +9,8 @@ import logging
from pathlib import Path from pathlib import Path
from errors import RemoteMountedError, InvalidMountError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Synchroniser: class Synchroniser:
@@ -175,16 +177,24 @@ class Synchroniser:
Mount the remote directory to make the local links work. Mount the remote directory to make the local links work.
This is achieved using sshfs. This is achieved using sshfs.
Raise: 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 - subprocess.CalledProcessError: An error occured with sshfs
""" """
# Get the absolute path to the correct .data directory resolving symlinks
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
if not path_to_mount.is_dir() or any(path_to_mount.iterdir()):
raise InvalidMountError
command = [ command = [
"/usr/bin/sshfs", "/usr/bin/sshfs",
"-o", "ControlPath={self.control_path}", "-o", "ControlPath={self.control_path}",
"-o", "ServerAliveInterval=15", "-o", "ServerAliveInterval=15",
"-p", str(self.remote_port), "-p", str(self.remote_port),
f"{self.remote_user}@{self.remote_ip}:{self.remote_dir}/.data", f"{self.remote_user}@{self.remote_ip}:{self.remote_dir}/.data",
# Get the absolute path to the correct .data directory resolving symlinks str(path_to_mount)
str(Path(f"{self.local}/../.data").resolve())
] ]
completed_process = subprocess.run(command) completed_process = subprocess.run(command)
completed_process.check_returncode() completed_process.check_returncode()