sshmirror 0.1.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.
- sshmirror/__init__.py +14 -0
- sshmirror/__main__.py +4 -0
- sshmirror/cli.py +294 -0
- sshmirror/config.py +94 -0
- sshmirror/core/__init__.py +0 -0
- sshmirror/core/exceptions.py +10 -0
- sshmirror/core/filemap.py +362 -0
- sshmirror/core/filewatcher.py +87 -0
- sshmirror/core/schemas.py +106 -0
- sshmirror/core/utils.py +83 -0
- sshmirror/prompts.py +126 -0
- sshmirror/sshmirror.py +1631 -0
- sshmirror-0.1.0.dist-info/METADATA +192 -0
- sshmirror-0.1.0.dist-info/RECORD +18 -0
- sshmirror-0.1.0.dist-info/WHEEL +5 -0
- sshmirror-0.1.0.dist-info/entry_points.txt +2 -0
- sshmirror-0.1.0.dist-info/licenses/LICENSE +21 -0
- sshmirror-0.1.0.dist-info/top_level.txt +1 -0
sshmirror/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .config import SSHMirrorCallbacks, SSHMirrorConfig
|
|
2
|
+
from .core.exceptions import ErrorLocalVersion, UserAbort, VersionAlreadyExists
|
|
3
|
+
from .sshmirror import SSHMirror
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
'ErrorLocalVersion',
|
|
7
|
+
'SSHMirror',
|
|
8
|
+
'SSHMirrorCallbacks',
|
|
9
|
+
'SSHMirrorConfig',
|
|
10
|
+
'UserAbort',
|
|
11
|
+
'VersionAlreadyExists',
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
__version__ = '0.1.0'
|
sshmirror/__main__.py
ADDED
sshmirror/cli.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from .config import SSHMirrorCallbacks, SSHMirrorConfig
|
|
9
|
+
from .prompts import prompt_choice, prompt_confirm, prompt_discard_files
|
|
10
|
+
from .sshmirror import SSHMirror, STASH_METADATA_FILE, console
|
|
11
|
+
from .core.exceptions import UserAbort
|
|
12
|
+
from .core.utils import read_text_file
|
|
13
|
+
except ImportError:
|
|
14
|
+
from config import SSHMirrorCallbacks, SSHMirrorConfig
|
|
15
|
+
from prompts import prompt_choice, prompt_confirm, prompt_discard_files
|
|
16
|
+
from sshmirror import SSHMirror, STASH_METADATA_FILE, console
|
|
17
|
+
from core.exceptions import UserAbort
|
|
18
|
+
from core.utils import read_text_file
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_sshmirror_initialized() -> bool:
|
|
22
|
+
if os.path.isdir(SSHMirror.versions_directory):
|
|
23
|
+
return any(os.scandir(SSHMirror.versions_directory))
|
|
24
|
+
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _find_default_cli_path(path: str) -> str | None:
|
|
29
|
+
for candidate in (path, os.path.join('.sshmirror', path)):
|
|
30
|
+
if os.path.exists(candidate):
|
|
31
|
+
return candidate
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _has_stashed_changes() -> bool:
|
|
36
|
+
return os.path.exists(STASH_METADATA_FILE)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _create_default_config() -> None:
|
|
40
|
+
config_example_path = 'sshmirror.config.example.yml'
|
|
41
|
+
target_path = 'sshmirror.config.yml'
|
|
42
|
+
|
|
43
|
+
if os.path.exists(target_path):
|
|
44
|
+
console.print(f'{target_path} already exists', style='yellow')
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
if os.path.exists(config_example_path):
|
|
48
|
+
with open(target_path, 'w', encoding='utf-8') as f:
|
|
49
|
+
f.write(read_text_file(config_example_path))
|
|
50
|
+
else:
|
|
51
|
+
with open(target_path, 'w', encoding='utf-8') as f:
|
|
52
|
+
f.write(
|
|
53
|
+
"host: '127.0.0.1'\n"
|
|
54
|
+
"port: '22'\n"
|
|
55
|
+
"username: 'root'\n"
|
|
56
|
+
"localdir: '.'\n"
|
|
57
|
+
"remotedir: '/app'\n"
|
|
58
|
+
"author: user\n"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
console.print(f'Created {target_path}', style='green')
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _create_default_ignore() -> None:
|
|
65
|
+
target_path = 'sshmirror.ignore.txt'
|
|
66
|
+
if os.path.exists(target_path):
|
|
67
|
+
console.print(f'{target_path} already exists', style='yellow')
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
with open(target_path, 'w', encoding='utf-8') as f:
|
|
71
|
+
f.write('# One path or pattern per line\n')
|
|
72
|
+
|
|
73
|
+
console.print(f'Created {target_path}', style='green')
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _configure_interactive_args(args: argparse.Namespace) -> argparse.Namespace:
|
|
77
|
+
while True:
|
|
78
|
+
has_config = _find_default_cli_path('sshmirror.config.yml') is not None
|
|
79
|
+
has_ignore = _find_default_cli_path('sshmirror.ignore.txt') is not None
|
|
80
|
+
initialized = _is_sshmirror_initialized()
|
|
81
|
+
has_stash = _has_stashed_changes()
|
|
82
|
+
|
|
83
|
+
if has_stash:
|
|
84
|
+
console.print('Reminder: stashed changes are waiting to be restored', style='yellow')
|
|
85
|
+
|
|
86
|
+
if not has_config:
|
|
87
|
+
console.print('SSHMirror config is missing', style='yellow')
|
|
88
|
+
choices = []
|
|
89
|
+
elif initialized:
|
|
90
|
+
console.print('SSHMirror is initialized', style='green')
|
|
91
|
+
choices = [
|
|
92
|
+
'Pull & Push',
|
|
93
|
+
'Status',
|
|
94
|
+
'View current changes',
|
|
95
|
+
'View version changes',
|
|
96
|
+
'Pull only',
|
|
97
|
+
'Restore stashed changes' if has_stash else 'Stash changes',
|
|
98
|
+
'Force pull',
|
|
99
|
+
'Discard all local changes',
|
|
100
|
+
'Discard selected files',
|
|
101
|
+
'Downgrade remote version',
|
|
102
|
+
'Test connection',
|
|
103
|
+
]
|
|
104
|
+
else:
|
|
105
|
+
console.print('SSHMirror is not initialized yet', style='yellow')
|
|
106
|
+
choices = [
|
|
107
|
+
'Initialization',
|
|
108
|
+
'Status',
|
|
109
|
+
'View current changes',
|
|
110
|
+
'Restore stashed changes' if has_stash else None,
|
|
111
|
+
'Test connection',
|
|
112
|
+
]
|
|
113
|
+
choices = [choice for choice in choices if choice is not None]
|
|
114
|
+
|
|
115
|
+
if not has_config:
|
|
116
|
+
choices.insert(0, 'Create sshmirror.config.yml')
|
|
117
|
+
if not has_ignore:
|
|
118
|
+
insert_at = 1 if not has_config else 0
|
|
119
|
+
choices.insert(insert_at, 'Create sshmirror.ignore.txt')
|
|
120
|
+
|
|
121
|
+
choices.append('Exit')
|
|
122
|
+
action = prompt_choice('SSHMirror action:', choices)
|
|
123
|
+
|
|
124
|
+
if action == 'Create sshmirror.config.yml':
|
|
125
|
+
_create_default_config()
|
|
126
|
+
continue
|
|
127
|
+
if action == 'Create sshmirror.ignore.txt':
|
|
128
|
+
_create_default_ignore()
|
|
129
|
+
continue
|
|
130
|
+
if action == 'Exit':
|
|
131
|
+
raise UserAbort('Cancelled by user')
|
|
132
|
+
if action == 'Pull only':
|
|
133
|
+
args.pull = True
|
|
134
|
+
elif action == 'Status':
|
|
135
|
+
args.status = True
|
|
136
|
+
elif action == 'View current changes':
|
|
137
|
+
args.current_diff = True
|
|
138
|
+
elif action == 'View version changes':
|
|
139
|
+
args.version_diff = True
|
|
140
|
+
elif action == 'Stash changes':
|
|
141
|
+
args.stash_changes = True
|
|
142
|
+
elif action == 'Restore stashed changes':
|
|
143
|
+
args.restore_stash = True
|
|
144
|
+
elif action == 'Force pull':
|
|
145
|
+
args.force_pull = True
|
|
146
|
+
elif action == 'Discard all local changes':
|
|
147
|
+
args.discard = True
|
|
148
|
+
elif action == 'Discard selected files':
|
|
149
|
+
args.discard_files = prompt_discard_files()
|
|
150
|
+
elif action == 'Downgrade remote version':
|
|
151
|
+
args.downgrade = True
|
|
152
|
+
elif action == 'Test connection':
|
|
153
|
+
args.test_connection = True
|
|
154
|
+
return args
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
158
|
+
parser = argparse.ArgumentParser('SSH directory synchronization')
|
|
159
|
+
parser.add_argument('-p', '--pull', action='store_true', help='Only pull from remote')
|
|
160
|
+
parser.add_argument('--status', action='store_true', help='Show local and remote synchronization status')
|
|
161
|
+
parser.add_argument('--current-diff', action='store_true', help='Interactively inspect current local versus remote file differences')
|
|
162
|
+
parser.add_argument('--version-diff', action='store_true', help='Interactively inspect file changes between local versions')
|
|
163
|
+
parser.add_argument('--stash-changes', action='store_true', help='Stash local changes and sync from remote')
|
|
164
|
+
parser.add_argument('--restore-stash', action='store_true', help='Restore previously stashed local changes')
|
|
165
|
+
parser.add_argument('--force-pull', action='store_true', help='Force pull from remote. Overwrite local files')
|
|
166
|
+
parser.add_argument('--discard', action='store_true', help='Discard all local changes')
|
|
167
|
+
parser.add_argument('--downgrade', action='store_true', help='Downgrade remote version')
|
|
168
|
+
parser.add_argument('--discard-files', nargs='+', help='Files to discard (will be load from remote)')
|
|
169
|
+
parser.add_argument('--test-connection', action='store_true', help='Test SSH access to the remote host and configured Docker host')
|
|
170
|
+
return parser
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _create_mirror_from_args(args: argparse.Namespace) -> SSHMirror:
|
|
174
|
+
args.config = _find_default_cli_path('sshmirror.config.yml')
|
|
175
|
+
args.ignore = _find_default_cli_path('sshmirror.ignore.txt')
|
|
176
|
+
|
|
177
|
+
if args.config and os.path.exists(args.config):
|
|
178
|
+
callbacks = SSHMirrorCallbacks(confirm=prompt_confirm, choose=prompt_choice)
|
|
179
|
+
return SSHMirror(
|
|
180
|
+
config=SSHMirrorConfig.from_file(
|
|
181
|
+
args.config,
|
|
182
|
+
ignore=args.ignore,
|
|
183
|
+
pull_only=args.pull,
|
|
184
|
+
downgrade=args.downgrade,
|
|
185
|
+
discard_files=args.discard_files,
|
|
186
|
+
),
|
|
187
|
+
callbacks=callbacks,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
raise FileNotFoundError('Config not found. Expected sshmirror.config.yml or .sshmirror/sshmirror.config.yml')
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def _show_current_changes_cli(mirror: SSHMirror) -> None:
|
|
194
|
+
console.print('Connect to remote...', style='yellow')
|
|
195
|
+
file_actions = await mirror.list_current_changes()
|
|
196
|
+
if len(file_actions) == 0:
|
|
197
|
+
console.print('No current file differences between local and remote', style='yellow')
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
while True:
|
|
201
|
+
choice_map = {f'{item.action} {item.path}': item for item in file_actions}
|
|
202
|
+
choice = prompt_choice('Choose current file change to inspect', list(choice_map.keys()) + ['Back'])
|
|
203
|
+
if choice == 'Back':
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
detail = await mirror.get_current_change_detail(choice_map[choice].path)
|
|
207
|
+
mirror.render_diff_detail(detail)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def _show_version_changes_cli(mirror: SSHMirror) -> None:
|
|
211
|
+
console.print('Connect to remote...', style='yellow')
|
|
212
|
+
versions = await mirror.list_remote_versions()
|
|
213
|
+
if len(versions) < 2:
|
|
214
|
+
console.print('Need at least two remote versions to inspect changes', style='yellow')
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
version_labels = {version.label: version for version in versions}
|
|
218
|
+
base_label = prompt_choice('Choose base version', list(version_labels.keys()))
|
|
219
|
+
base_version = version_labels[base_label]
|
|
220
|
+
base_index = versions.index(base_version)
|
|
221
|
+
later_versions = versions[base_index + 1:]
|
|
222
|
+
if len(later_versions) == 0:
|
|
223
|
+
console.print('No later versions available for comparison', style='yellow')
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
target_labels = {version.label: version for version in later_versions}
|
|
227
|
+
target_label = prompt_choice('Choose target version', list(target_labels.keys()))
|
|
228
|
+
target_version = target_labels[target_label]
|
|
229
|
+
|
|
230
|
+
file_actions = await mirror.list_version_changes(base_version.uid, target_version.uid)
|
|
231
|
+
if len(file_actions) == 0:
|
|
232
|
+
console.print('No file changes between selected versions', style='yellow')
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
while True:
|
|
236
|
+
choice_map = {f'{item.action} {item.path}': item for item in file_actions}
|
|
237
|
+
choice = prompt_choice('Choose file change to inspect', list(choice_map.keys()) + ['Back'])
|
|
238
|
+
if choice == 'Back':
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
detail = await mirror.get_version_change_detail(base_version.uid, target_version.uid, choice_map[choice].path)
|
|
242
|
+
mirror.render_diff_detail(detail)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def main(argv: list[str] | None = None) -> int:
|
|
246
|
+
def signal_term_handler(*_args):
|
|
247
|
+
console.print('\nCancel by user', style='red', end='')
|
|
248
|
+
raise SystemExit(1)
|
|
249
|
+
|
|
250
|
+
signal.signal(signal.SIGINT, signal_term_handler)
|
|
251
|
+
|
|
252
|
+
parser = build_parser()
|
|
253
|
+
args = parser.parse_args(argv)
|
|
254
|
+
is_interactive_launch = argv is None and len(sys.argv) == 1
|
|
255
|
+
if is_interactive_launch:
|
|
256
|
+
args = _configure_interactive_args(args)
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
mirror = _create_mirror_from_args(args)
|
|
260
|
+
except FileNotFoundError as exc:
|
|
261
|
+
console.print(str(exc), style='red')
|
|
262
|
+
return 1
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
if _has_stashed_changes() and not is_interactive_launch:
|
|
266
|
+
console.print('Reminder: stashed changes are available. Use restore stash to bring them back.', style='yellow')
|
|
267
|
+
if args.test_connection:
|
|
268
|
+
asyncio.run(mirror.test_connection())
|
|
269
|
+
elif args.status:
|
|
270
|
+
asyncio.run(mirror.status())
|
|
271
|
+
elif args.current_diff:
|
|
272
|
+
asyncio.run(_show_current_changes_cli(mirror))
|
|
273
|
+
elif args.version_diff:
|
|
274
|
+
asyncio.run(_show_version_changes_cli(mirror))
|
|
275
|
+
elif args.restore_stash:
|
|
276
|
+
asyncio.run(mirror.restore_stash())
|
|
277
|
+
elif args.stash_changes:
|
|
278
|
+
asyncio.run(mirror.stash_changes())
|
|
279
|
+
elif args.force_pull or args.discard:
|
|
280
|
+
asyncio.run(mirror.force_pull())
|
|
281
|
+
else:
|
|
282
|
+
asyncio.run(mirror.run())
|
|
283
|
+
return 0
|
|
284
|
+
except UserAbort as exc:
|
|
285
|
+
if str(exc):
|
|
286
|
+
console.print(str(exc), style='yellow')
|
|
287
|
+
return 0
|
|
288
|
+
except Exception as exc:
|
|
289
|
+
console.print(str(exc), style='red')
|
|
290
|
+
return 1
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
if __name__ == '__main__':
|
|
294
|
+
raise SystemExit(main())
|
sshmirror/config.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from .core.schemas import CmdConfig, Command
|
|
10
|
+
from .core.utils import read_text_file
|
|
11
|
+
except ImportError:
|
|
12
|
+
from core.schemas import CmdConfig, Command
|
|
13
|
+
from core.utils import read_text_file
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class SSHMirrorCallbacks:
|
|
18
|
+
confirm: typing.Callable[[str], bool] | None = None
|
|
19
|
+
choose: typing.Callable[[str, list[str]], str] | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class SSHMirrorConfig:
|
|
24
|
+
host: str | None = None
|
|
25
|
+
port: int = 22
|
|
26
|
+
username: str | None = None
|
|
27
|
+
password: str | None = None
|
|
28
|
+
private_key: str | None = None
|
|
29
|
+
private_key_passphrase: str | None = None
|
|
30
|
+
localdir: str | None = None
|
|
31
|
+
remotedir: str | None = None
|
|
32
|
+
ignore: str | None = None
|
|
33
|
+
restart_container: dict[str, typing.Any] | None = None
|
|
34
|
+
aliases: dict[str, typing.Any] = field(default_factory=dict)
|
|
35
|
+
watch: bool = False
|
|
36
|
+
no_sync: bool = False
|
|
37
|
+
author: str | None = None
|
|
38
|
+
pull_only: bool = False
|
|
39
|
+
downgrade: bool = False
|
|
40
|
+
discard_files: list[str] | None = None
|
|
41
|
+
commands: CmdConfig = field(default_factory=lambda: CmdConfig(after_pull=[], before_pull=[], after_push=[], before_push=[]))
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def parse_cmd_config(cmd_config: dict[str, typing.Any]) -> CmdConfig:
|
|
45
|
+
def parse_command_by_type(runtype: str) -> list[Command]:
|
|
46
|
+
return [Command(**item) for item in cmd_config.get(runtype, [])]
|
|
47
|
+
|
|
48
|
+
data = {
|
|
49
|
+
runtype: parse_command_by_type(runtype)
|
|
50
|
+
for runtype in ['before_pull', 'after_pull', 'before_push', 'after_push']
|
|
51
|
+
}
|
|
52
|
+
return CmdConfig(**data)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_file(
|
|
56
|
+
cls,
|
|
57
|
+
path: str,
|
|
58
|
+
*,
|
|
59
|
+
password: str | None = None,
|
|
60
|
+
private_key: str | None = None,
|
|
61
|
+
private_key_passphrase: str | None = None,
|
|
62
|
+
ignore: str | None = None,
|
|
63
|
+
author: str | None = None,
|
|
64
|
+
pull_only: bool = False,
|
|
65
|
+
downgrade: bool = False,
|
|
66
|
+
discard_files: list[str] | None = None,
|
|
67
|
+
aliases: dict[str, typing.Any] | None = None,
|
|
68
|
+
watch: bool = False,
|
|
69
|
+
no_sync: bool = False,
|
|
70
|
+
) -> SSHMirrorConfig:
|
|
71
|
+
data: dict[str, typing.Any] = yaml.load(read_text_file(path), yaml.CLoader)
|
|
72
|
+
return cls(
|
|
73
|
+
host=data['host'],
|
|
74
|
+
port=int(data['port']),
|
|
75
|
+
username=data['username'],
|
|
76
|
+
password=data.get('password', password),
|
|
77
|
+
private_key=data.get('private_key', data.get('ssh_key', private_key)),
|
|
78
|
+
private_key_passphrase=data.get(
|
|
79
|
+
'private_key_passphrase',
|
|
80
|
+
data.get('ssh_key_passphrase', private_key_passphrase),
|
|
81
|
+
),
|
|
82
|
+
localdir=data['localdir'],
|
|
83
|
+
remotedir=data['remotedir'],
|
|
84
|
+
ignore=data.get('ignore', ignore),
|
|
85
|
+
restart_container=data.get('restart_container'),
|
|
86
|
+
aliases=aliases or {},
|
|
87
|
+
watch=watch,
|
|
88
|
+
no_sync=no_sync,
|
|
89
|
+
author=data.get('author', author),
|
|
90
|
+
pull_only=pull_only,
|
|
91
|
+
downgrade=downgrade,
|
|
92
|
+
discard_files=discard_files,
|
|
93
|
+
commands=cls.parse_cmd_config(data.get('commands', {})),
|
|
94
|
+
)
|
|
File without changes
|