First commit

Project created using poetry and some code is written
This commit is contained in:
2025-06-14 16:14:28 +02:00
commit 96e86d5c76
7 changed files with 221 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
poetry.lock

4
README.md Normal file
View File

@@ -0,0 +1,4 @@
Unisync is a data synchronisation tool written in python and based on [unison](https://github.com/bcpierce00/unison).
The goal is to be able to keep data synchronised between multiple computers without needing to have all the data kept locally while at the same time being able to access everything.
The development just started so the documentation will be written later.

21
pyproject.toml Normal file
View File

@@ -0,0 +1,21 @@
[project]
name = "unisync"
version = "0.1.0"
description = ""
authors = [
{name = "Paul Retourné",email = "paul.retourne@orange.fr"}
]
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"pyaml (>=25.5.0,<26.0.0)",
"pyrallis (>=0.3.1,<0.4.0)"
]
[tool.poetry]
packages = [{include = "unisync", from = "src"}]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

0
src/unisync/__init__.py Normal file
View File

43
src/unisync/config.py Normal file
View File

@@ -0,0 +1,43 @@
from dataclasses import dataclass
import ipaddress
from typing import Union
import yaml
@dataclass
class ServerConfig:
hostname: str = ""
ip: str = ""
port: int = 22
user: str
sshargs: list
def __post_init__(self):
if self.ip == "" and self.hostname == "":
raise ValueError("A remote must be provided (ip or hostname)")
if self.ip != "":
try:
ipaddress.ip_address(self.ip)
except ValueError:
raise ValueError("The provided ip address is invalid")
@dataclass
class RootsConfig:
local: str
remote: str
@dataclass
class Config:
server: ServerConfig
roots: RootsConfig
def load_config(config_path:str):
with open(config_path, 'r') as file:
config = yaml.safe_load(file)
return config
if __name__ == "__main__":
config = load_config("config.yaml")
print(config)

152
src/unisync/synchroniser.py Normal file
View File

@@ -0,0 +1,152 @@
import subprocess
import os
import sys
import time
import logging
logger = logging.getLogger(__name__)
class Synchroniser:
def __init__(self, remote:str, local:str, user:str, ip:str,
port:int=22, args_bool:list=[], args_value:dict={}, ssh_settings:dict={}):
self.remote_dir:str = remote
self.local:str = local
self.args_bool:list[str] = args_bool
self.args_value:dict[str, str] = args_value
self.ssh_settings:dict[str, str] = dict()
self.remote_user:str = user
self.remote_ip:str = ip
self.remote_port:int = port
def create_ssh_master_connection(self, control_path:str="~/.ssh/control_%C", connection_timeout:int=60) -> int:
"""
Creates an ssh master connection so the user only has to authenticate once to the remote server.
The subsequent connections will be made through this master connection which speeds up connecting.
@control_path: Set the location of the ssh control socket
@connection_timeout:
Time given to the user to authenticate to the remote server.
On slow connections one might want to increase this.
Returns 0 on success.
"""
self.control_path = os.path.expanduser(control_path)
command = [
"/usr/bin/ssh",
"-fNT",
"-M",
"-S", self.control_path,
f"{self.remote_user}@{self.remote_ip}",
"-p", str(self.remote_port)
]
master_ssh = subprocess.Popen(command)
try:
ret_code = master_ssh.wait(timeout=connection_timeout)
except subprocess.TimeoutExpired:
print("Time to login expired", file=sys.stderr)
return 1
except KeyboardInterrupt:
return 2
if ret_code != 0:
print("Login to remote failed", file=sys.stderr)
return ret_code
return 0
def close_ssh_master_connection(self) -> int:
"""
Close the ssh master connection.
"""
command = [
"/usr/bin/ssh",
"-S", self.control_path,
"-O", "exit",
f"{self.remote_user}@{self.remote_ip}",
"-p", str(self.remote_port)
]
close = subprocess.Popen(command)
return close.wait()
def sync_files(self, paths:list, force:bool=False) -> int:
"""
Synchronises the files.
"""
return self.sync(
f"ssh://{self.remote_user}@{self.remote_ip}/{self.remote_dir}/.data",
self.local,
paths=paths,
force=force
)
def sync_links(self, ignore:list) -> int:
"""
Synchronises the links, they must exist already.
"""
return self.sync(
f"ssh://{self.remote_user}@{self.remote_ip}/{self.remote_dir}/links",
self.local,
ignore=ignore
)
def sync(self, remote_root:str, local_root:str,
paths:list=[], ignore:list=[], force:bool=False) -> int:
"""
Perform the synchronisation by calling unison.
@remote_root: The remote root, must be a full root usable by unison.
@local_root: The local root, must be a full root usable by unison.
@paths: List of paths to synchronise
@ignore: List of paths to ignore
The paths and everything under them will be ignored.
If you need to ignore some specific files use the arguments.
@force: Force all changes from remote to local.
Used mostly when replacing a link by the file.
Returns: the unison return code see section 6.11 of the documentation
"""
command = [ "/usr/bin/unison", "-root", remote_root, "-root", local_root ]
for arg in self.args_bool:
command.append(f"-{arg}")
for arg, value in self.args_value.items():
command.append(f"-{arg}")
command.append(value)
sshargs = f"-p {self.remote_port} "
for arg, value in self.ssh_settings.items():
sshargs += arg + " " + value + " "
command.append("-sshargs")
command.append(sshargs)
for path in paths:
command.append("-path")
command.append(path)
for path in ignore:
command.append("-ignore")
command.append(f"BelowPath {path}")
if force:
command.append("-force")
command.append(remote_root)
command.append("-batch")
print(command)
proc = subprocess.Popen(command)
ret_code = proc.wait()
return ret_code
if __name__ == "__main__":
sync = Synchroniser("/home/furtest/a", "/home/furtest/files/programmation/unisync/a", "furtest", "194.164.198.44", port=8443, args_bool=["auto"])
print("Creating master connection")
sync.create_ssh_master_connection()
print("Connected")
sync.sync_files(["salut", "a"])
sync.sync_links(["salut", "a"])
print("Closing master connection")
sync.close_ssh_master_connection()
print("Connection closed")
# roots: remote_files, remote_links, local
# arguments for unison
# force

0
tests/__init__.py Normal file
View File