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/ssh.py ADDED
@@ -0,0 +1,199 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ from typing import Generator, Optional
5
+
6
+ import paramiko
7
+
8
+ from mtr.logger import get_logger
9
+
10
+
11
+ class SSHError(Exception):
12
+ pass
13
+
14
+
15
+ # Constants for batch mode
16
+ BUFFER_SIZE = 32768 # 32KB for better performance
17
+
18
+
19
+ def _check_ssh_availability():
20
+ """Check if ssh command is available."""
21
+ if shutil.which("ssh") is None:
22
+ raise SSHError(
23
+ "SSH command not found. Please install OpenSSH client.\n"
24
+ " macOS: brew install openssh\n"
25
+ " Ubuntu/Debian: sudo apt-get install openssh-client\n"
26
+ " CentOS/RHEL: sudo yum install openssh-clients"
27
+ )
28
+
29
+
30
+ def _check_sshpass_availability():
31
+ """Check if sshpass command is available."""
32
+ if shutil.which("sshpass") is None:
33
+ raise SSHError(
34
+ "sshpass command not found. Please install sshpass for password authentication.\n"
35
+ " macOS: brew install hudochenkov/sshpass/sshpass\n"
36
+ " Ubuntu/Debian: sudo apt-get install sshpass\n"
37
+ " CentOS/RHEL: sudo yum install sshpass"
38
+ )
39
+
40
+
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")
mtr/sync.py ADDED
@@ -0,0 +1,338 @@
1
+ import fnmatch
2
+ import os
3
+ import shlex
4
+ import shutil
5
+ import subprocess
6
+ from abc import ABC, abstractmethod
7
+ from typing import List, Optional
8
+
9
+ import paramiko
10
+
11
+
12
+ class SyncError(Exception):
13
+ pass
14
+
15
+
16
+ class BaseSyncer(ABC):
17
+ def __init__(self, local_dir: str, remote_dir: str, exclude: List[str]):
18
+ self.local_dir = local_dir
19
+ self.remote_dir = remote_dir
20
+ self.exclude = exclude
21
+
22
+ @abstractmethod
23
+ def sync(self):
24
+ pass
25
+
26
+
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
+ class RsyncSyncer(BaseSyncer):
247
+ def __init__(
248
+ self,
249
+ local_dir: str,
250
+ remote_dir: str,
251
+ host: str,
252
+ user: str,
253
+ key_filename: Optional[str] = None,
254
+ password: Optional[str] = None,
255
+ port: int = 22,
256
+ exclude: List[str] = None,
257
+ ):
258
+ super().__init__(local_dir, remote_dir, exclude or [])
259
+ self.host = host
260
+ self.user = user
261
+ self.key_filename = key_filename
262
+ self.password = password
263
+ self.port = port
264
+
265
+ def _build_ssh_options(self) -> str:
266
+ """Build SSH options string for rsync."""
267
+ opts = f"ssh -p {self.port}"
268
+ if self.key_filename:
269
+ opts += f" -i {self.key_filename}"
270
+ return opts
271
+
272
+ def _build_rsync_base(self) -> List[str]:
273
+ """Build rsync base command with common options."""
274
+ cmd = ["rsync", "-azq"]
275
+
276
+ # Add excludes
277
+ for item in self.exclude:
278
+ cmd.append(f"--exclude={item}")
279
+
280
+ # SSH options
281
+ cmd.extend(["-e", self._build_ssh_options()])
282
+
283
+ return cmd
284
+
285
+ def _wrap_with_sshpass(self, cmd: List[str]) -> List[str]:
286
+ """Wrap command with sshpass if password authentication is used."""
287
+ if self.password and not self.key_filename:
288
+ return ["sshpass", "-p", self.password] + cmd
289
+ return cmd
290
+
291
+ def _check_sshpass(self):
292
+ """Check if sshpass is available when password authentication is used."""
293
+ if self.password and not self.key_filename:
294
+ if not shutil.which("sshpass"):
295
+ raise SyncError("Rsync with password requires 'sshpass'. Please install it or use SSH Key.")
296
+
297
+ def _build_rsync_command(self) -> List[str]:
298
+ """Build rsync command for uploading (local -> remote)."""
299
+ # Ensure local dir ends with / to sync contents, not the dir itself
300
+ src = self.local_dir if self.local_dir.endswith("/") else f"{self.local_dir}/"
301
+ dest = f"{self.user}@{self.host}:{shlex.quote(self.remote_dir)}"
302
+
303
+ cmd = self._build_rsync_base()
304
+ cmd.extend([src, dest])
305
+
306
+ return self._wrap_with_sshpass(cmd)
307
+
308
+ def _build_rsync_download_command(self, remote_path: str, local_path: str) -> List[str]:
309
+ """Build rsync command for downloading (remote -> local)."""
310
+ src = f"{self.user}@{self.host}:{shlex.quote(remote_path)}"
311
+
312
+ cmd = self._build_rsync_base()
313
+ cmd.extend([src, local_path])
314
+
315
+ return self._wrap_with_sshpass(cmd)
316
+
317
+ def sync(self):
318
+ self._check_sshpass()
319
+ cmd = self._build_rsync_command()
320
+ try:
321
+ subprocess.run(cmd, check=True)
322
+ except subprocess.CalledProcessError as e:
323
+ raise SyncError(f"Rsync failed with exit code {e.returncode}")
324
+
325
+ def download(self, remote_path: str, local_path: str):
326
+ """Download file or directory from remote to local."""
327
+ self._check_sshpass()
328
+
329
+ # Ensure local parent directory exists
330
+ local_dir = os.path.dirname(local_path)
331
+ if local_dir and not os.path.exists(local_dir):
332
+ os.makedirs(local_dir, exist_ok=True)
333
+
334
+ cmd = self._build_rsync_download_command(remote_path, local_path)
335
+ try:
336
+ subprocess.run(cmd, check=True)
337
+ except subprocess.CalledProcessError as e:
338
+ raise SyncError(f"Rsync download failed with exit code {e.returncode}")