diff --git a/README.md b/README.md index a06d93c..459514f 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. 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 diff --git a/src/unisync/config.py b/src/unisync/config.py index 0677fb9..89246b3 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,7 +13,7 @@ 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 @@ -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,10 +82,10 @@ 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.getint(server_section, "port", 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=22) ) roots_config = RootsConfig( config.get(roots_section, "local"), 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 diff --git a/src/unisync/main.py b/src/unisync/main.py index 98c3d59..828aa3f 100644 --- a/src/unisync/main.py +++ b/src/unisync/main.py @@ -3,22 +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 +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, @@ -29,15 +36,9 @@ def main(): config.unison.values ) - if synchroniser.create_ssh_master_connection() != 0: - print("Connection failed quitting") - return 1 - print("Connected to the remote.") - - synchroniser.sync_files(["salut"]) - - synchroniser.close_ssh_master_connection() + paths_manager = PathsManager(Path(config.roots.local), config.other.cache_dir_path) + cli_args.func(synchroniser, paths_manager) if __name__ == "__main__": diff --git a/src/unisync/paths.py b/src/unisync/paths.py index c06a15f..6bb6269 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 @@ -105,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") 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() diff --git a/src/unisync/synchroniser.py b/src/unisync/synchroniser.py index f1831d1..8ece7d9 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: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 = [ "/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()