mtr-cli 0.2.0__py3-none-any.whl → 0.3.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.
- mtr/cli.py +53 -6
- mtr/config.py +14 -0
- mtr/sync.py +157 -25
- {mtr_cli-0.2.0.dist-info → mtr_cli-0.3.0.dist-info}/METADATA +16 -6
- mtr_cli-0.3.0.dist-info/RECORD +11 -0
- mtr_cli-0.2.0.dist-info/RECORD +0 -11
- {mtr_cli-0.2.0.dist-info → mtr_cli-0.3.0.dist-info}/WHEEL +0 -0
- {mtr_cli-0.2.0.dist-info → mtr_cli-0.3.0.dist-info}/entry_points.txt +0 -0
- {mtr_cli-0.2.0.dist-info → mtr_cli-0.3.0.dist-info}/licenses/LICENSE +0 -0
mtr/cli.py
CHANGED
|
@@ -15,6 +15,11 @@ defaults:
|
|
|
15
15
|
# 选项: "rsync" (推荐), "sftp"
|
|
16
16
|
sync: "rsync"
|
|
17
17
|
|
|
18
|
+
# 是否尊重 .gitignore 文件(仅 rsync 模式支持)
|
|
19
|
+
# 设置为 true 时,rsync 会自动读取项目根目录的 .gitignore 并排除匹配的文件
|
|
20
|
+
# SFTP 模式不支持此选项,如启用会报错
|
|
21
|
+
respect_gitignore: true
|
|
22
|
+
|
|
18
23
|
exclude:
|
|
19
24
|
- ".git/"
|
|
20
25
|
- "__pycache__/"
|
|
@@ -186,6 +191,9 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
186
191
|
# Resolve exclude
|
|
187
192
|
exclude = config.global_defaults.get("exclude", []) + server_conf.get("exclude", [])
|
|
188
193
|
|
|
194
|
+
# Get respect_gitignore setting
|
|
195
|
+
respect_gitignore = config.get_respect_gitignore()
|
|
196
|
+
|
|
189
197
|
# Determine engine
|
|
190
198
|
engine = server_conf.get("sync", config.global_defaults.get("sync", "rsync"))
|
|
191
199
|
|
|
@@ -199,6 +207,7 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
199
207
|
password=password,
|
|
200
208
|
port=port,
|
|
201
209
|
exclude=exclude,
|
|
210
|
+
respect_gitignore=respect_gitignore,
|
|
202
211
|
)
|
|
203
212
|
elif engine == "sftp":
|
|
204
213
|
syncer = SftpSyncer(
|
|
@@ -210,6 +219,7 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
210
219
|
password=password,
|
|
211
220
|
port=port,
|
|
212
221
|
exclude=exclude,
|
|
222
|
+
respect_gitignore=respect_gitignore,
|
|
213
223
|
)
|
|
214
224
|
else:
|
|
215
225
|
click.secho(
|
|
@@ -225,11 +235,28 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
225
235
|
logger.info(f"[DryRun] Would sync {local_dir} -> {remote_dir}", module="mtr.sync")
|
|
226
236
|
else:
|
|
227
237
|
if is_interactive and console:
|
|
228
|
-
|
|
229
|
-
|
|
238
|
+
# TTY mode: single line real-time update using Rich Live
|
|
239
|
+
from rich.live import Live
|
|
240
|
+
from rich.text import Text
|
|
241
|
+
|
|
242
|
+
with Live(Text("Starting sync...", style="blue"), refresh_per_second=10) as live:
|
|
243
|
+
|
|
244
|
+
def show_sync_progress(filename):
|
|
245
|
+
# Get relative path for cleaner display
|
|
246
|
+
rel_path = os.path.relpath(filename, local_dir)
|
|
247
|
+
live.update(Text(f"Syncing: {rel_path}", style="blue"))
|
|
248
|
+
|
|
249
|
+
syncer.sync(show_progress=True, progress_callback=show_sync_progress)
|
|
250
|
+
live.update(Text("Sync completed!", style="green"))
|
|
230
251
|
else:
|
|
252
|
+
# no_tty mode: print each file on new line
|
|
253
|
+
def show_sync_progress(filename):
|
|
254
|
+
rel_path = os.path.relpath(filename, local_dir)
|
|
255
|
+
click.echo(f"Syncing: {rel_path}")
|
|
256
|
+
|
|
231
257
|
click.secho("Syncing code...", fg="blue")
|
|
232
|
-
syncer.sync()
|
|
258
|
+
syncer.sync(show_progress=True, progress_callback=show_sync_progress)
|
|
259
|
+
click.secho("Sync completed!", fg="green")
|
|
233
260
|
logger.info(f"Sync completed: {local_dir} -> {remote_dir}", module="mtr.sync")
|
|
234
261
|
except SyncError as e:
|
|
235
262
|
logger.error(f"Sync failed: {e}", module="mtr.sync")
|
|
@@ -260,6 +287,9 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
260
287
|
# Resolve exclude
|
|
261
288
|
exclude = config.global_defaults.get("exclude", []) + server_conf.get("exclude", [])
|
|
262
289
|
|
|
290
|
+
# Get respect_gitignore setting
|
|
291
|
+
respect_gitignore = config.get_respect_gitignore()
|
|
292
|
+
|
|
263
293
|
# Determine engine
|
|
264
294
|
engine = server_conf.get("sync", config.global_defaults.get("sync", "rsync"))
|
|
265
295
|
|
|
@@ -273,6 +303,7 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
273
303
|
password=password,
|
|
274
304
|
port=port,
|
|
275
305
|
exclude=exclude,
|
|
306
|
+
respect_gitignore=respect_gitignore,
|
|
276
307
|
)
|
|
277
308
|
elif engine == "sftp":
|
|
278
309
|
syncer = SftpSyncer(
|
|
@@ -284,6 +315,7 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
284
315
|
password=password,
|
|
285
316
|
port=port,
|
|
286
317
|
exclude=exclude,
|
|
318
|
+
respect_gitignore=respect_gitignore,
|
|
287
319
|
)
|
|
288
320
|
else:
|
|
289
321
|
click.secho(
|
|
@@ -298,12 +330,27 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
298
330
|
logger.info(f"[DryRun] Would download {remote_get_path} -> {local_dest}", module="mtr.sync")
|
|
299
331
|
else:
|
|
300
332
|
if is_interactive and console:
|
|
301
|
-
|
|
302
|
-
|
|
333
|
+
# TTY mode: single line real-time update using Rich Live
|
|
334
|
+
from rich.live import Live
|
|
335
|
+
from rich.text import Text
|
|
336
|
+
|
|
337
|
+
with Live(Text("Starting download...", style="blue"), refresh_per_second=10) as live:
|
|
338
|
+
|
|
339
|
+
def show_download_progress(filename):
|
|
340
|
+
live.update(Text(f"Downloading: {filename}", style="blue"))
|
|
341
|
+
|
|
342
|
+
syncer.download(
|
|
343
|
+
remote_get_path, local_dest, show_progress=True, progress_callback=show_download_progress
|
|
344
|
+
)
|
|
345
|
+
live.update(Text("Download completed!", style="green"))
|
|
303
346
|
console.print(f"✅ [green]Downloaded:[/green] {remote_get_path} -> {local_dest}")
|
|
304
347
|
else:
|
|
348
|
+
# no_tty mode: print each file on new line
|
|
349
|
+
def show_download_progress(filename):
|
|
350
|
+
click.echo(f"Downloading: {filename}")
|
|
351
|
+
|
|
305
352
|
click.secho(f"Downloading {remote_get_path}...", fg="blue")
|
|
306
|
-
syncer.download(remote_get_path, local_dest)
|
|
353
|
+
syncer.download(remote_get_path, local_dest, show_progress=True, progress_callback=show_download_progress)
|
|
307
354
|
click.secho(f"Download completed: {local_dest}", fg="green")
|
|
308
355
|
logger.info(f"Download completed: {remote_get_path} -> {local_dest}", module="mtr.sync")
|
|
309
356
|
except SyncError as e:
|
mtr/config.py
CHANGED
|
@@ -19,6 +19,20 @@ class Config:
|
|
|
19
19
|
server_config: Dict[str, Any]
|
|
20
20
|
global_defaults: Dict[str, Any]
|
|
21
21
|
|
|
22
|
+
def get_respect_gitignore(self) -> bool:
|
|
23
|
+
"""Get respect_gitignore setting, default True.
|
|
24
|
+
|
|
25
|
+
Priority: server config > global defaults > True (default)
|
|
26
|
+
"""
|
|
27
|
+
# Check server config first
|
|
28
|
+
if "respect_gitignore" in self.server_config:
|
|
29
|
+
return self.server_config["respect_gitignore"]
|
|
30
|
+
# Then check global defaults
|
|
31
|
+
if "respect_gitignore" in self.global_defaults:
|
|
32
|
+
return self.global_defaults["respect_gitignore"]
|
|
33
|
+
# Default to True
|
|
34
|
+
return True
|
|
35
|
+
|
|
22
36
|
|
|
23
37
|
class ConfigLoader:
|
|
24
38
|
def __init__(self, config_path: Optional[str] = None):
|
mtr/sync.py
CHANGED
|
@@ -14,13 +14,14 @@ class SyncError(Exception):
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
class BaseSyncer(ABC):
|
|
17
|
-
def __init__(self, local_dir: str, remote_dir: str, exclude: List[str]):
|
|
17
|
+
def __init__(self, local_dir: str, remote_dir: str, exclude: List[str], respect_gitignore: bool = True):
|
|
18
18
|
self.local_dir = local_dir
|
|
19
19
|
self.remote_dir = remote_dir
|
|
20
20
|
self.exclude = exclude
|
|
21
|
+
self.respect_gitignore = respect_gitignore
|
|
21
22
|
|
|
22
23
|
@abstractmethod
|
|
23
|
-
def sync(self):
|
|
24
|
+
def sync(self, show_progress: bool = False, progress_callback=None):
|
|
24
25
|
pass
|
|
25
26
|
|
|
26
27
|
|
|
@@ -35,8 +36,9 @@ class SftpSyncer(BaseSyncer):
|
|
|
35
36
|
password: Optional[str] = None,
|
|
36
37
|
port: int = 22,
|
|
37
38
|
exclude: List[str] = None,
|
|
39
|
+
respect_gitignore: bool = True,
|
|
38
40
|
):
|
|
39
|
-
super().__init__(local_dir, remote_dir, exclude or [])
|
|
41
|
+
super().__init__(local_dir, remote_dir, exclude or [], respect_gitignore)
|
|
40
42
|
self.host = host
|
|
41
43
|
self.user = user
|
|
42
44
|
self.key_filename = key_filename
|
|
@@ -45,6 +47,13 @@ class SftpSyncer(BaseSyncer):
|
|
|
45
47
|
self.transport = None
|
|
46
48
|
self.sftp = None
|
|
47
49
|
|
|
50
|
+
# SFTP mode does not support respect_gitignore
|
|
51
|
+
if respect_gitignore:
|
|
52
|
+
raise SyncError(
|
|
53
|
+
"respect_gitignore is not supported in SFTP mode. "
|
|
54
|
+
"Please set respect_gitignore to false in your config or use rsync mode."
|
|
55
|
+
)
|
|
56
|
+
|
|
48
57
|
def _should_ignore(self, filename: str) -> bool:
|
|
49
58
|
for pattern in self.exclude:
|
|
50
59
|
# Handle directory exclusion (basic)
|
|
@@ -110,7 +119,7 @@ class SftpSyncer(BaseSyncer):
|
|
|
110
119
|
except OSError:
|
|
111
120
|
pass # Already exists maybe
|
|
112
121
|
|
|
113
|
-
def sync(self):
|
|
122
|
+
def sync(self, show_progress: bool = False, progress_callback=None):
|
|
114
123
|
if not self.sftp:
|
|
115
124
|
self._connect()
|
|
116
125
|
|
|
@@ -156,7 +165,9 @@ class SftpSyncer(BaseSyncer):
|
|
|
156
165
|
pass # Does not exist, must upload
|
|
157
166
|
|
|
158
167
|
if should_upload:
|
|
159
|
-
#
|
|
168
|
+
# Call progress callback if provided
|
|
169
|
+
if show_progress and progress_callback:
|
|
170
|
+
progress_callback(local_file)
|
|
160
171
|
self.sftp.put(local_file, remote_file)
|
|
161
172
|
# Preserve permissions
|
|
162
173
|
mode = os.stat(local_file).st_mode
|
|
@@ -167,7 +178,7 @@ class SftpSyncer(BaseSyncer):
|
|
|
167
178
|
if self.transport:
|
|
168
179
|
self.transport.close()
|
|
169
180
|
|
|
170
|
-
def download(self, remote_path: str, local_path: str):
|
|
181
|
+
def download(self, remote_path: str, local_path: str, show_progress: bool = False, progress_callback=None):
|
|
171
182
|
"""Download file or directory from remote to local."""
|
|
172
183
|
if not self.sftp:
|
|
173
184
|
self._connect()
|
|
@@ -185,9 +196,13 @@ class SftpSyncer(BaseSyncer):
|
|
|
185
196
|
is_dir = stat.S_ISDIR(remote_stat.st_mode)
|
|
186
197
|
|
|
187
198
|
if is_dir:
|
|
188
|
-
self._download_dir(
|
|
199
|
+
self._download_dir(
|
|
200
|
+
remote_path, local_path, show_progress=show_progress, progress_callback=progress_callback
|
|
201
|
+
)
|
|
189
202
|
else:
|
|
190
|
-
self._download_file(
|
|
203
|
+
self._download_file(
|
|
204
|
+
remote_path, local_path, show_progress=show_progress, progress_callback=progress_callback
|
|
205
|
+
)
|
|
191
206
|
except FileNotFoundError:
|
|
192
207
|
raise SyncError(f"Remote path not found: {remote_path}")
|
|
193
208
|
except Exception as e:
|
|
@@ -213,17 +228,21 @@ class SftpSyncer(BaseSyncer):
|
|
|
213
228
|
except FileNotFoundError:
|
|
214
229
|
return True # Local file doesn't exist, must download
|
|
215
230
|
|
|
216
|
-
def _download_file(self, remote_file: str, local_file: str):
|
|
231
|
+
def _download_file(self, remote_file: str, local_file: str, show_progress: bool = False, progress_callback=None):
|
|
217
232
|
"""Download a single file with incremental check."""
|
|
218
233
|
if not self._should_download_file(remote_file, local_file):
|
|
219
234
|
return # No need to download
|
|
220
235
|
|
|
236
|
+
# Call progress callback if provided
|
|
237
|
+
if show_progress and progress_callback:
|
|
238
|
+
progress_callback(remote_file)
|
|
239
|
+
|
|
221
240
|
self.sftp.get(remote_file, local_file)
|
|
222
241
|
# Preserve permissions
|
|
223
242
|
remote_stat = self.sftp.stat(remote_file)
|
|
224
243
|
os.chmod(local_file, remote_stat.st_mode)
|
|
225
244
|
|
|
226
|
-
def _download_dir(self, remote_dir: str, local_dir: str):
|
|
245
|
+
def _download_dir(self, remote_dir: str, local_dir: str, show_progress: bool = False, progress_callback=None):
|
|
227
246
|
"""Recursively download a directory."""
|
|
228
247
|
if not os.path.exists(local_dir):
|
|
229
248
|
os.makedirs(local_dir, exist_ok=True)
|
|
@@ -238,9 +257,9 @@ class SftpSyncer(BaseSyncer):
|
|
|
238
257
|
import stat
|
|
239
258
|
|
|
240
259
|
if stat.S_ISDIR(entry.st_mode):
|
|
241
|
-
self._download_dir(remote_path, local_path)
|
|
260
|
+
self._download_dir(remote_path, local_path, show_progress=show_progress, progress_callback=progress_callback)
|
|
242
261
|
else:
|
|
243
|
-
self._download_file(remote_path, local_path)
|
|
262
|
+
self._download_file(remote_path, local_path, show_progress=show_progress, progress_callback=progress_callback)
|
|
244
263
|
|
|
245
264
|
|
|
246
265
|
class RsyncSyncer(BaseSyncer):
|
|
@@ -254,8 +273,9 @@ class RsyncSyncer(BaseSyncer):
|
|
|
254
273
|
password: Optional[str] = None,
|
|
255
274
|
port: int = 22,
|
|
256
275
|
exclude: List[str] = None,
|
|
276
|
+
respect_gitignore: bool = True,
|
|
257
277
|
):
|
|
258
|
-
super().__init__(local_dir, remote_dir, exclude or [])
|
|
278
|
+
super().__init__(local_dir, remote_dir, exclude or [], respect_gitignore)
|
|
259
279
|
self.host = host
|
|
260
280
|
self.user = user
|
|
261
281
|
self.key_filename = key_filename
|
|
@@ -269,9 +289,20 @@ class RsyncSyncer(BaseSyncer):
|
|
|
269
289
|
opts += f" -i {self.key_filename}"
|
|
270
290
|
return opts
|
|
271
291
|
|
|
272
|
-
def _build_rsync_base(self) -> List[str]:
|
|
292
|
+
def _build_rsync_base(self, show_progress: bool = False) -> List[str]:
|
|
273
293
|
"""Build rsync base command with common options."""
|
|
274
|
-
|
|
294
|
+
if show_progress:
|
|
295
|
+
# In progress mode, use -av --info=NAME to show filenames only
|
|
296
|
+
cmd = ["rsync", "-av", "--info=NAME"]
|
|
297
|
+
else:
|
|
298
|
+
# Silent mode
|
|
299
|
+
cmd = ["rsync", "-azq"]
|
|
300
|
+
|
|
301
|
+
# Add gitignore filter if enabled
|
|
302
|
+
if self.respect_gitignore:
|
|
303
|
+
gitignore_path = os.path.join(self.local_dir, ".gitignore")
|
|
304
|
+
if os.path.exists(gitignore_path):
|
|
305
|
+
cmd.append("--filter=:- .gitignore")
|
|
275
306
|
|
|
276
307
|
# Add excludes
|
|
277
308
|
for item in self.exclude:
|
|
@@ -294,35 +325,115 @@ class RsyncSyncer(BaseSyncer):
|
|
|
294
325
|
if not shutil.which("sshpass"):
|
|
295
326
|
raise SyncError("Rsync with password requires 'sshpass'. Please install it or use SSH Key.")
|
|
296
327
|
|
|
297
|
-
def
|
|
328
|
+
def _check_rsync_version(self) -> tuple:
|
|
329
|
+
"""Check local rsync version and return (major, minor, patch) tuple.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
Tuple of (major, minor, patch) version numbers
|
|
333
|
+
Raises:
|
|
334
|
+
SyncError: If rsync is not installed or version cannot be parsed
|
|
335
|
+
"""
|
|
336
|
+
try:
|
|
337
|
+
result = subprocess.run(["rsync", "--version"], capture_output=True, text=True, timeout=5)
|
|
338
|
+
if result.returncode != 0:
|
|
339
|
+
raise SyncError("Failed to check rsync version. Is rsync installed?")
|
|
340
|
+
|
|
341
|
+
# Parse version from first line, e.g., "rsync version 3.2.5 protocol version 31"
|
|
342
|
+
first_line = result.stdout.split("\n")[0]
|
|
343
|
+
import re
|
|
344
|
+
|
|
345
|
+
match = re.search(r"version\s+(\d+)\.(\d+)\.(\d+)", first_line)
|
|
346
|
+
if not match:
|
|
347
|
+
# Try alternative format: "rsync version 2.6.9 compatible"
|
|
348
|
+
match = re.search(r"version\s+(\d+)\.(\d+)\.(\d+)", first_line)
|
|
349
|
+
if not match:
|
|
350
|
+
raise SyncError(f"Cannot parse rsync version from: {first_line}")
|
|
351
|
+
|
|
352
|
+
major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3))
|
|
353
|
+
return (major, minor, patch)
|
|
354
|
+
except FileNotFoundError:
|
|
355
|
+
raise SyncError("rsync not found. Please install rsync.")
|
|
356
|
+
except subprocess.TimeoutExpired:
|
|
357
|
+
raise SyncError("Timeout while checking rsync version.")
|
|
358
|
+
except Exception as e:
|
|
359
|
+
raise SyncError(f"Failed to check rsync version: {e}")
|
|
360
|
+
|
|
361
|
+
def _is_rsync_version_supported(self, min_version: tuple = (3, 1, 0)) -> bool:
|
|
362
|
+
"""Check if local rsync version meets minimum requirement.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
min_version: Minimum required version as (major, minor, patch) tuple
|
|
366
|
+
Returns:
|
|
367
|
+
True if version is supported, False otherwise
|
|
368
|
+
"""
|
|
369
|
+
try:
|
|
370
|
+
current_version = self._check_rsync_version()
|
|
371
|
+
return current_version >= min_version
|
|
372
|
+
except SyncError:
|
|
373
|
+
return False
|
|
374
|
+
|
|
375
|
+
def _build_rsync_command(self, show_progress: bool = False) -> List[str]:
|
|
298
376
|
"""Build rsync command for uploading (local -> remote)."""
|
|
299
377
|
# Ensure local dir ends with / to sync contents, not the dir itself
|
|
300
378
|
src = self.local_dir if self.local_dir.endswith("/") else f"{self.local_dir}/"
|
|
301
379
|
dest = f"{self.user}@{self.host}:{shlex.quote(self.remote_dir)}"
|
|
302
380
|
|
|
303
|
-
cmd = self._build_rsync_base()
|
|
381
|
+
cmd = self._build_rsync_base(show_progress=show_progress)
|
|
304
382
|
cmd.extend([src, dest])
|
|
305
383
|
|
|
306
384
|
return self._wrap_with_sshpass(cmd)
|
|
307
385
|
|
|
308
|
-
def _build_rsync_download_command(self, remote_path: str, local_path: str) -> List[str]:
|
|
386
|
+
def _build_rsync_download_command(self, remote_path: str, local_path: str, show_progress: bool = False) -> List[str]:
|
|
309
387
|
"""Build rsync command for downloading (remote -> local)."""
|
|
310
388
|
src = f"{self.user}@{self.host}:{shlex.quote(remote_path)}"
|
|
311
389
|
|
|
312
|
-
cmd = self._build_rsync_base()
|
|
390
|
+
cmd = self._build_rsync_base(show_progress=show_progress)
|
|
313
391
|
cmd.extend([src, local_path])
|
|
314
392
|
|
|
315
393
|
return self._wrap_with_sshpass(cmd)
|
|
316
394
|
|
|
317
|
-
def sync(self):
|
|
395
|
+
def sync(self, show_progress: bool = False, progress_callback=None):
|
|
318
396
|
self._check_sshpass()
|
|
319
|
-
|
|
397
|
+
|
|
398
|
+
# Check rsync version if progress mode is requested
|
|
399
|
+
if show_progress and progress_callback:
|
|
400
|
+
if not self._is_rsync_version_supported():
|
|
401
|
+
version = self._check_rsync_version()
|
|
402
|
+
raise SyncError(
|
|
403
|
+
f"rsync version {version[0]}.{version[1]}.{version[2]} is too old. "
|
|
404
|
+
f"Progress display requires rsync >= 3.1.0. "
|
|
405
|
+
f"Please upgrade rsync or use --no-tty mode."
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
cmd = self._build_rsync_command(show_progress=show_progress)
|
|
409
|
+
|
|
320
410
|
try:
|
|
321
|
-
|
|
411
|
+
if show_progress and progress_callback:
|
|
412
|
+
# Run with real-time output parsing for progress display
|
|
413
|
+
process = subprocess.Popen(
|
|
414
|
+
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# Parse rsync output line by line
|
|
418
|
+
for line in process.stdout:
|
|
419
|
+
line = line.strip()
|
|
420
|
+
# Skip empty lines and summary lines
|
|
421
|
+
if line and not line.startswith("sent") and not line.startswith("total"):
|
|
422
|
+
# Extract filename from rsync output
|
|
423
|
+
# Rsync --info=NAME outputs filenames directly
|
|
424
|
+
if not line.startswith("receiving") and not line.startswith("building"):
|
|
425
|
+
progress_callback(line)
|
|
426
|
+
|
|
427
|
+
process.wait()
|
|
428
|
+
if process.returncode != 0:
|
|
429
|
+
raise SyncError(f"Rsync failed with exit code {process.returncode}")
|
|
430
|
+
else:
|
|
431
|
+
# Silent mode - use subprocess.run
|
|
432
|
+
subprocess.run(cmd, check=True)
|
|
322
433
|
except subprocess.CalledProcessError as e:
|
|
323
434
|
raise SyncError(f"Rsync failed with exit code {e.returncode}")
|
|
324
435
|
|
|
325
|
-
def download(self, remote_path: str, local_path: str):
|
|
436
|
+
def download(self, remote_path: str, local_path: str, show_progress: bool = False, progress_callback=None):
|
|
326
437
|
"""Download file or directory from remote to local."""
|
|
327
438
|
self._check_sshpass()
|
|
328
439
|
|
|
@@ -331,8 +442,29 @@ class RsyncSyncer(BaseSyncer):
|
|
|
331
442
|
if local_dir and not os.path.exists(local_dir):
|
|
332
443
|
os.makedirs(local_dir, exist_ok=True)
|
|
333
444
|
|
|
334
|
-
cmd = self._build_rsync_download_command(remote_path, local_path)
|
|
445
|
+
cmd = self._build_rsync_download_command(remote_path, local_path, show_progress=show_progress)
|
|
335
446
|
try:
|
|
336
|
-
|
|
447
|
+
if show_progress and progress_callback:
|
|
448
|
+
# Run with real-time output parsing for progress display
|
|
449
|
+
process = subprocess.Popen(
|
|
450
|
+
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
# Parse rsync output line by line
|
|
454
|
+
for line in process.stdout:
|
|
455
|
+
line = line.strip()
|
|
456
|
+
# Skip empty lines and summary lines
|
|
457
|
+
if line and not line.startswith("sent") and not line.startswith("total"):
|
|
458
|
+
# Extract filename from rsync output
|
|
459
|
+
# Rsync --info=NAME outputs filenames directly
|
|
460
|
+
if not line.startswith("receiving") and not line.startswith("building"):
|
|
461
|
+
progress_callback(line)
|
|
462
|
+
|
|
463
|
+
process.wait()
|
|
464
|
+
if process.returncode != 0:
|
|
465
|
+
raise SyncError(f"Rsync download failed with exit code {process.returncode}")
|
|
466
|
+
else:
|
|
467
|
+
# Silent mode - use subprocess.run
|
|
468
|
+
subprocess.run(cmd, check=True)
|
|
337
469
|
except subprocess.CalledProcessError as e:
|
|
338
470
|
raise SyncError(f"Rsync download failed with exit code {e.returncode}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mtr-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A CLI tool for seamless local development and remote execution on GPU servers.
|
|
5
5
|
Project-URL: Homepage, https://github.com/lecoan/mtremote
|
|
6
6
|
Project-URL: Repository, https://github.com/lecoan/mtremote
|
|
@@ -57,11 +57,21 @@ pip install mtr-cli
|
|
|
57
57
|
|
|
58
58
|
MTRemote 需要以下系统命令:
|
|
59
59
|
|
|
60
|
-
| 命令 | 用途 | 安装方式 |
|
|
61
|
-
|
|
62
|
-
| `ssh` | 交互式 Shell (TTY) | macOS/Linux 自带,或 `brew install openssh` |
|
|
63
|
-
| `rsync` | 快速文件同步 (推荐) | macOS/Linux 自带 |
|
|
64
|
-
| `sshpass` | 密码认证 (可选) | `brew install hudochenkov/sshpass/sshpass` (macOS) / `apt install sshpass` (Ubuntu) |
|
|
60
|
+
| 命令 | 用途 | 安装方式 | 版本要求 |
|
|
61
|
+
|------|------|----------|----------|
|
|
62
|
+
| `ssh` | 交互式 Shell (TTY) | macOS/Linux 自带,或 `brew install openssh` | - |
|
|
63
|
+
| `rsync` | 快速文件同步 (推荐) | macOS/Linux 自带 | **≥ 3.1.0** (TTY 进度显示需要) |
|
|
64
|
+
| `sshpass` | 密码认证 (可选) | `brew install hudochenkov/sshpass/sshpass` (macOS) / `apt install sshpass` (Ubuntu) | - |
|
|
65
|
+
|
|
66
|
+
**注意**:macOS 自带的 rsync 版本较旧(2.6.9),不支持 TTY 模式下的进度显示。建议通过 Homebrew 安装新版:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# macOS 用户建议升级 rsync
|
|
70
|
+
brew install rsync
|
|
71
|
+
|
|
72
|
+
# 验证版本
|
|
73
|
+
rsync --version # 应显示 3.1.0 或更高版本
|
|
74
|
+
```
|
|
65
75
|
|
|
66
76
|
**注意**:交互式 Shell 功能(如 `mtr bash`, `mtr ipython`)**必须**安装 `ssh`。密码认证**必须**安装 `sshpass`。
|
|
67
77
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
mtr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
mtr/cli.py,sha256=LXoXCdajKQe_oVXlf9FPGgHt7N3P0zQxE-mh9xPwnII,16363
|
|
3
|
+
mtr/config.py,sha256=HA_pZ3rTFPYa6JhgFnEQlf4ukdFovx8obv58UILloWQ,3568
|
|
4
|
+
mtr/logger.py,sha256=9DPKTTzYsNMF7vXnvJ0bJditNYgzhZWHwLKUErtwCBY,3669
|
|
5
|
+
mtr/ssh.py,sha256=fCEXxfEK6ao8YrCU9FBJ_e5zrK93AKz6bgjXWkORQkk,7170
|
|
6
|
+
mtr/sync.py,sha256=xrljWPt8keigyiri7oc1RNvxJxBNBHjJjhgGtXP0F_g,18668
|
|
7
|
+
mtr_cli-0.3.0.dist-info/METADATA,sha256=XFZfTrcxMzhCbvzwY6LQrJWq_r2lnboCqL8SbDSuYnQ,9806
|
|
8
|
+
mtr_cli-0.3.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
9
|
+
mtr_cli-0.3.0.dist-info/entry_points.txt,sha256=8BRK0VoSAWGzovrOdzWqpwwNj-dmjVY1iQcz5MQseV4,36
|
|
10
|
+
mtr_cli-0.3.0.dist-info/licenses/LICENSE,sha256=PkuO1VHNDkFylFSOtMADb93mjExarK6DBTjtCB3kBeU,1067
|
|
11
|
+
mtr_cli-0.3.0.dist-info/RECORD,,
|
mtr_cli-0.2.0.dist-info/RECORD
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
mtr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
mtr/cli.py,sha256=lhK5hoOlMSp5kAhiqYvmWGfDdyUozELDKgk1x1LjFwI,13910
|
|
3
|
-
mtr/config.py,sha256=v1lUg7z12jb-W0B61KKwiLuP6BO3I6ZLZSqxPSoAi7A,3037
|
|
4
|
-
mtr/logger.py,sha256=9DPKTTzYsNMF7vXnvJ0bJditNYgzhZWHwLKUErtwCBY,3669
|
|
5
|
-
mtr/ssh.py,sha256=fCEXxfEK6ao8YrCU9FBJ_e5zrK93AKz6bgjXWkORQkk,7170
|
|
6
|
-
mtr/sync.py,sha256=LvfGs7wGzlU-RSHsXF8IQ_LK70yYPnw-sqVEGqLknHQ,11799
|
|
7
|
-
mtr_cli-0.2.0.dist-info/METADATA,sha256=ZhfL78GWsse8Q1Ml17SqdOa2V2HUZwS9Tvl6kIKSMeU,9458
|
|
8
|
-
mtr_cli-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
9
|
-
mtr_cli-0.2.0.dist-info/entry_points.txt,sha256=8BRK0VoSAWGzovrOdzWqpwwNj-dmjVY1iQcz5MQseV4,36
|
|
10
|
-
mtr_cli-0.2.0.dist-info/licenses/LICENSE,sha256=PkuO1VHNDkFylFSOtMADb93mjExarK6DBTjtCB3kBeU,1067
|
|
11
|
-
mtr_cli-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|