ssh-handler 1.0.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.
- ssh_handler/__init__.py +52 -0
- ssh_handler/__main__.py +5 -0
- ssh_handler/cli.py +152 -0
- ssh_handler/config.py +114 -0
- ssh_handler/core.py +793 -0
- ssh_handler/credentials.py +144 -0
- ssh_handler/exceptions.py +49 -0
- ssh_handler/ftp.py +186 -0
- ssh_handler/pool.py +103 -0
- ssh_handler/pyqt_worker.py +94 -0
- ssh_handler/results.py +127 -0
- ssh_handler-1.0.0.dist-info/METADATA +182 -0
- ssh_handler-1.0.0.dist-info/RECORD +16 -0
- ssh_handler-1.0.0.dist-info/WHEEL +5 -0
- ssh_handler-1.0.0.dist-info/entry_points.txt +2 -0
- ssh_handler-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Confidential credential handling.
|
|
3
|
+
|
|
4
|
+
* ``Secret`` - wraps a password so it never shows up in logs, reprs,
|
|
5
|
+
tracebacks, or accidental ``print``/``str`` calls.
|
|
6
|
+
* ``mask`` - redact secrets inside arbitrary strings before logging.
|
|
7
|
+
* ``CredentialStore`` - store/retrieve passwords in the OS credential vault
|
|
8
|
+
(Windows Credential Manager / macOS Keychain / Secret
|
|
9
|
+
Service) via ``keyring``. Nothing is written in plaintext.
|
|
10
|
+
* ``prompt_password`` - read a password from the terminal without echoing it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import getpass
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from .exceptions import CredentialError
|
|
19
|
+
|
|
20
|
+
try: # keyring is optional; only needed for the persistent store
|
|
21
|
+
import keyring
|
|
22
|
+
_HAS_KEYRING = True
|
|
23
|
+
except Exception: # pragma: no cover
|
|
24
|
+
keyring = None
|
|
25
|
+
_HAS_KEYRING = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_REDACTED = "********"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Secret:
|
|
32
|
+
"""
|
|
33
|
+
A string-like wrapper that refuses to reveal itself except via ``reveal()``.
|
|
34
|
+
|
|
35
|
+
>>> s = Secret("hunter2")
|
|
36
|
+
>>> print(s) # ********
|
|
37
|
+
>>> repr(s) # "Secret('********')"
|
|
38
|
+
>>> f"pw={s}" # "pw=********"
|
|
39
|
+
>>> s.reveal() # "hunter2" (only when you explicitly ask)
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
__slots__ = ("_value",)
|
|
43
|
+
|
|
44
|
+
def __init__(self, value: Optional[str]):
|
|
45
|
+
self._value = value
|
|
46
|
+
|
|
47
|
+
def reveal(self) -> Optional[str]:
|
|
48
|
+
"""Return the real secret. Call only at the point of use."""
|
|
49
|
+
return self._value
|
|
50
|
+
|
|
51
|
+
def is_empty(self) -> bool:
|
|
52
|
+
return self._value is None or self._value == ""
|
|
53
|
+
|
|
54
|
+
def __bool__(self) -> bool:
|
|
55
|
+
return bool(self._value)
|
|
56
|
+
|
|
57
|
+
def __str__(self) -> str:
|
|
58
|
+
return _REDACTED
|
|
59
|
+
|
|
60
|
+
def __repr__(self) -> str:
|
|
61
|
+
return f"Secret('{_REDACTED}')"
|
|
62
|
+
|
|
63
|
+
# Avoid leaking through equality / hashing in logs.
|
|
64
|
+
def __eq__(self, other) -> bool:
|
|
65
|
+
if isinstance(other, Secret):
|
|
66
|
+
return self._value == other._value
|
|
67
|
+
return NotImplemented
|
|
68
|
+
|
|
69
|
+
def __hash__(self) -> int: # so it can live in dataclasses/sets safely
|
|
70
|
+
return hash(self._value)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def coerce_secret(value) -> Optional[Secret]:
|
|
74
|
+
"""Normalize None/str/Secret into a Secret (or None)."""
|
|
75
|
+
if value is None:
|
|
76
|
+
return None
|
|
77
|
+
if isinstance(value, Secret):
|
|
78
|
+
return value
|
|
79
|
+
return Secret(str(value))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def mask(text: str, *secrets) -> str:
|
|
83
|
+
"""Replace any revealed secret values found in *text* with ``********``."""
|
|
84
|
+
if not text:
|
|
85
|
+
return text
|
|
86
|
+
out = text
|
|
87
|
+
for sec in secrets:
|
|
88
|
+
raw = sec.reveal() if isinstance(sec, Secret) else sec
|
|
89
|
+
if raw:
|
|
90
|
+
out = out.replace(str(raw), _REDACTED)
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def prompt_password(prompt: str = "Password: ") -> Secret:
|
|
95
|
+
"""Read a password from the terminal without echo. Returns a Secret."""
|
|
96
|
+
return Secret(getpass.getpass(prompt))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CredentialStore:
|
|
100
|
+
"""
|
|
101
|
+
Persist passwords in the OS credential vault — never in plaintext files.
|
|
102
|
+
|
|
103
|
+
The ``service`` namespaces your app's credentials. ``username`` is the full
|
|
104
|
+
account identifier; for a domain account use ``"EU\\nkennedy"`` (the store
|
|
105
|
+
treats it as an opaque key, so domain accounts work transparently).
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, service: str = "ssh_handler"):
|
|
109
|
+
if not _HAS_KEYRING:
|
|
110
|
+
raise CredentialError(
|
|
111
|
+
"The 'keyring' package is required for CredentialStore. "
|
|
112
|
+
"Install it with: pip install keyring"
|
|
113
|
+
)
|
|
114
|
+
self.service = service
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def available() -> bool:
|
|
118
|
+
return _HAS_KEYRING
|
|
119
|
+
|
|
120
|
+
def set(self, username: str, password) -> None:
|
|
121
|
+
"""Store (or overwrite) a password for *username*."""
|
|
122
|
+
raw = password.reveal() if isinstance(password, Secret) else password
|
|
123
|
+
try:
|
|
124
|
+
keyring.set_password(self.service, username, raw)
|
|
125
|
+
except Exception as exc: # pragma: no cover
|
|
126
|
+
raise CredentialError(f"Could not store credential: {exc}") from exc
|
|
127
|
+
|
|
128
|
+
def get(self, username: str) -> Optional[Secret]:
|
|
129
|
+
"""Return the stored password as a Secret, or None if not found."""
|
|
130
|
+
try:
|
|
131
|
+
raw = keyring.get_password(self.service, username)
|
|
132
|
+
except Exception as exc: # pragma: no cover
|
|
133
|
+
raise CredentialError(f"Could not read credential: {exc}") from exc
|
|
134
|
+
return Secret(raw) if raw is not None else None
|
|
135
|
+
|
|
136
|
+
def delete(self, username: str) -> bool:
|
|
137
|
+
"""Delete a stored password. Returns False if it was not present."""
|
|
138
|
+
try:
|
|
139
|
+
keyring.delete_password(self.service, username)
|
|
140
|
+
return True
|
|
141
|
+
except keyring.errors.PasswordDeleteError: # type: ignore[attr-defined]
|
|
142
|
+
return False
|
|
143
|
+
except Exception as exc: # pragma: no cover
|
|
144
|
+
raise CredentialError(f"Could not delete credential: {exc}") from exc
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Exception hierarchy for ssh_handler. Catch SSHError for everything."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SSHError(Exception):
|
|
7
|
+
"""Base class for all errors raised by this package."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SSHConnectionError(SSHError):
|
|
11
|
+
"""Network / TCP / DNS level failure establishing a connection."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SSHAuthenticationError(SSHError):
|
|
15
|
+
"""Every configured authentication strategy was rejected."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SSHTimeoutError(SSHError):
|
|
19
|
+
"""An operation exceeded its allotted time."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SSHCommandError(SSHError):
|
|
23
|
+
"""A remote command exited non-zero while check=True."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, command: str, result):
|
|
26
|
+
self.command = command
|
|
27
|
+
self.result = result
|
|
28
|
+
stderr = getattr(result, "stderr", "") or ""
|
|
29
|
+
exit_code = getattr(result, "exit_code", "?")
|
|
30
|
+
super().__init__(
|
|
31
|
+
f"Command failed (exit={exit_code}): {command!r}\n"
|
|
32
|
+
f"stderr: {stderr.strip()[:500]}"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SSHTransferError(SSHError):
|
|
37
|
+
"""An SFTP/SCP upload or download failed."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SSHNotConnectedError(SSHError):
|
|
41
|
+
"""An operation was attempted before connecting (auto_reconnect off)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FTPError(SSHError):
|
|
45
|
+
"""An FTP/FTPS operation failed."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CredentialError(SSHError):
|
|
49
|
+
"""A credential could not be stored or retrieved."""
|
ssh_handler/ftp.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plain FTP / FTPS handler (separate protocol from SSH/SFTP), built on the
|
|
3
|
+
standard-library ``ftplib`` so it needs no extra dependency. Mirrors the
|
|
4
|
+
push/pull/listing surface of SSHHandler and returns the same result objects.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
import logging
|
|
12
|
+
from ftplib import FTP, FTP_TLS, error_perm, all_errors
|
|
13
|
+
from typing import Callable, Optional
|
|
14
|
+
|
|
15
|
+
from .config import FTPConfig
|
|
16
|
+
from .credentials import Secret, mask
|
|
17
|
+
from .results import TransferResult, OperationResult
|
|
18
|
+
from .exceptions import FTPError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FTPHandler:
|
|
22
|
+
"""
|
|
23
|
+
>>> with FTPHandler(FTPConfig(host="ftp.example.com", username="u",
|
|
24
|
+
... password="p", use_tls=True)) as ftp:
|
|
25
|
+
... ftp.push("local.txt", "remote.txt")
|
|
26
|
+
... ftp.pull("remote.txt", "copy.txt")
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: FTPConfig, *,
|
|
30
|
+
log_callback: Optional[Callable[[str], None]] = None,
|
|
31
|
+
logger: Optional[logging.Logger] = None, safe: bool = False):
|
|
32
|
+
self.config = config
|
|
33
|
+
self._safe_default = safe
|
|
34
|
+
self._log_callback = log_callback
|
|
35
|
+
self.log = logger or logging.getLogger(f"ftp_handler.{config.host}")
|
|
36
|
+
if not self.log.handlers:
|
|
37
|
+
h = logging.StreamHandler()
|
|
38
|
+
h.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
|
39
|
+
self.log.addHandler(h)
|
|
40
|
+
self.log.setLevel(logging.INFO)
|
|
41
|
+
self._ftp: Optional[FTP] = None
|
|
42
|
+
|
|
43
|
+
def _emit(self, level, msg):
|
|
44
|
+
msg = mask(msg, self.config.password)
|
|
45
|
+
self.log.log(level, msg)
|
|
46
|
+
if self._log_callback:
|
|
47
|
+
try:
|
|
48
|
+
self._log_callback(f"[{logging.getLevelName(level)}] {msg}")
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
def _guard(self, action, fn, *a, safe=None, **k):
|
|
53
|
+
use_safe = self._safe_default if safe is None else safe
|
|
54
|
+
if not use_safe:
|
|
55
|
+
return fn(*a, **k)
|
|
56
|
+
try:
|
|
57
|
+
return OperationResult(True, action, value=fn(*a, **k))
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
self._emit(logging.ERROR, f"{action} failed: {exc}")
|
|
60
|
+
return OperationResult(False, action, error=exc)
|
|
61
|
+
|
|
62
|
+
# --- connection ---
|
|
63
|
+
def connect(self, *, safe=None):
|
|
64
|
+
return self._guard("connect", self._connect, safe=safe)
|
|
65
|
+
|
|
66
|
+
def _connect(self):
|
|
67
|
+
cfg = self.config
|
|
68
|
+
pw = cfg.password.reveal() if isinstance(cfg.password, Secret) else cfg.password
|
|
69
|
+
try:
|
|
70
|
+
self._ftp = FTP_TLS() if cfg.use_tls else FTP()
|
|
71
|
+
self._ftp.encoding = cfg.encoding
|
|
72
|
+
self._ftp.connect(cfg.host, cfg.port, timeout=cfg.timeout)
|
|
73
|
+
self._ftp.login(cfg.username, pw or "")
|
|
74
|
+
if cfg.use_tls:
|
|
75
|
+
self._ftp.prot_p() # encrypt the data channel too
|
|
76
|
+
self._ftp.set_pasv(cfg.passive)
|
|
77
|
+
self._emit(logging.INFO, f"Connected to ftp://{cfg.username}@{cfg.host}.")
|
|
78
|
+
except all_errors as exc:
|
|
79
|
+
raise FTPError(f"FTP connect/login failed for {cfg.host}: {exc}") from exc
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def disconnect(self):
|
|
83
|
+
if self._ftp is not None:
|
|
84
|
+
try:
|
|
85
|
+
self._ftp.quit()
|
|
86
|
+
except Exception:
|
|
87
|
+
try:
|
|
88
|
+
self._ftp.close()
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
self._emit(logging.INFO, f"Disconnected from {self.config.host}.")
|
|
92
|
+
self._ftp = None
|
|
93
|
+
|
|
94
|
+
close = disconnect
|
|
95
|
+
|
|
96
|
+
def _require(self) -> FTP:
|
|
97
|
+
if self._ftp is None:
|
|
98
|
+
raise FTPError("Not connected. Call connect() first.")
|
|
99
|
+
return self._ftp
|
|
100
|
+
|
|
101
|
+
# --- operations ---
|
|
102
|
+
def listdir(self, path: str = ".", *, safe=None):
|
|
103
|
+
return self._guard("listdir", lambda: self._require().nlst(path), safe=safe)
|
|
104
|
+
|
|
105
|
+
def cwd(self, path: str, *, safe=None):
|
|
106
|
+
return self._guard("cwd", lambda: self._require().cwd(path), safe=safe)
|
|
107
|
+
|
|
108
|
+
def pwd(self, *, safe=None):
|
|
109
|
+
return self._guard("pwd", lambda: self._require().pwd(), safe=safe)
|
|
110
|
+
|
|
111
|
+
def mkdir(self, path: str, *, safe=None):
|
|
112
|
+
return self._guard("mkdir", lambda: self._require().mkd(path), safe=safe)
|
|
113
|
+
|
|
114
|
+
def remove(self, path: str, *, safe=None):
|
|
115
|
+
return self._guard("remove", lambda: self._require().delete(path), safe=safe)
|
|
116
|
+
|
|
117
|
+
def rmdir(self, path: str, *, safe=None):
|
|
118
|
+
return self._guard("rmdir", lambda: self._require().rmd(path), safe=safe)
|
|
119
|
+
|
|
120
|
+
def rename(self, old: str, new: str, *, safe=None):
|
|
121
|
+
return self._guard("rename", lambda: self._require().rename(old, new), safe=safe)
|
|
122
|
+
|
|
123
|
+
def size(self, path: str, *, safe=None):
|
|
124
|
+
return self._guard("size", lambda: self._require().size(path), safe=safe)
|
|
125
|
+
|
|
126
|
+
def exists(self, path: str, *, safe=None):
|
|
127
|
+
def _do():
|
|
128
|
+
try:
|
|
129
|
+
self._require().size(path)
|
|
130
|
+
return True
|
|
131
|
+
except error_perm:
|
|
132
|
+
# size() fails on dirs; fall back to a listing probe.
|
|
133
|
+
try:
|
|
134
|
+
return path in self._require().nlst(os.path.dirname(path) or ".")
|
|
135
|
+
except all_errors:
|
|
136
|
+
return False
|
|
137
|
+
return self._guard("exists", _do, safe=safe)
|
|
138
|
+
|
|
139
|
+
def push(self, local_path: str, remote_path: str, *, callback=None, safe=None):
|
|
140
|
+
"""Upload a single file (STOR)."""
|
|
141
|
+
def _do():
|
|
142
|
+
lp = os.path.expanduser(local_path)
|
|
143
|
+
if not os.path.isfile(lp):
|
|
144
|
+
raise FTPError(f"Local file not found: {lp}")
|
|
145
|
+
start = time.time()
|
|
146
|
+
self._emit(logging.INFO, f"PUSH {lp} -> {remote_path}")
|
|
147
|
+
try:
|
|
148
|
+
with open(lp, "rb") as fh:
|
|
149
|
+
self._require().storbinary(f"STOR {remote_path}", fh,
|
|
150
|
+
callback=callback)
|
|
151
|
+
except all_errors as exc:
|
|
152
|
+
raise FTPError(f"FTP upload failed: {exc}") from exc
|
|
153
|
+
return TransferResult(lp, remote_path, "push", "ftp",
|
|
154
|
+
os.path.getsize(lp), time.time() - start)
|
|
155
|
+
return self._guard("push", _do, safe=safe)
|
|
156
|
+
|
|
157
|
+
def pull(self, remote_path: str, local_path: str, *, callback=None, safe=None):
|
|
158
|
+
"""Download a single file (RETR)."""
|
|
159
|
+
def _do():
|
|
160
|
+
lp = os.path.expanduser(local_path)
|
|
161
|
+
parent = os.path.dirname(lp)
|
|
162
|
+
if parent and not os.path.exists(parent):
|
|
163
|
+
os.makedirs(parent, exist_ok=True)
|
|
164
|
+
start = time.time()
|
|
165
|
+
self._emit(logging.INFO, f"PULL {remote_path} -> {lp}")
|
|
166
|
+
try:
|
|
167
|
+
with open(lp, "wb") as fh:
|
|
168
|
+
def _cb(data):
|
|
169
|
+
fh.write(data)
|
|
170
|
+
if callback:
|
|
171
|
+
callback(len(data))
|
|
172
|
+
self._require().retrbinary(f"RETR {remote_path}", _cb)
|
|
173
|
+
except all_errors as exc:
|
|
174
|
+
raise FTPError(f"FTP download failed: {exc}") from exc
|
|
175
|
+
return TransferResult(remote_path, lp, "pull", "ftp",
|
|
176
|
+
os.path.getsize(lp), time.time() - start)
|
|
177
|
+
return self._guard("pull", _do, safe=safe)
|
|
178
|
+
|
|
179
|
+
def __enter__(self):
|
|
180
|
+
r = self.connect()
|
|
181
|
+
if isinstance(r, OperationResult) and not r.success:
|
|
182
|
+
raise r.error or FTPError("connect failed")
|
|
183
|
+
return self
|
|
184
|
+
|
|
185
|
+
def __exit__(self, *exc):
|
|
186
|
+
self.disconnect()
|
ssh_handler/pool.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Run the same operation across many hosts in parallel — useful for test labs
|
|
3
|
+
and fleet operations where speed matters. Each host gets its own SSHHandler.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
10
|
+
from typing import Callable, Optional, Sequence
|
|
11
|
+
|
|
12
|
+
from .config import SSHConfig
|
|
13
|
+
from .core import SSHHandler
|
|
14
|
+
from .results import OperationResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SSHPool:
|
|
18
|
+
"""
|
|
19
|
+
>>> pool = SSHPool([cfg1, cfg2, cfg3], max_workers=8)
|
|
20
|
+
>>> results = pool.run("uptime") # {host: OperationResult}
|
|
21
|
+
>>> pool.push("a.bin", "/tmp/a.bin")
|
|
22
|
+
>>> pool.close()
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, configs: Sequence[SSHConfig], *, max_workers: int = 10,
|
|
26
|
+
log_callback: Optional[Callable[[str], None]] = None):
|
|
27
|
+
self.handlers = {
|
|
28
|
+
cfg.host: SSHHandler(cfg, log_callback=log_callback, safe=True)
|
|
29
|
+
for cfg in configs
|
|
30
|
+
}
|
|
31
|
+
self.max_workers = max_workers
|
|
32
|
+
|
|
33
|
+
def _fanout(self, method: str, *args, **kwargs) -> dict:
|
|
34
|
+
results: dict[str, OperationResult] = {}
|
|
35
|
+
with ThreadPoolExecutor(max_workers=self.max_workers) as ex:
|
|
36
|
+
futures = {}
|
|
37
|
+
for host, handler in self.handlers.items():
|
|
38
|
+
# ensure each worker is connected first
|
|
39
|
+
def _job(h=handler):
|
|
40
|
+
if not h.is_connected:
|
|
41
|
+
c = h.connect()
|
|
42
|
+
if isinstance(c, OperationResult) and not c.success:
|
|
43
|
+
return c
|
|
44
|
+
return getattr(h, method)(*args, **kwargs)
|
|
45
|
+
futures[ex.submit(_job)] = host
|
|
46
|
+
for fut in as_completed(futures):
|
|
47
|
+
host = futures[fut]
|
|
48
|
+
try:
|
|
49
|
+
results[host] = fut.result()
|
|
50
|
+
except Exception as exc: # pragma: no cover
|
|
51
|
+
results[host] = OperationResult(False, method, error=exc)
|
|
52
|
+
return results
|
|
53
|
+
|
|
54
|
+
def connect(self) -> dict:
|
|
55
|
+
return self._fanout_connect()
|
|
56
|
+
|
|
57
|
+
def _fanout_connect(self) -> dict:
|
|
58
|
+
results = {}
|
|
59
|
+
with ThreadPoolExecutor(max_workers=self.max_workers) as ex:
|
|
60
|
+
futures = {ex.submit(h.connect): host
|
|
61
|
+
for host, h in self.handlers.items()}
|
|
62
|
+
for fut in as_completed(futures):
|
|
63
|
+
results[futures[fut]] = fut.result()
|
|
64
|
+
return results
|
|
65
|
+
|
|
66
|
+
def run(self, command: str, **kwargs) -> dict:
|
|
67
|
+
return self._fanout("run", command, **kwargs)
|
|
68
|
+
|
|
69
|
+
def push(self, local_path: str, remote_path: str, **kwargs) -> dict:
|
|
70
|
+
return self._fanout("push", local_path, remote_path, **kwargs)
|
|
71
|
+
|
|
72
|
+
def pull(self, remote_path: str, local_path_template: str, **kwargs) -> dict:
|
|
73
|
+
"""
|
|
74
|
+
Pull from every host. ``local_path_template`` may contain ``{host}`` so
|
|
75
|
+
downloads don't collide, e.g. ``"logs/{host}_syslog.txt"``.
|
|
76
|
+
"""
|
|
77
|
+
results = {}
|
|
78
|
+
with ThreadPoolExecutor(max_workers=self.max_workers) as ex:
|
|
79
|
+
futures = {}
|
|
80
|
+
for host, handler in self.handlers.items():
|
|
81
|
+
local = local_path_template.format(host=host)
|
|
82
|
+
|
|
83
|
+
def _job(h=handler, l=local):
|
|
84
|
+
if not h.is_connected:
|
|
85
|
+
c = h.connect()
|
|
86
|
+
if isinstance(c, OperationResult) and not c.success:
|
|
87
|
+
return c
|
|
88
|
+
return h.pull(remote_path, l, **kwargs)
|
|
89
|
+
futures[ex.submit(_job)] = host
|
|
90
|
+
for fut in as_completed(futures):
|
|
91
|
+
results[futures[fut]] = fut.result()
|
|
92
|
+
return results
|
|
93
|
+
|
|
94
|
+
def close(self) -> None:
|
|
95
|
+
for h in self.handlers.values():
|
|
96
|
+
h.disconnect()
|
|
97
|
+
|
|
98
|
+
def __enter__(self):
|
|
99
|
+
self.connect()
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def __exit__(self, *exc):
|
|
103
|
+
self.close()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyQt5 integration helpers.
|
|
3
|
+
|
|
4
|
+
Import is guarded so the rest of the package works even where PyQt5 is absent
|
|
5
|
+
(e.g. on a headless CI runner). Import names from here only inside your GUI.
|
|
6
|
+
|
|
7
|
+
A worker that runs SSH work off the GUI thread, streams log lines to a signal,
|
|
8
|
+
and never lets an SSH error crash the event loop (the handler runs in safe
|
|
9
|
+
mode, so failures come back as OperationResult).
|
|
10
|
+
|
|
11
|
+
Usage sketch::
|
|
12
|
+
|
|
13
|
+
from PyQt5.QtCore import QThread
|
|
14
|
+
from ssh_handler import SSHConfig
|
|
15
|
+
from ssh_handler.pyqt_worker import SSHWorker
|
|
16
|
+
|
|
17
|
+
worker = SSHWorker(SSHConfig(host="10.0.0.5", domain="EU",
|
|
18
|
+
username="nkennedy", password=secret))
|
|
19
|
+
thread = QThread()
|
|
20
|
+
worker.moveToThread(thread)
|
|
21
|
+
worker.log.connect(text_edit.append)
|
|
22
|
+
worker.command_done.connect(on_done)
|
|
23
|
+
thread.started.connect(lambda: worker.run_command("uptime"))
|
|
24
|
+
thread.start()
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from PyQt5.QtCore import QObject, pyqtSignal
|
|
31
|
+
except Exception as exc: # pragma: no cover
|
|
32
|
+
raise ImportError(
|
|
33
|
+
"ssh_handler.pyqt_worker requires PyQt5. Install it with: pip install PyQt5"
|
|
34
|
+
) from exc
|
|
35
|
+
|
|
36
|
+
from .config import SSHConfig
|
|
37
|
+
from .core import SSHHandler
|
|
38
|
+
from .results import OperationResult
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SSHWorker(QObject):
|
|
42
|
+
"""A QObject wrapper around SSHHandler (safe mode) for use in a QThread."""
|
|
43
|
+
|
|
44
|
+
log = pyqtSignal(str) # every log line (already secret-masked)
|
|
45
|
+
connected = pyqtSignal(bool) # connect() finished
|
|
46
|
+
command_done = pyqtSignal(object) # CommandResult or Exception
|
|
47
|
+
transfer_done = pyqtSignal(object) # TransferResult or Exception
|
|
48
|
+
progress = pyqtSignal(int, int) # bytes_done, bytes_total
|
|
49
|
+
error = pyqtSignal(str) # human-readable error
|
|
50
|
+
finished = pyqtSignal() # work item complete
|
|
51
|
+
|
|
52
|
+
def __init__(self, config: SSHConfig, parent=None):
|
|
53
|
+
super().__init__(parent)
|
|
54
|
+
self.ssh = SSHHandler(config, log_callback=self.log.emit, safe=True)
|
|
55
|
+
|
|
56
|
+
def _resolve(self, res: OperationResult):
|
|
57
|
+
"""Emit error if an OperationResult failed; return its value or None."""
|
|
58
|
+
if isinstance(res, OperationResult):
|
|
59
|
+
if not res.success:
|
|
60
|
+
self.error.emit(str(res.error))
|
|
61
|
+
return None
|
|
62
|
+
return res.value
|
|
63
|
+
return res
|
|
64
|
+
|
|
65
|
+
# --- slots (call via signals/queued connection) ---
|
|
66
|
+
def connect_host(self):
|
|
67
|
+
res = self.ssh.connect()
|
|
68
|
+
ok = bool(res)
|
|
69
|
+
if not ok and isinstance(res, OperationResult):
|
|
70
|
+
self.error.emit(str(res.error))
|
|
71
|
+
self.connected.emit(ok)
|
|
72
|
+
self.finished.emit()
|
|
73
|
+
|
|
74
|
+
def run_command(self, command: str, timeout: float = None):
|
|
75
|
+
res = self.ssh.run(command, timeout=timeout)
|
|
76
|
+
value = self._resolve(res)
|
|
77
|
+
self.command_done.emit(value if value is not None else res)
|
|
78
|
+
self.finished.emit()
|
|
79
|
+
|
|
80
|
+
def push(self, local_path: str, remote_path: str, recursive: bool = False):
|
|
81
|
+
res = self.ssh.push(local_path, remote_path, recursive=recursive,
|
|
82
|
+
callback=lambda done, total: self.progress.emit(done, total))
|
|
83
|
+
self.transfer_done.emit(self._resolve(res))
|
|
84
|
+
self.finished.emit()
|
|
85
|
+
|
|
86
|
+
def pull(self, remote_path: str, local_path: str, recursive: bool = False):
|
|
87
|
+
res = self.ssh.pull(remote_path, local_path, recursive=recursive,
|
|
88
|
+
callback=lambda done, total: self.progress.emit(done, total))
|
|
89
|
+
self.transfer_done.emit(self._resolve(res))
|
|
90
|
+
self.finished.emit()
|
|
91
|
+
|
|
92
|
+
def disconnect_host(self):
|
|
93
|
+
self.ssh.disconnect()
|
|
94
|
+
self.finished.emit()
|