xshell-like 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.
xshell/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """XShell-like terminal tool: SSH + serial, tabs, batch connect, sessions, SFTP."""
2
+
3
+ __version__ = "0.1.0"
xshell/__main__.py ADDED
@@ -0,0 +1,22 @@
1
+ """Entry point: python -m xshell (GUI; use QT_QPA_PLATFORM=offscreen for headless)."""
2
+ import sys
3
+
4
+
5
+ def main() -> int:
6
+ from PySide6.QtWidgets import QApplication
7
+
8
+ from xshell.core.storage import SessionStore
9
+ from xshell.ui.main_window import MainWindow
10
+
11
+ app = QApplication(sys.argv)
12
+ app.setApplicationName("xshell-like")
13
+ app.setOrganizationName("xshell-like")
14
+
15
+ store = SessionStore()
16
+ window = MainWindow(store=store)
17
+ window.show()
18
+ return app.exec()
19
+
20
+
21
+ if __name__ == "__main__":
22
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Core layer: pure Python, no Qt dependency (fully headless-testable)."""
xshell/core/batch.py ADDED
@@ -0,0 +1,59 @@
1
+ """Batch orchestration: connect many sessions at once, isolate failures."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Callable, List, Optional, Tuple
6
+
7
+ from .session import SessionConfig
8
+ from .transport import TransportBase, default_transport_factory
9
+
10
+
11
+ @dataclass
12
+ class BatchResult:
13
+ opened: List[Tuple[SessionConfig, TransportBase]] = field(default_factory=list)
14
+ failed: List[Tuple[SessionConfig, str]] = field(default_factory=list)
15
+
16
+ @property
17
+ def ok_count(self) -> int:
18
+ return len(self.opened)
19
+
20
+ @property
21
+ def fail_count(self) -> int:
22
+ return len(self.failed)
23
+
24
+ def summary(self) -> str:
25
+ lines = [f"connected {self.ok_count}, failed {self.fail_count}"]
26
+ for cfg, err in self.failed:
27
+ lines.append(f" ✗ {cfg.display_name()}: {err}")
28
+ return "\n".join(lines)
29
+
30
+
31
+ class BatchManager:
32
+ """Opens one transport per config; a bad session never blocks the rest."""
33
+
34
+ def __init__(self, transport_factory: Optional[Callable] = None):
35
+ self._factory = transport_factory or default_transport_factory
36
+
37
+ def connect_one(self, config: SessionConfig, **kwargs) -> TransportBase:
38
+ transport = self._factory(config, **kwargs)
39
+ transport.open()
40
+ return transport
41
+
42
+ def connect_all(self, configs: List[SessionConfig], **kwargs) -> BatchResult:
43
+ result = BatchResult()
44
+ for config in configs:
45
+ try:
46
+ transport = self.connect_one(config, **kwargs)
47
+ except Exception as exc:
48
+ result.failed.append((config, str(exc)))
49
+ continue
50
+ result.opened.append((config, transport))
51
+ return result
52
+
53
+ def close_all(self, transports: List[TransportBase]) -> None:
54
+ for transport in transports:
55
+ try:
56
+ if transport.is_open:
57
+ transport.close()
58
+ except Exception:
59
+ pass # closing must never throw back into the UI
@@ -0,0 +1,127 @@
1
+ """Deploy the local public key to a remote host (passwordless setup).
2
+
3
+ Equivalent of `ssh-copy-id`, implemented over paramiko so it also works on
4
+ Windows: connect with the credentials the session already has (password,
5
+ existing key, or agent), then run the append through the remote shell.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import stat
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ import paramiko
16
+
17
+ from .session import SessionConfig
18
+
19
+ _PUBKEY_RE = re.compile(r"^(ssh-(rsa|dss|ed25519)|ecdsa-[a-z0-9-]+) [A-Za-z0-9+/=]+[ @]")
20
+ _CHECK_CMD = "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat ~/.ssh/authorized_keys 2>/dev/null || true"
21
+ _APPEND_CMD = "mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo {key} >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
22
+
23
+
24
+ @dataclass
25
+ class DeployResult:
26
+ ok: bool
27
+ already_present: bool
28
+ message: str
29
+
30
+
31
+ def find_local_public_key(ssh_dir: Path = None) -> tuple | None:
32
+ """Find an existing default keypair. Returns (private_path, pubkey_text)."""
33
+ ssh_dir = Path(ssh_dir) if ssh_dir else Path.home() / ".ssh"
34
+ for name in ("id_ed25519", "id_rsa", "id_ecdsa", "id_dsa"):
35
+ priv = ssh_dir / name
36
+ pub = ssh_dir / (name + ".pub")
37
+ if pub.exists():
38
+ return str(priv), pub.read_text(encoding="utf-8").strip()
39
+ return None
40
+
41
+
42
+ def ensure_local_keypair(ssh_dir: Path = None) -> tuple:
43
+ """Find or generate a default keypair (RSA 3072, no passphrase).
44
+ Returns (private_path, pubkey_text)."""
45
+ existing = find_local_public_key(ssh_dir)
46
+ if existing:
47
+ return existing
48
+
49
+ ssh_dir = Path(ssh_dir) if ssh_dir else Path.home() / ".ssh"
50
+ ssh_dir.mkdir(parents=True, exist_ok=True)
51
+ key = paramiko.RSAKey.generate(3072)
52
+ priv_path = ssh_dir / "id_rsa"
53
+ key.write_private_key_file(str(priv_path))
54
+ os.chmod(priv_path, stat.S_IRUSR | stat.S_IWUSR) # 0600
55
+ pub_path = ssh_dir / "id_rsa.pub"
56
+ pub_path.write_text(f"{key.get_name()} {key.get_base64()} xshell-like\n", encoding="utf-8")
57
+ return str(priv_path), pub_path.read_text(encoding="utf-8").strip()
58
+
59
+
60
+ def _run(client: paramiko.SSHClient, command: str) -> str:
61
+ _, stdout, _ = client.exec_command(command, timeout=15)
62
+ output = stdout.read().decode("utf-8", errors="replace")
63
+ exit_code = stdout.channel.recv_exit_status()
64
+ if exit_code != 0:
65
+ raise RuntimeError(f"远端命令失败(退出码 {exit_code}): {command[:60]}…")
66
+ return output
67
+
68
+
69
+ def deploy_public_key(config: SessionConfig, ssh_dir: Path = None) -> DeployResult:
70
+ """Append the local public key to the remote authorized_keys.
71
+
72
+ Uses the session's own credentials for the first hop: a stored password,
73
+ an explicit key file, or the agent/default keys.
74
+ """
75
+ priv_path, pub_text = ensure_local_keypair(ssh_dir)
76
+ if not _PUBKEY_RE.match(pub_text):
77
+ return DeployResult(False, False, f"本机公钥格式异常,拒绝部署: {pub_text[:40]}…")
78
+
79
+ cfg = config
80
+ client = paramiko.SSHClient()
81
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
82
+ kwargs = dict(
83
+ hostname=cfg.host,
84
+ port=int(cfg.port),
85
+ username=cfg.username or None,
86
+ timeout=10,
87
+ banner_timeout=10,
88
+ allow_agent=False,
89
+ look_for_keys=False,
90
+ )
91
+ if cfg.auth_method == "key" and cfg.key_path:
92
+ kwargs["key_filename"] = cfg.key_path
93
+ kwargs["allow_agent"] = True
94
+ elif cfg.auth_method == "auto":
95
+ kwargs["allow_agent"] = True
96
+ kwargs["look_for_keys"] = True
97
+ elif cfg.password:
98
+ kwargs["password"] = cfg.password
99
+ elif cfg.auth_method == "key" or cfg.auth_method == "auto":
100
+ kwargs["allow_agent"] = True
101
+ kwargs["look_for_keys"] = True
102
+ else: # password auth but no password stored — caller must prompt first
103
+ return DeployResult(
104
+ False, False, "该会话没有可用凭据:请先在会话里保存密码,或已配置免密/agent"
105
+ )
106
+
107
+ try:
108
+ client.connect(**kwargs)
109
+ except paramiko.AuthenticationException:
110
+ return DeployResult(False, False, "认证失败:密码错误且无可用密钥")
111
+ except (paramiko.SSHException, OSError) as exc:
112
+ return DeployResult(False, False, f"连接失败: {exc}")
113
+
114
+ try:
115
+ existing = _run(client, _CHECK_CMD)
116
+ if pub_text in existing:
117
+ return DeployResult(True, True, "公钥已存在,无需重复部署")
118
+ append_cmd = _APPEND_CMD.format(key="'" + pub_text + "'")
119
+ _run(client, append_cmd)
120
+ confirm = _run(client, _CHECK_CMD)
121
+ if pub_text not in confirm:
122
+ return DeployResult(False, False, "部署后校验失败:远端 authorized_keys 未见公钥")
123
+ return DeployResult(True, False, f"已部署公钥 {Path(priv_path).name} → {cfg.summary()}")
124
+ except (paramiko.SSHException, OSError, RuntimeError) as exc:
125
+ return DeployResult(False, False, f"部署失败: {exc}")
126
+ finally:
127
+ client.close()
@@ -0,0 +1,98 @@
1
+ """Serial transport on top of pyserial (device files / USB adapters)."""
2
+ from __future__ import annotations
3
+
4
+ import threading
5
+
6
+ import serial
7
+
8
+ from .session import SessionConfig
9
+ from .transport import TransportBase
10
+
11
+ READ_TIMEOUT = 0.2 # seconds — read() returns up to this early
12
+ WRITE_TIMEOUT = 10 # seconds — how long a blocked write may hold the caller
13
+
14
+
15
+ class SerialTransport(TransportBase):
16
+ def __init__(self, config: SessionConfig, **kwargs):
17
+ super().__init__(config, **kwargs)
18
+ self._port: serial.Serial | None = None
19
+ self._reader: threading.Thread | None = None
20
+ self._stop = threading.Event()
21
+
22
+ @property
23
+ def is_open(self) -> bool:
24
+ return (
25
+ self._port is not None
26
+ and self._port.is_open
27
+ and not self._stop.is_set()
28
+ )
29
+
30
+ def open(self) -> None:
31
+ cfg = self.config
32
+ try:
33
+ port = serial.Serial(
34
+ port=cfg.device,
35
+ baudrate=int(cfg.baudrate),
36
+ bytesize=int(cfg.bytesize),
37
+ parity=cfg.parity,
38
+ stopbits=int(cfg.stopbits),
39
+ timeout=READ_TIMEOUT,
40
+ write_timeout=WRITE_TIMEOUT,
41
+ )
42
+ except (serial.SerialException, OSError, ValueError) as exc:
43
+ raise ConnectionError(f"cannot open {cfg.device}: {exc}") from exc
44
+
45
+ self._port = port
46
+ self._stop.clear()
47
+ self._reader = threading.Thread(
48
+ target=self._read_loop, name=f"serial-read-{cfg.name}", daemon=True
49
+ )
50
+ self._reader.start()
51
+ self._emit("connected", cfg.summary())
52
+
53
+ def _read_loop(self) -> None:
54
+ port = self._port
55
+ try:
56
+ while not self._stop.is_set():
57
+ try:
58
+ chunk = port.read(4096)
59
+ except (serial.SerialException, OSError, TypeError):
60
+ if self._stop.is_set():
61
+ break
62
+ self._emit("error", "serial port read failed")
63
+ break
64
+ if chunk:
65
+ cb = self.on_data
66
+ if cb:
67
+ cb(chunk)
68
+ if not self._stop.is_set():
69
+ self._emit("disconnected", "device disappeared")
70
+ except Exception as exc:
71
+ if not self._stop.is_set():
72
+ self._emit("error", f"reader thread: {exc}")
73
+ finally:
74
+ self._stop.set()
75
+
76
+ def write(self, data: bytes) -> None:
77
+ if self.is_open:
78
+ try:
79
+ self._port.write(data)
80
+ if self.config.local_echo and self.on_data:
81
+ self.on_data(data)
82
+ except (serial.SerialException, OSError) as exc:
83
+ self._emit("error", f"write failed: {exc}")
84
+
85
+ def resize(self, cols: int, rows: int) -> None:
86
+ self.cols, self.rows = cols, rows # no pty geometry on a serial line
87
+
88
+ def close(self) -> None:
89
+ self._stop.set()
90
+ port, self._port = self._port, None
91
+ if port is not None:
92
+ try:
93
+ port.close()
94
+ except Exception:
95
+ pass
96
+ if self._reader and self._reader.is_alive():
97
+ self._reader.join(timeout=1.0)
98
+ self._emit("disconnected", "")
xshell/core/session.py ADDED
@@ -0,0 +1,199 @@
1
+ """Session data model: SSH / serial connection config with persistence support."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import time
6
+ import uuid
7
+ from dataclasses import asdict, dataclass, field, fields
8
+ from typing import Any, Dict
9
+
10
+ TYPE_SSH = "ssh"
11
+ TYPE_SERIAL = "serial"
12
+
13
+ TYPE_BADGE = {TYPE_SSH: "[SSH]", TYPE_SERIAL: "[串口]"}
14
+
15
+
16
+ def parse_ssh_target(text: str) -> SessionConfig:
17
+ """Parse a quick-connect string into a session config.
18
+
19
+ Accepted forms (leading 'ssh ' optional):
20
+ ts@10.0.28.34 user@host (passwordless key/agent auth)
21
+ ts@10.0.28.34:2222 user@host:port
22
+ ssh -p 2222 ts@10.0.28.34 (-p flag, before or after target)
23
+ """
24
+ raw = (text or "").strip()
25
+ if raw.lower().startswith("ssh "):
26
+ raw = raw[4:].strip()
27
+ if not raw or raw.lower() == "ssh":
28
+ raise ValueError("请输入连接目标,如 ssh ts@10.0.28.34")
29
+
30
+ port = 22
31
+ tokens = raw.split()
32
+ # pull out an optional -p <port> / -p<port> flag wherever it appears
33
+ i = 0
34
+ targets: list = []
35
+ while i < len(tokens):
36
+ tok = tokens[i]
37
+ if tok == "-p":
38
+ if i + 1 >= len(tokens):
39
+ raise ValueError("-p 缺少端口号")
40
+ port = int(tokens[i + 1])
41
+ i += 2
42
+ continue
43
+ if tok.startswith("-p") and tok != "-p" and tok[2:].isdigit():
44
+ port = int(tok[2:])
45
+ i += 1
46
+ continue
47
+ if tok.startswith("-"):
48
+ raise ValueError(f"暂不支持参数: {tok}")
49
+ targets.append(tok)
50
+ i += 1
51
+ if not targets:
52
+ raise ValueError("缺少主机地址,如 ssh ts@10.0.28.34")
53
+ if len(targets) > 1:
54
+ raise ValueError(f"一次只能连接一个目标,收到: {' '.join(targets)}")
55
+
56
+ target = targets[0]
57
+ if ":" in target:
58
+ target, _, port_part = target.rpartition(":")
59
+ if not port_part.isdigit():
60
+ raise ValueError(f"端口不合法: {port_part}")
61
+ port = int(port_part)
62
+ target = target.strip("[]") # bare [ipv6]:port
63
+ if "@" in target:
64
+ username, _, host = target.partition("@")
65
+ else:
66
+ username, host = "", target
67
+ if not host:
68
+ raise ValueError("缺少主机地址")
69
+ if not username:
70
+ import getpass
71
+
72
+ username = getpass.getuser()
73
+
74
+ return SessionConfig(
75
+ name=f"{username}@{host}" if port == 22 else f"{username}@{host}:{port}",
76
+ type=TYPE_SSH,
77
+ host=host,
78
+ port=port,
79
+ username=username,
80
+ auth_method="auto", # passwordless first: agent + default keys
81
+ )
82
+
83
+
84
+ def _encode_secret(plain: str) -> str:
85
+ """Light obfuscation (base64) — not encryption; use key auth for real security."""
86
+ return base64.b64encode(plain.encode("utf-8")).decode("ascii") if plain else ""
87
+
88
+
89
+ def _decode_secret(stored: str) -> str:
90
+ try:
91
+ return base64.b64decode(stored.encode("ascii")).decode("utf-8") if stored else ""
92
+ except (ValueError, UnicodeDecodeError):
93
+ return ""
94
+
95
+
96
+ @dataclass
97
+ class SessionConfig:
98
+ """One saved connection (SSH or serial). All fields JSON-serializable."""
99
+
100
+ name: str = ""
101
+ type: str = TYPE_SSH
102
+ id: str = field(default_factory=lambda: uuid.uuid4().hex)
103
+
104
+ # --- SSH specific ---
105
+ host: str = ""
106
+ port: int = 22
107
+ username: str = ""
108
+ auth_method: str = "password" # "password" | "key"
109
+ password_enc: str = "" # base64-obfuscated password, never plain in JSON
110
+ key_path: str = ""
111
+
112
+ # --- Serial specific ---
113
+ device: str = "/dev/ttyUSB0"
114
+ baudrate: int = 115200
115
+ bytesize: int = 8
116
+ parity: str = "N" # N / E / O
117
+ stopbits: int = 1
118
+
119
+ # --- Common ---
120
+ encoding: str = "utf-8"
121
+ local_echo: bool = False # serial devices often need it; SSH remote echoes
122
+ group: str = ""
123
+ created_at: float = field(default_factory=time.time)
124
+ last_used: float = 0.0
125
+
126
+ # ---------- persistence ----------
127
+
128
+ def to_dict(self) -> Dict[str, Any]:
129
+ d = asdict(self)
130
+ return d
131
+
132
+ @classmethod
133
+ def from_dict(cls, d: Dict[str, Any]) -> "SessionConfig":
134
+ known = {f.name for f in fields(cls)}
135
+ filtered = {k: v for k, v in d.items() if k in known}
136
+ # tolerate hand-edited files: coerce types, drop fields that stay garbage
137
+ type_hints = {f.name: f.type for f in fields(cls)}
138
+ for k, v in list(filtered.items()):
139
+ hint = str(type_hints.get(k, ""))
140
+ try:
141
+ if "bool" in hint:
142
+ filtered[k] = bool(v)
143
+ elif "int" in hint:
144
+ filtered[k] = int(v)
145
+ elif "float" in hint:
146
+ filtered[k] = float(v)
147
+ elif "str" in hint:
148
+ filtered[k] = "" if v is None else str(v)
149
+ except (TypeError, ValueError):
150
+ del filtered[k] # fall back to the dataclass default
151
+ stype = filtered.get("type", TYPE_SSH)
152
+ if stype not in (TYPE_SSH, TYPE_SERIAL):
153
+ raise ValueError(f"unknown session type: {stype!r}")
154
+ return cls(**filtered)
155
+
156
+ # ---------- duplication avoidance (R6) ----------
157
+
158
+ def same_identity(self, other: "SessionConfig") -> bool:
159
+ """Two sessions are 'the same connection' if their identity matches."""
160
+ if self is other:
161
+ return True
162
+ if self.type != other.type:
163
+ return False
164
+ if self.type == TYPE_SSH:
165
+ return (
166
+ self.host == other.host
167
+ and int(self.port) == int(other.port)
168
+ and self.username == other.username
169
+ )
170
+ return self.device == other.device and int(self.baudrate) == int(other.baudrate)
171
+
172
+ # ---------- convenience ----------
173
+
174
+ @property
175
+ def password(self) -> str:
176
+ return _decode_secret(self.password_enc)
177
+
178
+ @password.setter
179
+ def password(self, plain: str) -> None:
180
+ self.password_enc = _encode_secret(plain)
181
+
182
+ def display_name(self) -> str:
183
+ if self.type == TYPE_SSH:
184
+ return f"{self.name or self.host}"
185
+ return f"{self.name or self.device}"
186
+
187
+ def summary(self) -> str:
188
+ if self.type == TYPE_SSH:
189
+ return f"ssh://{self.username}@{self.host}:{self.port}"
190
+ return f"{self.device}@{self.baudrate} {self.parity}{self.bytesize}{self.stopbits}"
191
+
192
+ def clone(self, new_name: str | None = None) -> "SessionConfig":
193
+ """Copy with a fresh id — batch-creating similar devices only edits host/IP."""
194
+ data = self.to_dict()
195
+ data["id"] = uuid.uuid4().hex
196
+ data["name"] = new_name if new_name is not None else f"{self.name} copy"
197
+ data["created_at"] = time.time()
198
+ data["last_used"] = 0.0
199
+ return SessionConfig.from_dict(data)
xshell/core/sftp.py ADDED
@@ -0,0 +1,123 @@
1
+ """SFTP file transfer over an established SSH connection (paramiko SFTPClient)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import posixpath
6
+ import stat as statmod
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from typing import Callable, List, Optional
10
+
11
+ import paramiko
12
+
13
+ CHUNK = 32768
14
+
15
+
16
+ @dataclass
17
+ class FileEntry:
18
+ name: str
19
+ path: str
20
+ size: int
21
+ is_dir: bool
22
+ mtime: float
23
+
24
+ @property
25
+ def size_human(self) -> str:
26
+ if self.is_dir:
27
+ return "<dir>"
28
+ for unit in ("B", "KB", "MB", "GB"):
29
+ if self.size < 1024 or unit == "GB":
30
+ return f"{self.size:.0f}{unit}" if unit == "B" else f"{self.size / 1:.1f}{unit}"
31
+ self.size /= 1024
32
+ return f"{self.size}B"
33
+
34
+ @property
35
+ def mtime_human(self) -> str:
36
+ return datetime.fromtimestamp(self.mtime).strftime("%Y-%m-%d %H:%M")
37
+
38
+
39
+ class SftpClient:
40
+ """Reuses the SSH connection of an open SshTransport."""
41
+
42
+ def __init__(self, ssh_transport) -> None:
43
+ client = getattr(ssh_transport, "_client", None)
44
+ if client is None:
45
+ raise ConnectionError("SSH transport is not open")
46
+ self._sftp: paramiko.SFTPClient = client.open_sftp()
47
+
48
+ # ---------- browsing ----------
49
+
50
+ def listdir(self, path: str = ".") -> List[FileEntry]:
51
+ entries: List[FileEntry] = []
52
+ for attr in self._sftp.listdir_attr(path):
53
+ full = posixpath.join(path, attr.filename)
54
+ entries.append(
55
+ FileEntry(
56
+ name=attr.filename,
57
+ path=full,
58
+ size=attr.st_size or 0,
59
+ is_dir=statmod.S_ISDIR(attr.st_mode or 0),
60
+ mtime=attr.st_mtime or 0,
61
+ )
62
+ )
63
+ dirs = [e for e in entries if e.is_dir]
64
+ files = [e for e in entries if not e.is_dir]
65
+ return sorted(dirs, key=lambda e: e.name.lower()) + sorted(
66
+ files, key=lambda e: e.name.lower()
67
+ )
68
+
69
+ @staticmethod
70
+ def join(path: str, name: str) -> str:
71
+ return posixpath.join(path, name)
72
+
73
+ @staticmethod
74
+ def parent(path: str) -> str:
75
+ parent = posixpath.dirname(path.rstrip("/"))
76
+ return parent or "/"
77
+
78
+ # ---------- transfer ----------
79
+
80
+ def upload(self, local_path: str, remote_path: str,
81
+ progress: Optional[Callable[[int, int], None]] = None) -> None:
82
+ total = os.path.getsize(local_path)
83
+ done = 0
84
+ with open(local_path, "rb") as lf, self._sftp.open(remote_path, "wb") as rf:
85
+ while True:
86
+ chunk = lf.read(CHUNK)
87
+ if not chunk:
88
+ break
89
+ rf.write(chunk)
90
+ done += len(chunk)
91
+ if progress:
92
+ progress(done, total)
93
+
94
+ def download(self, remote_path: str, local_path: str,
95
+ progress: Optional[Callable[[int, int], None]] = None) -> None:
96
+ total = self._sftp.stat(remote_path).st_size
97
+ done = 0
98
+ with self._sftp.open(remote_path, "rb") as rf, open(local_path, "wb") as lf:
99
+ while True:
100
+ chunk = rf.read(CHUNK)
101
+ if not chunk:
102
+ break
103
+ lf.write(chunk)
104
+ done += len(chunk)
105
+ if progress:
106
+ progress(done, total)
107
+
108
+ # ---------- maintenance ----------
109
+
110
+ def mkdir(self, remote_path: str) -> None:
111
+ self._sftp.mkdir(remote_path)
112
+
113
+ def remove_file(self, remote_path: str) -> None:
114
+ self._sftp.remove(remote_path)
115
+
116
+ def remove_dir(self, remote_path: str) -> None:
117
+ self._sftp.rmdir(remote_path)
118
+
119
+ def close(self) -> None:
120
+ try:
121
+ self._sftp.close()
122
+ except Exception:
123
+ pass