mtr-cli 0.2.0__py3-none-any.whl → 2.0.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/__init__.py +3 -0
- mtr/cli.py +153 -110
- mtr/config.py +14 -0
- mtr/ssh.py +99 -166
- mtr/sync.py +129 -237
- mtr/updater.py +130 -0
- {mtr_cli-0.2.0.dist-info → mtr_cli-2.0.0.dist-info}/METADATA +37 -25
- mtr_cli-2.0.0.dist-info/RECORD +12 -0
- mtr_cli-0.2.0.dist-info/RECORD +0 -11
- {mtr_cli-0.2.0.dist-info → mtr_cli-2.0.0.dist-info}/WHEEL +0 -0
- {mtr_cli-0.2.0.dist-info → mtr_cli-2.0.0.dist-info}/entry_points.txt +0 -0
- {mtr_cli-0.2.0.dist-info → mtr_cli-2.0.0.dist-info}/licenses/LICENSE +0 -0
mtr/sync.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import fnmatch
|
|
2
1
|
import os
|
|
3
2
|
import shlex
|
|
4
3
|
import shutil
|
|
@@ -6,243 +5,23 @@ import subprocess
|
|
|
6
5
|
from abc import ABC, abstractmethod
|
|
7
6
|
from typing import List, Optional
|
|
8
7
|
|
|
9
|
-
import paramiko
|
|
10
|
-
|
|
11
8
|
|
|
12
9
|
class SyncError(Exception):
|
|
13
10
|
pass
|
|
14
11
|
|
|
15
12
|
|
|
16
13
|
class BaseSyncer(ABC):
|
|
17
|
-
def __init__(self, local_dir: str, remote_dir: str, exclude: List[str]):
|
|
14
|
+
def __init__(self, local_dir: str, remote_dir: str, exclude: List[str], respect_gitignore: bool = True):
|
|
18
15
|
self.local_dir = local_dir
|
|
19
16
|
self.remote_dir = remote_dir
|
|
20
17
|
self.exclude = exclude
|
|
18
|
+
self.respect_gitignore = respect_gitignore
|
|
21
19
|
|
|
22
20
|
@abstractmethod
|
|
23
|
-
def sync(self):
|
|
21
|
+
def sync(self, show_progress: bool = False, progress_callback=None):
|
|
24
22
|
pass
|
|
25
23
|
|
|
26
24
|
|
|
27
|
-
class SftpSyncer(BaseSyncer):
|
|
28
|
-
def __init__(
|
|
29
|
-
self,
|
|
30
|
-
local_dir: str,
|
|
31
|
-
remote_dir: str,
|
|
32
|
-
host: str,
|
|
33
|
-
user: str,
|
|
34
|
-
key_filename: Optional[str] = None,
|
|
35
|
-
password: Optional[str] = None,
|
|
36
|
-
port: int = 22,
|
|
37
|
-
exclude: List[str] = None,
|
|
38
|
-
):
|
|
39
|
-
super().__init__(local_dir, remote_dir, exclude or [])
|
|
40
|
-
self.host = host
|
|
41
|
-
self.user = user
|
|
42
|
-
self.key_filename = key_filename
|
|
43
|
-
self.password = password
|
|
44
|
-
self.port = port
|
|
45
|
-
self.transport = None
|
|
46
|
-
self.sftp = None
|
|
47
|
-
|
|
48
|
-
def _should_ignore(self, filename: str) -> bool:
|
|
49
|
-
for pattern in self.exclude:
|
|
50
|
-
# Handle directory exclusion (basic)
|
|
51
|
-
if pattern.endswith("/") and filename.startswith(pattern.rstrip("/")):
|
|
52
|
-
return True
|
|
53
|
-
if fnmatch.fnmatch(filename, pattern):
|
|
54
|
-
return True
|
|
55
|
-
return False
|
|
56
|
-
|
|
57
|
-
def _connect(self):
|
|
58
|
-
try:
|
|
59
|
-
self.transport = paramiko.Transport((self.host, self.port))
|
|
60
|
-
connect_kwargs = {"username": self.user}
|
|
61
|
-
|
|
62
|
-
if self.key_filename:
|
|
63
|
-
key_path = os.path.expanduser(self.key_filename)
|
|
64
|
-
# Try different key types? For now assuming RSA or standard loading
|
|
65
|
-
# Or just use connect method of SSHClient? No, Sftp is lower level usually,
|
|
66
|
-
# but we can use SSHClient to get sftp
|
|
67
|
-
|
|
68
|
-
# Simpler approach: Use SSHClientWrapper logic or just Paramiko SSHClient
|
|
69
|
-
client = paramiko.SSHClient()
|
|
70
|
-
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
71
|
-
|
|
72
|
-
k_kwargs = {
|
|
73
|
-
"hostname": self.host,
|
|
74
|
-
"username": self.user,
|
|
75
|
-
"port": self.port,
|
|
76
|
-
"key_filename": key_path,
|
|
77
|
-
}
|
|
78
|
-
if self.password:
|
|
79
|
-
k_kwargs["password"] = self.password
|
|
80
|
-
|
|
81
|
-
client.connect(**k_kwargs)
|
|
82
|
-
self.sftp = client.open_sftp()
|
|
83
|
-
return
|
|
84
|
-
|
|
85
|
-
if self.password:
|
|
86
|
-
connect_kwargs["password"] = self.password
|
|
87
|
-
self.transport.connect(**connect_kwargs)
|
|
88
|
-
self.sftp = paramiko.SFTPClient.from_transport(self.transport)
|
|
89
|
-
else:
|
|
90
|
-
raise SyncError("No auth method provided (need key or password)")
|
|
91
|
-
|
|
92
|
-
except Exception as e:
|
|
93
|
-
raise SyncError(f"SFTP Connection failed: {e}")
|
|
94
|
-
|
|
95
|
-
def _ensure_remote_dir(self, remote_path: str):
|
|
96
|
-
"""Recursively create remote directory."""
|
|
97
|
-
# This is a bit expensive, optimization: assume parent exists or try/except
|
|
98
|
-
# Simple implementation:
|
|
99
|
-
dirs = remote_path.split("/")
|
|
100
|
-
current = ""
|
|
101
|
-
for d in dirs:
|
|
102
|
-
if not d:
|
|
103
|
-
continue
|
|
104
|
-
current += f"/{d}"
|
|
105
|
-
try:
|
|
106
|
-
self.sftp.stat(current)
|
|
107
|
-
except FileNotFoundError:
|
|
108
|
-
try:
|
|
109
|
-
self.sftp.mkdir(current)
|
|
110
|
-
except OSError:
|
|
111
|
-
pass # Already exists maybe
|
|
112
|
-
|
|
113
|
-
def sync(self):
|
|
114
|
-
if not self.sftp:
|
|
115
|
-
self._connect()
|
|
116
|
-
|
|
117
|
-
# Ensure base remote dir exists
|
|
118
|
-
try:
|
|
119
|
-
self.sftp.stat(self.remote_dir)
|
|
120
|
-
except FileNotFoundError:
|
|
121
|
-
self._ensure_remote_dir(self.remote_dir)
|
|
122
|
-
|
|
123
|
-
# Walk local tree
|
|
124
|
-
for root, dirs, files in os.walk(self.local_dir):
|
|
125
|
-
# Filtering dirs in place to prevent recursion
|
|
126
|
-
dirs[:] = [d for d in dirs if not self._should_ignore(d)]
|
|
127
|
-
|
|
128
|
-
rel_path = os.path.relpath(root, self.local_dir)
|
|
129
|
-
if rel_path == ".":
|
|
130
|
-
remote_root = self.remote_dir
|
|
131
|
-
else:
|
|
132
|
-
remote_root = os.path.join(self.remote_dir, rel_path)
|
|
133
|
-
|
|
134
|
-
# Check/Create remote dir
|
|
135
|
-
try:
|
|
136
|
-
self.sftp.stat(remote_root)
|
|
137
|
-
except FileNotFoundError:
|
|
138
|
-
self.sftp.mkdir(remote_root)
|
|
139
|
-
|
|
140
|
-
for file in files:
|
|
141
|
-
if self._should_ignore(file):
|
|
142
|
-
continue
|
|
143
|
-
|
|
144
|
-
local_file = os.path.join(root, file)
|
|
145
|
-
remote_file = os.path.join(remote_root, file)
|
|
146
|
-
|
|
147
|
-
# Check sync necessity (Size & Mtime)
|
|
148
|
-
should_upload = True
|
|
149
|
-
try:
|
|
150
|
-
remote_stat = self.sftp.stat(remote_file)
|
|
151
|
-
local_stat = os.stat(local_file)
|
|
152
|
-
|
|
153
|
-
if remote_stat.st_size == local_stat.st_size and int(remote_stat.st_mtime) >= int(local_stat.st_mtime):
|
|
154
|
-
should_upload = False
|
|
155
|
-
except FileNotFoundError:
|
|
156
|
-
pass # Does not exist, must upload
|
|
157
|
-
|
|
158
|
-
if should_upload:
|
|
159
|
-
# print(f"Uploading {local_file} -> {remote_file}")
|
|
160
|
-
self.sftp.put(local_file, remote_file)
|
|
161
|
-
# Preserve permissions
|
|
162
|
-
mode = os.stat(local_file).st_mode
|
|
163
|
-
self.sftp.chmod(remote_file, mode)
|
|
164
|
-
|
|
165
|
-
if self.sftp:
|
|
166
|
-
self.sftp.close()
|
|
167
|
-
if self.transport:
|
|
168
|
-
self.transport.close()
|
|
169
|
-
|
|
170
|
-
def download(self, remote_path: str, local_path: str):
|
|
171
|
-
"""Download file or directory from remote to local."""
|
|
172
|
-
if not self.sftp:
|
|
173
|
-
self._connect()
|
|
174
|
-
|
|
175
|
-
try:
|
|
176
|
-
# Ensure local directory exists
|
|
177
|
-
local_dir = os.path.dirname(local_path)
|
|
178
|
-
if local_dir and not os.path.exists(local_dir):
|
|
179
|
-
os.makedirs(local_dir, exist_ok=True)
|
|
180
|
-
|
|
181
|
-
try:
|
|
182
|
-
import stat
|
|
183
|
-
|
|
184
|
-
remote_stat = self.sftp.stat(remote_path)
|
|
185
|
-
is_dir = stat.S_ISDIR(remote_stat.st_mode)
|
|
186
|
-
|
|
187
|
-
if is_dir:
|
|
188
|
-
self._download_dir(remote_path, local_path)
|
|
189
|
-
else:
|
|
190
|
-
self._download_file(remote_path, local_path)
|
|
191
|
-
except FileNotFoundError:
|
|
192
|
-
raise SyncError(f"Remote path not found: {remote_path}")
|
|
193
|
-
except Exception as e:
|
|
194
|
-
raise SyncError(f"Download failed: {e}")
|
|
195
|
-
finally:
|
|
196
|
-
if self.sftp:
|
|
197
|
-
self.sftp.close()
|
|
198
|
-
if self.transport:
|
|
199
|
-
self.transport.close()
|
|
200
|
-
|
|
201
|
-
def _should_download_file(self, remote_file: str, local_file: str) -> bool:
|
|
202
|
-
"""Check if file should be downloaded based on size and mtime."""
|
|
203
|
-
try:
|
|
204
|
-
remote_stat = self.sftp.stat(remote_file)
|
|
205
|
-
local_stat = os.stat(local_file)
|
|
206
|
-
|
|
207
|
-
# Size different, need download
|
|
208
|
-
if remote_stat.st_size != local_stat.st_size:
|
|
209
|
-
return True
|
|
210
|
-
|
|
211
|
-
# Remote file is newer than local file
|
|
212
|
-
return int(remote_stat.st_mtime) > int(local_stat.st_mtime)
|
|
213
|
-
except FileNotFoundError:
|
|
214
|
-
return True # Local file doesn't exist, must download
|
|
215
|
-
|
|
216
|
-
def _download_file(self, remote_file: str, local_file: str):
|
|
217
|
-
"""Download a single file with incremental check."""
|
|
218
|
-
if not self._should_download_file(remote_file, local_file):
|
|
219
|
-
return # No need to download
|
|
220
|
-
|
|
221
|
-
self.sftp.get(remote_file, local_file)
|
|
222
|
-
# Preserve permissions
|
|
223
|
-
remote_stat = self.sftp.stat(remote_file)
|
|
224
|
-
os.chmod(local_file, remote_stat.st_mode)
|
|
225
|
-
|
|
226
|
-
def _download_dir(self, remote_dir: str, local_dir: str):
|
|
227
|
-
"""Recursively download a directory."""
|
|
228
|
-
if not os.path.exists(local_dir):
|
|
229
|
-
os.makedirs(local_dir, exist_ok=True)
|
|
230
|
-
|
|
231
|
-
for entry in self.sftp.listdir_attr(remote_dir):
|
|
232
|
-
remote_path = f"{remote_dir}/{entry.filename}"
|
|
233
|
-
local_path = os.path.join(local_dir, entry.filename)
|
|
234
|
-
|
|
235
|
-
if self._should_ignore(entry.filename):
|
|
236
|
-
continue
|
|
237
|
-
|
|
238
|
-
import stat
|
|
239
|
-
|
|
240
|
-
if stat.S_ISDIR(entry.st_mode):
|
|
241
|
-
self._download_dir(remote_path, local_path)
|
|
242
|
-
else:
|
|
243
|
-
self._download_file(remote_path, local_path)
|
|
244
|
-
|
|
245
|
-
|
|
246
25
|
class RsyncSyncer(BaseSyncer):
|
|
247
26
|
def __init__(
|
|
248
27
|
self,
|
|
@@ -254,8 +33,9 @@ class RsyncSyncer(BaseSyncer):
|
|
|
254
33
|
password: Optional[str] = None,
|
|
255
34
|
port: int = 22,
|
|
256
35
|
exclude: List[str] = None,
|
|
36
|
+
respect_gitignore: bool = True,
|
|
257
37
|
):
|
|
258
|
-
super().__init__(local_dir, remote_dir, exclude or [])
|
|
38
|
+
super().__init__(local_dir, remote_dir, exclude or [], respect_gitignore)
|
|
259
39
|
self.host = host
|
|
260
40
|
self.user = user
|
|
261
41
|
self.key_filename = key_filename
|
|
@@ -269,9 +49,20 @@ class RsyncSyncer(BaseSyncer):
|
|
|
269
49
|
opts += f" -i {self.key_filename}"
|
|
270
50
|
return opts
|
|
271
51
|
|
|
272
|
-
def _build_rsync_base(self) -> List[str]:
|
|
52
|
+
def _build_rsync_base(self, show_progress: bool = False) -> List[str]:
|
|
273
53
|
"""Build rsync base command with common options."""
|
|
274
|
-
|
|
54
|
+
if show_progress:
|
|
55
|
+
# In progress mode, use -av --info=NAME to show filenames only
|
|
56
|
+
cmd = ["rsync", "-av", "--info=NAME"]
|
|
57
|
+
else:
|
|
58
|
+
# Silent mode
|
|
59
|
+
cmd = ["rsync", "-azq"]
|
|
60
|
+
|
|
61
|
+
# Add gitignore filter if enabled
|
|
62
|
+
if self.respect_gitignore:
|
|
63
|
+
gitignore_path = os.path.join(self.local_dir, ".gitignore")
|
|
64
|
+
if os.path.exists(gitignore_path):
|
|
65
|
+
cmd.append("--filter=:- .gitignore")
|
|
275
66
|
|
|
276
67
|
# Add excludes
|
|
277
68
|
for item in self.exclude:
|
|
@@ -294,35 +85,115 @@ class RsyncSyncer(BaseSyncer):
|
|
|
294
85
|
if not shutil.which("sshpass"):
|
|
295
86
|
raise SyncError("Rsync with password requires 'sshpass'. Please install it or use SSH Key.")
|
|
296
87
|
|
|
297
|
-
def
|
|
88
|
+
def _check_rsync_version(self) -> tuple:
|
|
89
|
+
"""Check local rsync version and return (major, minor, patch) tuple.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Tuple of (major, minor, patch) version numbers
|
|
93
|
+
Raises:
|
|
94
|
+
SyncError: If rsync is not installed or version cannot be parsed
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
result = subprocess.run(["rsync", "--version"], capture_output=True, text=True, timeout=5)
|
|
98
|
+
if result.returncode != 0:
|
|
99
|
+
raise SyncError("Failed to check rsync version. Is rsync installed?")
|
|
100
|
+
|
|
101
|
+
# Parse version from first line, e.g., "rsync version 3.2.5 protocol version 31"
|
|
102
|
+
first_line = result.stdout.split("\n")[0]
|
|
103
|
+
import re
|
|
104
|
+
|
|
105
|
+
match = re.search(r"version\s+(\d+)\.(\d+)\.(\d+)", first_line)
|
|
106
|
+
if not match:
|
|
107
|
+
# Try alternative format: "rsync version 2.6.9 compatible"
|
|
108
|
+
match = re.search(r"version\s+(\d+)\.(\d+)\.(\d+)", first_line)
|
|
109
|
+
if not match:
|
|
110
|
+
raise SyncError(f"Cannot parse rsync version from: {first_line}")
|
|
111
|
+
|
|
112
|
+
major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3))
|
|
113
|
+
return (major, minor, patch)
|
|
114
|
+
except FileNotFoundError:
|
|
115
|
+
raise SyncError("rsync not found. Please install rsync.")
|
|
116
|
+
except subprocess.TimeoutExpired:
|
|
117
|
+
raise SyncError("Timeout while checking rsync version.")
|
|
118
|
+
except Exception as e:
|
|
119
|
+
raise SyncError(f"Failed to check rsync version: {e}")
|
|
120
|
+
|
|
121
|
+
def _is_rsync_version_supported(self, min_version: tuple = (3, 1, 0)) -> bool:
|
|
122
|
+
"""Check if local rsync version meets minimum requirement.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
min_version: Minimum required version as (major, minor, patch) tuple
|
|
126
|
+
Returns:
|
|
127
|
+
True if version is supported, False otherwise
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
current_version = self._check_rsync_version()
|
|
131
|
+
return current_version >= min_version
|
|
132
|
+
except SyncError:
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
def _build_rsync_command(self, show_progress: bool = False) -> List[str]:
|
|
298
136
|
"""Build rsync command for uploading (local -> remote)."""
|
|
299
137
|
# Ensure local dir ends with / to sync contents, not the dir itself
|
|
300
138
|
src = self.local_dir if self.local_dir.endswith("/") else f"{self.local_dir}/"
|
|
301
139
|
dest = f"{self.user}@{self.host}:{shlex.quote(self.remote_dir)}"
|
|
302
140
|
|
|
303
|
-
cmd = self._build_rsync_base()
|
|
141
|
+
cmd = self._build_rsync_base(show_progress=show_progress)
|
|
304
142
|
cmd.extend([src, dest])
|
|
305
143
|
|
|
306
144
|
return self._wrap_with_sshpass(cmd)
|
|
307
145
|
|
|
308
|
-
def _build_rsync_download_command(self, remote_path: str, local_path: str) -> List[str]:
|
|
146
|
+
def _build_rsync_download_command(self, remote_path: str, local_path: str, show_progress: bool = False) -> List[str]:
|
|
309
147
|
"""Build rsync command for downloading (remote -> local)."""
|
|
310
148
|
src = f"{self.user}@{self.host}:{shlex.quote(remote_path)}"
|
|
311
149
|
|
|
312
|
-
cmd = self._build_rsync_base()
|
|
150
|
+
cmd = self._build_rsync_base(show_progress=show_progress)
|
|
313
151
|
cmd.extend([src, local_path])
|
|
314
152
|
|
|
315
153
|
return self._wrap_with_sshpass(cmd)
|
|
316
154
|
|
|
317
|
-
def sync(self):
|
|
155
|
+
def sync(self, show_progress: bool = False, progress_callback=None):
|
|
318
156
|
self._check_sshpass()
|
|
319
|
-
|
|
157
|
+
|
|
158
|
+
# Check rsync version if progress mode is requested
|
|
159
|
+
if show_progress and progress_callback:
|
|
160
|
+
if not self._is_rsync_version_supported():
|
|
161
|
+
version = self._check_rsync_version()
|
|
162
|
+
raise SyncError(
|
|
163
|
+
f"rsync version {version[0]}.{version[1]}.{version[2]} is too old. "
|
|
164
|
+
f"Progress display requires rsync >= 3.1.0. "
|
|
165
|
+
f"Please upgrade rsync or use --no-tty mode."
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
cmd = self._build_rsync_command(show_progress=show_progress)
|
|
169
|
+
|
|
320
170
|
try:
|
|
321
|
-
|
|
171
|
+
if show_progress and progress_callback:
|
|
172
|
+
# Run with real-time output parsing for progress display
|
|
173
|
+
process = subprocess.Popen(
|
|
174
|
+
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Parse rsync output line by line
|
|
178
|
+
for line in process.stdout:
|
|
179
|
+
line = line.strip()
|
|
180
|
+
# Skip empty lines and summary lines
|
|
181
|
+
if line and not line.startswith("sent") and not line.startswith("total"):
|
|
182
|
+
# Extract filename from rsync output
|
|
183
|
+
# Rsync --info=NAME outputs filenames directly
|
|
184
|
+
if not line.startswith("receiving") and not line.startswith("building"):
|
|
185
|
+
progress_callback(line)
|
|
186
|
+
|
|
187
|
+
process.wait()
|
|
188
|
+
if process.returncode != 0:
|
|
189
|
+
raise SyncError(f"Rsync failed with exit code {process.returncode}")
|
|
190
|
+
else:
|
|
191
|
+
# Silent mode - use subprocess.run
|
|
192
|
+
subprocess.run(cmd, check=True)
|
|
322
193
|
except subprocess.CalledProcessError as e:
|
|
323
194
|
raise SyncError(f"Rsync failed with exit code {e.returncode}")
|
|
324
195
|
|
|
325
|
-
def download(self, remote_path: str, local_path: str):
|
|
196
|
+
def download(self, remote_path: str, local_path: str, show_progress: bool = False, progress_callback=None):
|
|
326
197
|
"""Download file or directory from remote to local."""
|
|
327
198
|
self._check_sshpass()
|
|
328
199
|
|
|
@@ -331,8 +202,29 @@ class RsyncSyncer(BaseSyncer):
|
|
|
331
202
|
if local_dir and not os.path.exists(local_dir):
|
|
332
203
|
os.makedirs(local_dir, exist_ok=True)
|
|
333
204
|
|
|
334
|
-
cmd = self._build_rsync_download_command(remote_path, local_path)
|
|
205
|
+
cmd = self._build_rsync_download_command(remote_path, local_path, show_progress=show_progress)
|
|
335
206
|
try:
|
|
336
|
-
|
|
207
|
+
if show_progress and progress_callback:
|
|
208
|
+
# Run with real-time output parsing for progress display
|
|
209
|
+
process = subprocess.Popen(
|
|
210
|
+
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Parse rsync output line by line
|
|
214
|
+
for line in process.stdout:
|
|
215
|
+
line = line.strip()
|
|
216
|
+
# Skip empty lines and summary lines
|
|
217
|
+
if line and not line.startswith("sent") and not line.startswith("total"):
|
|
218
|
+
# Extract filename from rsync output
|
|
219
|
+
# Rsync --info=NAME outputs filenames directly
|
|
220
|
+
if not line.startswith("receiving") and not line.startswith("building"):
|
|
221
|
+
progress_callback(line)
|
|
222
|
+
|
|
223
|
+
process.wait()
|
|
224
|
+
if process.returncode != 0:
|
|
225
|
+
raise SyncError(f"Rsync download failed with exit code {process.returncode}")
|
|
226
|
+
else:
|
|
227
|
+
# Silent mode - use subprocess.run
|
|
228
|
+
subprocess.run(cmd, check=True)
|
|
337
229
|
except subprocess.CalledProcessError as e:
|
|
338
230
|
raise SyncError(f"Rsync download failed with exit code {e.returncode}")
|
mtr/updater.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Update checker for mtr-cli."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import urllib.request
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from packaging import version
|
|
11
|
+
|
|
12
|
+
from mtr import __version__
|
|
13
|
+
|
|
14
|
+
PYPI_API_URL = "https://pypi.org/pypi/mtr-cli/json"
|
|
15
|
+
CACHE_DIR = Path.home() / ".cache" / "mtr"
|
|
16
|
+
CACHE_FILE = CACHE_DIR / "update_cache.json"
|
|
17
|
+
CHECK_INTERVAL_HOURS = 24
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UpdateChecker:
|
|
21
|
+
"""Check for updates from PyPI."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, current_version: str = __version__):
|
|
24
|
+
self.current_version = version.parse(current_version)
|
|
25
|
+
self.cache_file = CACHE_FILE
|
|
26
|
+
|
|
27
|
+
def _ensure_cache_dir(self) -> None:
|
|
28
|
+
"""Ensure cache directory exists."""
|
|
29
|
+
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
|
|
31
|
+
def _load_cache(self) -> dict:
|
|
32
|
+
"""Load cache from file."""
|
|
33
|
+
if not self.cache_file.exists():
|
|
34
|
+
return {}
|
|
35
|
+
try:
|
|
36
|
+
with open(self.cache_file, "r") as f:
|
|
37
|
+
return json.load(f)
|
|
38
|
+
except (json.JSONDecodeError, IOError):
|
|
39
|
+
return {}
|
|
40
|
+
|
|
41
|
+
def _save_cache(self, data: dict) -> None:
|
|
42
|
+
"""Save cache to file."""
|
|
43
|
+
self._ensure_cache_dir()
|
|
44
|
+
try:
|
|
45
|
+
with open(self.cache_file, "w") as f:
|
|
46
|
+
json.dump(data, f)
|
|
47
|
+
except IOError:
|
|
48
|
+
pass # Silently fail if we can't write cache
|
|
49
|
+
|
|
50
|
+
def should_check(self) -> bool:
|
|
51
|
+
"""Check if we should perform an update check."""
|
|
52
|
+
# Check if disabled via environment variable
|
|
53
|
+
if os.environ.get("MTR_DISABLE_UPDATE_CHECK", "").lower() in ("1", "true", "yes"):
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
cache = self._load_cache()
|
|
57
|
+
last_check = cache.get("last_check_time")
|
|
58
|
+
|
|
59
|
+
if not last_check:
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
last_check_time = datetime.fromisoformat(last_check)
|
|
64
|
+
next_check_time = last_check_time + timedelta(hours=CHECK_INTERVAL_HOURS)
|
|
65
|
+
return datetime.now() >= next_check_time
|
|
66
|
+
except ValueError:
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
def get_latest_version(self) -> Optional[str]:
|
|
70
|
+
"""Fetch latest version from PyPI."""
|
|
71
|
+
try:
|
|
72
|
+
with urllib.request.urlopen(PYPI_API_URL, timeout=5) as response:
|
|
73
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
74
|
+
return data["info"]["version"]
|
|
75
|
+
except Exception:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
def check(self) -> Optional[str]:
|
|
79
|
+
"""Perform update check and return update message if available.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Update message string if a new version is available, None otherwise.
|
|
83
|
+
"""
|
|
84
|
+
if not self.should_check():
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
latest_version_str = self.get_latest_version()
|
|
88
|
+
|
|
89
|
+
# Save check result regardless of success (to avoid hammering PyPI on failures)
|
|
90
|
+
cache_data = {
|
|
91
|
+
"last_check_time": datetime.now().isoformat(),
|
|
92
|
+
"current_version": str(self.current_version),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if latest_version_str:
|
|
96
|
+
cache_data["latest_version"] = latest_version_str
|
|
97
|
+
self._save_cache(cache_data)
|
|
98
|
+
|
|
99
|
+
latest = version.parse(latest_version_str)
|
|
100
|
+
if latest > self.current_version:
|
|
101
|
+
return self._format_update_message(latest_version_str)
|
|
102
|
+
else:
|
|
103
|
+
self._save_cache(cache_data)
|
|
104
|
+
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
def _format_update_message(self, latest_version: str) -> str:
|
|
108
|
+
"""Format update message."""
|
|
109
|
+
return (
|
|
110
|
+
f"\n"
|
|
111
|
+
f"⚠️ Update available: {self.current_version} → {latest_version}\n"
|
|
112
|
+
f" Run: uv tool upgrade mtr-cli\n"
|
|
113
|
+
f" Or: pip install -U mtr-cli\n"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def get_cached_update_message(self) -> Optional[str]:
|
|
117
|
+
"""Get update message from cache without making network request.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Update message string if a new version was previously detected, None otherwise.
|
|
121
|
+
"""
|
|
122
|
+
cache = self._load_cache()
|
|
123
|
+
latest_version_str = cache.get("latest_version")
|
|
124
|
+
|
|
125
|
+
if latest_version_str:
|
|
126
|
+
latest = version.parse(latest_version_str)
|
|
127
|
+
if latest > self.current_version:
|
|
128
|
+
return self._format_update_message(latest_version_str)
|
|
129
|
+
|
|
130
|
+
return None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mtr-cli
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.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
|
|
@@ -20,7 +20,7 @@ Classifier: Topic :: Software Development :: Build Tools
|
|
|
20
20
|
Classifier: Topic :: System :: Systems Administration
|
|
21
21
|
Requires-Python: >=3.10
|
|
22
22
|
Requires-Dist: click>=8.0.0
|
|
23
|
-
Requires-Dist:
|
|
23
|
+
Requires-Dist: packaging>=21.0
|
|
24
24
|
Requires-Dist: pyyaml>=6.0
|
|
25
25
|
Requires-Dist: rich>=12.0.0
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
@@ -33,8 +33,7 @@ MTRemote 是一个专为 AI Infra 和 Python/C++ 混合开发设计的命令行
|
|
|
33
33
|
|
|
34
34
|
* **多服务器管理**:通过配置文件管理多个 GPU 节点,支持默认服务器 (Implicit/Explicit)。
|
|
35
35
|
* **智能同步引擎**:
|
|
36
|
-
* **Rsync
|
|
37
|
-
* **SFTP (兼容)**:纯 Python 实现,适用于无 `rsync` 的环境,配置简单。
|
|
36
|
+
* **Rsync**:调用系统 `rsync`,支持增量同步,速度极快。支持 `sshpass` 自动处理密码认证。
|
|
38
37
|
* **双向同步**:支持从远端下载文件/文件夹到本地(`--get` 参数)。
|
|
39
38
|
* **双模式交互 (Dual-Mode Interaction)**:
|
|
40
39
|
* **交互模式 (Interactive)**:自动检测 TTY,支持 PTY 分配、Raw Mode、Rich UI 动画。完美支持 `vim`, `ipython`, `pdb`, `htop`。
|
|
@@ -57,11 +56,21 @@ pip install mtr-cli
|
|
|
57
56
|
|
|
58
57
|
MTRemote 需要以下系统命令:
|
|
59
58
|
|
|
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) |
|
|
59
|
+
| 命令 | 用途 | 安装方式 | 版本要求 |
|
|
60
|
+
|------|------|----------|----------|
|
|
61
|
+
| `ssh` | 交互式 Shell (TTY) | macOS/Linux 自带,或 `brew install openssh` | - |
|
|
62
|
+
| `rsync` | 快速文件同步 (推荐) | macOS/Linux 自带 | **≥ 3.1.0** (TTY 进度显示需要) |
|
|
63
|
+
| `sshpass` | 密码认证 (可选) | `brew install hudochenkov/sshpass/sshpass` (macOS) / `apt install sshpass` (Ubuntu) | - |
|
|
64
|
+
|
|
65
|
+
**注意**:macOS 自带的 rsync 版本较旧(2.6.9),不支持 TTY 模式下的进度显示。建议通过 Homebrew 安装新版:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# macOS 用户建议升级 rsync
|
|
69
|
+
brew install rsync
|
|
70
|
+
|
|
71
|
+
# 验证版本
|
|
72
|
+
rsync --version # 应显示 3.1.0 或更高版本
|
|
73
|
+
```
|
|
65
74
|
|
|
66
75
|
**注意**:交互式 Shell 功能(如 `mtr bash`, `mtr ipython`)**必须**安装 `ssh`。密码认证**必须**安装 `sshpass`。
|
|
67
76
|
|
|
@@ -83,7 +92,7 @@ mtr --init
|
|
|
83
92
|
|
|
84
93
|
```yaml
|
|
85
94
|
defaults:
|
|
86
|
-
sync: "rsync"
|
|
95
|
+
sync: "rsync"
|
|
87
96
|
exclude: [".git/", "__pycache__/"]
|
|
88
97
|
download_dir: "./downloads" # 默认下载位置(可选)
|
|
89
98
|
|
|
@@ -115,6 +124,21 @@ mtr ipython
|
|
|
115
124
|
mtr -s prod-node python train.py
|
|
116
125
|
```
|
|
117
126
|
|
|
127
|
+
### ⚠️ 参数传递注意事项
|
|
128
|
+
|
|
129
|
+
当执行的命令包含以 `-` 开头的参数时(如 `python -c`, `gcc -O2`),建议使用 `--` 作为分隔符,避免被误认为是 `mtr` 的选项:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# ❌ 错误:-s 会被当作 --server 的短选项
|
|
133
|
+
mtr python3 -s
|
|
134
|
+
|
|
135
|
+
# ✅ 正确:使用 -- 分隔符
|
|
136
|
+
mtr -- python3 -s
|
|
137
|
+
|
|
138
|
+
# ✅ 指定服务器时也使用 --
|
|
139
|
+
mtr --server prod-node -- python3 -c "print('hello')"
|
|
140
|
+
```
|
|
141
|
+
|
|
118
142
|
## 📖 命令行选项
|
|
119
143
|
|
|
120
144
|
```bash
|
|
@@ -159,21 +183,9 @@ mtr --enable-log --log-file ./debug.log python train.py
|
|
|
159
183
|
mtr --no-tty python train.py > log.txt
|
|
160
184
|
```
|
|
161
185
|
|
|
162
|
-
### 2.
|
|
163
|
-
如果本地或远程无法使用 rsync,可以在配置中指定 `sync: sftp`:
|
|
164
|
-
|
|
165
|
-
```yaml
|
|
166
|
-
servers:
|
|
167
|
-
win-server:
|
|
168
|
-
host: "10.0.0.9"
|
|
169
|
-
sync: "sftp"
|
|
170
|
-
password: "secret_password"
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
### 3. 密码认证
|
|
186
|
+
### 2. 密码认证
|
|
174
187
|
支持 SSH 密码认证,但推荐使用 SSH Key。
|
|
175
188
|
* **交互式 Shell**: 使用 `sshpass` 包装 `ssh -t` 命令。
|
|
176
|
-
* **SFTP**: 原生支持密码。
|
|
177
189
|
* **Rsync**: 需要本地安装 `sshpass` 工具才能使用密码认证。
|
|
178
190
|
|
|
179
191
|
**密码认证依赖**: 使用密码认证时,必须安装 `sshpass`:
|
|
@@ -188,7 +200,7 @@ sudo apt-get install sshpass
|
|
|
188
200
|
sudo yum install sshpass
|
|
189
201
|
```
|
|
190
202
|
|
|
191
|
-
###
|
|
203
|
+
### 3. 从远端下载文件 (--get)
|
|
192
204
|
使用 `--get` 参数可以从远端服务器下载文件或文件夹到本地:
|
|
193
205
|
|
|
194
206
|
```bash
|
|
@@ -231,7 +243,7 @@ servers:
|
|
|
231
243
|
3. 默认配置中的 `download_dir`
|
|
232
244
|
4. 当前工作目录
|
|
233
245
|
|
|
234
|
-
###
|
|
246
|
+
### 4. 调试日志 (--enable-log)
|
|
235
247
|
当遇到问题需要排查时,可以启用文件日志:
|
|
236
248
|
|
|
237
249
|
```bash
|