mtr-cli 0.3.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 CHANGED
@@ -0,0 +1,3 @@
1
+ """MTRemote - A CLI tool for seamless local development and remote execution."""
2
+
3
+ __version__ = "2.0.0"
mtr/cli.py CHANGED
@@ -4,20 +4,20 @@ from datetime import datetime
4
4
 
5
5
  import click
6
6
 
7
+ from mtr import __version__
7
8
  from mtr.config import ConfigError, ConfigLoader
8
9
  from mtr.logger import LogLevel, get_logger, setup_logging
9
- from mtr.ssh import SSHClientWrapper, SSHError
10
- from mtr.sync import RsyncSyncer, SftpSyncer, SyncError
10
+ from mtr.ssh import SSHError, run_ssh_command
11
+ from mtr.sync import RsyncSyncer, SyncError
12
+ from mtr.updater import UpdateChecker
11
13
 
12
14
  DEFAULT_CONFIG_TEMPLATE = """# MTRemote Configuration
13
15
  defaults:
14
- # 默认同步引擎
15
- # 选项: "rsync" (推荐), "sftp"
16
+ # 默认同步引擎 (仅支持 rsync)
16
17
  sync: "rsync"
17
18
 
18
- # 是否尊重 .gitignore 文件(仅 rsync 模式支持)
19
+ # 是否尊重 .gitignore 文件
19
20
  # 设置为 true 时,rsync 会自动读取项目根目录的 .gitignore 并排除匹配的文件
20
- # SFTP 模式不支持此选项,如启用会报错
21
21
  respect_gitignore: true
22
22
 
23
23
  exclude:
@@ -39,12 +39,9 @@ servers:
39
39
  # 预设命令 (可选)
40
40
  # pre_cmd: "source ~/.bashrc && conda activate myenv"
41
41
 
42
- # 密码认证 (可选)
42
+ # 密码认证 (可选,需要安装 sshpass)
43
43
  # password: "secret"
44
44
 
45
- # 强制同步引擎 (可选)
46
- # sync: "sftp"
47
-
48
45
  # 该服务器的下载位置(可选,覆盖 defaults)
49
46
  # download_dir: "./backups/dev-node"
50
47
  """
@@ -71,6 +68,7 @@ def _init_config():
71
68
 
72
69
 
73
70
  @click.command(context_settings=dict(ignore_unknown_options=True, allow_extra_args=True))
71
+ @click.version_option(version=__version__, prog_name="mtr-cli", help="Show version and exit.")
74
72
  @click.option("-s", "--server", help="Target server alias")
75
73
  @click.option("--sync/--no-sync", default=True, help="Enable/Disable code sync")
76
74
  @click.option("--dry-run", is_flag=True, help="Print commands without executing")
@@ -81,10 +79,36 @@ def _init_config():
81
79
  @click.option("--log-file", help="Path to log file (default: ./.mtr/logs/mtr_YYYYMMDD_HHMMSS.log)")
82
80
  @click.option("--get", "remote_get_path", help="Remote path to download from")
83
81
  @click.option("--to", "local_dest_path", help="Local destination path for download (optional)")
82
+ @click.option("--no-check-update", is_flag=True, help="Disable update check")
84
83
  @click.argument("command", nargs=-1, type=click.UNPROCESSED)
85
- def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remote_get_path, local_dest_path, command):
84
+ def cli(
85
+ server,
86
+ sync,
87
+ dry_run,
88
+ tty,
89
+ init,
90
+ enable_log,
91
+ log_level,
92
+ log_file,
93
+ remote_get_path,
94
+ local_dest_path,
95
+ no_check_update,
96
+ command,
97
+ ):
86
98
  """MTRemote: Sync and Execute code on remote server."""
87
99
 
100
+ # Check for updates (async, non-blocking)
101
+ update_message = None
102
+ if not no_check_update and not init:
103
+ checker = UpdateChecker()
104
+ # Try to get cached update message first (from previous check)
105
+ update_message = checker.get_cached_update_message()
106
+ # Trigger background check for next time
107
+ try:
108
+ checker.check()
109
+ except Exception:
110
+ pass # Silently fail update check
111
+
88
112
  # Get logger instance (will be no-op if not setup)
89
113
  logger = get_logger()
90
114
 
@@ -197,71 +221,61 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
197
221
  # Determine engine
198
222
  engine = server_conf.get("sync", config.global_defaults.get("sync", "rsync"))
199
223
 
