Compare commits
11 Commits
138bc6d24a
...
a0eb371a65
| Author | SHA1 | Date | |
|---|---|---|---|
|
a0eb371a65
|
|||
|
addbdb87df
|
|||
|
885050cf84
|
|||
|
bd65c623a4
|
|||
|
89fa5bca70
|
|||
|
5af5374f77
|
|||
|
27924013d9
|
|||
|
48179034a7
|
|||
|
f9001ecb9d
|
|||
|
86a6c8acce
|
|||
|
4f6f48247d
|
13
README.md
13
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
5
src/unisync/errors.py
Normal file
@@ -0,0 +1,5 @@
|
||||
class RemoteMountedError(BaseException):
|
||||
pass
|
||||
|
||||
class InvalidMountError(BaseException):
|
||||
pass
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
32
src/unisync/runners.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user