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 +0 -0
- mtr/cli.py +358 -0
- mtr/config.py +87 -0
- mtr/logger.py +133 -0
- mtr/ssh.py +199 -0
- mtr/sync.py +338 -0
- mtr_cli-0.1.0.dist-info/METADATA +291 -0
- mtr_cli-0.1.0.dist-info/RECORD +11 -0
- mtr_cli-0.1.0.dist-info/WHEEL +4 -0
- mtr_cli-0.1.0.dist-info/entry_points.txt +2 -0
- mtr_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|