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 ADDED
@@ -0,0 +1,2 @@
1
+ from .folder import Folder
2
+ from .sync import sync
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
+ [![PyPI version](https://badge.fury.io/py/livesync.svg)](https://pypi.org/project/livesync/)
28
+ [![PyPI - Downloads](https://img.shields.io/pypi/dm/livesync)](https://pypi.org/project/livesync/)
29
+ [![GitHub commit activity](https://img.shields.io/github/commit-activity/m/zauberzeug/livesync)](https://github.com/zauberzeug/livesync/graphs/commit-activity)
30
+ [![GitHub issues](https://img.shields.io/github/issues/zauberzeug/livesync)](https://github.com/zauberzeug/livesync/issues)
31
+ [![GitHub license](https://img.shields.io/github/license/zauberzeug/livesync)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.4.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ livesync=livesync.livesync:main
3
+
@@ -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.