200
- if engine == "rsync":
201
- syncer = RsyncSyncer(
202
- local_dir=local_dir,
203
- remote_dir=remote_dir,
204
- host=host,
205
- user=user,
206
- key_filename=key_filename,
207
- password=password,
208
- port=port,
209
- exclude=exclude,
210
- respect_gitignore=respect_gitignore,
211
- )
212
- elif engine == "sftp":
213
- syncer = SftpSyncer(
214
- local_dir=local_dir,
215
- remote_dir=remote_dir,
216
- host=host,
217
- user=user,
218
- key_filename=key_filename,
219
- password=password,
220
- port=port,
221
- exclude=exclude,
222
- respect_gitignore=respect_gitignore,
223
- )
224
- else:
224
+ # Check if SFTP is configured
225
+ if engine == "sftp":
226
+ logger.error("SFTP mode is no longer supported. Please use rsync.", module="mtr.cli")
225
227
  click.secho(
226
- f"Warning: Sync engine '{engine}' not supported yet. Fallback/Skipping.",
227
- fg="yellow",
228
+ "Error: SFTP mode has been removed. Please update your config to use 'sync: rsync'.",
229
+ fg="red",
230
+ err=True,
228
231
  )
229
- syncer = None
232
+ sys.exit(1)
233
+
234
+ syncer = RsyncSyncer(
235
+ local_dir=local_dir,
236
+ remote_dir=remote_dir,
237
+ host=host,
238
+ user=user,
239
+ key_filename=key_filename,
240
+ password=password,
241
+ port=port,
242
+ exclude=exclude,
243
+ respect_gitignore=respect_gitignore,
244
+ )
245
+
246
+ try:
247
+ if dry_run:
248
+ click.echo(f"[DryRun] Would sync {local_dir} -> {remote_dir}")
249
+ logger.info(f"[DryRun] Would sync {local_dir} -> {remote_dir}", module="mtr.sync")
250
+ else:
251
+ if is_interactive and console:
252
+ # TTY mode: single line real-time update using Rich Live
253
+ from rich.live import Live
254
+ from rich.text import Text
255
+
256
+ with Live(Text("Starting sync...", style="blue"), refresh_per_second=10) as live:
230
257
 
231
- if syncer:
232
- try:
233
- if dry_run:
234
- click.echo(f"[DryRun] Would sync {local_dir} -> {remote_dir}")
235
- logger.info(f"[DryRun] Would sync {local_dir} -> {remote_dir}", module="mtr.sync")
236
- else:
237
- if is_interactive and console:
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"))
251
- else:
252
- # no_tty mode: print each file on new line
253
258
  def show_sync_progress(filename):
259
+ # Get relative path for cleaner display
254
260
  rel_path = os.path.relpath(filename, local_dir)
255
- click.echo(f"Syncing: {rel_path}")
261
+ live.update(Text(f"Syncing: {rel_path}", style="blue"))
256
262
 
257
- click.secho("Syncing code...", fg="blue")
258
263
  syncer.sync(show_progress=True, progress_callback=show_sync_progress)
259
- click.secho("Sync completed!", fg="green")
260
- logger.info(f"Sync completed: {local_dir} -> {remote_dir}", module="mtr.sync")
261
- except SyncError as e:
262
- logger.error(f"Sync failed: {e}", module="mtr.sync")
263
- click.secho(f"Sync Failed: {e}", fg="red", err=True)
264
- sys.exit(1)
264
+ live.update(Text("Sync completed!", style="green"))
265
+ else:
266
+ # no_tty mode: print each file on new line
267
+ def show_sync_progress(filename):
268
+ rel_path = os.path.relpath(filename, local_dir)
269
+ click.echo(f"Syncing: {rel_path}")
270
+
271
+ click.secho("Syncing code...", fg="blue")
272
+ syncer.sync(show_progress=True, progress_callback=show_sync_progress)
273
+ click.secho("Sync completed!", fg="green")
274
+ logger.info(f"Sync completed: {local_dir} -> {remote_dir}", module="mtr.sync")
275
+ except SyncError as e:
276
+ logger.error(f"Sync failed: {e}", module="mtr.sync")
277
+ click.secho(f"Sync Failed: {e}", fg="red", err=True)
278
+ sys.exit(1)
265
279
 
266
280
  # 3. Download from remote (if --get is specified)
267
281
  if remote_get_path:
@@ -293,37 +307,28 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
293
307
  # Determine engine
294
308
  engine = server_conf.get("sync", config.global_defaults.get("sync", "rsync"))
295
309
 
