Compare commits
36 Commits
main
...
3dbd7fc445
| Author | SHA1 | Date | |
|---|---|---|---|
|
3dbd7fc445
|
|||
|
10a79554d3
|
|||
|
f2b676043c
|
|||
|
24bc6bcc94
|
|||
|
7dd01260b3
|
|||
|
2dafcc8c6b
|
|||
|
cbfbb32b86
|
|||
|
942b6c3cef
|
|||
|
a281fab8db
|
|||
|
033de7e7ca
|
|||
|
405e978796
|
|||
|
68c03c18d5
|
|||
|
d0cd6353d7
|
|||
|
9fd70deb9d
|
|||
|
dd042910a9
|
|||
|
fd825f7e87
|
|||
|
c7f0a67f17
|
|||
|
7705731dd5
|
|||
|
23a661107e
|
|||
|
cf508eb94c
|
|||
|
5ec43f9166
|
|||
|
cf49ffb8e8
|
|||
|
c34d30a006
|
|||
|
bb05990464
|
|||
|
aaa4a8f12c
|
|||
|
56da79f124
|
|||
|
0e8d568fea
|
|||
|
2ae9c38627
|
|||
|
667c418f09
|
|||
|
f618932584
|
|||
|
f5e455fc79
|
|||
|
78a4d9df36
|
|||
|
e639c12c20
|
|||
|
c10077392e
|
|||
|
7dd7b57e1f
|
|||
|
b10ed69d59
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
poetry.lock
|
poetry.lock
|
||||||
__pycache__
|
__pycache__
|
||||||
|
docs/build
|
||||||
|
dist/
|
||||||
|
|||||||
20
docs/Makefile
Normal file
20
docs/Makefile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Minimal makefile for Sphinx documentation
|
||||||
|
#
|
||||||
|
|
||||||
|
# You can set these variables from the command line, and also
|
||||||
|
# from the environment for the first two.
|
||||||
|
SPHINXOPTS ?=
|
||||||
|
SPHINXBUILD ?= sphinx-build
|
||||||
|
SOURCEDIR = source
|
||||||
|
BUILDDIR = build
|
||||||
|
|
||||||
|
# Put it first so that "make" without argument is like "make help".
|
||||||
|
help:
|
||||||
|
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||||
|
|
||||||
|
.PHONY: help Makefile
|
||||||
|
|
||||||
|
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||||
|
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||||
|
%: Makefile
|
||||||
|
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||||
35
docs/make.bat
Normal file
35
docs/make.bat
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
@ECHO OFF
|
||||||
|
|
||||||
|
pushd %~dp0
|
||||||
|
|
||||||
|
REM Command file for Sphinx documentation
|
||||||
|
|
||||||
|
if "%SPHINXBUILD%" == "" (
|
||||||
|
set SPHINXBUILD=sphinx-build
|
||||||
|
)
|
||||||
|
set SOURCEDIR=source
|
||||||
|
set BUILDDIR=build
|
||||||
|
|
||||||
|
%SPHINXBUILD% >NUL 2>NUL
|
||||||
|
if errorlevel 9009 (
|
||||||
|
echo.
|
||||||
|
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||||
|
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||||
|
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||||
|
echo.may add the Sphinx directory to PATH.
|
||||||
|
echo.
|
||||||
|
echo.If you don't have Sphinx installed, grab it from
|
||||||
|
echo.https://www.sphinx-doc.org/
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "" goto help
|
||||||
|
|
||||||
|
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||||
|
goto end
|
||||||
|
|
||||||
|
:help
|
||||||
|
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||||
|
|
||||||
|
:end
|
||||||
|
popd
|
||||||
36
docs/source/conf.py
Normal file
36
docs/source/conf.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Configuration file for the Sphinx documentation builder.
|
||||||
|
#
|
||||||
|
# For the full list of built-in configuration values, see the documentation:
|
||||||
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||||
|
|
||||||
|
# -- Project information -----------------------------------------------------
|
||||||
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||||
|
|
||||||
|
project = 'unisync'
|
||||||
|
copyright = '2026, Paul Retourné'
|
||||||
|
author = 'Paul Retourné'
|
||||||
|
release = '0.1.0'
|
||||||
|
|
||||||
|
# -- General configuration ---------------------------------------------------
|
||||||
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||||
|
|
||||||
|
extensions = [
|
||||||
|
'sphinx.ext.autodoc',
|
||||||
|
'sphinx.ext.viewcode',
|
||||||
|
'sphinx.ext.napoleon',
|
||||||
|
'sphinx.ext.todo'
|
||||||
|
]
|
||||||
|
|
||||||
|
templates_path = ['_templates']
|
||||||
|
exclude_patterns = []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# -- Options for HTML output -------------------------------------------------
|
||||||
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
||||||
|
|
||||||
|
#html_theme = 'alabaster'
|
||||||
|
html_theme = 'sphinx_rtd_theme'
|
||||||
|
html_static_path = ['_static']
|
||||||
|
|
||||||
|
autodoc_docstring_signature = True
|
||||||
24
docs/source/example.rst
Normal file
24
docs/source/example.rst
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
.. _example_how_it_works:
|
||||||
|
|
||||||
|
Example of how unisync works
|
||||||
|
============================
|
||||||
|
|
||||||
|
Let's say you have the following structure::
|
||||||
|
|
||||||
|
$ tree .
|
||||||
|
.
|
||||||
|
├── big_file
|
||||||
|
└── folder
|
||||||
|
├── file
|
||||||
|
└── other_file
|
||||||
|
|
||||||
|
If you only want to synchronise `folder` and its content on your laptop the following will be automatically generated::
|
||||||
|
|
||||||
|
$ tree .
|
||||||
|
.
|
||||||
|
├── big_file -> ../.data/big_file
|
||||||
|
└── folder
|
||||||
|
├── file
|
||||||
|
└── other_file
|
||||||
|
|
||||||
|
`big_file` is now a symbolic link and by mounting the remote directory you can still seemlessly access `big_file` through the network.
|
||||||
31
docs/source/index.rst
Normal file
31
docs/source/index.rst
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
.. unisync documentation master file, created by
|
||||||
|
sphinx-quickstart on Sun Jan 4 15:02:58 2026.
|
||||||
|
You can adapt this file completely to your liking, but it should at least
|
||||||
|
contain the root `toctree` directive.
|
||||||
|
|
||||||
|
Documentation for unisync
|
||||||
|
=========================
|
||||||
|
|
||||||
|
Unisync is a data synchronising tool built around `unison`_ and expending on it.
|
||||||
|
|
||||||
|
Unisync tries to solve two problems that are often solved separately but never together :
|
||||||
|
|
||||||
|
* Keeping your data synchronised between multiple machines (through a central server), examples of this are rsync and of course unison.
|
||||||
|
* Being able to access and edit files stored on your server without having to download them, the gui interface of nextcloud for example.
|
||||||
|
* And of course I want to be able to do all of this without ever having to leave my terminal.
|
||||||
|
|
||||||
|
Unisync solves this problem by placing each file on your local machine but with only the selected files and folders being physically present on your drive,
|
||||||
|
the others are replaced by symbolic links pointing to a directory that is mounted from your server.
|
||||||
|
|
||||||
|
See this
|
||||||
|
:ref:`example_how_it_works`.
|
||||||
|
|
||||||
|
.. _unison: https://github.com/bcpierce00/unison
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
:caption: Contents:
|
||||||
|
|
||||||
|
example
|
||||||
|
modules
|
||||||
|
|
||||||
7
docs/source/modules.rst
Normal file
7
docs/source/modules.rst
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
unisync
|
||||||
|
=======
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 4
|
||||||
|
|
||||||
|
unisync
|
||||||
77
docs/source/unisync.rst
Normal file
77
docs/source/unisync.rst
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
unisync package
|
||||||
|
===============
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
unisync.argparser module
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
.. automodule:: unisync.argparser
|
||||||
|
:members:
|
||||||
|
:show-inheritance:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
unisync.config module
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
.. automodule:: unisync.config
|
||||||
|
:members:
|
||||||
|
:show-inheritance:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
unisync.defaults module
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
.. automodule:: unisync.defaults
|
||||||
|
:members:
|
||||||
|
:show-inheritance:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
unisync.errors module
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
.. automodule:: unisync.errors
|
||||||
|
:members:
|
||||||
|
:show-inheritance:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
unisync.main module
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
.. automodule:: unisync.main
|
||||||
|
:members:
|
||||||
|
:show-inheritance:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
unisync.paths module
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
.. automodule:: unisync.paths
|
||||||
|
:members:
|
||||||
|
:show-inheritance:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
unisync.runners module
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
.. automodule:: unisync.runners
|
||||||
|
:members:
|
||||||
|
:show-inheritance:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
unisync.synchroniser module
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
.. automodule:: unisync.synchroniser
|
||||||
|
:members:
|
||||||
|
:show-inheritance:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: unisync
|
||||||
|
:members:
|
||||||
|
:show-inheritance:
|
||||||
|
:undoc-members:
|
||||||
@@ -20,3 +20,12 @@ packages = [{include = "unisync", from = "src"}]
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
docs = [
|
||||||
|
"sphinx (>=9.1.0,<10.0.0)",
|
||||||
|
"sphinx-rtd-theme (>=3.0.2,<4.0.0)",
|
||||||
|
]
|
||||||
|
dev = [
|
||||||
|
"pylint (>=4.0.4,<5.0.0)"
|
||||||
|
]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from configparser import UNNAMED_SECTION
|
from configparser import UNNAMED_SECTION
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import configparser
|
import configparser
|
||||||
@@ -30,8 +30,8 @@ class ServerConfig:
|
|||||||
if self.ip != "":
|
if self.ip != "":
|
||||||
try:
|
try:
|
||||||
ipaddress.ip_address(self.ip)
|
ipaddress.ip_address(self.ip)
|
||||||
except ValueError:
|
except ValueError as e:
|
||||||
raise ValueError("The provided ip address is invalid")
|
raise ValueError("The provided ip address is invalid") from e
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RootsConfig:
|
class RootsConfig:
|
||||||
@@ -49,6 +49,18 @@ class UnisonConfig:
|
|||||||
bools: list
|
bools: list
|
||||||
values: dict
|
values: dict
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BackupConfig:
|
||||||
|
"""
|
||||||
|
Configuration options relative to backing up the files.
|
||||||
|
"""
|
||||||
|
enabled: bool
|
||||||
|
selection: str
|
||||||
|
location: str
|
||||||
|
max_backups: int
|
||||||
|
backupsuffix: str
|
||||||
|
backupprefix: str
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class OtherConfig:
|
class OtherConfig:
|
||||||
"""
|
"""
|
||||||
@@ -64,6 +76,7 @@ class Config:
|
|||||||
server: ServerConfig
|
server: ServerConfig
|
||||||
roots: RootsConfig
|
roots: RootsConfig
|
||||||
unison: UnisonConfig
|
unison: UnisonConfig
|
||||||
|
backup: BackupConfig
|
||||||
other: OtherConfig
|
other: OtherConfig
|
||||||
|
|
||||||
|
|
||||||
@@ -81,6 +94,7 @@ def load_config(config_path:str) -> Config:
|
|||||||
# Check if sections are provided
|
# Check if sections are provided
|
||||||
server_section = "Server" if "Server" in config.sections() else UNNAMED_SECTION
|
server_section = "Server" if "Server" in config.sections() else UNNAMED_SECTION
|
||||||
roots_section = "Roots" if "Roots" in config.sections() else UNNAMED_SECTION
|
roots_section = "Roots" if "Roots" in config.sections() else UNNAMED_SECTION
|
||||||
|
backup_section = "Backup"
|
||||||
other_section = "Other" if "Other" in config.sections() else UNNAMED_SECTION
|
other_section = "Other" if "Other" in config.sections() else UNNAMED_SECTION
|
||||||
|
|
||||||
server_config = ServerConfig(
|
server_config = ServerConfig(
|
||||||
@@ -94,20 +108,28 @@ def load_config(config_path:str) -> Config:
|
|||||||
config.get(roots_section, "local", fallback=DEFAULT_ROOTS_LOCAL),
|
config.get(roots_section, "local", fallback=DEFAULT_ROOTS_LOCAL),
|
||||||
config.get(roots_section, "remote")
|
config.get(roots_section, "remote")
|
||||||
)
|
)
|
||||||
|
backup_config = BackupConfig(
|
||||||
|
config.getboolean(backup_section, "enabled", fallback=DEFAULT_BACKUP_ENABLED),
|
||||||
|
config.get(backup_section, "selection", fallback=DEFAULT_BACKUP_SELECTION),
|
||||||
|
config.get(backup_section, "loction", fallback=DEFAULT_BACKUP_LOC),
|
||||||
|
config.getint(backup_section, "max_backups", fallback=DEFAULT_BACKUP_MAX_BACKUPS),
|
||||||
|
config.get(backup_section, "backupsuffix", fallback=DEFAULT_BACKUP_BACKUPSUFFIX),
|
||||||
|
config.get(backup_section, "backupprefix", fallback=DEFAULT_BACKUP_BACKUPPREFIX)
|
||||||
|
)
|
||||||
other_config = OtherConfig(
|
other_config = OtherConfig(
|
||||||
Path(config.get(other_section, "cache_dir_path", fallback=DEFAULT_MISC_CACHE_DIR_PATH)).expanduser()
|
Path(config.get(other_section, "cache_dir_path", fallback=DEFAULT_MISC_CACHE_DIR_PATH)).expanduser()
|
||||||
)
|
)
|
||||||
|
|
||||||
args_bool = list()
|
args_bool = []
|
||||||
args_val = dict()
|
args_val = {}
|
||||||
if "Unison" in config.sections():
|
if "Unison" in config.sections():
|
||||||
for key, val in config.items("Unison"):
|
for key, val in config.items("Unison"):
|
||||||
if key in config["DEFAULT"].keys():
|
if key in config["DEFAULT"].keys():
|
||||||
continue
|
continue
|
||||||
elif val == "" or val == None:
|
if val in ("", None):
|
||||||
args_bool.append(key)
|
args_bool.append(key)
|
||||||
else:
|
else:
|
||||||
args_val[key] = val
|
args_val[key] = val
|
||||||
unison_config = UnisonConfig(args_bool, args_val)
|
unison_config = UnisonConfig(args_bool, args_val)
|
||||||
|
|
||||||
return Config(server_config, roots_config, unison_config, other_config)
|
return Config(server_config, roots_config, unison_config, backup_config, other_config)
|
||||||
|
|||||||
@@ -15,4 +15,11 @@ DEFAULT_SERVER_PORT: int = 22
|
|||||||
DEFAULT_ROOTS_LOCAL: str = str(Path("~/files").expanduser())
|
DEFAULT_ROOTS_LOCAL: str = str(Path("~/files").expanduser())
|
||||||
# DEFAULT_ROOTS_REMOTE: str = ""
|
# DEFAULT_ROOTS_REMOTE: str = ""
|
||||||
|
|
||||||
DEFAULT_MISC_CACHE_DIR_PATH: Path = Path("~/.unisync").expanduser()
|
DEFAULT_MISC_CACHE_DIR_PATH: str = "~/.unisync"
|
||||||
|
|
||||||
|
DEFAULT_BACKUP_ENABLED: bool = False
|
||||||
|
DEFAULT_BACKUP_SELECTION: str = ""
|
||||||
|
DEFAULT_BACKUP_LOC: str = "local"
|
||||||
|
DEFAULT_BACKUP_MAX_BACKUPS: int = 2
|
||||||
|
DEFAULT_BACKUP_BACKUPSUFFIX: str = ".$VERSION.bak"
|
||||||
|
DEFAULT_BACKUP_BACKUPPREFIX: str = ".unison_backups/"
|
||||||
|
|||||||
@@ -1,31 +1,29 @@
|
|||||||
# Copyright (C) 2025-2026 Paul Retourné
|
# Copyright (C) 2025-2026 Paul Retourné
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from unisync.argparser import create_argparser
|
from unisync.argparser import create_argparser
|
||||||
from unisync.runners import unisync_sync, unisync_add, unisync_mount
|
from unisync.runners import unisync_sync, unisync_add, unisync_mount
|
||||||
from unisync.config import load_config
|
from unisync.config import load_config
|
||||||
from unisync.synchroniser import Synchroniser
|
from unisync.synchroniser import Synchroniser
|
||||||
from unisync.paths import *
|
from unisync.paths import PathsManager
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = create_argparser(unisync_sync, unisync_add, unisync_mount)
|
parser = create_argparser(unisync_sync, unisync_add, unisync_mount)
|
||||||
cli_args = parser.parse_args()
|
cli_args = parser.parse_args()
|
||||||
|
|
||||||
config_path = os.path.expanduser("~/.config/unisync/config.ini")
|
config_path: Path = Path("~/.config/unisync/config.ini").expanduser()
|
||||||
# Check if --config is set
|
# Check if --config is set
|
||||||
if cli_args.config != None and os.path.isfile(cli_args.config):
|
if cli_args.config is not None and Path(cli_args.config).is_file():
|
||||||
config = load_config(cli_args.config)
|
config = load_config(cli_args.config)
|
||||||
elif os.path.isfile(config_path):
|
elif config_path.is_file():
|
||||||
config = load_config(config_path)
|
config = load_config(str(config_path))
|
||||||
else:
|
else:
|
||||||
# TODO replace the next line with something to do if no config file is found
|
# TODO replace the next line with something to do if no config file is found
|
||||||
config = load_config(config_path)
|
config = load_config(str(config_path))
|
||||||
pass
|
|
||||||
|
|
||||||
# TODO make the command line arguments work and override the config options
|
# TODO: make the command line arguments work and override the config options
|
||||||
|
|
||||||
synchroniser = Synchroniser(
|
synchroniser = Synchroniser(
|
||||||
config.roots.remote,
|
config.roots.remote,
|
||||||
@@ -34,12 +32,13 @@ def main():
|
|||||||
config.server.ip if config.server.ip != "" else config.server.hostname,
|
config.server.ip if config.server.ip != "" else config.server.hostname,
|
||||||
config.server.port,
|
config.server.port,
|
||||||
config.unison.bools,
|
config.unison.bools,
|
||||||
config.unison.values
|
config.unison.values,
|
||||||
|
backup=config.backup
|
||||||
)
|
)
|
||||||
|
|
||||||
paths_manager = PathsManager(Path(config.roots.local), config.other.cache_dir_path)
|
paths_manager = PathsManager(Path(config.roots.local), config.other.cache_dir_path)
|
||||||
|
|
||||||
cli_args.func(synchroniser, paths_manager)
|
cli_args.func(synchroniser, paths_manager, config)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class PathsManager:
|
|||||||
Writes a list of new paths to the file
|
Writes a list of new paths to the file
|
||||||
"""
|
"""
|
||||||
current_paths = self.get_paths_to_sync()
|
current_paths = self.get_paths_to_sync()
|
||||||
paths_to_add = list()
|
paths_to_add = []
|
||||||
# Check if one of the parent is already being synchronised
|
# Check if one of the parent is already being synchronised
|
||||||
# If so there is no need to add the child path
|
# If so there is no need to add the child path
|
||||||
for new_path in paths:
|
for new_path in paths:
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
|
|
||||||
from unisync.synchroniser import Synchroniser
|
from unisync.synchroniser import Synchroniser
|
||||||
from unisync.paths import PathsManager
|
from unisync.paths import PathsManager
|
||||||
|
from unisync.config import Config
|
||||||
|
|
||||||
|
def unisync_sync(synchroniser:Synchroniser, paths_manager:PathsManager, config: Config):
|
||||||
def unisync_sync(synchroniser:Synchroniser, paths_manager:PathsManager):
|
del config # The function signature must be the same for all runners
|
||||||
if synchroniser.create_ssh_master_connection() != 0:
|
if synchroniser.create_ssh_master_connection() != 0:
|
||||||
print("Connection failed quitting")
|
print("Connection failed quitting")
|
||||||
return 1
|
return 1
|
||||||
@@ -21,16 +22,23 @@ def unisync_sync(synchroniser:Synchroniser, paths_manager:PathsManager):
|
|||||||
synchroniser.close_ssh_master_connection()
|
synchroniser.close_ssh_master_connection()
|
||||||
|
|
||||||
|
|
||||||
def unisync_add(synchroniser:Synchroniser, paths_manager:PathsManager):
|
def unisync_add(synchroniser:Synchroniser, paths_manager:PathsManager, config: Config):
|
||||||
|
del config # The function signature must be the same for all runners
|
||||||
if synchroniser.create_ssh_master_connection() != 0:
|
if synchroniser.create_ssh_master_connection() != 0:
|
||||||
print("Connection failed quitting")
|
print("Connection failed quitting")
|
||||||
return 1
|
return 1
|
||||||
print("Connected to the remote.")
|
print("Connected to the remote.")
|
||||||
|
|
||||||
|
# TODO config or cli to skip this first sync
|
||||||
|
synchroniser.sync_files(paths_manager.get_paths_to_sync())
|
||||||
|
|
||||||
paths_manager.add_files_to_sync()
|
paths_manager.add_files_to_sync()
|
||||||
|
synchroniser.sync_files(paths_manager.get_paths_to_sync(), force=True)
|
||||||
|
|
||||||
synchroniser.close_ssh_master_connection()
|
synchroniser.close_ssh_master_connection()
|
||||||
|
|
||||||
|
|
||||||
def unisync_mount(synchroniser:Synchroniser, paths_manager:PathsManager):
|
def unisync_mount(synchroniser:Synchroniser, paths_manager:PathsManager, config: Config):
|
||||||
|
del paths_manager # The function signature must be the same for all runners
|
||||||
|
del config # The function signature must be the same for all runners
|
||||||
synchroniser.mount_remote_dir()
|
synchroniser.mount_remote_dir()
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
# Copyright (C) 2025-2026 Paul Retourné
|
# Copyright (C) 2025-2026 Paul Retourné
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
"""Exports the Synchroniser class.
|
||||||
|
|
||||||
|
This class is used to perform all the actions that require a connection to
|
||||||
|
the remote.
|
||||||
|
"""
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -8,15 +14,47 @@ import time
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from unisync.errors import RemoteMountedError, InvalidMountError
|
from unisync.errors import RemoteMountedError, InvalidMountError
|
||||||
|
from unisync.config import BackupConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class Synchroniser:
|
class Synchroniser:
|
||||||
|
"""Synchroniser used to synchronise with a server.
|
||||||
|
|
||||||
def __init__(self, remote:str, local:str, user:str, ip:str,
|
It is used to perform every action needing a connection to the remote.
|
||||||
port:int=22, args_bool:list=[], args_value:dict={}, ssh_settings:dict={}):
|
Create an ssh connection.
|
||||||
|
Perform the various synchronisation steps (files, links).
|
||||||
|
Update the links on the remote.
|
||||||
|
Mount the remote directory.
|
||||||
|
Close the ssh connection.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
remote: The directory to synchronise to on the remote.
|
||||||
|
local: The directory to synchronise from locally.
|
||||||
|
user: The user on the remote server.
|
||||||
|
ip: The ip of the remote server.
|
||||||
|
port: The ssh port on the remote.
|
||||||
|
args_bool:
|
||||||
|
A list of boolean arguments for unison.
|
||||||
|
They will be passed directly to unison when calling it.
|
||||||
|
For example : auto will be passed as -auto
|
||||||
|
args_value:
|
||||||
|
Same as args_bool but for key value arguments.
|
||||||
|
Will be passed to unison as "-key value".
|
||||||
|
ssh_settings:
|
||||||
|
Settings to pass to the underlying ssh connection.
|
||||||
|
Currently unused.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, remote:str, local:str, user:str, ip:str, port:int=22,
|
||||||
|
args_bool:list=[], args_value:dict={}, ssh_settings:dict={},
|
||||||
|
backup:BackupConfig | None = None
|
||||||
|
):
|
||||||
|
"""Initialises an instance of Synchroniser.
|
||||||
|
"""
|
||||||
self.remote_dir:str = remote
|
self.remote_dir:str = remote
|
||||||
self.local:str = local
|
self.local:str = local
|
||||||
self.args_bool:list[str] = args_bool
|
self.args_bool:list[str] = args_bool
|
||||||
@@ -25,16 +63,50 @@ class Synchroniser:
|
|||||||
self.remote_user:str = user
|
self.remote_user:str = user
|
||||||
self.remote_ip:str = ip
|
self.remote_ip:str = ip
|
||||||
self.remote_port:int = port
|
self.remote_port:int = port
|
||||||
|
self.files_extra:list = list()
|
||||||
|
self.links_extra:list = list()
|
||||||
|
|
||||||
|
if(backup != None and backup.enabled):
|
||||||
|
backup = cast(BackupConfig, backup)
|
||||||
|
self.files_extra.append("-backup")
|
||||||
|
if(backup.selection != ""):
|
||||||
|
self.files_extra.append(backup.selection)
|
||||||
|
else:
|
||||||
|
self.files_extra.append("Name *")
|
||||||
|
|
||||||
|
self.files_extra.extend([
|
||||||
|
"-backuploc",
|
||||||
|
backup.location,
|
||||||
|
"-maxbackups",
|
||||||
|
str(backup.max_backups),
|
||||||
|
"-backupsuffix",
|
||||||
|
backup.backupsuffix,
|
||||||
|
"-backupprefix",
|
||||||
|
backup.backupprefix,
|
||||||
|
"-ignore",
|
||||||
|
f"Name {backup.backupprefix[:-1]}"
|
||||||
|
])
|
||||||
|
|
||||||
|
self.links_extra.extend([
|
||||||
|
"-ignore",
|
||||||
|
f"Name {backup.backupprefix[:-1]}"
|
||||||
|
])
|
||||||
|
|
||||||
def create_ssh_master_connection(self, control_path:str="~/.ssh/control_%C", connection_timeout:int=60) -> int:
|
def create_ssh_master_connection(self, control_path:str="~/.ssh/control_%C", connection_timeout:int=60) -> int:
|
||||||
"""
|
"""Creates an ssh master connection.
|
||||||
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.
|
It is used so the user only has to authenticate once to the remote server.
|
||||||
@control_path: Set the location of the ssh control socket
|
The subsequent connections will be made through this master connection
|
||||||
@connection_timeout:
|
which speeds up connnection.
|
||||||
Time given to the user to authenticate to the remote server.
|
The users only have to enter their password once per synchronisation.
|
||||||
On slow connections one might want to increase this.
|
|
||||||
Returns 0 on success.
|
Args:
|
||||||
|
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:
|
||||||
|
An error code (0 success, 1 TimeoutExpired, 2 KeyboardInterrupt).
|
||||||
"""
|
"""
|
||||||
self.control_path = os.path.expanduser(control_path)
|
self.control_path = os.path.expanduser(control_path)
|
||||||
command = [
|
command = [
|
||||||
@@ -46,6 +118,7 @@ class Synchroniser:
|
|||||||
"-p", str(self.remote_port)
|
"-p", str(self.remote_port)
|
||||||
]
|
]
|
||||||
master_ssh = subprocess.Popen(command)
|
master_ssh = subprocess.Popen(command)
|
||||||
|
# TODO: Raise an exception instead of changing the return value
|
||||||
try:
|
try:
|
||||||
ret_code = master_ssh.wait(timeout=connection_timeout)
|
ret_code = master_ssh.wait(timeout=connection_timeout)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
@@ -61,8 +134,10 @@ class Synchroniser:
|
|||||||
|
|
||||||
|
|
||||||
def close_ssh_master_connection(self) -> int:
|
def close_ssh_master_connection(self) -> int:
|
||||||
"""
|
"""Closes the ssh master connection.
|
||||||
Close the ssh master connection.
|
|
||||||
|
Returns:
|
||||||
|
The return code of the ssh call.
|
||||||
"""
|
"""
|
||||||
command = [
|
command = [
|
||||||
"/usr/bin/ssh",
|
"/usr/bin/ssh",
|
||||||
@@ -75,39 +150,64 @@ class Synchroniser:
|
|||||||
return close.wait()
|
return close.wait()
|
||||||
|
|
||||||
def sync_files(self, paths:list, force:bool=False) -> int:
|
def sync_files(self, paths:list, force:bool=False) -> int:
|
||||||
|
"""Synchronises the files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
paths: List of paths to synchronise.
|
||||||
|
force: Force the changes from remote to local.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The return code of sync.
|
||||||
"""
|
"""
|
||||||
Synchronises the files.
|
|
||||||
"""
|
|
||||||
return self.sync(
|
return self.sync(
|
||||||
f"ssh://{self.remote_user}@{self.remote_ip}/{self.remote_dir}/.data",
|
f"ssh://{self.remote_user}@{self.remote_ip}/{self.remote_dir}/.data",
|
||||||
self.local,
|
self.local,
|
||||||
paths=paths,
|
paths=paths,
|
||||||
force=force
|
force=force,
|
||||||
|
other=self.files_extra
|
||||||
)
|
)
|
||||||
|
|
||||||
def sync_links(self, ignore:list) -> int:
|
def sync_links(self, ignore:list) -> int:
|
||||||
"""
|
"""Synchronises the links, they must exist already.
|
||||||
Synchronises the links, they must exist already.
|
|
||||||
|
Args:
|
||||||
|
ignore: List of paths to ignore.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The return code of sync.
|
||||||
"""
|
"""
|
||||||
return self.sync(
|
return self.sync(
|
||||||
f"ssh://{self.remote_user}@{self.remote_ip}/{self.remote_dir}/links",
|
f"ssh://{self.remote_user}@{self.remote_ip}/{self.remote_dir}/links",
|
||||||
self.local,
|
self.local,
|
||||||
ignore=ignore
|
ignore=ignore,
|
||||||
|
other=self.links_extra
|
||||||
)
|
)
|
||||||
|
|
||||||
def sync(self, remote_root:str, local_root:str,
|
def sync(self, remote_root:str, local_root:str,
|
||||||
paths:list=[], ignore:list=[], force:bool=False) -> int:
|
paths:list=[], ignore:list=[], force:bool=False,
|
||||||
"""
|
other:list=[]
|
||||||
Perform the synchronisation by calling unison.
|
) -> int:
|
||||||
@remote_root: The remote root, must be a full root usable by unison.
|
"""Performs the synchronisation by calling unison.
|
||||||
@local_root: The local root, must be a full root usable by unison.
|
|
||||||
@paths: List of paths to synchronise
|
Args:
|
||||||
@ignore: List of paths to ignore
|
remote_root: The remote root, must be a full root usable by unison.
|
||||||
The paths and everything under them will be ignored.
|
local_root: The local root, must be a full root usable by unison.
|
||||||
If you need to ignore some specific files use the arguments.
|
paths: List of paths to synchronise
|
||||||
@force: Force all changes from remote to local.
|
ignore: List of paths to ignore
|
||||||
Used mostly when replacing a link by the file.
|
The paths and everything under them will be ignored.
|
||||||
Returns: the unison return code see section 6.11 of the documentation
|
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.
|
||||||
|
other:
|
||||||
|
Other arguments to add to unison.
|
||||||
|
These arguments will only be used for this sync which is not
|
||||||
|
the case for the ones in self.args_bool and self.args_value.
|
||||||
|
They will be added to the command as is no - in front.
|
||||||
|
For exemple backups are implemented using this argument.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
the unison return code see section 6.11 of the documentation
|
||||||
"""
|
"""
|
||||||
command = [ "/usr/bin/unison", "-root", remote_root, "-root", local_root ]
|
command = [ "/usr/bin/unison", "-root", remote_root, "-root", local_root ]
|
||||||
for arg in self.args_bool:
|
for arg in self.args_bool:
|
||||||
@@ -117,6 +217,7 @@ class Synchroniser:
|
|||||||
command.append(value)
|
command.append(value)
|
||||||
|
|
||||||
sshargs = f"-p {self.remote_port} "
|
sshargs = f"-p {self.remote_port} "
|
||||||
|
sshargs += f"-S {self.control_path} "
|
||||||
for arg, value in self.ssh_settings.items():
|
for arg, value in self.ssh_settings.items():
|
||||||
sshargs += arg + " " + value + " "
|
sshargs += arg + " " + value + " "
|
||||||
command.append("-sshargs")
|
command.append("-sshargs")
|
||||||
@@ -131,21 +232,25 @@ class Synchroniser:
|
|||||||
command.append(f"BelowPath {path}")
|
command.append(f"BelowPath {path}")
|
||||||
|
|
||||||
if force:
|
if force:
|
||||||
command.append("-force")
|
command.append("-prefer")
|
||||||
command.append(remote_root)
|
command.append(remote_root)
|
||||||
command.append("-batch")
|
command.append("-batch")
|
||||||
|
|
||||||
|
for arg in other:
|
||||||
|
command.append(arg)
|
||||||
|
|
||||||
proc = subprocess.Popen(command)
|
proc = subprocess.Popen(command)
|
||||||
ret_code = proc.wait()
|
ret_code = proc.wait()
|
||||||
return ret_code
|
return ret_code
|
||||||
|
|
||||||
def update_links(self, background:bool=True):
|
def update_links(self, background:bool=True):
|
||||||
"""
|
"""Updates the links on the remote.
|
||||||
Update the links on the remote.
|
|
||||||
First calls cleanlinks to remove deadlinks and empty directories.
|
First calls cleanlinks to remove deadlinks and empty directories.
|
||||||
Then calls lndir to create the new links.
|
Then calls lndir to create the new links.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
- background: controls if the update is done in the background or waited for
|
background: controls if the update is done in the background or waited for.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
link_update_script = (f"cd {self.remote_dir}/links && "
|
link_update_script = (f"cd {self.remote_dir}/links && "
|
||||||
@@ -165,7 +270,7 @@ class Synchroniser:
|
|||||||
link_background_wrapper
|
link_background_wrapper
|
||||||
]
|
]
|
||||||
|
|
||||||
link_update_process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
link_update_process = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
|
||||||
if not background:
|
if not background:
|
||||||
print("Starting links update.")
|
print("Starting links update.")
|
||||||
@@ -173,13 +278,14 @@ class Synchroniser:
|
|||||||
print("Done")
|
print("Done")
|
||||||
|
|
||||||
def mount_remote_dir(self):
|
def mount_remote_dir(self):
|
||||||
"""
|
"""Mounts 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 which may fail.
|
||||||
Raise:
|
|
||||||
- RemoteMountedError: The .data directory is already a mount point
|
Raises:
|
||||||
- InvalidMountError: .data is either not a directory or not empty
|
RemoteMountedError: The .data directory is already a mount point.
|
||||||
- subprocess.CalledProcessError: An error occured with sshfs
|
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
|
# Get the absolute path to the correct .data directory resolving symlinks
|
||||||
path_to_mount:Path = Path(f"{self.local}/../.data").resolve()
|
path_to_mount:Path = Path(f"{self.local}/../.data").resolve()
|
||||||
|
|||||||
8
tests/runners.py
Normal file
8
tests/runners.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Copyright (C) 2026 Paul Retourné
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from unisync.synchroniser import Synchroniser
|
||||||
|
from unisync.paths import PathsManager
|
||||||
|
|
||||||
|
def unisync_test(synchroniser:Synchroniser, paths_manager:PathsManager):
|
||||||
|
print("Testing")
|
||||||
39
tests/test.py
Normal file
39
tests/test.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Copyright (C) 2026 Paul Retourné
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from unisync.argparser import create_argparser
|
||||||
|
from unisync.runners import unisync_sync, unisync_add, unisync_mount
|
||||||
|
from unisync.config import load_config
|
||||||
|
from unisync.synchroniser import Synchroniser
|
||||||
|
from unisync.paths import *
|
||||||
|
|
||||||
|
from runners import *
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = create_argparser(unisync_test, unisync_add, unisync_mount)
|
||||||
|
cli_args = parser.parse_args()
|
||||||
|
|
||||||
|
config_path = os.path.expanduser("./config.ini")
|
||||||
|
config = load_config(config_path)
|
||||||
|
|
||||||
|
print(config)
|
||||||
|
|
||||||
|
synchroniser = Synchroniser(
|
||||||
|
config.roots.remote,
|
||||||
|
config.roots.local,
|
||||||
|
config.server.user,
|
||||||
|
config.server.ip if config.server.ip != "" else config.server.hostname,
|
||||||
|
config.server.port,
|
||||||
|
config.unison.bools,
|
||||||
|
config.unison.values
|
||||||
|
)
|
||||||
|
|
||||||
|
paths_manager = PathsManager(Path(config.roots.local), config.other.cache_dir_path)
|
||||||
|
|
||||||
|
cli_args.func(synchroniser, paths_manager)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user