livesync 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- livesync/__init__.py +2 -0
- livesync/folder.py +130 -0
- livesync/livesync.py +28 -0
- livesync/mutex.py +61 -0
- livesync/py.typed +0 -0
- livesync/run_subprocess.py +18 -0
- livesync/sync.py +70 -0
- livesync-0.5.0.dist-info/METADATA +186 -0
- livesync-0.5.0.dist-info/RECORD +12 -0
- livesync-0.5.0.dist-info/WHEEL +4 -0
- livesync-0.5.0.dist-info/entry_points.txt +3 -0
- livesync-0.5.0.dist-info/licenses/LICENSE +21 -0
livesync/__init__.py
ADDED
livesync/folder.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable, List, Optional, Union
|
|
8
|
+
|
|
9
|
+
import pathspec
|
|
10
|
+
import watchfiles
|
|
11
|
+
|
|
12
|
+
from .run_subprocess import run_subprocess
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Folder:
|
|
16
|
+
"""Represents a folder to be synchronized with a remote target via rsync over SSH."""
|
|
17
|
+
DEFAULT_IGNORES = ['.git/', '.jj/', '__pycache__/', '.DS_Store', '*.tmp', '.env', '.venv']
|
|
18
|
+
DEFAULT_RSYNC_ARGS = ['--prune-empty-dirs', '--delete', '-a', '-v', '-z', '--checksum', '--no-t']
|
|
19
|
+
|
|
20
|
+
def __init__(self,
|
|
21
|
+
source_path: Union[str, Path],
|
|
22
|
+
target: str, *,
|
|
23
|
+
ssh_port: int = 22,
|
|
24
|
+
on_change: Optional[Union[str, Callable]] = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.source_path = Path(source_path).resolve() # one should avoid `absolute` if Python < 3.11
|
|
27
|
+
if ':' not in target:
|
|
28
|
+
target = f'{target}:{self.source_path.name}'
|
|
29
|
+
self.target = target
|
|
30
|
+
self.host, self.target_path = target.split(':')
|
|
31
|
+
self.ssh_port = ssh_port
|
|
32
|
+
self.on_change = on_change or None
|
|
33
|
+
self._rsync_args: List[str] = self.DEFAULT_RSYNC_ARGS[:]
|
|
34
|
+
self._stop_watching = asyncio.Event()
|
|
35
|
+
|
|
36
|
+
if not self.source_path.is_dir():
|
|
37
|
+
print(f'Invalid path: {self.source_path}')
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
|
|
40
|
+
match_pattern = pathspec.patterns.gitwildmatch.GitWildMatchPattern # https://stackoverflow.com/a/22090594/3419103
|
|
41
|
+
self._ignore_spec = pathspec.PathSpec.from_lines(match_pattern, self._get_ignores())
|
|
42
|
+
|
|
43
|
+
def rsync_args(self,
|
|
44
|
+
add: Optional[str] = None,
|
|
45
|
+
remove: Optional[str] = None,
|
|
46
|
+
replace: Optional[str] = None) -> Folder:
|
|
47
|
+
"""Add, remove, or replace rsync arguments for this folder."""
|
|
48
|
+
if replace is not None:
|
|
49
|
+
self._rsync_args.clear()
|
|
50
|
+
add_args = (add or '').split() + (replace or '').split()
|
|
51
|
+
remove_args = remove.split() if remove else []
|
|
52
|
+
self._rsync_args += [arg for arg in add_args if arg not in self._rsync_args]
|
|
53
|
+
self._rsync_args = [arg for arg in self._rsync_args if arg not in remove_args]
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def _get_ignores(self) -> List[str]:
|
|
57
|
+
path = self.source_path / '.syncignore'
|
|
58
|
+
if not path.is_file():
|
|
59
|
+
path.write_text('\n'.join(self.DEFAULT_IGNORES))
|
|
60
|
+
ignores = [line.strip() for line in path.read_text().splitlines() if not line.startswith('#')]
|
|
61
|
+
ignores += [ignore.rstrip('/\\') for ignore in ignores if ignore.endswith('/') or ignore.endswith('\\')]
|
|
62
|
+
return ignores
|
|
63
|
+
|
|
64
|
+
def get_summary(self) -> str:
|
|
65
|
+
"""Return a summary of the folder's source and target paths, along with git revision information if applicable."""
|
|
66
|
+
summary = f'{self.source_path} --> {self.target}\n'
|
|
67
|
+
try:
|
|
68
|
+
cmd = ['git', 'rev-parse', '--is-inside-work-tree']
|
|
69
|
+
subprocess.run(cmd, check=True, cwd=self.source_path, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
70
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
71
|
+
pass # not a git repo or git is not installed
|
|
72
|
+
else:
|
|
73
|
+
if version := self._build_version():
|
|
74
|
+
summary += f'[{version}]\n'
|
|
75
|
+
cmd = ['git', 'status', '--short', '--branch']
|
|
76
|
+
summary += subprocess.check_output(cmd, cwd=self.source_path).decode().strip() + '\n'
|
|
77
|
+
return summary
|
|
78
|
+
|
|
79
|
+
def _build_version(self) -> str:
|
|
80
|
+
"""Build a dunamai-style version string from git, e.g. `0.1.0.post43.dev0+3f6ee0e`.
|
|
81
|
+
|
|
82
|
+
The nearest tag is the base version, the number of commits since then is `.post<distance>.dev0`,
|
|
83
|
+
and the short commit hash is appended as `+<hash>` (always, so the exact revision is included
|
|
84
|
+
even when sitting right on a tag: `<base>.post0.dev0+<hash>`). With no tag the base is `0.0.0`
|
|
85
|
+
and the distance is the total number of commits. Returns '' if the repo has no commit yet.
|
|
86
|
+
No dunamai dependency; uncommon cases (custom tag patterns, pre-releases, epochs, dirty
|
|
87
|
+
markers) are not replicated.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
cmd = ['git', 'rev-parse', '--short', 'HEAD']
|
|
91
|
+
commit = subprocess.check_output(cmd, cwd=self.source_path, stderr=subprocess.PIPE).decode().strip()
|
|
92
|
+
except subprocess.CalledProcessError:
|
|
93
|
+
return '' # no commit yet
|
|
94
|
+
try:
|
|
95
|
+
cmd = ['git', 'describe', '--tags', '--abbrev=0']
|
|
96
|
+
tag = subprocess.check_output(cmd, cwd=self.source_path, stderr=subprocess.PIPE).decode().strip()
|
|
97
|
+
base = tag.removeprefix('v') if tag[1:2].isdigit() else tag
|
|
98
|
+
cmd = ['git', 'rev-list', f'refs/tags/{tag}..HEAD', '--count']
|
|
99
|
+
except subprocess.CalledProcessError:
|
|
100
|
+
base = '0.0.0' # no tags -> 0.0.0 with the total commit count as distance
|
|
101
|
+
cmd = ['git', 'rev-list', 'HEAD', '--count']
|
|
102
|
+
try:
|
|
103
|
+
distance = subprocess.check_output(cmd, cwd=self.source_path, stderr=subprocess.PIPE).decode().strip()
|
|
104
|
+
except subprocess.CalledProcessError:
|
|
105
|
+
return '' # e.g. a shallow clone where the found tag is not reachable
|
|
106
|
+
return f'{base}.post{distance}.dev0+{commit}'
|
|
107
|
+
|
|
108
|
+
async def watch(self) -> None:
|
|
109
|
+
"""Watch the source folder for changes and synchronize to the target when changes occur."""
|
|
110
|
+
try:
|
|
111
|
+
async for changes in watchfiles.awatch(self.source_path, stop_event=self._stop_watching,
|
|
112
|
+
watch_filter=lambda _, filepath: not self._ignore_spec.match_file(filepath)):
|
|
113
|
+
for change, filepath in changes:
|
|
114
|
+
print('?+U-'[change], filepath)
|
|
115
|
+
await self.sync()
|
|
116
|
+
except RuntimeError as e:
|
|
117
|
+
if 'Already borrowed' not in str(e):
|
|
118
|
+
raise
|
|
119
|
+
|
|
120
|
+
async def sync(self) -> None:
|
|
121
|
+
"""Synchronize the source folder to the target using rsync over SSH, and run the on_change command if specified."""
|
|
122
|
+
args = ' '.join(self._rsync_args)
|
|
123
|
+
args += ''.join(f' --exclude="{e}"' for e in self._get_ignores())
|
|
124
|
+
args += f' -e "ssh -p {self.ssh_port}"' # NOTE: use SSH with custom port
|
|
125
|
+
args += f' --rsync-path="mkdir -p {self.target_path} && rsync"' # NOTE: create target folder if not exists
|
|
126
|
+
await run_subprocess(f'rsync {args} "{self.source_path}/" "{self.target}/"', quiet=True)
|
|
127
|
+
if isinstance(self.on_change, str):
|
|
128
|
+
await run_subprocess(f'ssh {self.host} -p {self.ssh_port} "cd {self.target_path}; {self.on_change}"')
|
|
129
|
+
if callable(self.on_change):
|
|
130
|
+
self.on_change()
|
livesync/livesync.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
|
|
4
|
+
from livesync import Folder, sync
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
"""Main entry point for the livesync command-line tool."""
|
|
9
|
+
parser = argparse.ArgumentParser(
|
|
10
|
+
description='Repeatedly synchronize a local directory with a remote machine',
|
|
11
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
|
12
|
+
parser.add_argument('source', type=str, default='.', help='local source folder')
|
|
13
|
+
parser.add_argument('target', type=str, help='target path (e.g. username@hostname:/path/to/target)')
|
|
14
|
+
parser.add_argument('--ssh-port', type=int, default=22, help='SSH port on target')
|
|
15
|
+
parser.add_argument('--on-change', type=str, help='command to be executed on remote host after any file change')
|
|
16
|
+
parser.add_argument('--mutex-interval', type=int, default=10, help='interval in which mutex is updated')
|
|
17
|
+
parser.add_argument('--ignore-mutex', action='store_true', help='ignore mutex (use with caution)')
|
|
18
|
+
parser.add_argument('--no-watch', action='store_true', help='do not watch for changes')
|
|
19
|
+
parser.add_argument('rsync_args', nargs=argparse.REMAINDER, help='arbitrary rsync parameters after "--"')
|
|
20
|
+
args = parser.parse_args()
|
|
21
|
+
|
|
22
|
+
folder = Folder(args.source, args.target, ssh_port=args.ssh_port, on_change=args.on_change)
|
|
23
|
+
folder.rsync_args(' '.join(args.rsync_args))
|
|
24
|
+
sync(folder, mutex_interval=args.mutex_interval, ignore_mutex=args.ignore_mutex, watch=not args.no_watch)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == '__main__':
|
|
28
|
+
main()
|
livesync/mutex.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import socket
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Mutex:
|
|
9
|
+
"""A simple mutex mechanism to prevent concurrent synchronization to the same target host."""
|
|
10
|
+
DEFAULT_FILEPATH = '~/.livesync_mutex'
|
|
11
|
+
|
|
12
|
+
def __init__(self, host: str, port: int) -> None:
|
|
13
|
+
self.host = host
|
|
14
|
+
self.port = port
|
|
15
|
+
self.occupant: Optional[str] = None
|
|
16
|
+
self.user_id = socket.gethostname()
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def tag(self) -> str:
|
|
20
|
+
"""Unique tag for this mutex, based on the user ID and current timestamp."""
|
|
21
|
+
return f'{self.user_id} {datetime.now().isoformat()}'
|
|
22
|
+
|
|
23
|
+
async def set(self, info: str) -> bool:
|
|
24
|
+
"""Attempt to set the mutex on the target host with the given info and return whether successful."""
|
|
25
|
+
if not await self._is_free():
|
|
26
|
+
return False
|
|
27
|
+
try:
|
|
28
|
+
# NOTE: pass the content via stdin so the remote shell does not interpret it
|
|
29
|
+
await self._run_ssh_command(f'cat > {self.DEFAULT_FILEPATH}', stdin=f'{self.tag}\n{info}\n')
|
|
30
|
+
return True
|
|
31
|
+
except RuntimeError:
|
|
32
|
+
print('Could not write mutex file')
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
async def _is_free(self) -> bool:
|
|
36
|
+
try:
|
|
37
|
+
command = f'[ -f {self.DEFAULT_FILEPATH} ] && cat {self.DEFAULT_FILEPATH} || echo'
|
|
38
|
+
output = (await self._run_ssh_command(command)).strip()
|
|
39
|
+
if not output:
|
|
40
|
+
return True
|
|
41
|
+
words = output.splitlines()[0].strip().split()
|
|
42
|
+
self.occupant = words[0]
|
|
43
|
+
occupant_ok = self.occupant == self.user_id
|
|
44
|
+
mutex_datetime = datetime.fromisoformat(words[1])
|
|
45
|
+
mutex_expired = datetime.now() - mutex_datetime > timedelta(seconds=15)
|
|
46
|
+
return occupant_ok or mutex_expired
|
|
47
|
+
except Exception: # pylint: disable=broad-except
|
|
48
|
+
logging.exception('Could not access target system')
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
async def _run_ssh_command(self, command: str, stdin: Optional[str] = None) -> str:
|
|
52
|
+
process = await asyncio.create_subprocess_exec(
|
|
53
|
+
'ssh', self.host, '-p', str(self.port), command,
|
|
54
|
+
stdin=asyncio.subprocess.PIPE if stdin is not None else None,
|
|
55
|
+
stdout=asyncio.subprocess.PIPE,
|
|
56
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
57
|
+
)
|
|
58
|
+
stdout, _ = await process.communicate(stdin.encode() if stdin is not None else None)
|
|
59
|
+
if process.returncode != 0:
|
|
60
|
+
raise RuntimeError(f'SSH command failed with return code {process.returncode}')
|
|
61
|
+
return stdout.decode()
|
livesync/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
async def run_subprocess(command: str, *, quiet: bool = False) -> None:
|
|
6
|
+
"""Run a subprocess asynchronously, capturing its output and raising an exception on failure."""
|
|
7
|
+
process = await asyncio.create_subprocess_shell(
|
|
8
|
+
command,
|
|
9
|
+
stdout=asyncio.subprocess.PIPE,
|
|
10
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
11
|
+
)
|
|
12
|
+
stdout, _ = await process.communicate()
|
|
13
|
+
assert process.returncode is not None # the process has terminated after communicate()
|
|
14
|
+
if process.returncode != 0:
|
|
15
|
+
print(stdout.decode())
|
|
16
|
+
raise subprocess.CalledProcessError(process.returncode, command, stdout)
|
|
17
|
+
if not quiet:
|
|
18
|
+
print(stdout.decode())
|
livesync/sync.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import sys
|
|
3
|
+
from typing import Dict, Iterable, List, Tuple
|
|
4
|
+
|
|
5
|
+
from .folder import Folder
|
|
6
|
+
from .mutex import Mutex
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def sync(*folders: Folder, mutex_interval: float = 10, ignore_mutex: bool = False, watch: bool = True) -> None:
|
|
10
|
+
"""Synchronize one or more folders, optionally watching for changes."""
|
|
11
|
+
try:
|
|
12
|
+
asyncio.run(_run_folder_tasks(folders, mutex_interval, ignore_mutex=ignore_mutex, watch=watch))
|
|
13
|
+
except KeyboardInterrupt:
|
|
14
|
+
print('Bye!')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def _run_folder_tasks(
|
|
18
|
+
folders: Iterable[Folder],
|
|
19
|
+
mutex_interval: float, ignore_mutex: bool = False, watch: bool = True) -> None:
|
|
20
|
+
try:
|
|
21
|
+
if not ignore_mutex:
|
|
22
|
+
summary = _get_summary(folders)
|
|
23
|
+
mutexes = {folder.host: Mutex(folder.host, folder.ssh_port) for folder in folders}
|
|
24
|
+
for mutex in mutexes.values():
|
|
25
|
+
print(f'Checking mutex on {mutex.host}', flush=True)
|
|
26
|
+
if not await mutex.set(summary):
|
|
27
|
+
print(f'Target is in use by {mutex.occupant}')
|
|
28
|
+
sys.exit(1)
|
|
29
|
+
|
|
30
|
+
for folder in folders:
|
|
31
|
+
print(f' {folder.source_path} --> {folder.target}', flush=True)
|
|
32
|
+
await folder.sync()
|
|
33
|
+
|
|
34
|
+
if watch:
|
|
35
|
+
tasks: List[Tuple[Folder, asyncio.Task]] = []
|
|
36
|
+
for folder in folders:
|
|
37
|
+
print(f'Watch folder {folder.source_path}', flush=True)
|
|
38
|
+
tasks.append((folder, asyncio.create_task(folder.watch())))
|
|
39
|
+
|
|
40
|
+
while True:
|
|
41
|
+
if not ignore_mutex:
|
|
42
|
+
tasks = await _renew_mutexes(folders, mutexes, tasks)
|
|
43
|
+
if not tasks:
|
|
44
|
+
print('No more folders to watch, exiting', flush=True)
|
|
45
|
+
break
|
|
46
|
+
await asyncio.sleep(mutex_interval)
|
|
47
|
+
except Exception as e: # pylint: disable=broad-except
|
|
48
|
+
print(e)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def _renew_mutexes(
|
|
52
|
+
folders: Iterable[Folder],
|
|
53
|
+
mutexes: Dict[str, Mutex],
|
|
54
|
+
tasks: List[Tuple[Folder, asyncio.Task]],
|
|
55
|
+
) -> List[Tuple[Folder, asyncio.Task]]:
|
|
56
|
+
summary = _get_summary(folders)
|
|
57
|
+
for host in list(mutexes):
|
|
58
|
+
if await mutexes[host].set(summary):
|
|
59
|
+
continue
|
|
60
|
+
print(f'Target {host} is in use by {mutexes[host].occupant}, stopping watch tasks', flush=True)
|
|
61
|
+
for folder, task in tasks:
|
|
62
|
+
if folder.host == host:
|
|
63
|
+
task.cancel()
|
|
64
|
+
tasks = [(folder, task) for folder, task in tasks if folder.host != host]
|
|
65
|
+
del mutexes[host]
|
|
66
|
+
return tasks
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _get_summary(folders: Iterable[Folder]) -> str:
|
|
70
|
+
return '\n'.join(folder.get_summary() for folder in folders).replace('"', '\'')
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: livesync
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Repeatedly synchronize local workspace with a (slow) remote machine
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: sync,remote,watch,filesystem,development,deploy,live,hot,reload
|
|
8
|
+
Author: Zauberzeug GmbH
|
|
9
|
+
Author-email: info@zauberzeug.com
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Requires-Dist: pathspec
|
|
18
|
+
Requires-Dist: watchfiles
|
|
19
|
+
Project-URL: Repository, https://github.com/zauberzeug/livesync
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# LiveSync
|
|
23
|
+
|
|
24
|
+
Repeatedly synchronize local workspace with a (slow) remote machine.
|
|
25
|
+
It is available as [PyPI package](https://pypi.org/project/livesync/) and hosted on [GitHub](https://github.com/zauberzeug/livesync).
|
|
26
|
+
|
|
27
|
+
[](https://pypi.org/project/livesync/)
|
|
28
|
+
[](https://pypi.org/project/livesync/)
|
|
29
|
+
[](https://github.com/zauberzeug/livesync/graphs/commit-activity)
|
|
30
|
+
[](https://github.com/zauberzeug/livesync/issues)
|
|
31
|
+
[](https://github.com/zauberzeug/livesync/blob/main/LICENSE)
|
|
32
|
+
|
|
33
|
+
## Use Case
|
|
34
|
+
|
|
35
|
+
[VS Code Remote Development](https://code.visualstudio.com/docs/remote/remote-overview) and similar tools are great as long as your remote machine is powerful enough.
|
|
36
|
+
But if your target is a Raspberry Pi, Jetson Nano/Xavier/Orin, Beagle Board or similar, it feels like coding in jelly.
|
|
37
|
+
Especially if you run powerful extensions like Pylance, GitHub Copilot or Duet AI.
|
|
38
|
+
LiveSync solves this by watching your code for changes and just copying the modifications to the slow remote machine.
|
|
39
|
+
So you can develop on your own machine (and run tests there in the background) while all your changes appear also on the remote.
|
|
40
|
+
It works best if you have some kind of reload mechanism in place on the target ([NiceGUI](https://nicegui.io), [FastAPI](https://fastapi.tiangolo.com/) or [Flask](https://flask.palletsprojects.com/) for example).
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
### BASH
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
livesync <source> <username>@<host>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
LiveSync uses rsync (SSH) to copy the files, so the `<username>@<host>` must be accessible via SSH (ideally by key, not password or passphrase, because it will be called over and over).
|
|
51
|
+
|
|
52
|
+
Press `CTRL-C` to abort the synchronization.
|
|
53
|
+
|
|
54
|
+
Positional arguments:
|
|
55
|
+
|
|
56
|
+
- `<source>`
|
|
57
|
+
local folder
|
|
58
|
+
- `<target>`
|
|
59
|
+
target user, host and path (e.g. user@host:~/path; path defaults to source folder name in home directory)
|
|
60
|
+
- `<rsync_args>`
|
|
61
|
+
arbitrary rsync parameters after "--"
|
|
62
|
+
|
|
63
|
+
Options:
|
|
64
|
+
|
|
65
|
+
- `--ssh-port SSH_PORT`
|
|
66
|
+
SSH port on target (default: 22)
|
|
67
|
+
- `--on-change ON_CHANGE`
|
|
68
|
+
command to be executed on remote host after any file change (default: None)
|
|
69
|
+
- `--mutex-interval MUTEX_INTERVAL`
|
|
70
|
+
interval in which mutex is updated (default: 10 seconds)
|
|
71
|
+
- `--ignore-mutex`
|
|
72
|
+
ignore mutex (use with caution) (default: False)
|
|
73
|
+
- `--no-watch`
|
|
74
|
+
don't keep watching the copied folders for changes after the sync (default: False)
|
|
75
|
+
|
|
76
|
+
### Python
|
|
77
|
+
|
|
78
|
+
Simple example (where `robot` is the ssh hostname of the target system):
|
|
79
|
+
|
|
80
|
+
```py
|
|
81
|
+
from livesync import Folder, sync
|
|
82
|
+
|
|
83
|
+
sync(
|
|
84
|
+
Folder('.', 'robot:~/navigation'),
|
|
85
|
+
Folder('../rosys', 'robot:~/rosys'),
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The `sync` call will block until the script is aborted.
|
|
90
|
+
Only if `watch=False` is used, the `sync` call will end after copying the folders to the target once.
|
|
91
|
+
The `Folder` class allows to set the `port` and an `on_change` bash command which is executed after a sync has been performed.
|
|
92
|
+
Via the `rsync_args` build method you can pass additional options to configure rsync.
|
|
93
|
+
|
|
94
|
+
#### Version in the mutex
|
|
95
|
+
|
|
96
|
+
The mutex file (`~/.livesync_mutex`) on the target contains, per folder, a block with the source path, the current git revision in brackets and the `git status`.
|
|
97
|
+
If the source repository has git tags, LiveSync derives a [dunamai](https://github.com/mtkennerly/dunamai)-style version from them and writes it **into the brackets** instead of the bare commit hash, so the remote side can tell which revision it currently holds:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
/path/to/navigation --> robot:~/navigation
|
|
101
|
+
[0.1.0.post43.dev0+3f6ee0e]
|
|
102
|
+
## main
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The format is always `[<base>.post<N>.dev0+<short-hash>]`:
|
|
106
|
+
|
|
107
|
+
- `<base>` is the latest tag (a leading `v` directly followed by a digit is stripped, as in `v1.2.3`), `<N>` the number of commits since that tag, and `<short-hash>` the current commit — so the exact revision is always included, even sitting right on a tag (`[0.1.0.post0.dev0+3f6ee0e]`).
|
|
108
|
+
- Without any tag the base is `0.0.0` and the distance is the total number of commits, e.g. `[0.0.0.post12.dev0+63e867f]`.
|
|
109
|
+
- A repository without any commit yet, or a source that is not a git repository, gets no revision brackets at all.
|
|
110
|
+
|
|
111
|
+
The version is recomputed from git on every mutex update (roughly every `mutex_interval` seconds), so it stays live while syncing.
|
|
112
|
+
It is derived purely from `git` (no extra dependency): this is close to dunamai's PEP 440 output but not identical (dunamai drops the `.post0.dev0+<hash>` suffix on an exact tag, and does not replicate custom tag patterns, pre-releases, epochs or dirty markers).
|
|
113
|
+
The string contains no `"` characters, so it survives the `"`→`'` replacement LiveSync applies across the whole summary.
|
|
114
|
+
|
|
115
|
+
**Parser contract for the target side:** read the content of the `[...]` brackets on the revision line of each folder block; it is `<base>.post<N>.dev0+<short-hash>`.
|
|
116
|
+
If you only need the bare commit hash, take the part after the last `+`.
|
|
117
|
+
|
|
118
|
+
Advanced example:
|
|
119
|
+
|
|
120
|
+
```py
|
|
121
|
+
import argparse
|
|
122
|
+
from livesync import Folder, sync
|
|
123
|
+
|
|
124
|
+
parser = argparse.ArgumentParser(description='Sync local code with robot.')
|
|
125
|
+
parser.add_argument('robot', help='Robot hostname')
|
|
126
|
+
|
|
127
|
+
args = parser.parse_args()
|
|
128
|
+
|
|
129
|
+
touch = 'touch ~/robot/main.py'
|
|
130
|
+
sync(
|
|
131
|
+
Folder('.', f'{args.robot}:~/navigation', on_change='touch ~/navigation/main.py'),
|
|
132
|
+
Folder('../rosys', f'{args.robot}:~/rosys').rsync_args(add='-L', remove='--checksum'),
|
|
133
|
+
mutex_interval=30,
|
|
134
|
+
)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Notes
|
|
138
|
+
|
|
139
|
+
- We suggest you have some auto-reloading in place on the (slow) target machine, like [NiceGUI](https://nicegui.io).
|
|
140
|
+
- Only one user per target host should run LiveSync at a time. Therefore LiveSync provides a mutex mechanism.
|
|
141
|
+
- You can create a `.syncignore` file in any source directory to skip additional files and directories from syncing.
|
|
142
|
+
- If a `.syncignore` file doesn't exist, it is automatically created containing `.git/`, `__pycache__/`, `.DS_Store`, `*.tmp`, and `.env`.
|
|
143
|
+
|
|
144
|
+
## Installation
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
python3 -m pip install livesync
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Development
|
|
151
|
+
|
|
152
|
+
For development we use [uv](https://docs.astral.sh/uv/):
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
git clone git@github.com:zauberzeug/livesync.git
|
|
156
|
+
cd livesync
|
|
157
|
+
uv sync
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
This creates a virtual environment in `.venv` with LiveSync installed in editable mode.
|
|
161
|
+
Now you can change the code and run the `livesync` command with the modified code via `uv run livesync ...` (or activate the environment with `source .venv/bin/activate`).
|
|
162
|
+
|
|
163
|
+
To run the unit tests:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
uv run pytest
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
We use [pre-commit](https://pre-commit.com/) to keep `uv.lock` in sync with `pyproject.toml` and to run formatting (autopep8, isort) and linting (mypy, pylint).
|
|
170
|
+
Install the git hooks once with:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
uv run pre-commit install
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Testing
|
|
177
|
+
|
|
178
|
+
We have build a small testing infrastructure with two docker containers.
|
|
179
|
+
See [tests/README.md](https://github.com/zauberzeug/livesync/blob/main/tests/README.md) for details.
|
|
180
|
+
|
|
181
|
+
## Releases
|
|
182
|
+
|
|
183
|
+
Just create and push a new tag with the new version name (v0.2.1 for example).
|
|
184
|
+
After a successful build a new release will be created.
|
|
185
|
+
This should be edited to describe the changes in the release notes.
|
|
186
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
livesync/__init__.py,sha256=tZrvnAIpVuo9cQHMCvi6l2vup66NXPxRwyR5UBNtCTs,50
|
|
2
|
+
livesync/folder.py,sha256=7lRQsud-nLDqJTtwmRqCPovEK-0_MfLKrLJl0lGOtqk,6653
|
|
3
|
+
livesync/livesync.py,sha256=C1I-fDUcJ6w7AEweGETRK30MdoVWlbJF67rHK5q5oHo,1474
|
|
4
|
+
livesync/mutex.py,sha256=CKZw357mhdHoa3Ib8VdIHWWvrhwCnIKx5Uer5Vu-A2c,2596
|
|
5
|
+
livesync/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
livesync/run_subprocess.py,sha256=xpAAuc7u7vDeYFLFeQJccz4JVwpXg3IvroyU4EkfZbU,695
|
|
7
|
+
livesync/sync.py,sha256=55kOp3Rw-sFEreiD22mnuCiF6Ku-8UVAddDvCM_edEA,2657
|
|
8
|
+
livesync-0.5.0.dist-info/METADATA,sha256=icnRWHyKwUppzjvzPAhXHXY49eSMQ3E0epUcHZS9X6U,8057
|
|
9
|
+
livesync-0.5.0.dist-info/WHEEL,sha256=eY7nduwzv-ldUxpzbRlxwvC693Hg6PX8bWDjEHjZ_dk,88
|
|
10
|
+
livesync-0.5.0.dist-info/entry_points.txt,sha256=V6J1eEls4AAZ1oE5bE8T39OpCRIYBfij1ptnOs5M7U8,51
|
|
11
|
+
livesync-0.5.0.dist-info/licenses/LICENSE,sha256=QcBlwggRQYhvfTAE481AAMFQfMS_N6Bj8Svh1T6dsnI,1072
|
|
12
|
+
livesync-0.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 Zauberzeug GmbH
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|