296
- if engine == "rsync":
297
- syncer = RsyncSyncer(
298
- local_dir=".", # Not used for download
299
- remote_dir=".", # Not used for download
300
- host=host,
301
- user=user,
302
- key_filename=key_filename,
303
- password=password,
304
- port=port,
305
- exclude=exclude,
306
- respect_gitignore=respect_gitignore,
307
- )
308
- elif engine == "sftp":
309
- syncer = SftpSyncer(
310
- local_dir=".", # Not used for download
311
- remote_dir=".", # Not used for download
312
- host=host,
313
- user=user,
314
- key_filename=key_filename,
315
- password=password,
316
- port=port,
317
- exclude=exclude,
318
- respect_gitignore=respect_gitignore,
319
- )
320
- else:
310
+ # Check if SFTP is configured
311
+ if engine == "sftp":
312
+ logger.error("SFTP mode is no longer supported. Please use rsync.", module="mtr.cli")
321
313
  click.secho(
322
- f"Warning: Sync engine '{engine}' not supported yet.",
323
- fg="yellow",
314
+ "Error: SFTP mode has been removed. Please update your config to use 'sync: rsync'.",
315
+ fg="red",
316
+ err=True,
324
317
  )
325
318
  sys.exit(1)
326
319
 
320
+ syncer = RsyncSyncer(
321
+ local_dir=".", # Not used for download
322
+ remote_dir=".", # Not used for download
323
+ host=host,
324
+ user=user,
325
+ key_filename=key_filename,
326
+ password=password,
327
+ port=port,
328
+ exclude=exclude,
329
+ respect_gitignore=respect_gitignore,
330
+ )
331
+
327
332
  try:
328
333
  if dry_run:
329
334
  click.echo(f"[DryRun] Would download {remote_get_path} -> {local_dest}")
@@ -369,43 +374,34 @@ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remot
369
374
  click.echo(f"[DryRun] Would run on {host}: {remote_cmd} (workdir={remote_dir})")
370
375
  return
371
376
 
372
- ssh = SSHClientWrapper(host, user, port=port, key_filename=key_filename, password=password)
373
377
  try:
374
- ssh.connect()
375
- logger.info(f"SSH connection established to {host}", module="mtr.ssh")
376
-
377
- if is_interactive:
378
- # Run interactive shell (full TTY support)
379
- logger.info(f"Executing interactive command: {remote_cmd}", module="mtr.cli")
380
- exit_code = ssh.run_interactive_shell(remote_cmd, workdir=remote_dir, pre_cmd=pre_cmd)
381
- logger.info(f"Command completed with exit code: {exit_code}", module="mtr.cli")
382
- sys.exit(exit_code)
383
- else:
384
- # Run stream mode (for scripts/pipes)
385
- # pty=False ensures clean output for parsing (separates stdout/stderr if we implemented that,
386
- # but currently streams merged or just stdout. Let's keep pty=False to avoid control chars)
387
- logger.info(f"Executing command: {remote_cmd}", module="mtr.cli")
388
- stream = ssh.exec_command_stream(remote_cmd, workdir=remote_dir, pre_cmd=pre_cmd, pty=False)
389
-
390
- # Consume generator and print
391
- exit_code = 0
392
- try:
393
- while True:
394
- line = next(stream)
395
- click.echo(line, nl=False)
396
- except StopIteration as e:
397
- exit_code = e.value
398
-
399
- logger.info(f"Command completed with exit code: {exit_code}", module="mtr.cli")
400
- sys.exit(exit_code)
378
+ # Execute command via SSH
379
+ logger.info(f"Executing command: {remote_cmd}", module="mtr.cli")
380
+ exit_code = run_ssh_command(
381
+ host=host,
382
+ user=user,
383
+ command=remote_cmd,
384
+ port=port,
385
+ key_filename=key_filename,
386
+ password=password,
387
+ workdir=remote_dir,
388
+ pre_cmd=pre_cmd,
389
+ tty=is_interactive,
390
+ )
391
+ logger.info(f"Command completed with exit code: {exit_code}", module="mtr.cli")
392
+
393
+ # Show update message if available
394
+ if update_message:
395
+ click.echo(update_message, err=True)
396
+ sys.exit(exit_code)
401
397
 
402
398
  except SSHError as e:
403
399
  logger.error(f"SSH error: {e}", module="mtr.ssh")
404
400
  click.secho(f"SSH Error: {e}", fg="red", err=True)
401
+ # Show update message if available even on error
402
+ if update_message:
403
+ click.echo(update_message, err=True)
405
404
  sys.exit(1)
406
- finally:
407
- logger.info("Closing SSH connection", module="mtr.ssh")
408
- ssh.close()
409
405
 
410
406
 
411
407
  if __name__ == "__main__":
mtr/ssh.py CHANGED
@@ -1,19 +1,17 @@
1
+ """SSH utilities for MTRemote."""
2
+
1
3
  import os
2
4
  import shutil
3
5
  import subprocess
4
- from typing import Generator, Optional
5
-
6
- import paramiko
6
+ from typing import Optional
7
7
 
8
8
  from mtr.logger import get_logger
9
9
 
10
10
 
11
11
  class SSHError(Exception):
12
- pass
12
+ """SSH-related errors."""
13
13
 
