LiveSync 0.2.2__tar.gz → 0.3.1__tar.gz
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-0.2.2 → LiveSync-0.3.1/LiveSync.egg-info}/PKG-INFO +48 -12
- {LiveSync-0.2.2 → LiveSync-0.3.1}/LiveSync.egg-info/SOURCES.txt +3 -1
- {LiveSync-0.2.2/LiveSync.egg-info → LiveSync-0.3.1}/PKG-INFO +48 -12
- {LiveSync-0.2.2 → LiveSync-0.3.1}/README.md +47 -11
- LiveSync-0.3.1/livesync/__init__.py +2 -0
- LiveSync-0.3.1/livesync/folder.py +93 -0
- LiveSync-0.3.1/livesync/livesync.py +25 -0
- {LiveSync-0.2.2 → LiveSync-0.3.1}/livesync/mutex.py +13 -12
- LiveSync-0.3.1/livesync/run_subprocess.py +11 -0
- LiveSync-0.3.1/livesync/sync.py +45 -0
- LiveSync-0.2.2/livesync/__init__.py +0 -2
- LiveSync-0.2.2/livesync/folder.py +0 -97
- LiveSync-0.2.2/livesync/livesync.py +0 -80
- {LiveSync-0.2.2 → LiveSync-0.3.1}/LICENSE +0 -0
- {LiveSync-0.2.2 → LiveSync-0.3.1}/LiveSync.egg-info/dependency_links.txt +0 -0
- {LiveSync-0.2.2 → LiveSync-0.3.1}/LiveSync.egg-info/entry_points.txt +0 -0
- {LiveSync-0.2.2 → LiveSync-0.3.1}/LiveSync.egg-info/requires.txt +1 -1
- {LiveSync-0.2.2 → LiveSync-0.3.1}/LiveSync.egg-info/top_level.txt +0 -0
- {LiveSync-0.2.2 → LiveSync-0.3.1}/setup.cfg +0 -0
- {LiveSync-0.2.2 → LiveSync-0.3.1}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: LiveSync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Repeatedly synchronize local workspace with a (slow) remote machine
|
|
5
5
|
Home-page: https://github.com/zauberzeug/livesync
|
|
6
6
|
Author: Zauberzeug GmbH
|
|
@@ -32,29 +32,65 @@ It works best if you have some kind of reload mechanism in place on the target (
|
|
|
32
32
|
|
|
33
33
|
## Usage
|
|
34
34
|
|
|
35
|
+
### BASH
|
|
36
|
+
|
|
35
37
|
```bash
|
|
36
|
-
|
|
37
|
-
livesync <username>@<host>
|
|
38
|
+
livesync <source> <username>@<host>
|
|
38
39
|
```
|
|
39
40
|
|
|
40
41
|
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).
|
|
41
42
|
|
|
42
43
|
Press `CTRL-C` to abort the synchronization.
|
|
43
44
|
|
|
45
|
+
Positional arguments:
|
|
46
|
+
|
|
47
|
+
- `<source>`
|
|
48
|
+
local folder
|
|
49
|
+
- `<target>`
|
|
50
|
+
target user, host and path (e.g. user@host:~/path; path defaults to source folder name in home directory)
|
|
51
|
+
- `<rsync_args>`
|
|
52
|
+
arbitrary rsync parameters after "--"
|
|
53
|
+
|
|
54
|
+
Options:
|
|
55
|
+
|
|
56
|
+
- `--ssh-port SSH_PORT`
|
|
57
|
+
SSH port on target (default: 22)
|
|
58
|
+
- `--on-change ON_CHANGE`
|
|
59
|
+
command to be executed on remote host after any file change (default: None)
|
|
60
|
+
- `--mutex-interval MUTEX_INTERVAL`
|
|
61
|
+
interval in which mutex is updated (default: 10 seconds)
|
|
62
|
+
|
|
63
|
+
### Python
|
|
64
|
+
|
|
65
|
+
Simple example:
|
|
66
|
+
|
|
67
|
+
```py
|
|
68
|
+
from livesync import Folder, sync
|
|
69
|
+
|
|
70
|
+
sync(
|
|
71
|
+
Folder('.', 'robot:~/navigation'),
|
|
72
|
+
Folder('../rosys', 'robot:~/rosys'),
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Advanced example:
|
|
77
|
+
|
|
78
|
+
```py
|
|
79
|
+
from livesync import Folder, sync
|
|
80
|
+
|
|
81
|
+
sync(
|
|
82
|
+
Folder('.', 'robot:~/navigation', on_change='touch ~/navigation/main.py'),
|
|
83
|
+
Folder('../rosys', 'robot:~/rosys', ssh_port=2222).rsync_args(add='-L', remove='--checksum'),
|
|
84
|
+
mutex_interval=30,
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
44
88
|
### Notes
|
|
45
89
|
|
|
46
90
|
- We suggest you have some auto-reloading in place on the (slow) target machine, like [NiceGUI](https://nicegui.io).
|
|
47
91
|
- Only one user per target host should run LiveSync at a time. Therefore LiveSync provides a mutex mechanism.
|
|
48
|
-
- By default `.git/` folders are not synchronized.
|
|
49
|
-
- All files and directories from the `.gitignore` of any source directory are also excluded from synchronization.
|
|
50
92
|
- You can create a `.syncignore` file in any source directory to skip additional files and directories from syncing.
|
|
51
|
-
- If
|
|
52
|
-
|
|
53
|
-
### Options
|
|
54
|
-
|
|
55
|
-
- `--on-change [command]` command to be executed on remote host after any file change
|
|
56
|
-
- `--source [SOURCE]` source folder on local host instead of VSCode workspace file
|
|
57
|
-
- `--mutex-interval [INTERVAL]` interval for updating the mutex
|
|
93
|
+
- If a `.syncignore` file doesn't exist, it is automatically created containing `.git/`, `__pycache__/`, `.DS_Store`, `*.tmp`, and `.env`.
|
|
58
94
|
|
|
59
95
|
## Installation
|
|
60
96
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: LiveSync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Repeatedly synchronize local workspace with a (slow) remote machine
|
|
5
5
|
Home-page: https://github.com/zauberzeug/livesync
|
|
6
6
|
Author: Zauberzeug GmbH
|
|
@@ -32,29 +32,65 @@ It works best if you have some kind of reload mechanism in place on the target (
|
|
|
32
32
|
|
|
33
33
|
## Usage
|
|
34
34
|
|
|
35
|
+
### BASH
|
|
36
|
+
|
|
35
37
|
```bash
|
|
36
|
-
|
|
37
|
-
livesync <username>@<host>
|
|
38
|
+
livesync <source> <username>@<host>
|
|
38
39
|
```
|
|
39
40
|
|
|
40
41
|
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).
|
|
41
42
|
|
|
42
43
|
Press `CTRL-C` to abort the synchronization.
|
|
43
44
|
|
|
45
|
+
Positional arguments:
|
|
46
|
+
|
|
47
|
+
- `<source>`
|
|
48
|
+
local folder
|
|
49
|
+
- `<target>`
|
|
50
|
+
target user, host and path (e.g. user@host:~/path; path defaults to source folder name in home directory)
|
|
51
|
+
- `<rsync_args>`
|
|
52
|
+
arbitrary rsync parameters after "--"
|
|
53
|
+
|
|
54
|
+
Options:
|
|
55
|
+
|
|
56
|
+
- `--ssh-port SSH_PORT`
|
|
57
|
+
SSH port on target (default: 22)
|
|
58
|
+
- `--on-change ON_CHANGE`
|
|
59
|
+
command to be executed on remote host after any file change (default: None)
|
|
60
|
+
- `--mutex-interval MUTEX_INTERVAL`
|
|
61
|
+
interval in which mutex is updated (default: 10 seconds)
|
|
62
|
+
|
|
63
|
+
### Python
|
|
64
|
+
|
|
65
|
+
Simple example:
|
|
66
|
+
|
|
67
|
+
```py
|
|
68
|
+
from livesync import Folder, sync
|
|
69
|
+
|
|
70
|
+
sync(
|
|
71
|
+
Folder('.', 'robot:~/navigation'),
|
|
72
|
+
Folder('../rosys', 'robot:~/rosys'),
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Advanced example:
|
|
77
|
+
|
|
78
|
+
```py
|
|
79
|
+
from livesync import Folder, sync
|
|
80
|
+
|
|
81
|
+
sync(
|
|
82
|
+
Folder('.', 'robot:~/navigation', on_change='touch ~/navigation/main.py'),
|
|
83
|
+
Folder('../rosys', 'robot:~/rosys', ssh_port=2222).rsync_args(add='-L', remove='--checksum'),
|
|
84
|
+
mutex_interval=30,
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
44
88
|
### Notes
|
|
45
89
|
|
|
46
90
|
- We suggest you have some auto-reloading in place on the (slow) target machine, like [NiceGUI](https://nicegui.io).
|
|
47
91
|
- Only one user per target host should run LiveSync at a time. Therefore LiveSync provides a mutex mechanism.
|
|
48
|
-
- By default `.git/` folders are not synchronized.
|
|
49
|
-
- All files and directories from the `.gitignore` of any source directory are also excluded from synchronization.
|
|
50
92
|
- You can create a `.syncignore` file in any source directory to skip additional files and directories from syncing.
|
|
51
|
-
- If
|
|
52
|
-
|
|
53
|
-
### Options
|
|
54
|
-
|
|
55
|
-
- `--on-change [command]` command to be executed on remote host after any file change
|
|
56
|
-
- `--source [SOURCE]` source folder on local host instead of VSCode workspace file
|
|
57
|
-
- `--mutex-interval [INTERVAL]` interval for updating the mutex
|
|
93
|
+
- If a `.syncignore` file doesn't exist, it is automatically created containing `.git/`, `__pycache__/`, `.DS_Store`, `*.tmp`, and `.env`.
|
|
58
94
|
|
|
59
95
|
## Installation
|
|
60
96
|
|
|
@@ -19,29 +19,65 @@ It works best if you have some kind of reload mechanism in place on the target (
|
|
|
19
19
|
|
|
20
20
|
## Usage
|
|
21
21
|
|
|
22
|
+
### BASH
|
|
23
|
+
|
|
22
24
|
```bash
|
|
23
|
-
|
|
24
|
-
livesync <username>@<host>
|
|
25
|
+
livesync <source> <username>@<host>
|
|
25
26
|
```
|
|
26
27
|
|
|
27
28
|
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).
|
|
28
29
|
|
|
29
30
|
Press `CTRL-C` to abort the synchronization.
|
|
30
31
|
|
|
32
|
+
Positional arguments:
|
|
33
|
+
|
|
34
|
+
- `<source>`
|
|
35
|
+
local folder
|
|
36
|
+
- `<target>`
|
|
37
|
+
target user, host and path (e.g. user@host:~/path; path defaults to source folder name in home directory)
|
|
38
|
+
- `<rsync_args>`
|
|
39
|
+
arbitrary rsync parameters after "--"
|
|
40
|
+
|
|
41
|
+
Options:
|
|
42
|
+
|
|
43
|
+
- `--ssh-port SSH_PORT`
|
|
44
|
+
SSH port on target (default: 22)
|
|
45
|
+
- `--on-change ON_CHANGE`
|
|
46
|
+
command to be executed on remote host after any file change (default: None)
|
|
47
|
+
- `--mutex-interval MUTEX_INTERVAL`
|
|
48
|
+
interval in which mutex is updated (default: 10 seconds)
|
|
49
|
+
|
|
50
|
+
### Python
|
|
51
|
+
|
|
52
|
+
Simple example:
|
|
53
|
+
|
|
54
|
+
```py
|
|
55
|
+
from livesync import Folder, sync
|
|
56
|
+
|
|
57
|
+
sync(
|
|
58
|
+
Folder('.', 'robot:~/navigation'),
|
|
59
|
+
Folder('../rosys', 'robot:~/rosys'),
|
|
60
|
+
)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Advanced example:
|
|
64
|
+
|
|
65
|
+
```py
|
|
66
|
+
from livesync import Folder, sync
|
|
67
|
+
|
|
68
|
+
sync(
|
|
69
|
+
Folder('.', 'robot:~/navigation', on_change='touch ~/navigation/main.py'),
|
|
70
|
+
Folder('../rosys', 'robot:~/rosys', ssh_port=2222).rsync_args(add='-L', remove='--checksum'),
|
|
71
|
+
mutex_interval=30,
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
31
75
|
### Notes
|
|
32
76
|
|
|
33
77
|
- We suggest you have some auto-reloading in place on the (slow) target machine, like [NiceGUI](https://nicegui.io).
|
|
34
78
|
- Only one user per target host should run LiveSync at a time. Therefore LiveSync provides a mutex mechanism.
|
|
35
|
-
- By default `.git/` folders are not synchronized.
|
|
36
|
-
- All files and directories from the `.gitignore` of any source directory are also excluded from synchronization.
|
|
37
79
|
- You can create a `.syncignore` file in any source directory to skip additional files and directories from syncing.
|
|
38
|
-
- If
|
|
39
|
-
|
|
40
|
-
### Options
|
|
41
|
-
|
|
42
|
-
- `--on-change [command]` command to be executed on remote host after any file change
|
|
43
|
-
- `--source [SOURCE]` source folder on local host instead of VSCode workspace file
|
|
44
|
-
- `--mutex-interval [INTERVAL]` interval for updating the mutex
|
|
80
|
+
- If a `.syncignore` file doesn't exist, it is automatically created containing `.git/`, `__pycache__/`, `.DS_Store`, `*.tmp`, and `.env`.
|
|
45
81
|
|
|
46
82
|
## Installation
|
|
47
83
|
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
DEFAULT_IGNORES = ['.git/', '__pycache__/', '.DS_Store', '*.tmp', '.env']
|
|
17
|
+
DEFAULT_RSYNC_ARGS = ['--prune-empty-dirs', '--delete', '-a', '-v', '-z', '--checksum', '--no-t']
|
|
18
|
+
|
|
19
|
+
def __init__(self,
|
|
20
|
+
source_path: Union[str, Path],
|
|
21
|
+
target: str, *,
|
|
22
|
+
ssh_port: int = 22,
|
|
23
|
+
on_change: Optional[Union[str, Callable]] = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.source_path = Path(source_path).resolve() # one should avoid `absolute` if Python < 3.11
|
|
26
|
+
if ':' not in target:
|
|
27
|
+
target = f'{target}:{self.source_path.name}'
|
|
28
|
+
self.target = target
|
|
29
|
+
self.host, self.target_path = target.split(':')
|
|
30
|
+
self.ssh_port = ssh_port
|
|
31
|
+
self.on_change = on_change or None
|
|
32
|
+
self._rsync_args: List[str] = self.DEFAULT_RSYNC_ARGS[:]
|
|
33
|
+
self._stop_watching = asyncio.Event()
|
|
34
|
+
|
|
35
|
+
if not self.source_path.is_dir():
|
|
36
|
+
print(f'Invalid path: {self.source_path}')
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
|
|
39
|
+
match_pattern = pathspec.patterns.gitwildmatch.GitWildMatchPattern # https://stackoverflow.com/a/22090594/3419103
|
|
40
|
+
self._ignore_spec = pathspec.PathSpec.from_lines(match_pattern, self._get_ignores())
|
|
41
|
+
|
|
42
|
+
def rsync_args(self,
|
|
43
|
+
add: Optional[str] = None,
|
|
44
|
+
remove: Optional[str] = None,
|
|
45
|
+
replace: Optional[str] = None) -> Folder:
|
|
46
|
+
if replace is not None:
|
|
47
|
+
self._rsync_args.clear()
|
|
48
|
+
add_args = (add or '').split() + (replace or '').split()
|
|
49
|
+
remove_args = remove.split() if remove else []
|
|
50
|
+
self._rsync_args += [arg for arg in add_args if arg not in self._rsync_args]
|
|
51
|
+
self._rsync_args = [arg for arg in self._rsync_args if arg not in remove_args]
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def _get_ignores(self) -> List[str]:
|
|
55
|
+
path = self.source_path / '.syncignore'
|
|
56
|
+
if not path.is_file():
|
|
57
|
+
path.write_text('\n'.join(self.DEFAULT_IGNORES))
|
|
58
|
+
return [line.strip() for line in path.read_text().splitlines() if not line.startswith('#')]
|
|
59
|
+
|
|
60
|
+
def get_summary(self) -> str:
|
|
61
|
+
summary = f'{self.source_path} --> {self.target}\n'
|
|
62
|
+
if not (self.source_path / '.git').exists():
|
|
63
|
+
return summary
|
|
64
|
+
try:
|
|
65
|
+
cmd = ['git', 'log', '--pretty=format:[%h]\n', '-n', '1']
|
|
66
|
+
summary += subprocess.check_output(cmd, cwd=self.source_path).decode()
|
|
67
|
+
cmd = ['git', 'status', '--short', '--branch']
|
|
68
|
+
summary += subprocess.check_output(cmd, cwd=self.source_path).decode().strip() + '\n'
|
|
69
|
+
except Exception:
|
|
70
|
+
pass # maybe git is not installed
|
|
71
|
+
return summary
|
|
72
|
+
|
|
73
|
+
async def watch(self) -> None:
|
|
74
|
+
try:
|
|
75
|
+
async for changes in watchfiles.awatch(self.source_path, stop_event=self._stop_watching,
|
|
76
|
+
watch_filter=lambda _, filepath: not self._ignore_spec.match_file(filepath)):
|
|
77
|
+
for change, filepath in changes:
|
|
78
|
+
print('?+U-'[change], filepath)
|
|
79
|
+
self.sync()
|
|
80
|
+
except RuntimeError as e:
|
|
81
|
+
if 'Already borrowed' not in str(e):
|
|
82
|
+
raise
|
|
83
|
+
|
|
84
|
+
def sync(self) -> None:
|
|
85
|
+
args = ' '.join(self._rsync_args)
|
|
86
|
+
args += ''.join(f' --exclude="{e}"' for e in self._get_ignores())
|
|
87
|
+
args += f' -e "ssh -p {self.ssh_port}"' # NOTE: use SSH with custom port
|
|
88
|
+
args += f' --rsync-path="mkdir -p {self.target_path} && rsync"' # NOTE: create target folder if not exists
|
|
89
|
+
run_subprocess(f'rsync {args} {self.source_path}/ {self.target}/', quiet=True)
|
|
90
|
+
if isinstance(self.on_change, str):
|
|
91
|
+
run_subprocess(f'ssh {self.host} -p {self.ssh_port} "cd {self.target_path}; {self.on_change}"')
|
|
92
|
+
if callable(self.on_change):
|
|
93
|
+
self.on_change()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
|
|
4
|
+
from livesync import Folder, sync
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
parser = argparse.ArgumentParser(
|
|
9
|
+
description='Repeatedly synchronize a local directory with a remote machine',
|
|
10
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
|
11
|
+
parser.add_argument('source', type=str, default='.', help='local source folder')
|
|
12
|
+
parser.add_argument('target', type=str, help='target path (e.g. username@hostname:/path/to/target)')
|
|
13
|
+
parser.add_argument('--ssh-port', type=int, default=22, help='SSH port on target')
|
|
14
|
+
parser.add_argument('--on-change', type=str, help='command to be executed on remote host after any file change')
|
|
15
|
+
parser.add_argument('--mutex-interval', type=int, default=10, help='interval in which mutex is updated')
|
|
16
|
+
parser.add_argument('rsync_args', nargs=argparse.REMAINDER, help='arbitrary rsync parameters after "--"')
|
|
17
|
+
args = parser.parse_args()
|
|
18
|
+
|
|
19
|
+
folder = Folder(args.source, args.target, ssh_port=args.ssh_port, on_change=args.on_change)
|
|
20
|
+
folder.rsync_args(' '.join(args.rsync_args))
|
|
21
|
+
sync(folder, mutex_interval=args.mutex_interval)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == '__main__':
|
|
25
|
+
main()
|
|
@@ -4,22 +4,23 @@ import subprocess
|
|
|
4
4
|
from datetime import datetime, timedelta
|
|
5
5
|
from typing import Optional
|
|
6
6
|
|
|
7
|
-
from .folder import Target
|
|
8
|
-
|
|
9
|
-
MUTEX_FILEPATH = '~/.livesync_mutex'
|
|
10
|
-
|
|
11
7
|
|
|
12
8
|
class Mutex:
|
|
9
|
+
DEFAULT_FILEPATH = '~/.livesync_mutex'
|
|
13
10
|
|
|
14
|
-
def __init__(self,
|
|
15
|
-
self.
|
|
11
|
+
def __init__(self, host: str, port: int) -> None:
|
|
12
|
+
self.host = host
|
|
13
|
+
self.port = port
|
|
16
14
|
self.occupant: Optional[str] = None
|
|
17
15
|
self.user_id = socket.gethostname()
|
|
18
16
|
|
|
19
|
-
def is_free(self
|
|
17
|
+
def is_free(self) -> bool:
|
|
20
18
|
try:
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
command = f'[ -f {self.DEFAULT_FILEPATH} ] && cat {self.DEFAULT_FILEPATH} || echo'
|
|
20
|
+
output = self._run_ssh_command(command).strip()
|
|
21
|
+
if not output:
|
|
22
|
+
return True
|
|
23
|
+
words = output.splitlines()[0].strip().split()
|
|
23
24
|
self.occupant = words[0]
|
|
24
25
|
occupant_ok = self.occupant == self.user_id
|
|
25
26
|
mutex_datetime = datetime.fromisoformat(words[1])
|
|
@@ -30,10 +31,10 @@ class Mutex:
|
|
|
30
31
|
return False
|
|
31
32
|
|
|
32
33
|
def set(self, info: str) -> bool:
|
|
33
|
-
if not self.is_free(
|
|
34
|
+
if not self.is_free():
|
|
34
35
|
return False
|
|
35
36
|
try:
|
|
36
|
-
self._run_ssh_command(f'echo "{self.tag}\n{info}" > {
|
|
37
|
+
self._run_ssh_command(f'echo "{self.tag}\n{info}" > {self.DEFAULT_FILEPATH}')
|
|
37
38
|
return True
|
|
38
39
|
except subprocess.CalledProcessError:
|
|
39
40
|
print('Could not write mutex file')
|
|
@@ -44,5 +45,5 @@ class Mutex:
|
|
|
44
45
|
return f'{self.user_id} {datetime.now().isoformat()}'
|
|
45
46
|
|
|
46
47
|
def _run_ssh_command(self, command: str) -> str:
|
|
47
|
-
ssh_command = ['ssh', self.
|
|
48
|
+
ssh_command = ['ssh', self.host, '-p', str(self.port), command]
|
|
48
49
|
return subprocess.check_output(ssh_command, stderr=subprocess.DEVNULL).decode()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def run_subprocess(command: str, *, quiet: bool = False) -> None:
|
|
5
|
+
try:
|
|
6
|
+
result = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=True)
|
|
7
|
+
if not quiet:
|
|
8
|
+
print(result.stdout.decode())
|
|
9
|
+
except subprocess.CalledProcessError as e:
|
|
10
|
+
print(e.stdout.decode())
|
|
11
|
+
raise
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import sys
|
|
3
|
+
from typing import Iterable
|
|
4
|
+
|
|
5
|
+
from .folder import Folder
|
|
6
|
+
from .mutex import Mutex
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_summary(folders: Iterable[Folder]) -> str:
|
|
10
|
+
return '\n'.join(folder.get_summary() for folder in folders).replace('"', '\'')
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def run_folder_tasks(folders: Iterable[Folder], mutex_interval: float) -> None:
|
|
14
|
+
try:
|
|
15
|
+
summary = get_summary(folders)
|
|
16
|
+
mutexes = {folder.host: Mutex(folder.host, folder.ssh_port) for folder in folders}
|
|
17
|
+
for mutex in mutexes.values():
|
|
18
|
+
print(f'Checking mutex on {mutex.host}', flush=True)
|
|
19
|
+
if not mutex.set(summary):
|
|
20
|
+
print(f'Target is in use by {mutex.occupant}')
|
|
21
|
+
sys.exit(1)
|
|
22
|
+
|
|
23
|
+
for folder in folders:
|
|
24
|
+
print(f' {folder.source_path} --> {folder.target}', flush=True)
|
|
25
|
+
folder.sync()
|
|
26
|
+
|
|
27
|
+
for folder in folders:
|
|
28
|
+
print(f'Watch folder {folder.source_path}', flush=True)
|
|
29
|
+
asyncio.create_task(folder.watch())
|
|
30
|
+
|
|
31
|
+
while True:
|
|
32
|
+
summary = get_summary(folders)
|
|
33
|
+
for mutex in mutexes.values():
|
|
34
|
+
if not mutex.set(summary):
|
|
35
|
+
break
|
|
36
|
+
await asyncio.sleep(mutex_interval)
|
|
37
|
+
except Exception as e:
|
|
38
|
+
print(e)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def sync(*folders: Folder, mutex_interval: float = 10) -> None:
|
|
42
|
+
try:
|
|
43
|
+
asyncio.run(run_folder_tasks(folders, mutex_interval))
|
|
44
|
+
except KeyboardInterrupt:
|
|
45
|
+
print('Bye!')
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import subprocess
|
|
3
|
-
import sys
|
|
4
|
-
from dataclasses import dataclass
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from typing import List, Optional
|
|
7
|
-
|
|
8
|
-
import pathspec
|
|
9
|
-
import watchfiles
|
|
10
|
-
|
|
11
|
-
KWONLY_SLOTS = {'kw_only': True, 'slots': True} if sys.version_info >= (3, 10) else {}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def run_subprocess(command: str, *, quiet: bool = False) -> None:
|
|
15
|
-
result = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=True)
|
|
16
|
-
if not quiet:
|
|
17
|
-
print(result.stdout.decode())
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
@dataclass(**KWONLY_SLOTS)
|
|
21
|
-
class Target:
|
|
22
|
-
host: str
|
|
23
|
-
port: int
|
|
24
|
-
root: Path
|
|
25
|
-
|
|
26
|
-
def make_target_root_directory(self) -> None:
|
|
27
|
-
print(f'make target root directory {self.root}')
|
|
28
|
-
run_subprocess(f'ssh {self.host} -p {self.port} "mkdir -p {self.root}"')
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class Folder:
|
|
32
|
-
|
|
33
|
-
def __init__(self, local_dir: Path, target: Target) -> None:
|
|
34
|
-
self.local_path = local_dir.resolve() # one should avoid `absolute` if Python < 3.11
|
|
35
|
-
self.target = target
|
|
36
|
-
|
|
37
|
-
# from https://stackoverflow.com/a/22090594/3419103
|
|
38
|
-
match_pattern = pathspec.patterns.gitwildmatch.GitWildMatchPattern
|
|
39
|
-
self._ignore_spec = pathspec.PathSpec.from_lines(match_pattern, self.get_excludes())
|
|
40
|
-
|
|
41
|
-
self._stop_watching = asyncio.Event()
|
|
42
|
-
|
|
43
|
-
@property
|
|
44
|
-
def target_path(self) -> Path:
|
|
45
|
-
return self.target.root / self.local_path.stem
|
|
46
|
-
|
|
47
|
-
@property
|
|
48
|
-
def ssh_path(self) -> str:
|
|
49
|
-
return f'{self.target.host}:{self.target_path}'
|
|
50
|
-
|
|
51
|
-
def get_excludes(self) -> List[str]:
|
|
52
|
-
return ['.git/', '__pycache__/', '.DS_Store', '*.tmp', '.env'] + \
|
|
53
|
-
self._parse_ignore_file(self.local_path / '.syncignore') + \
|
|
54
|
-
self._parse_ignore_file(self.local_path / '.gitignore')
|
|
55
|
-
|
|
56
|
-
@staticmethod
|
|
57
|
-
def _parse_ignore_file(path: Path) -> List[str]:
|
|
58
|
-
if not path.is_file():
|
|
59
|
-
return []
|
|
60
|
-
with path.open() as f:
|
|
61
|
-
return [line.strip() for line in f.readlines() if not line.startswith('#')]
|
|
62
|
-
|
|
63
|
-
def get_summary(self) -> str:
|
|
64
|
-
summary = f'{self.local_path} --> {self.ssh_path}\n'
|
|
65
|
-
if not (self.local_path / '.git').exists():
|
|
66
|
-
return summary
|
|
67
|
-
try:
|
|
68
|
-
cmd = ['git', 'log', '--pretty=format:[%h]\n', '-n', '1']
|
|
69
|
-
summary += subprocess.check_output(cmd, cwd=self.local_path).decode()
|
|
70
|
-
cmd = ['git', 'status', '--short', '--branch']
|
|
71
|
-
summary += subprocess.check_output(cmd, cwd=self.local_path).decode().strip() + '\n'
|
|
72
|
-
except Exception:
|
|
73
|
-
pass # maybe git is not installed
|
|
74
|
-
return summary
|
|
75
|
-
|
|
76
|
-
async def watch(self, on_change_command: Optional[str]) -> None:
|
|
77
|
-
try:
|
|
78
|
-
async for changes in watchfiles.awatch(self.local_path, stop_event=self._stop_watching,
|
|
79
|
-
watch_filter=lambda _, filepath: not self._ignore_spec.match_file(filepath)):
|
|
80
|
-
for change, filepath in changes:
|
|
81
|
-
print('?+U-'[change], filepath)
|
|
82
|
-
self.sync(on_change_command)
|
|
83
|
-
except RuntimeError as e:
|
|
84
|
-
if 'Already borrowed' not in str(e):
|
|
85
|
-
raise
|
|
86
|
-
|
|
87
|
-
def stop_watching(self) -> None:
|
|
88
|
-
self._stop_watching.set()
|
|
89
|
-
|
|
90
|
-
def sync(self, post_sync_command: Optional[str] = None) -> None:
|
|
91
|
-
args = '--prune-empty-dirs --delete -avz --checksum --no-t'
|
|
92
|
-
# args += ' --mkdirs' # INFO: this option is not available in rsync < 3.2.3
|
|
93
|
-
args += ''.join(f' --exclude="{e}"' for e in self.get_excludes())
|
|
94
|
-
args += f' -e "ssh -p {self.target.port}"'
|
|
95
|
-
run_subprocess(f'rsync {args} {self.local_path}/ {self.ssh_path}/', quiet=True)
|
|
96
|
-
if post_sync_command:
|
|
97
|
-
run_subprocess(f'ssh {self.target.host} -p {self.target.port} "cd {self.target_path}; {post_sync_command}"')
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
import argparse
|
|
3
|
-
import asyncio
|
|
4
|
-
import json
|
|
5
|
-
import sys
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import List
|
|
8
|
-
|
|
9
|
-
from livesync import Folder, Mutex, Target
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def git_summary(folders: List[Folder]) -> str:
|
|
13
|
-
return '\n'.join(f.get_summary() for f in folders).replace('"', '\'')
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
async def async_main() -> None:
|
|
17
|
-
parser = argparse.ArgumentParser(description='Repeatedly synchronize local workspace with remote machine')
|
|
18
|
-
parser.add_argument('--on-change', type=str, help='command to be executed on remote host after any file change')
|
|
19
|
-
parser.add_argument('--source', type=str, help='source folder on local host instead of VSCode workspace file')
|
|
20
|
-
parser.add_argument('--mutex-interval', type=int, default=10, help='interval in which mutex is updated')
|
|
21
|
-
parser.add_argument('--target-root', type=str, default='', help='subfolder on target to synchronize to')
|
|
22
|
-
parser.add_argument('--target-port', type=int, default=22, help='SSH port on target')
|
|
23
|
-
parser.add_argument('host', type=str, help='the target host (e.g. username@hostname)')
|
|
24
|
-
args = parser.parse_args()
|
|
25
|
-
target = Target(host=args.host, port=args.target_port, root=Path(args.target_root))
|
|
26
|
-
|
|
27
|
-
folders: List[Folder] = []
|
|
28
|
-
workspaces = list(Path.cwd().glob('*.code-workspace'))
|
|
29
|
-
if args.source is None and workspaces:
|
|
30
|
-
|
|
31
|
-
if len(workspaces) > 1:
|
|
32
|
-
print('Multiple VSCode workspace files found.')
|
|
33
|
-
print('Provide --source argument or run livesync in a directory with a single *.code-workspace file.')
|
|
34
|
-
sys.exit(1)
|
|
35
|
-
|
|
36
|
-
print(f'Reading VSCode workspace file {workspaces[0]}...')
|
|
37
|
-
|
|
38
|
-
workspace = json.loads(workspaces[0].read_text())
|
|
39
|
-
paths = [Path(f['path']) for f in workspace['folders']]
|
|
40
|
-
folders = [Folder(p, target) for p in paths if p.is_dir()]
|
|
41
|
-
else:
|
|
42
|
-
source_path = Path(args.source or '.')
|
|
43
|
-
if not source_path.is_dir():
|
|
44
|
-
print(f'Invalid source path: {source_path}')
|
|
45
|
-
sys.exit(1)
|
|
46
|
-
|
|
47
|
-
folders = [Folder(source_path, target)]
|
|
48
|
-
|
|
49
|
-
print('Checking mutex...')
|
|
50
|
-
mutex = Mutex(target)
|
|
51
|
-
if not mutex.set(git_summary(folders)):
|
|
52
|
-
print(f'Target is in use by {mutex.occupant}')
|
|
53
|
-
sys.exit(1)
|
|
54
|
-
|
|
55
|
-
if args.target_root:
|
|
56
|
-
print('Creating target root directory...')
|
|
57
|
-
target.make_target_root_directory()
|
|
58
|
-
|
|
59
|
-
print('Initial sync...')
|
|
60
|
-
for folder in folders:
|
|
61
|
-
print(f' {folder.local_path} --> {folder.ssh_path}')
|
|
62
|
-
folder.sync(post_sync_command=args.on_change)
|
|
63
|
-
|
|
64
|
-
print('Watching for file changes...')
|
|
65
|
-
for folder in folders:
|
|
66
|
-
asyncio.create_task(folder.watch(on_change_command=args.on_change))
|
|
67
|
-
|
|
68
|
-
while mutex.set(git_summary(folders)):
|
|
69
|
-
await asyncio.sleep(args.mutex_interval)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def main():
|
|
73
|
-
try:
|
|
74
|
-
asyncio.run(async_main())
|
|
75
|
-
except KeyboardInterrupt:
|
|
76
|
-
print('Bye!')
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if __name__ == '__main__':
|
|
80
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|