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 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
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+
4
+ raise SystemExit(main())
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
@@ -0,0 +1,10 @@
1
+ class ErrorLocalVersion(Exception):
2
+ pass
3
+
4
+
5
+ class UserAbort(Exception):
6
+ pass
7
+
8
+
9
+ class VersionAlreadyExists(Exception):
10
+ pass