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/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}")
|