LiveSync 0.3.0__tar.gz → 0.3.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: LiveSync
3
- Version: 0.3.0
3
+ Version: 0.3.2
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
@@ -26,12 +26,15 @@ It is available as [PyPI package](https://pypi.org/project/livesync/) and hosted
26
26
 
27
27
  [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.
28
28
  But if your target is a Raspberry Pi, Jetson Nano/Xavier/Orin, Beagle Board or similar, it feels like coding in jelly.
29
- Especially if you run powerful extensions like Pylance.
29
+ Especially if you run powerful extensions like Pylance, GitHub Copilot or Duet AI.
30
30
  LiveSync solves this by watching your code for changes and just copying the modifications to the slow remote machine.
31
+ So you can develop on your own machine (and run tests there in the background) while all your changes appear also on the remote.
31
32
  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).
32
33
 
33
34
  ## Usage
34
35
 
36
+ ### BASH
37
+
35
38
  ```bash
36
39
  livesync <source> <username>@<host>
37
40
  ```
@@ -43,20 +46,58 @@ Press `CTRL-C` to abort the synchronization.
43
46
  Positional arguments:
44
47
 
45
48
  - `<source>`
46
- local folder or VSCode workspace file
47
- - `<username>@<host>`
48
- target user and host (e.g. username@hostname)
49
+ local folder
50
+ - `<target>`
51
+ target user, host and path (e.g. user@host:~/path; path defaults to source folder name in home directory)
52
+ - `<rsync_args>`
53
+ arbitrary rsync parameters after "--"
49
54
 
50
55
  Options:
51
56
 
52
- - `--target-root TARGET_ROOT`
53
- subfolder on target to synchronize to (default: "")
54
- - `--target-port TARGET_PORT`
57
+ - `--ssh-port SSH_PORT`
55
58
  SSH port on target (default: 22)
56
59
  - `--on-change ON_CHANGE`
57
60
  command to be executed on remote host after any file change (default: None)
58
61
  - `--mutex-interval MUTEX_INTERVAL`
59
62
  interval in which mutex is updated (default: 10 seconds)
63
+ - `--ignore-mutex`
64
+ ignore mutex (use with caution) (default: False)
65
+
66
+ ### Python
67
+
68
+ Simple example (where `robot` is the ssh hostname of the target system):
69
+
70
+ ```py
71
+ from livesync import Folder, sync
72
+
73
+ sync(
74
+ Folder('.', 'robot:~/navigation'),
75
+ Folder('../rosys', 'robot:~/rosys'),
76
+ )
77
+ ```
78
+
79
+ The `sync` call will block until the script is aborted.
80
+ The `Folder` class allows to set the `port` and an `on_change` bash command which is executed after a sync has been performed.
81
+ Via the `rsync_args` build method you can pass additional options to configure rsync.
82
+
83
+ Advanced example:
84
+
85
+ ```py
86
+ import argparse
87
+ from livesync import Folder, sync
88
+
89
+ parser = argparse.ArgumentParser(description='Sync local code with robot.')
90
+ parser.add_argument('robot', help='Robot hostname')
91
+
92
+ args = parser.parse_args()
93
+
94
+ touch = 'touch ~/robot/main.py'
95
+ sync(
96
+ Folder('.', f'{args.robot}:~/navigation', on_change='touch ~/navigation/main.py'),
97
+ Folder('../rosys', f'{args.robot}:~/rosys').rsync_args(add='-L', remove='--checksum'),
98
+ mutex_interval=30,
99
+ )
100
+ ```
60
101
 
61
102
  ### Notes
62
103
 
@@ -64,7 +105,6 @@ Options:
64
105
  - Only one user per target host should run LiveSync at a time. Therefore LiveSync provides a mutex mechanism.
65
106
  - You can create a `.syncignore` file in any source directory to skip additional files and directories from syncing.
66
107
  - If a `.syncignore` file doesn't exist, it is automatically created containing `.git/`, `__pycache__/`, `.DS_Store`, `*.tmp`, and `.env`.
67
- - If you pass a VSCode workspace file as `source`, LiveSync will synchronize each directory listed in the `folders` section.
68
108
 
69
109
  ## Installation
70
110
 
@@ -10,4 +10,6 @@ LiveSync.egg-info/top_level.txt
10
10
  livesync/__init__.py
11
11
  livesync/folder.py
12
12
  livesync/livesync.py
13
- livesync/mutex.py
13
+ livesync/mutex.py
14
+ livesync/run_subprocess.py
15
+ livesync/sync.py
@@ -1,3 +1,2 @@
1
1
  pathspec
2
- pyjson5
3
2
  watchfiles
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: LiveSync
3
- Version: 0.3.0
3
+ Version: 0.3.2
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
@@ -26,12 +26,15 @@ It is available as [PyPI package](https://pypi.org/project/livesync/) and hosted
26
26
 
27
27
  [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.
28
28
  But if your target is a Raspberry Pi, Jetson Nano/Xavier/Orin, Beagle Board or similar, it feels like coding in jelly.
29
- Especially if you run powerful extensions like Pylance.
29
+ Especially if you run powerful extensions like Pylance, GitHub Copilot or Duet AI.
30
30
  LiveSync solves this by watching your code for changes and just copying the modifications to the slow remote machine.
31
+ So you can develop on your own machine (and run tests there in the background) while all your changes appear also on the remote.
31
32
  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).
32
33
 
33
34
  ## Usage
34
35
 
36
+ ### BASH
37
+
35
38
  ```bash
36
39
  livesync <source> <username>@<host>
37
40
  ```
@@ -43,20 +46,58 @@ Press `CTRL-C` to abort the synchronization.
43
46
  Positional arguments:
44
47
 
45
48
  - `<source>`
46
- local folder or VSCode workspace file
47
- - `<username>@<host>`
48
- target user and host (e.g. username@hostname)
49
+ local folder
50
+ - `<target>`
51
+ target user, host and path (e.g. user@host:~/path; path defaults to source folder name in home directory)
52
+ - `<rsync_args>`
53
+ arbitrary rsync parameters after "--"
49
54
 
50
55
  Options:
51
56
 
52
- - `--target-root TARGET_ROOT`
53
- subfolder on target to synchronize to (default: "")
54
- - `--target-port TARGET_PORT`
57
+ - `--ssh-port SSH_PORT`
55
58
  SSH port on target (default: 22)
56
59
  - `--on-change ON_CHANGE`
57
60
  command to be executed on remote host after any file change (default: None)
58
61
  - `--mutex-interval MUTEX_INTERVAL`
59
62
  interval in which mutex is updated (default: 10 seconds)
63
+ - `--ignore-mutex`
64
+ ignore mutex (use with caution) (default: False)
65
+
66
+ ### Python
67
+
68
+ Simple example (where `robot` is the ssh hostname of the target system):
69
+
70
+ ```py
71
+ from livesync import Folder, sync
72
+
73
+ sync(
74
+ Folder('.', 'robot:~/navigation'),
75
+ Folder('../rosys', 'robot:~/rosys'),
76
+ )
77
+ ```
78
+
79
+ The `sync` call will block until the script is aborted.
80
+ The `Folder` class allows to set the `port` and an `on_change` bash command which is executed after a sync has been performed.
81
+ Via the `rsync_args` build method you can pass additional options to configure rsync.
82
+
83
+ Advanced example:
84
+
85
+ ```py
86
+ import argparse
87
+ from livesync import Folder, sync
88
+
89
+ parser = argparse.ArgumentParser(description='Sync local code with robot.')
90
+ parser.add_argument('robot', help='Robot hostname')
91
+
92
+ args = parser.parse_args()
93
+
94
+ touch = 'touch ~/robot/main.py'
95
+ sync(
96
+ Folder('.', f'{args.robot}:~/navigation', on_change='touch ~/navigation/main.py'),
97
+ Folder('../rosys', f'{args.robot}:~/rosys').rsync_args(add='-L', remove='--checksum'),
98
+ mutex_interval=30,
99
+ )
100
+ ```
60
101
 
61
102
  ### Notes
62
103
 
@@ -64,7 +105,6 @@ Options:
64
105
  - Only one user per target host should run LiveSync at a time. Therefore LiveSync provides a mutex mechanism.
65
106
  - You can create a `.syncignore` file in any source directory to skip additional files and directories from syncing.
66
107
  - If a `.syncignore` file doesn't exist, it is automatically created containing `.git/`, `__pycache__/`, `.DS_Store`, `*.tmp`, and `.env`.
67
- - If you pass a VSCode workspace file as `source`, LiveSync will synchronize each directory listed in the `folders` section.
68
108
 
69
109
  ## Installation
70
110
 
@@ -13,12 +13,15 @@ It is available as [PyPI package](https://pypi.org/project/livesync/) and hosted
13
13
 
14
14
  [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.
15
15
  But if your target is a Raspberry Pi, Jetson Nano/Xavier/Orin, Beagle Board or similar, it feels like coding in jelly.
16
- Especially if you run powerful extensions like Pylance.
16
+ Especially if you run powerful extensions like Pylance, GitHub Copilot or Duet AI.
17
17
  LiveSync solves this by watching your code for changes and just copying the modifications to the slow remote machine.
18
+ So you can develop on your own machine (and run tests there in the background) while all your changes appear also on the remote.
18
19
  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).
19
20
 
20
21
  ## Usage
21
22
 
23
+ ### BASH
24
+
22
25
  ```bash
23
26
  livesync <source> <username>@<host>
24
27
  ```
@@ -30,20 +33,58 @@ Press `CTRL-C` to abort the synchronization.
30
33
  Positional arguments:
31
34
 
32
35
  - `<source>`
33
- local folder or VSCode workspace file
34
- - `<username>@<host>`
35
- target user and host (e.g. username@hostname)
36
+ local folder
37
+ - `<target>`
38
+ target user, host and path (e.g. user@host:~/path; path defaults to source folder name in home directory)
39
+ - `<rsync_args>`
40
+ arbitrary rsync parameters after "--"
36
41
 
37
42
  Options:
38
43
 
39
- - `--target-root TARGET_ROOT`
40
- subfolder on target to synchronize to (default: "")
41
- - `--target-port TARGET_PORT`
44
+ - `--ssh-port SSH_PORT`
42
45
  SSH port on target (default: 22)
43
46
  - `--on-change ON_CHANGE`
44
47
  command to be executed on remote host after any file change (default: None)
45
48
  - `--mutex-interval MUTEX_INTERVAL`
46
49
  interval in which mutex is updated (default: 10 seconds)
50
+ - `--ignore-mutex`
51
+ ignore mutex (use with caution) (default: False)
52
+
53
+ ### Python
54
+
55
+ Simple example (where `robot` is the ssh hostname of the target system):
56
+
57
+ ```py
58
+ from livesync import Folder, sync
59
+
60
+ sync(
61
+ Folder('.', 'robot:~/navigation'),
62
+ Folder('../rosys', 'robot:~/rosys'),
63
+ )
64
+ ```
65
+
66
+ The `sync` call will block until the script is aborted.
67
+ The `Folder` class allows to set the `port` and an `on_change` bash command which is executed after a sync has been performed.
68
+ Via the `rsync_args` build method you can pass additional options to configure rsync.
69
+
70
+ Advanced example:
71
+
72
+ ```py
73
+ import argparse
74
+ from livesync import Folder, sync
75
+
76
+ parser = argparse.ArgumentParser(description='Sync local code with robot.')
77
+ parser.add_argument('robot', help='Robot hostname')
78
+
79
+ args = parser.parse_args()
80
+
81
+ touch = 'touch ~/robot/main.py'
82
+ sync(
83
+ Folder('.', f'{args.robot}:~/navigation', on_change='touch ~/navigation/main.py'),
84
+ Folder('../rosys', f'{args.robot}:~/rosys').rsync_args(add='-L', remove='--checksum'),
85
+ mutex_interval=30,
86
+ )
87
+ ```
47
88
 
48
89
  ### Notes
49
90
 
@@ -51,7 +92,6 @@ Options:
51
92
  - Only one user per target host should run LiveSync at a time. Therefore LiveSync provides a mutex mechanism.
52
93
  - You can create a `.syncignore` file in any source directory to skip additional files and directories from syncing.
53
94
  - If a `.syncignore` file doesn't exist, it is automatically created containing `.git/`, `__pycache__/`, `.DS_Store`, `*.tmp`, and `.env`.
54
- - If you pass a VSCode workspace file as `source`, LiveSync will synchronize each directory listed in the `folders` section.
55
95
 
56
96
  ## Installation
57
97
 
@@ -0,0 +1,2 @@
1
+ from .folder import Folder
2
+ from .sync import sync
@@ -0,0 +1,91 @@
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
+ try:
63
+ cmd = ['git', 'log', '--pretty=format:[%h]\n', '-n', '1']
64
+ summary += subprocess.check_output(cmd, cwd=self.source_path).decode()
65
+ cmd = ['git', 'status', '--short', '--branch']
66
+ summary += subprocess.check_output(cmd, cwd=self.source_path).decode().strip() + '\n'
67
+ except Exception:
68
+ pass # not a git repo, git is not installed, or something else
69
+ return summary
70
+
71
+ async def watch(self) -> None:
72
+ try:
73
+ async for changes in watchfiles.awatch(self.source_path, stop_event=self._stop_watching,
74
+ watch_filter=lambda _, filepath: not self._ignore_spec.match_file(filepath)):
75
+ for change, filepath in changes:
76
+ print('?+U-'[change], filepath)
77
+ self.sync()
78
+ except RuntimeError as e:
79
+ if 'Already borrowed' not in str(e):
80
+ raise
81
+
82
+ def sync(self) -> None:
83
+ args = ' '.join(self._rsync_args)
84
+ args += ''.join(f' --exclude="{e}"' for e in self._get_ignores())
85
+ args += f' -e "ssh -p {self.ssh_port}"' # NOTE: use SSH with custom port
86
+ args += f' --rsync-path="mkdir -p {self.target_path} && rsync"' # NOTE: create target folder if not exists
87
+ run_subprocess(f'rsync {args} "{self.source_path}/" "{self.target}/"', quiet=True)
88
+ if isinstance(self.on_change, str):
89
+ run_subprocess(f'ssh {self.host} -p {self.ssh_port} "cd {self.target_path}; {self.on_change}"')
90
+ if callable(self.on_change):
91
+ self.on_change()
@@ -0,0 +1,26 @@
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('--ignore-mutex', action='store_true', help='ignore mutex (use with caution)')
17
+ parser.add_argument('rsync_args', nargs=argparse.REMAINDER, help='arbitrary rsync parameters after "--"')
18
+ args = parser.parse_args()
19
+
20
+ folder = Folder(args.source, args.target, ssh_port=args.ssh_port, on_change=args.on_change)
21
+ folder.rsync_args(' '.join(args.rsync_args))
22
+ sync(folder, mutex_interval=args.mutex_interval, ignore_mutex=args.ignore_mutex)
23
+
24
+
25
+ if __name__ == '__main__':
26
+ 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, target: Target) -> None:
15
- self.target = target
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, info: str) -> bool:
17
+ def is_free(self) -> bool:
20
18
  try:
21
- output = self._run_ssh_command(f'cat {MUTEX_FILEPATH} || echo "{self.tag}\n{info}"').splitlines()[0]
22
- words = output.strip().split()
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(info):
34
+ if not self.is_free():
34
35
  return False
35
36
  try:
36
- self._run_ssh_command(f'echo "{self.tag}\n{info}" > {MUTEX_FILEPATH}')
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.target.host, '-p', str(self.target.port), command]
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,47 @@
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, ignore_mutex: bool = False) -> None:
14
+ try:
15
+ if not ignore_mutex:
16
+ summary = get_summary(folders)
17
+ mutexes = {folder.host: Mutex(folder.host, folder.ssh_port) for folder in folders}
18
+ for mutex in mutexes.values():
19
+ print(f'Checking mutex on {mutex.host}', flush=True)
20
+ if not mutex.set(summary):
21
+ print(f'Target is in use by {mutex.occupant}')
22
+ sys.exit(1)
23
+
24
+ for folder in folders:
25
+ print(f' {folder.source_path} --> {folder.target}', flush=True)
26
+ folder.sync()
27
+
28
+ for folder in folders:
29
+ print(f'Watch folder {folder.source_path}', flush=True)
30
+ asyncio.create_task(folder.watch())
31
+
32
+ while True:
33
+ if not ignore_mutex:
34
+ summary = get_summary(folders)
35
+ for mutex in mutexes.values():
36
+ if not mutex.set(summary):
37
+ break
38
+ await asyncio.sleep(mutex_interval)
39
+ except Exception as e:
40
+ print(e)
41
+
42
+
43
+ def sync(*folders: Folder, mutex_interval: float = 10, ignore_mutex: bool = False) -> None:
44
+ try:
45
+ asyncio.run(run_folder_tasks(folders, mutex_interval, ignore_mutex=ignore_mutex))
46
+ except KeyboardInterrupt:
47
+ print('Bye!')
@@ -1,2 +0,0 @@
1
- from .folder import Folder, Target
2
- from .mutex import Mutex
@@ -1,92 +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
- DEFAULT_IGNORES = ['.git/', '__pycache__/', '.DS_Store', '*.tmp', '.env']
13
-
14
-
15
- def run_subprocess(command: str, *, quiet: bool = False) -> None:
16
- result = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=True)
17
- if not quiet:
18
- print(result.stdout.decode())
19
-
20
-
21
- @dataclass(**KWONLY_SLOTS)
22
- class Target:
23
- host: str
24
- port: int
25
- root: Path
26
-
27
- def make_target_root_directory(self) -> None:
28
- print(f'make target root directory {self.root}')
29
- run_subprocess(f'ssh {self.host} -p {self.port} "mkdir -p {self.root}"')
30
-
31
-
32
- class Folder:
33
-
34
- def __init__(self, local_dir: Path, target: Target) -> None:
35
- self.local_path = local_dir.resolve() # one should avoid `absolute` if Python < 3.11
36
- self.target = target
37
-
38
- # from https://stackoverflow.com/a/22090594/3419103
39
- match_pattern = pathspec.patterns.gitwildmatch.GitWildMatchPattern
40
- self._ignore_spec = pathspec.PathSpec.from_lines(match_pattern, self.get_ignores())
41
-
42
- self._stop_watching = asyncio.Event()
43
-
44
- @property
45
- def target_path(self) -> Path:
46
- return self.target.root / self.local_path.stem
47
-
48
- @property
49
- def ssh_path(self) -> str:
50
- return f'{self.target.host}:{self.target_path}'
51
-
52
- def get_ignores(self) -> List[str]:
53
- path = self.local_path / '.syncignore'
54
- if not path.is_file():
55
- path.write_text('\n'.join(DEFAULT_IGNORES))
56
- return [line.strip() for line in path.read_text().splitlines() if not line.startswith('#')]
57
-
58
- def get_summary(self) -> str:
59
- summary = f'{self.local_path} --> {self.ssh_path}\n'
60
- if not (self.local_path / '.git').exists():
61
- return summary
62
- try:
63
- cmd = ['git', 'log', '--pretty=format:[%h]\n', '-n', '1']
64
- summary += subprocess.check_output(cmd, cwd=self.local_path).decode()
65
- cmd = ['git', 'status', '--short', '--branch']
66
- summary += subprocess.check_output(cmd, cwd=self.local_path).decode().strip() + '\n'
67
- except Exception:
68
- pass # maybe git is not installed
69
- return summary
70
-
71
- async def watch(self, on_change_command: Optional[str]) -> None:
72
- try:
73
- async for changes in watchfiles.awatch(self.local_path, stop_event=self._stop_watching,
74
- watch_filter=lambda _, filepath: not self._ignore_spec.match_file(filepath)):
75
- for change, filepath in changes:
76
- print('?+U-'[change], filepath)
77
- self.sync(on_change_command)
78
- except RuntimeError as e:
79
- if 'Already borrowed' not in str(e):
80
- raise
81
-
82
- def stop_watching(self) -> None:
83
- self._stop_watching.set()
84
-
85
- def sync(self, post_sync_command: Optional[str] = None) -> None:
86
- args = '--prune-empty-dirs --delete -avz --checksum --no-t'
87
- # args += ' --mkdirs' # INFO: this option is not available in rsync < 3.2.3
88
- args += ''.join(f' --exclude="{e}"' for e in self.get_ignores())
89
- args += f' -e "ssh -p {self.target.port}"'
90
- run_subprocess(f'rsync {args} {self.local_path}/ {self.ssh_path}/', quiet=True)
91
- if post_sync_command:
92
- run_subprocess(f'ssh {self.target.host} -p {self.target.port} "cd {self.target_path}; {post_sync_command}"')
@@ -1,75 +0,0 @@
1
- #!/usr/bin/env python3
2
- import argparse
3
- import asyncio
4
- import sys
5
- from pathlib import Path
6
- from typing import List
7
-
8
- import pyjson5
9
-
10
- from livesync import Folder, Mutex, Target
11
-
12
-
13
- def git_summary(folders: List[Folder]) -> str:
14
- return '\n'.join(f.get_summary() for f in folders).replace('"', '\'')
15
-
16
-
17
- async def async_main() -> None:
18
- parser = argparse.ArgumentParser(
19
- description='Repeatedly synchronize local directories with remote machine',
20
- formatter_class=argparse.ArgumentDefaultsHelpFormatter)
21
- parser.add_argument('source', type=str, help='local source folder or VSCode workspace file')
22
- parser.add_argument('--target-root', type=str, default='', help='subfolder on target to synchronize to')
23
- parser.add_argument('--target-port', type=int, default=22, help='SSH port on target')
24
- parser.add_argument('--on-change', type=str, help='command to be executed on remote host after any file change')
25
- parser.add_argument('--mutex-interval', type=int, default=10, help='interval in which mutex is updated')
26
- parser.add_argument('host', type=str, help='the target host (e.g. username@hostname)')
27
- args = parser.parse_args()
28
- source = Path(args.source)
29
- target = Target(host=args.host, port=args.target_port, root=Path(args.target_root))
30
-
31
- folders: List[Folder] = []
32
- if source.is_file():
33
- workspace = pyjson5.decode(source.read_text())
34
- paths = [Path(f['path']) for f in workspace['folders']]
35
- folders = [Folder(p, target) for p in paths if p.is_dir()]
36
- else:
37
- folders = [Folder(source, target)]
38
-
39
- for folder in folders:
40
- if not folder.local_path.is_dir():
41
- print(f'Invalid path: {folder.local_path}')
42
- sys.exit(1)
43
-
44
- print('Checking mutex...')
45
- mutex = Mutex(target)
46
- if not mutex.set(git_summary(folders)):
47
- print(f'Target is in use by {mutex.occupant}')
48
- sys.exit(1)
49
-
50
- if args.target_root:
51
- print('Creating target root directory...')
52
- target.make_target_root_directory()
53
-
54
- print('Initial sync...')
55
- for folder in folders:
56
- print(f' {folder.local_path} --> {folder.ssh_path}')
57
- folder.sync(post_sync_command=args.on_change)
58
-
59
- print('Watching for file changes...')
60
- for folder in folders:
61
- asyncio.create_task(folder.watch(on_change_command=args.on_change))
62
-
63
- while mutex.set(git_summary(folders)):
64
- await asyncio.sleep(args.mutex_interval)
65
-
66
-
67
- def main():
68
- try:
69
- asyncio.run(async_main())
70
- except KeyboardInterrupt:
71
- print('Bye!')
72
-
73
-
74
- if __name__ == '__main__':
75
- main()
File without changes
File without changes
File without changes