14
-
15
- # Constants for batch mode
16
- BUFFER_SIZE = 32768 # 32KB for better performance
14
+ pass
17
15
 
18
16
 
19
17
  def _check_ssh_availability():
@@ -38,162 +36,97 @@ def _check_sshpass_availability():
38
36
  )
39
37
 
40
38
 
41
- class SSHClientWrapper:
42
- def __init__(
43
- self,
44
- host: str,
45
- user: str,
46
- port: int = 22,
47
- key_filename: Optional[str] = None,
48
- password: Optional[str] = None,
49
- ):
50
- self.host = host
51
- self.user = user
52
- self.port = port
53
- self.key_filename = key_filename
54
- self.password = password
55
- self.client: Optional[paramiko.SSHClient] = None
56
-
57
- def connect(self):
58
- logger = get_logger()
59
- logger.info(f"Connecting to {self.host}:{self.port} as {self.user}", module="mtr.ssh")
60
- self.client = paramiko.SSHClient()
61
- self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
62
-
63
- connect_kwargs = {
64
- "hostname": self.host,
65
- "username": self.user,
66
- "port": self.port,
67
- "timeout": 10,
68
- }
69
-
70
- if self.key_filename:
71
- expanded_key = os.path.expanduser(self.key_filename)
72
- connect_kwargs["key_filename"] = expanded_key
73
- logger.info("Using key-based authentication", module="mtr.ssh")
74
- elif self.password:
75
- connect_kwargs["password"] = self.password
76
- logger.info("Using password authentication", module="mtr.ssh")
77
- else:
78
- logger.info("No authentication method specified, using default", module="mtr.ssh")
79
-
80
- try:
81
- self.client.connect(**connect_kwargs)
82
- logger.info(f"SSH connection established to {self.host}", module="mtr.ssh")
83
- except paramiko.SSHException as e:
84
- logger.error(f"Failed to connect to {self.host}: {e}", module="mtr.ssh")
85
- raise SSHError(f"Failed to connect to {self.host}: {e}")
86
-
87
- def _build_command(self, command: str, workdir: Optional[str] = None, pre_cmd: Optional[str] = None) -> str:
88
- parts = []
89
- if workdir:
90
- parts.append(f"cd {workdir}")
91
- if pre_cmd:
92
- parts.append(pre_cmd)
93
- parts.append(command)
94
- return " && ".join(parts)
95
-
96
- def exec_command_stream(
97
- self,
98
- command: str,
99
- workdir: Optional[str] = None,
100
- pre_cmd: Optional[str] = None,
101
- pty: bool = True,
102
- ) -> Generator[str, None, int]:
103
- """
104
- Executes command and yields output lines.
105
- Returns the exit code.
106
- Suitable for batch mode or when interactivity is not required.
107
- """
108
- logger = get_logger()
109
- logger.info(f"Executing command (stream mode): {command}", module="mtr.ssh")
110
- logger.debug(f"Workdir: {workdir}, Pre-cmd: {pre_cmd}, PTY: {pty}", module="mtr.ssh")
111
-
112
- if not self.client:
113
- raise SSHError("Client not connected")
114
-
115
- full_command = self._build_command(command, workdir, pre_cmd)
116
- logger.debug(f"Full command: {full_command}", module="mtr.ssh")
117
-
118
- try:
119
- stdin, stdout, stderr = self.client.exec_command(full_command, get_pty=pty)
120
- stdin.close()
121
-
122
- logger.debug("Command executed, starting output stream", module="mtr.ssh")
123
-
124
- line_count = 0
125
- for line in stdout:
126
- line_count += 1
127
- if line_count % 100 == 0:
128
- logger.debug(f"Streamed {line_count} lines so far", module="mtr.ssh")
129
- yield line
130
-
131
- logger.debug(f"Output stream ended, total lines: {line_count}", module="mtr.ssh")
132
-
133
- exit_code = stdout.channel.recv_exit_status()
134
- logger.info(f"Command exited with code: {exit_code}", module="mtr.ssh")
135
- return exit_code
136
-
137
- except paramiko.SSHException as e:
138
- logger.error(f"Command execution failed: {e}", module="mtr.ssh")
139
- raise SSHError(f"Command execution failed: {e}")
140
-
141
- def run_interactive_shell(self, command: str, workdir: Optional[str] = None, pre_cmd: Optional[str] = None) -> int:
142
- """
143
- Runs an interactive shell using system ssh -t command.
144
- This provides full TTY support with proper signal handling.
145
- Returns exit code.
146
- """
147
- logger = get_logger()
148
- logger.info(f"Starting interactive shell via ssh -t: {command}", module="mtr.ssh")
149
- logger.debug(f"Workdir: {workdir}, Pre-cmd: {pre_cmd}", module="mtr.ssh")
150
-
151
- # Check SSH availability
152
- _check_ssh_availability()
153
-
154
- # Check sshpass availability if password is used
155
- if self.password:
156
- _check_sshpass_availability()
157
-
158
- full_command = self._build_command(command, workdir, pre_cmd)
159
-
160
- # Build ssh command
161
- ssh_cmd = ["ssh", "-t"]
162
-
163
- # Port
164
- if self.port != 22:
165
- ssh_cmd.extend(["-p", str(self.port)])
166
-
167
- # Key authentication
168
- if self.key_filename:
169
- ssh_cmd.extend(["-i", os.path.expanduser(self.key_filename)])
170
-
171
- # Target host and command
172
- target = f"{self.user}@{self.host}"
173
- ssh_cmd.extend([target, full_command])
174
-
175
- # Wrap with sshpass if password is provided
176
- if self.password:
177
- ssh_cmd = ["sshpass", "-p", self.password] + ssh_cmd
178
-
179
- logger.debug(f"Executing: {' '.join(ssh_cmd)}", module="mtr.ssh")
180
-
181
- # Run command with direct stdin/stdout/stderr forwarding
182
- try:
183
- result = subprocess.run(ssh_cmd)
184
- logger.info(f"Interactive shell exited with code: {result.returncode}", module="mtr.ssh")
185
- return result.returncode
186
- except FileNotFoundError as e:
187
- # This shouldn't happen if _check_ssh_availability passed, but just in case
188
- logger.error(f"Command not found: {e}", module="mtr.ssh")
189
- raise SSHError(f"SSH command execution failed: {e}")
190
- except Exception as e:
191
- logger.error(f"Interactive shell failed: {e}", module="mtr.ssh")
192
- raise SSHError(f"Interactive shell failed: {e}")
193
-
194
- def close(self):
195
- if self.client:
196
- logger = get_logger()
197
- logger.debug("Closing SSH connection", module="mtr.ssh")
198
- self.client.close()
199
- logger.debug("SSH connection closed", module="mtr.ssh")
39
+ def _build_command(command: str, workdir: Optional[str] = None, pre_cmd: Optional[str] = None) -> str:
40
+ """Build the full command string with workdir and pre_cmd."""
41
+ parts = []
42
+ if workdir:
43
+ parts.append(f"cd {workdir}")
44
+ if pre_cmd:
45
+ parts.append(pre_cmd)
46
+ parts.append(command)
47
+ return " && ".join(parts)
48
+
49
+
50
+ def run_ssh_command(
51
+ host: str,
52
+ user: str,
53
+ command: str,
54
+ port: int = 22,
55
+ key_filename: Optional[str] = None,
56
+ password: Optional[str] = None,
57
+ workdir: Optional[str] = None,
58
+ pre_cmd: Optional[str] = None,
59
+ tty: bool = True,
60
+ ) -> int:
61
+ """
62
+ Run a command on remote host via system SSH.
63
+
64
+ Args:
65
+ host: Remote host address
66
+ user: SSH username
67
+ command: Command to execute
68
+ port: SSH port (default: 22)
69
+ key_filename: Path to SSH private key
70
+ password: SSH password (requires sshpass)
71
+ workdir: Working directory on remote host
72
+ pre_cmd: Command to run before main command
73
+ tty: If True, use ssh -t for TTY allocation
74
+
75
+ Returns:
76
+ Exit code from the remote command
77
+
78
+ Raises:
79
+ SSHError: If SSH command fails or is not available
80
+ """
81
+ logger = get_logger()
82
+ mode_str = "interactive" if tty else "batch"
83
+ logger.info(f"Executing {mode_str} command via SSH: {command}", module="mtr.ssh")
84
+ logger.debug(f"Host: {host}, User: {user}, Port: {port}, TTY: {tty}", module="mtr.ssh")
85
+
86
+ # Check SSH availability
87
+ _check_ssh_availability()
88
+
89
+ # Check sshpass availability if password is used
90
+ if password and not key_filename:
91
+ _check_sshpass_availability()
92
+
93
+ # Build the full command
94
+ full_command = _build_command(command, workdir, pre_cmd)
95
+ logger.debug(f"Full command: {full_command}", module="mtr.ssh")
96
+
97
+ # Build SSH command
98
+ ssh_cmd = ["ssh"]
99
+
100
+ # Add -t flag for TTY mode
101
+ if tty:
102
+ ssh_cmd.append("-t")
103
+
104
+ # Port
105
+ if port != 22:
106
+ ssh_cmd.extend(["-p", str(port)])
107
+
108
+ # Key authentication
109
+ if key_filename:
110
+ ssh_cmd.extend(["-i", os.path.expanduser(key_filename)])
111
+
112
+ # Target host and command
113
+ target = f"{user}@{host}"
114
+ ssh_cmd.extend([target, full_command])
115
+
116
+ # Wrap with sshpass if password is provided
117
+ if password and not key_filename:
118
+ ssh_cmd = ["sshpass", "-p", password] + ssh_cmd
119
+
120
+ logger.debug(f"Executing: {' '.join(ssh_cmd)}", module="mtr.ssh")
121
+
122
+ # Run command
123
+ try:
124
+ result = subprocess.run(ssh_cmd)
125
+ logger.info(f"Command exited with code: {result.returncode}", module="mtr.ssh")
126
+ return result.returncode
127
+ except FileNotFoundError as e:
128
+ logger.error(f"Command not found: {e}", module="mtr.ssh")
129
+ raise SSHError(f"SSH command execution failed: {e}")
130
+ except Exception as e:
131
+ logger.error(f"SSH command failed: {e}", module="mtr.ssh")
132
+ raise SSHError(f"SSH command failed: {e}")
mtr/sync.py CHANGED
@@ -1,4 +1,3 @@
1
- import fnmatch
2
1
  import os
