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.
@@ -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()