Compare commits

..

11 Commits

Author SHA1 Message Date
a0eb371a65 Merge branch 'dev'
Got to a state that seems stable enough to go into main
2026-01-01 23:26:10 +01:00
addbdb87df 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
2026-01-01 23:19:57 +01:00
885050cf84 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.
2026-01-01 23:18:52 +01:00
bd65c623a4 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.
2026-01-01 23:15:02 +01:00
89fa5bca70 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'.
2026-01-01 18:51:45 +01:00
5af5374f77 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
2026-01-01 17:30:28 +01:00
27924013d9 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
2026-01-01 17:24:46 +01:00
48179034a7 Adds prerequisites to README 2025-12-31 00:03:59 +01:00
f9001ecb9d Adds usage of the paths manager 2025-12-31 00:03:17 +01:00
86a6c8acce Adds error handling for the paths 2025-12-30 17:56:03 +01:00
4f6f48247d Add classes for error handling 2025-12-30 17:54:47 +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).
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.

View File

@@ -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 <https://gnu.org/licenses/gpl.html>."""
epilog="Copyright © 2025 Paul Retourné.\n"
"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("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

View File

@@ -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"),

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
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__":

View File

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

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