3
2
  import shlex
4
3
  import shutil
@@ -6,8 +5,6 @@ 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
@@ -25,243 +22,6 @@ class BaseSyncer(ABC):
25
22
  pass
26
23
 
27
24
 
28
- class SftpSyncer(BaseSyncer):
29
- def __init__(
30
- self,
31
- local_dir: str,
32
- remote_dir: str,
33
- host: str,
34
- user: str,
35
- key_filename: Optional[str] = None,
36
- password: Optional[str] = None,
37
- port: int = 22,
38
- exclude: List[str] = None,
39
- respect_gitignore: bool = True,
40
- ):
41
- super().__init__(local_dir, remote_dir, exclude or [], respect_gitignore)
42
- self.host = host
43
- self.user = user
44
- self.key_filename = key_filename
45
- self.password = password
46
- self.port = port
47
- self.transport = None
48
- self.sftp = None
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
-
57
- def _should_ignore(self, filename: str) -> bool:
58
- for pattern in self.exclude:
59
- # Handle directory exclusion (basic)
60
- if pattern.endswith("/") and filename.startswith(pattern.rstrip("/")):
61
- return True
62
- if fnmatch.fnmatch(filename, pattern):
63
- return True
64
- return False
65
-
66
- def _connect(self):
67
- try:
68
- self.transport = paramiko.Transport((self.host, self.port))
69
- connect_kwargs = {"username": self.user}
70
-
71
- if self.key_filename:
72
- key_path = os.path.expanduser(self.key_filename)
73
- # Try different key types? For now assuming RSA or standard loading
74
- # Or just use connect method of SSHClient? No, Sftp is lower level usually,
75
- # but we can use SSHClient to get sftp
76
-
77
- # Simpler approach: Use SSHClientWrapper logic or just Paramiko SSHClient
78
- client = paramiko.SSHClient()
79
- client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
80
-
81
- k_kwargs = {
82
- "hostname": self.host,
83
- "username": self.user,
84
- "port": self.port,
85
- "key_filename": key_path,
86
- }
87
- if self.password:
88
- k_kwargs["password"] = self.password
89
-
90
- client.connect(**k_kwargs)
91
- self.sftp = client.open_sftp()
92
- return
93
-
94
- if self.password:
95
- connect_kwargs["password"] = self.password
96
- self.transport.connect(**connect_kwargs)
97
- self.sftp = paramiko.SFTPClient.from_transport(self.transport)
98
- else:
99
- raise SyncError("No auth method provided (need key or password)")
100
-
101
- except Exception as e:
102
- raise SyncError(f"SFTP Connection failed: {e}")
103
-
104
- def _ensure_remote_dir(self, remote_path: str):
105
- """Recursively create remote directory."""
106
- # This is a bit expensive, optimization: assume parent exists or try/except
107
- # Simple implementation:
108
- dirs = remote_path.split("/")
109
- current = ""
110
- for d in dirs:
111
- if not d:
112
- continue
113
- current += f"/{d}"
114
- try:
115
- self.sftp.stat(current)
116
- except FileNotFoundError:
117
- try:
118
- self.sftp.mkdir(current)
119
- except OSError:
120
- pass # Already exists maybe
121
-
122
- def sync(self, show_progress: bool = False, progress_callback=None):
123
- if not self.sftp:
124
- self._connect()
125
-
126
- # Ensure base remote dir exists
127
- try:
128
- self.sftp.stat(self.remote_dir)
129
- except FileNotFoundError:
130
- self._ensure_remote_dir(self.remote_dir)
131
-
132
- # Walk local tree
133
- for root, dirs, files in os.walk(self.local_dir):
134
- # Filtering dirs in place to prevent recursion
135
- dirs[:] = [d for d in dirs if not self._should_ignore(d)]
136
-
137
- rel_path = os.path.relpath(root, self.local_dir)
138
- if rel_path == ".":
139
- remote_root = self.remote_dir
140
- else:
141
- remote_root = os.path.join(self.remote_dir, rel_path)
142
-
143
- # Check/Create remote dir
144
- try:
145
- self.sftp.stat(remote_root)
146
- except FileNotFoundError:
147
- self.sftp.mkdir(remote_root)
148
-
149
- for file in files:
150
- if self._should_ignore(file):
151
- continue
152
-
153
- local_file = os.path.join(root, file)
154
- remote_file = os.path.join(remote_root, file)
155
-
156
- # Check sync necessity (Size & Mtime)
157
- should_upload = True
158
- try:
159
- remote_stat = self.sftp.stat(remote_file)
160
- local_stat = os.stat(local_file)
161
-
162
- if remote_stat.st_size == local_stat.st_size and int(remote_stat.st_mtime) >= int(local_stat.st_mtime):
163
- should_upload = False
164
- except FileNotFoundError:
165
- pass # Does not exist, must upload
166
-
167
- if should_upload:
168
- # Call progress callback if provided
169
- if show_progress and progress_callback:
170
- progress_callback(local_file)
171
- self.sftp.put(local_file, remote_file)
172
- # Preserve permissions
173
- mode = os.stat(local_file).st_mode
174
- self.sftp.chmod(remote_file, mode)
175
-
176
- if self.sftp:
177
- self.sftp.close()
178
- if self.transport:
179
- self.transport.close()
180
-
181
- def download(self, remote_path: str, local_path: str, show_progress: bool = False, progress_callback=None):
182
- """Download file or directory from remote to local."""
183
- if not self.sftp:
184
- self._connect()
185
-
186
- try:
187
- # Ensure local directory exists
188
- local_dir = os.path.dirname(local_path)
189
- if local_dir and not os.path.exists(local_dir):
190
- os.makedirs(local_dir, exist_ok=True)
191
-
192
- try:
193
- import stat
194
-
195
- remote_stat = self.sftp.stat(remote_path)
196
- is_dir = stat.S_ISDIR(remote_stat.st_mode)
197
-
198
- if is_dir:
199
- self._download_dir(
200
- remote_path, local_path, show_progress=show_progress, progress_callback=progress_callback
201
- )
202
- else:
203
- self._download_file(
204
- remote_path, local_path, show_progress=show_progress, progress_callback=progress_callback
205
- )
206
- except FileNotFoundError:
207
- raise SyncError(f"Remote path not found: {remote_path}")
208
- except Exception as e:
209
- raise SyncError(f"Download failed: {e}")
210
- finally:
211
- if self.sftp:
212
- self.sftp.close()
213
- if self.transport:
214
- self.transport.close()
215
-
216
- def _should_download_file(self, remote_file: str, local_file: str) -> bool:
217
- """Check if file should be downloaded based on size and mtime."""
218
- try:
219
- remote_stat = self.sftp.stat(remote_file)
220
- local_stat = os.stat(local_file)
221
-
222
- # Size different, need download
223
- if remote_stat.st_size != local_stat.st_size:
224
- return True
225
-
226
- # Remote file is newer than local file
227
- return int(remote_stat.st_mtime) > int(local_stat.st_mtime)
228
- except FileNotFoundError:
229
- return True # Local file doesn't exist, must download
230
-
231
- def _download_file(self, remote_file: str, local_file: str, show_progress: bool = False, progress_callback=None):
232
- """Download a single file with incremental check."""
233
- if not self._should_download_file(remote_file, local_file):
234
- return # No need to download
235
-
236
- # Call progress callback if provided
237
- if show_progress and progress_callback:
238
- progress_callback(remote_file)
239
-
240
- self.sftp.get(remote_file, local_file)
241
- # Preserve permissions
242
- remote_stat = self.sftp.stat(remote_file)
243
- os.chmod(local_file, remote_stat.st_mode)
244
-
245
- def _download_dir(self, remote_dir: str, local_dir: str, show_progress: bool = False, progress_callback=None):
246
- """Recursively download a directory."""
247
- if not os.path.exists(local_dir):
248
- os.makedirs(local_dir, exist_ok=True)
249
-
250
- for entry in self.sftp.listdir_attr(remote_dir):
251
- remote_path = f"{remote_dir}/{entry.filename}"
252
- local_path = os.path.join(local_dir, entry.filename)
253
-
254
- if self._should_ignore(entry.filename):
255
- continue
256
-
257
- import stat
258
-
259
- if stat.S_ISDIR(entry.st_mode):
260
- self._download_dir(remote_path, local_path, show_progress=show_progress, progress_callback=progress_callback)
261
- else:
262
- self._download_file(remote_path, local_path, show_progress=show_progress, progress_callback=progress_callback)
263
-
264
-
265
25
  class RsyncSyncer(BaseSyncer):
