mtr-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mtr/__init__.py ADDED
File without changes
mtr/cli.py ADDED
@@ -0,0 +1,358 @@
1
+ import os
2
+ import sys
3
+ from datetime import datetime
4
+
5
+ import click
6
+
7
+ from mtr.config import ConfigError, ConfigLoader
8
+ from mtr.logger import LogLevel, get_logger, setup_logging
9
+ from mtr.ssh import SSHClientWrapper, SSHError
10
+ from mtr.sync import RsyncSyncer, SftpSyncer, SyncError
11
+
12
+ DEFAULT_CONFIG_TEMPLATE = """# MTRemote Configuration
13
+ defaults:
14
+ # 默认同步引擎
15
+ # 选项: "rsync" (推荐), "sftp"
16
+ sync: "rsync"
17
+
18
+ exclude:
19
+ - ".git/"
20
+ - "__pycache__/"
21
+ - "*.pyc"
22
+
23
+ # 默认下载位置(可选,使用 --get 时生效)
24
+ # download_dir: "./downloads"
25
+
26
+ servers:
27
+ # === 服务器示例 ===
28
+ dev-node:
29
+ host: "192.168.1.100"
30
+ user: "your_username"
31
+ key_filename: "~/.ssh/id_rsa"
32
+ remote_dir: "/home/your_username/projects/current_project"
33
+
34
+ # 预设命令 (可选)
35
+ # pre_cmd: "source ~/.bashrc && conda activate myenv"
36
+
37
+ # 密码认证 (可选)
38
+ # password: "secret"
39
+
40
+ # 强制同步引擎 (可选)
41
+ # sync: "sftp"
42
+
43
+ # 该服务器的下载位置(可选,覆盖 defaults)
44
+ # download_dir: "./backups/dev-node"
45
+ """
46
+
47
+
48
+ def _init_config():
49
+ """Initialize .mtr/config.yaml in current directory."""
50
+ mtr_dir = os.path.join(os.getcwd(), ".mtr")
51
+ config_file = os.path.join(mtr_dir, "config.yaml")
52
+
53
+ if os.path.exists(config_file):
54
+ click.secho(f"Configuration already exists at {config_file}", fg="yellow")
55
+ return
56
+
57
+ if not os.path.exists(mtr_dir):
58
+ os.makedirs(mtr_dir)
59
+ click.echo(f"Created directory: {mtr_dir}")
60
+
61
+ with open(config_file, "w") as f:
62
+ f.write(DEFAULT_CONFIG_TEMPLATE)
63
+
64
+ click.secho(f"Created configuration: {config_file}", fg="green")
65
+ click.echo("Please edit it to match your environment.")
66
+
67
+
68
+ @click.command(context_settings=dict(ignore_unknown_options=True, allow_extra_args=True))
69
+ @click.option("-s", "--server", help="Target server alias")
70
+ @click.option("--sync/--no-sync", default=True, help="Enable/Disable code sync")
71
+ @click.option("--dry-run", is_flag=True, help="Print commands without executing")
72
+ @click.option("--tty/--no-tty", default=True, help="Force enable/disable TTY")
73
+ @click.option("--init", is_flag=True, help="Initialize a configuration file in current directory")
74
+ @click.option("--enable-log", is_flag=True, help="Enable logging to file")
75
+ @click.option("--log-level", default="INFO", help="Log level (DEBUG/INFO/WARNING/ERROR)")
76
+ @click.option("--log-file", help="Path to log file (default: ~/.mtr/logs/mtr_YYYYMMDD_HHMMSS.log)")
77
+ @click.option("--get", "remote_get_path", help="Remote path to download from")
78
+ @click.option("--to", "local_dest_path", help="Local destination path for download (optional)")
79
+ @click.argument("command", nargs=-1, type=click.UNPROCESSED)
80
+ def cli(server, sync, dry_run, tty, init, enable_log, log_level, log_file, remote_get_path, local_dest_path, command):
81
+ """MTRemote: Sync and Execute code on remote server."""
82
+
83
+ # Get logger instance (will be no-op if not setup)
84
+ logger = get_logger()
85
+
86
+ # Setup logging if enabled
87
+ if enable_log:
88
+ if not log_file:
89
+ # Generate default log file path: ~/.mtr/logs/mtr_YYYYMMDD_HHMMSS.log
90
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
91
+ log_dir = os.path.expanduser("~/.mtr/logs")
92
+ log_file = os.path.join(log_dir, f"mtr_{timestamp}.log")
93
+
94
+ try:
95
+ level = LogLevel.from_string(log_level)
96
+ setup_logging(log_file, level)
97
+ except ValueError:
98
+ click.secho(f"Warning: Invalid log level '{log_level}', using INFO", fg="yellow")
99
+ setup_logging(log_file, LogLevel.INFO)
100
+
101
+ # Re-get logger after setup to use the real logger instead of no-op
102
+ logger = get_logger()
103
+
104
+ if init:
105
+ logger = get_logger()
106
+ _init_config()
107
+ logger.info("Initialized configuration file", module="mtr.cli")
108
+ return
109
+
110
+ # Handle --get mode
111
+ cli_dest = local_dest_path
112
+
113
+ if not command and not remote_get_path:
114
+ click.echo(cli.get_help(click.get_current_context()))
115
+ return
116
+
117
+ # Join command parts back into a string
118
+ remote_cmd = " ".join(command)
119
+
120
+ logger.info(f"Starting mtr with command: {remote_cmd}", module="mtr.cli")
121
+ logger.debug(f"Options: server={server}, sync={sync}, dry_run={dry_run}, tty={tty}", module="mtr.cli")
122
+
123
+ # Check for interactive mode (TTY)
124
+ # Interactive if: TTY is enabled by flag AND stdout is a real terminal
125
+ is_interactive = tty and sys.stdout.isatty()
126
+
127
+ # Import rich if interactive
128
+ console = None
129
+ if is_interactive:
130
+ try:
131
+ from rich.console import Console
132
+
133
+ console = Console()
134
+ except ImportError:
135
+ is_interactive = False # Fallback if rich is missing (should not happen with dependencies)
136
+
137
+ # 1. Load Configuration
138
+ try:
139
+ loader = ConfigLoader()
140
+ config = loader.load(server_name=server)
141
+ logger.info(f"Loaded configuration, target server: {config.target_server}", module="mtr.config")
142
+ except ConfigError as e:
143
+ logger.error(f"Configuration error: {e}", module="mtr.config")
144
+ if console:
145
+ console.print(f"[bold red]Configuration Error:[/bold red] {e}")
146
+ else:
147
+ click.secho(f"Configuration Error: {e}", fg="red", err=True)
148
+ sys.exit(1)
149
+
150
+ server_conf = config.server_config
151
+ host = server_conf.get("host")
152
+ port = server_conf.get("port", 22)
153
+ user = server_conf.get("user")
154
+ key_filename = server_conf.get("key_filename")
155
+ password = server_conf.get("password")
156
+ remote_dir = server_conf.get("remote_dir")
157
+ pre_cmd = server_conf.get("pre_cmd")
158
+
159
+ if not host or not user:
160
+ logger.error("Missing required config: host or user", module="mtr.cli")
161
+ click.secho(
162
+ "Error: 'host' and 'user' are required in server config.",
163
+ fg="red",
164
+ err=True,
165
+ )
166
+ sys.exit(1)
167
+
168
+ auth_method = "key" if key_filename else ("password" if password else "none")
169
+ logger.info(f"Connecting to {host} as {user} (auth: {auth_method})", module="mtr.ssh")
170
+
171
+ if console:
172
+ console.print(
173
+ f"🎯 [bold green]Target:[/bold green] {user}@{host}\t 🔖 [bold green]Tag[/bold green]: {config.target_server} "
174
+ )
175
+ else:
176
+ click.secho(f"Target: {user}@{host} [{config.target_server}]", fg="green")
177
+
178
+ # 2. Sync Code
179
+ if sync:
180
+ local_dir = os.getcwd()
181
+ # Ensure remote_dir is set
182
+ if not remote_dir:
183
+ click.secho("Error: 'remote_dir' is required for sync.", fg="red", err=True)
184
+ sys.exit(1)
185
+
186
+ # Resolve exclude
187
+ exclude = config.global_defaults.get("exclude", []) + server_conf.get("exclude", [])
188
+
189
+ # Determine engine
190
+ engine = server_conf.get("sync", config.global_defaults.get("sync", "rsync"))
191
+
192
+ if engine == "rsync":
193
+ syncer = RsyncSyncer(
194
+ local_dir=local_dir,
195
+ remote_dir=remote_dir,
196
+ host=host,
197
+ user=user,
198
+ key_filename=key_filename,
199
+ password=password,
200
+ port=port,
201
+ exclude=exclude,
202
+ )
203
+ elif engine == "sftp":
204
+ syncer = SftpSyncer(
205
+ local_dir=local_dir,
206
+ remote_dir=remote_dir,
207
+ host=host,
208
+ user=user,
209
+ key_filename=key_filename,
210
+ password=password,
211
+ port=port,
212
+ exclude=exclude,
213
+ )
214
+ else:
215
+ click.secho(
216
+ f"Warning: Sync engine '{engine}' not supported yet. Fallback/Skipping.",
217
+ fg="yellow",
218
+ )
219
+ syncer = None
220
+
221
+ if syncer:
222
+ try:
223
+ if dry_run:
224
+ click.echo(f"[DryRun] Would sync {local_dir} -> {remote_dir}")
225
+ logger.info(f"[DryRun] Would sync {local_dir} -> {remote_dir}", module="mtr.sync")
226
+ else:
227
+ if is_interactive and console:
228
+ with console.status("[bold blue]Syncing code...", spinner="dots"):
229
+ syncer.sync()
230
+ else:
231
+ click.secho("Syncing code...", fg="blue")
232
+ syncer.sync()
233
+ logger.info(f"Sync completed: {local_dir} -> {remote_dir}", module="mtr.sync")
234
+ except SyncError as e:
235
+ logger.error(f"Sync failed: {e}", module="mtr.sync")
236
+ click.secho(f"Sync Failed: {e}", fg="red", err=True)
237
+ sys.exit(1)
238
+
239
+ # 3. Download from remote (if --get is specified)
240
+ if remote_get_path:
241
+ # Resolve local destination path
242
+ if cli_dest:
243
+ local_dest = cli_dest
244
+ else:
245
+ # Use config: server > defaults > current directory
246
+ download_base = server_conf.get("download_dir") or config.global_defaults.get("download_dir") or "."
247
+ remote_basename = os.path.basename(remote_get_path.rstrip("/"))
248
+ local_dest = os.path.join(download_base, remote_basename)
249
+
250
+ # Expand user path
251
+ local_dest = os.path.expanduser(local_dest)
252
+
253
+ # Resolve exclude
254
+ exclude = config.global_defaults.get("exclude", []) + server_conf.get("exclude", [])
255
+
256
+ # Determine engine
257
+ engine = server_conf.get("sync", config.global_defaults.get("sync", "rsync"))
258
+
259
+ if engine == "rsync":
260
+ syncer = RsyncSyncer(
261
+ local_dir=".", # Not used for download
262
+ remote_dir=".", # Not used for download
263
+ host=host,
264
+ user=user,
265
+ key_filename=key_filename,
266
+ password=password,
267
+ port=port,
268
+ exclude=exclude,
269
+ )
270
+ elif engine == "sftp":
271
+ syncer = SftpSyncer(
272
+ local_dir=".", # Not used for download
273
+ remote_dir=".", # Not used for download
274
+ host=host,
275
+ user=user,
276
+ key_filename=key_filename,
277
+ password=password,
278
+ port=port,
279
+ exclude=exclude,
280
+ )
281
+ else:
282
+ click.secho(
283
+ f"Warning: Sync engine '{engine}' not supported yet.",
284
+ fg="yellow",
285
+ )
286
+ sys.exit(1)
287
+
288
+ try:
289
+ if dry_run:
290
+ click.echo(f"[DryRun] Would download {remote_get_path} -> {local_dest}")
291
+ logger.info(f"[DryRun] Would download {remote_get_path} -> {local_dest}", module="mtr.sync")
292
+ else:
293
+ if is_interactive and console:
294
+ with console.status(f"[bold blue]Downloading {remote_get_path}...", spinner="dots"):
295
+ syncer.download(remote_get_path, local_dest)
296
+ console.print(f"✅ [green]Downloaded:[/green] {remote_get_path} -> {local_dest}")
297
+ else:
298
+ click.secho(f"Downloading {remote_get_path}...", fg="blue")
299
+ syncer.download(remote_get_path, local_dest)
300
+ click.secho(f"Download completed: {local_dest}", fg="green")
301
+ logger.info(f"Download completed: {remote_get_path} -> {local_dest}", module="mtr.sync")
302
+ except SyncError as e:
303
+ logger.error(f"Download failed: {e}", module="mtr.sync")
304
+ click.secho(f"Download Failed: {e}", fg="red", err=True)
305
+ sys.exit(1)
306
+
307
+ # Download mode doesn't execute commands
308
+ return
309
+
310
+ # 4. Execute Command
311
+ if not is_interactive:
312
+ click.secho(f"Executing: {remote_cmd}", fg="blue")
313
+
314
+ if dry_run:
315
+ click.echo(f"[DryRun] Would run on {host}: {remote_cmd} (workdir={remote_dir})")
316
+ return
317
+
318
+ ssh = SSHClientWrapper(host, user, port=port, key_filename=key_filename, password=password)
319
+ try:
320
+ ssh.connect()
321
+ logger.info(f"SSH connection established to {host}", module="mtr.ssh")
322
+
323
+ if is_interactive:
324
+ # Run interactive shell (full TTY support)
325
+ logger.info(f"Executing interactive command: {remote_cmd}", module="mtr.cli")
326
+ exit_code = ssh.run_interactive_shell(remote_cmd, workdir=remote_dir, pre_cmd=pre_cmd)
327
+ logger.info(f"Command completed with exit code: {exit_code}", module="mtr.cli")
328
+ sys.exit(exit_code)
329
+ else:
330
+ # Run stream mode (for scripts/pipes)
331
+ # pty=False ensures clean output for parsing (separates stdout/stderr if we implemented that,
332
+ # but currently streams merged or just stdout. Let's keep pty=False to avoid control chars)
333
+ logger.info(f"Executing command: {remote_cmd}", module="mtr.cli")
334
+ stream = ssh.exec_command_stream(remote_cmd, workdir=remote_dir, pre_cmd=pre_cmd, pty=False)
335
+
336
+ # Consume generator and print
337
+ exit_code = 0
338
+ try:
339
+ while True:
340
+ line = next(stream)
341
+ click.echo(line, nl=False)
342
+ except StopIteration as e:
343
+ exit_code = e.value
344
+
345
+ logger.info(f"Command completed with exit code: {exit_code}", module="mtr.cli")
346
+ sys.exit(exit_code)
347
+
348
+ except SSHError as e:
349
+ logger.error(f"SSH error: {e}", module="mtr.ssh")
350
+ click.secho(f"SSH Error: {e}", fg="red", err=True)
351
+ sys.exit(1)
352
+ finally:
353
+ logger.info("Closing SSH connection", module="mtr.ssh")
354
+ ssh.close()
355
+
356
+
357
+ if __name__ == "__main__":
358
+ cli()
mtr/config.py ADDED
@@ -0,0 +1,87 @@
1
+ import os
2
+ from dataclasses import dataclass
3
+ from typing import Any, Dict, Optional
4
+
5
+ import yaml
6
+
7
+
8
+ class ConfigError(Exception):
9
+ """Base exception for configuration errors."""
10
+
11
+ pass
12
+
13
+
14
+ @dataclass
15
+ class Config:
16
+ """Holds the resolved configuration for a specific execution context."""
17
+
18
+ target_server: str
19
+ server_config: Dict[str, Any]
20
+ global_defaults: Dict[str, Any]
21
+
22
+
23
+ class ConfigLoader:
24
+ def __init__(self, config_path: Optional[str] = None):
25
+ self.config_path = config_path or self._resolve_default_config_path()
26
+
27
+ def _resolve_default_config_path(self) -> str:
28
+ # Priority: ./.mtr/config.yaml -> ~/.config/mtr/config.yaml
29
+ local_config = os.path.join(os.getcwd(), ".mtr", "config.yaml")
30
+ if os.path.exists(local_config):
31
+ return local_config
32
+
33
+ user_config = os.path.expanduser("~/.config/mtr/config.yaml")
34
+ if os.path.exists(user_config):
35
+ return user_config
36
+
37
+ # Fallback to local if neither exists (will fail later on read if needed, or we can handle it)
38
+ return local_config
39
+
40
+ def load(self, server_name: Optional[str] = None) -> Config:
41
+ if not os.path.exists(self.config_path):
42
+ raise ConfigError(f"Configuration file not found at: {self.config_path}")
43
+
44
+ try:
45
+ with open(self.config_path, "r") as f:
46
+ raw_config = yaml.safe_load(f) or {}
47
+ except yaml.YAMLError as e:
48
+ raise ConfigError(f"Failed to parse config file: {e}")
49
+
50
+ servers = raw_config.get("servers", {})
51
+ if not servers:
52
+ raise ConfigError("No servers defined in configuration.")
53
+
54
+ # Determine target server
55
+ target_server = server_name
56
+ if not target_server:
57
+ # Check for 'default' field (supports both 'default' and 'defaults' key potential confusion,
58
+ # let's stick to 'default' for server alias as per spec)
59
+ target_server = raw_config.get("default")
60
+
61
+ if not target_server:
62
+ # Implicit default: first server
63
+ # In Python 3.7+, dicts preserve insertion order
64
+ target_server = next(iter(servers))
65
+
66
+ if target_server not in servers:
67
+ raise ConfigError(f"Server '{target_server}' not found in configuration.")
68
+
69
+ server_config = servers[target_server]
70
+
71
+ # Merge global defaults into server config if needed
72
+ # (This is a simplified merge, real one might need deep merge)
73
+ global_defaults = raw_config.get("defaults", {})
74
+
75
+ # Apply defaults to server config if keys are missing
76
+ # For now, let's just return them separately or we can merge them.
77
+ # The prompt implies we might want to use defaults.
78
+ # Let's simple merge: defaults < server_config
79
+
80
+ merged_config = global_defaults.copy()
81
+ merged_config.update(server_config)
82
+
83
+ return Config(
84
+ target_server=target_server,
85
+ server_config=merged_config,
86
+ global_defaults=global_defaults,
87
+ )
mtr/logger.py ADDED
@@ -0,0 +1,133 @@
1
+ import os
2
+ from datetime import datetime
3
+ from enum import Enum
4
+ from typing import Optional
5
+
6
+
7
+ class LogLevel(Enum):
8
+ """Log level enumeration."""
9
+
10
+ DEBUG = 10
11
+ INFO = 20
12
+ WARNING = 30
13
+ ERROR = 40
14
+
15
+ @classmethod
16
+ def from_string(cls, level_str: str) -> "LogLevel":
17
+ """Convert string to LogLevel (case-insensitive)."""
18
+ level_map = {
19
+ "DEBUG": cls.DEBUG,
20
+ "INFO": cls.INFO,
21
+ "WARNING": cls.WARNING,
22
+ "ERROR": cls.ERROR,
23
+ }
24
+ level_upper = level_str.upper()
25
+ if level_upper not in level_map:
26
+ raise ValueError(f"Invalid log level: {level_str}")
27
+ return level_map[level_upper]
28
+
29
+
30
+ class _NoOpLogger:
31
+ """No-op logger that silently discards all log messages.
32
+
33
+ This is returned by get_logger() when logging is not initialized.
34
+ It provides the same interface as Logger but does nothing.
35
+ """
36
+
37
+ def debug(self, message: str, module: str = "") -> None:
38
+ """No-op debug log."""
39
+ pass
40
+
41
+ def info(self, message: str, module: str = "") -> None:
42
+ """No-op info log."""
43
+ pass
44
+
45
+ def warning(self, message: str, module: str = "") -> None:
46
+ """No-op warning log."""
47
+ pass
48
+
49
+ def error(self, message: str, module: str = "") -> None:
50
+ """No-op error log."""
51
+ pass
52
+
53
+
54
+ class Logger:
55
+ """Simple file-based logger."""
56
+
57
+ def __init__(self, log_file: str, level: LogLevel = LogLevel.INFO):
58
+ """Initialize logger.
59
+
60
+ Args:
61
+ log_file: Path to log file
62
+ level: Minimum log level to record
63
+ """
64
+ self.log_file = log_file
65
+ self.level = level
66
+
67
+ def _write(self, level: LogLevel, message: str, module: str = ""):
68
+ """Write log message to file if level is sufficient."""
69
+ if level.value < self.level.value:
70
+ return
71
+
72
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
73
+ module_str = f"[{module}]" if module else ""
74
+ log_line = f"[{timestamp}] [{level.name}] {module_str} {message}\n"
75
+
76
+ with open(self.log_file, "a", encoding="utf-8") as f:
77
+ f.write(log_line)
78
+
79
+ def debug(self, message: str, module: str = ""):
80
+ """Log debug message."""
81
+ self._write(LogLevel.DEBUG, message, module)
82
+
83
+ def info(self, message: str, module: str = ""):
84
+ """Log info message."""
85
+ self._write(LogLevel.INFO, message, module)
86
+
87
+ def warning(self, message: str, module: str = ""):
88
+ """Log warning message."""
89
+ self._write(LogLevel.WARNING, message, module)
90
+
91
+ def error(self, message: str, module: str = ""):
92
+ """Log error message."""
93
+ self._write(LogLevel.ERROR, message, module)
94
+
95
+
96
+ # Global logger instance
97
+ _logger: Optional[Logger] = None
98
+ # No-op logger singleton
99
+ _no_op_logger = _NoOpLogger()
100
+
101
+
102
+ def setup_logging(log_file: str, level: LogLevel = LogLevel.INFO) -> Logger:
103
+ """Setup logging with file output.
104
+
105
+ Args:
106
+ log_file: Path to log file
107
+ level: Minimum log level
108
+
109
+ Returns:
110
+ Configured logger instance
111
+ """
112
+ global _logger
113
+
114
+ # Create log directory if not exists
115
+ log_dir = os.path.dirname(log_file)
116
+ if log_dir and not os.path.exists(log_dir):
117
+ os.makedirs(log_dir, exist_ok=True)
118
+
119
+ _logger = Logger(log_file, level)
120
+ return _logger
121
+
122
+
123
+ def get_logger():
124
+ """Get the global logger instance.
125
+
126
+ Returns:
127
+ Logger instance if logging is initialized, otherwise a no-op logger.
128
+ The returned object always has debug(), info(), warning(), error() methods.
129
+ """
130
+ global _logger
131
+ if _logger is None:
132
+ return _no_op_logger
133
+ return _logger