mtr-cli 0.1.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 +63 -9
- mtr/config.py +14 -0
- mtr/sync.py +157 -25
- {mtr_cli-0.1.0.dist-info → mtr_cli-0.3.0.dist-info}/METADATA +29 -12
- mtr_cli-0.3.0.dist-info/RECORD +11 -0
- mtr_cli-0.1.0.dist-info/RECORD +0 -11
- {mtr_cli-0.1.0.dist-info → mtr_cli-0.3.0.dist-info}/WHEEL +0 -0
- {mtr_cli-0.1.0.dist-info → mtr_cli-0.3.0.dist-info}/entry_points.txt +0 -0
- {mtr_cli-0.1.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__/"
|
|
@@ -73,7 +78,7 @@ def _init_config():
|
|
|
73
78
|
@click.option("--init", is_flag=True, help="Initialize a configuration file in current directory")
|
|
74
79
|
@click.option("--enable-log", is_flag=True, help="Enable logging to file")
|
|
75
80
|
@click.option("--log-level", default="INFO", help="Log level (DEBUG/INFO/WARNING/ERROR)")
|
|
76
|
-
@click.option("--log-file", help="Path to log file (default:
|
|
81
|
+
@click.option("--log-file", help="Path to log file (default: ./.mtr/logs/mtr_YYYYMMDD_HHMMSS.log)")
|
|
77
82
|
@click.option("--get", "remote_get_path", help="Remote path to download from")
|
|
78
83
|
@click.option("--to", "local_dest_path", help="Local destination path for download (optional)")
|
|
79
84
|
@click.argument("command", nargs=-1, type=click.UNPROCESSED)
|
|
@@ -86,9 +91,9 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
86
91
|
# Setup logging if enabled
|
|
87
92
|
if enable_log:
|
|
88
93
|
if not log_file:
|
|
89
|
-
# Generate default log file path:
|
|
94
|
+
# Generate default log file path: ./.mtr/logs/mtr_YYYYMMDD_HHMMSS.log
|
|
90
95
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
91
|
-
log_dir = os.path.
|
|
96
|
+
log_dir = os.path.join(os.getcwd(), ".mtr/logs")
|
|
92
97
|
log_file = os.path.join(log_dir, f"mtr_{timestamp}.log")
|
|
93
98
|
|
|
94
99
|
try:
|
|
@@ -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")
|
|
@@ -238,6 +265,13 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
238
265
|
|
|
239
266
|
# 3. Download from remote (if --get is specified)
|
|
240
267
|
if remote_get_path:
|
|
268
|
+
# Resolve relative remote path from remote_dir
|
|
269
|
+
if not remote_get_path.startswith("/"):
|
|
270
|
+
if not remote_dir:
|
|
271
|
+
click.secho("Error: 'remote_dir' is required for relative --get path.", fg="red", err=True)
|
|
272
|
+
sys.exit(1)
|
|
273
|
+
remote_get_path = os.path.join(remote_dir, remote_get_path)
|
|
274
|
+
|
|
241
275
|
# Resolve local destination path
|
|
242
276
|
if cli_dest:
|
|
243
277
|
local_dest = cli_dest
|
|
@@ -253,6 +287,9 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
253
287
|
# Resolve exclude
|
|
254
288
|
exclude = config.global_defaults.get("exclude", []) + server_conf.get("exclude", [])
|
|
255
289
|
|
|
290
|
+
# Get respect_gitignore setting
|
|
291
|
+
respect_gitignore = config.get_respect_gitignore()
|
|
292
|
+
|
|
256
293
|
# Determine engine
|
|
257
294
|
engine = server_conf.get("sync", config.global_defaults.get("sync", "rsync"))
|
|
258
295
|
|
|
@@ -266,6 +303,7 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
266
303
|
password=password,
|
|
267
304
|
port=port,
|
|
268
305
|
exclude=exclude,
|
|
306
|
+
respect_gitignore=respect_gitignore,
|
|
269
307
|
)
|
|
270
308
|
elif engine == "sftp":
|
|
271
309
|
syncer = SftpSyncer(
|
|
@@ -277,6 +315,7 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
277
315
|
password=password,
|
|
278
316
|
port=port,
|
|
279
317
|
exclude=exclude,
|
|
318
|
+
respect_gitignore=respect_gitignore,
|
|
280
319
|
)
|
|
281
320
|
else:
|
|
282
321
|
click.secho(
|
|
@@ -291,12 +330,27 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
|
|
|
291
330
|
logger.info(f"[DryRun] Would download {remote_get_path} -> {local_dest}", module="mtr.sync")
|
|
292
331
|
else:
|
|
293
332
|
if is_interactive and console:
|
|
294
|
-
|
|
295
|
-
|
|
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"))
|
|
296
346
|
console.print(f"✅ [green]Downloaded:[/green] {remote_get_path} -> {local_dest}")
|
|
297
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
|
+
|
|
298
352
|
click.secho(f"Downloading {remote_get_path}...", fg="blue")
|
|
299
|
-
syncer.download(remote_get_path, local_dest)
|
|
353
|
+
syncer.download(remote_get_path, local_dest, show_progress=True, progress_callback=show_download_progress)
|
|
300
354
|
click.secho(f"Download completed: {local_dest}", fg="green")
|
|
301
355
|
logger.info(f"Download completed: {remote_get_path} -> {local_dest}", module="mtr.sync")
|
|
302
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
|
|
@@ -25,7 +25,7 @@ Requires-Dist: pyyaml>=6.0
|
|
|
25
25
|
Requires-Dist: rich>=12.0.0
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
|
|
28
|
-
# MTRemote (mtr)
|
|
28
|
+
# MTRemote (mtr-cli)
|
|
29
29
|
|
|
30
30
|
MTRemote 是一个专为 AI Infra 和 Python/C++ 混合开发设计的命令行工具。它允许你在本地修改代码,通过简单的 `mtr` 前缀,自动将代码同步到远端 GPU 服务器并执行命令,同时保留本地的交互体验(实时日志、颜色高亮、Ctrl+C 支持)。
|
|
31
31
|
|
|
@@ -48,20 +48,30 @@ MTRemote 是一个专为 AI Infra 和 Python/C++ 混合开发设计的命令行
|
|
|
48
48
|
推荐使用 `uv` 或 `pipx` 安装:
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
|
-
uv tool install
|
|
51
|
+
uv tool install mtr-cli
|
|
52
52
|
# 或者
|
|
53
|
-
pip install
|
|
53
|
+
pip install mtr-cli
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
### 系统依赖
|
|
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
|
|
|
@@ -129,7 +139,7 @@ Options:
|
|
|
129
139
|
--to TEXT Local destination path for download (optional)
|
|
130
140
|
--enable-log Enable logging to file
|
|
131
141
|
--log-level TEXT Log level: DEBUG/INFO/WARNING/ERROR [default: INFO]
|
|
132
|
-
--log-file PATH Custom log file path (default:
|
|
142
|
+
--log-file PATH Custom log file path (default: ./.mtr/logs/mtr_YYYYMMDD_HHMMSS.log)
|
|
133
143
|
--init Initialize configuration file
|
|
134
144
|
--help Show this message and exit
|
|
135
145
|
```
|
|
@@ -192,9 +202,12 @@ sudo yum install sshpass
|
|
|
192
202
|
使用 `--get` 参数可以从远端服务器下载文件或文件夹到本地:
|
|
193
203
|
|
|
194
204
|
```bash
|
|
195
|
-
#
|
|
205
|
+
# 下载文件(绝对路径)
|
|
196
206
|
mtr --get /remote/path/to/file.txt
|
|
197
207
|
|
|
208
|
+
# 下载文件(相对路径,基于 remote_dir)
|
|
209
|
+
mtr --get checkpoints/model.pt
|
|
210
|
+
|
|
198
211
|
# 下载文件到指定位置
|
|
199
212
|
mtr --get /remote/path/to/file.txt --to ./local/path/
|
|
200
213
|
|
|
@@ -205,6 +218,10 @@ mtr --get /remote/path/to/checkpoints/ --to ./backups/
|
|
|
205
218
|
mtr --no-sync --get /remote/path/to/file.txt
|
|
206
219
|
```
|
|
207
220
|
|
|
221
|
+
**路径解析规则**:
|
|
222
|
+
- **绝对路径**(以 `/` 开头):直接使用指定的完整路径
|
|
223
|
+
- **相对路径**:自动拼接 `remote_dir`,例如配置 `remote_dir: "/workdir/project"`,执行 `--get checkpoints/model.pt` 将下载 `/workdir/project/checkpoints/model.pt`
|
|
224
|
+
|
|
208
225
|
**配置下载目录**:
|
|
209
226
|
可以在配置文件中设置默认下载位置:
|
|
210
227
|
|
|
@@ -235,7 +252,7 @@ mtr --enable-log python train.py
|
|
|
235
252
|
mtr --enable-log --log-level DEBUG python train.py
|
|
236
253
|
|
|
237
254
|
# 查看日志
|
|
238
|
-
cat
|
|
255
|
+
cat ./.mtr/logs/mtr_20260128_171216.log
|
|
239
256
|
```
|
|
240
257
|
|
|
241
258
|
日志文件按会话独立生成,格式为 `mtr_YYYYMMDD_HHMMSS.log`,包含:
|
|
@@ -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.1.0.dist-info/RECORD
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
mtr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
mtr/cli.py,sha256=YZ3uZT6VASmhIvO2gwCHpX0WWiU0WsnVCyrVRjhq71A,13562
|
|
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.1.0.dist-info/METADATA,sha256=NaUUobMr8LM4OCthQCO_dOWFD4SJg_hnvVNTOxShD2c,9083
|
|
8
|
-
mtr_cli-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
9
|
-
mtr_cli-0.1.0.dist-info/entry_points.txt,sha256=8BRK0VoSAWGzovrOdzWqpwwNj-dmjVY1iQcz5MQseV4,36
|
|
10
|
-
mtr_cli-0.1.0.dist-info/licenses/LICENSE,sha256=PkuO1VHNDkFylFSOtMADb93mjExarK6DBTjtCB3kBeU,1067
|
|
11
|
-
mtr_cli-0.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|