266
26
  def __init__(
267
27
  self,
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: 0.3.0
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: paramiko>=2.11.0
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 (推荐)**:调用系统 `rsync`,支持增量同步,速度极快。支持 `sshpass` 自动处理密码认证。
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`。
@@ -93,7 +92,7 @@ mtr --init
93
92
 
94
93
  ```yaml
95
94
  defaults:
96
- sync: "rsync" # 或 "sftp"
95
+ sync: "rsync"
97
96
  exclude: [".git/", "__pycache__/"]
98
97
  download_dir: "./downloads" # 默认下载位置(可选)
99
98
 
@@ -125,6 +124,21 @@ mtr ipython
125
124
  mtr -s prod-node python train.py
126
125
  ```
127
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
+
128
142
  ## 📖 命令行选项
129
143
 
130
144
  ```bash
@@ -169,21 +183,9 @@ mtr --enable-log --log-file ./debug.log python train.py
169
183
  mtr --no-tty python train.py > log.txt
170
184
  ```
171
185
 
172
- ### 2. 使用 SFTP 模式
173
- 如果本地或远程无法使用 rsync,可以在配置中指定 `sync: sftp`:
174
-
175
- ```yaml
176
- servers:
177
- win-server:
178
- host: "10.0.0.9"
179
- sync: "sftp"
180
- password: "secret_password"
181
- ```
182
-
183
- ### 3. 密码认证
186
+ ### 2. 密码认证
184
187
  支持 SSH 密码认证,但推荐使用 SSH Key。
185
188
  * **交互式 Shell**: 使用 `sshpass` 包装 `ssh -t` 命令。
186
- * **SFTP**: 原生支持密码。
187
189
  * **Rsync**: 需要本地安装 `sshpass` 工具才能使用密码认证。
188
190
 
189
191
  **密码认证依赖**: 使用密码认证时,必须安装 `sshpass`:
@@ -198,7 +200,7 @@ sudo apt-get install sshpass
198
200
  sudo yum install sshpass
199
201
  ```
200
202
 
201
- ### 4. 从远端下载文件 (--get)
203
+ ### 3. 从远端下载文件 (--get)
202
204
  使用 `--get` 参数可以从远端服务器下载文件或文件夹到本地:
203
205
 
204
206
  ```bash
@@ -241,7 +243,7 @@ servers:
241
243
  3. 默认配置中的 `download_dir`
242
244
  4. 当前工作目录
243
245
 
244
- ### 5. 调试日志 (--enable-log)
246
+ ### 4. 调试日志 (--enable-log)
245
247
  当遇到问题需要排查时,可以启用文件日志:
246
248
 
247
249
  ```bash
@@ -0,0 +1,12 @@
1
+ mtr/__init__.py,sha256=8cyDYQIUDSlgJOLnC8PLiPDXLQa-H8yH9pp8WAgZ_-0,104
2
+ mtr/cli.py,sha256=KxrWYOe360nhvFz05Ffk5kP6Qn72B5TU-uehnaimLAQ,15480
3
+ mtr/config.py,sha256=HA_pZ3rTFPYa6JhgFnEQlf4ukdFovx8obv58UILloWQ,3568
4
+ mtr/logger.py,sha256=9DPKTTzYsNMF7vXnvJ0bJditNYgzhZWHwLKUErtwCBY,3669
5
+ mtr/ssh.py,sha256=z2MZf5sI2TIve62aUOOGHnYMC_zU6p8SXwyoLiCF1Sg,3962
6
+ mtr/sync.py,sha256=UFpqW_QIdmYVfWtflQsADkvoizFhfdG_owJCAvYIwmE,9529
7
+ mtr/updater.py,sha256=2R6d2ohsTDXY1Oor0NZy4wRfF-Iy6Un9g_2hkqbUlmA,4263
8
+ mtr_cli-2.0.0.dist-info/METADATA,sha256=jz604P5fjikJAo69oIFYB7yRGtcKtmwoz-EZ0Kg_PHw,9860
9
+ mtr_cli-2.0.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
10
+ mtr_cli-2.0.0.dist-info/entry_points.txt,sha256=8BRK0VoSAWGzovrOdzWqpwwNj-dmjVY1iQcz5MQseV4,36
11
+ mtr_cli-2.0.0.dist-info/licenses/LICENSE,sha256=PkuO1VHNDkFylFSOtMADb93mjExarK6DBTjtCB3kBeU,1067
12
+ mtr_cli-2.0.0.dist-info/RECORD,,
@@ -1,11 +0,0 @@
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,,