ssh-handler 1.5.0__tar.gz → 1.6.0__tar.gz

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.
Files changed (33) hide show
  1. {ssh_handler-1.5.0/ssh_handler.egg-info → ssh_handler-1.6.0}/PKG-INFO +1 -1
  2. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/pyproject.toml +6 -2
  3. ssh_handler-1.6.0/ssh_handler/README.md +536 -0
  4. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/__init__.py +1 -1
  5. ssh_handler-1.6.0/ssh_handler/assets/icon.ico +0 -0
  6. ssh_handler-1.6.0/ssh_handler/assets/icon.png +0 -0
  7. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/cli.py +28 -0
  8. ssh_handler-1.6.0/ssh_handler/gui_app.py +402 -0
  9. {ssh_handler-1.5.0 → ssh_handler-1.6.0/ssh_handler.egg-info}/PKG-INFO +1 -1
  10. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler.egg-info/SOURCES.txt +4 -0
  11. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler.egg-info/entry_points.txt +4 -0
  12. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/LICENSE +0 -0
  13. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/README.md +0 -0
  14. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/setup.cfg +0 -0
  15. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/__main__.py +0 -0
  16. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/config.py +0 -0
  17. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/core.py +0 -0
  18. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/credentials.py +0 -0
  19. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/exceptions.py +0 -0
  20. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/ftp.py +0 -0
  21. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/openssh/OpenSSH-ARM64.zip +0 -0
  22. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/openssh/OpenSSH-Win32.zip +0 -0
  23. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/openssh/OpenSSH-Win64.zip +0 -0
  24. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/pool.py +0 -0
  25. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/pyqt_worker.py +0 -0
  26. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/results.py +0 -0
  27. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/serial_handler.py +0 -0
  28. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/setup_openssh_server.ps1 +0 -0
  29. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler/winrm_bootstrap.py +0 -0
  30. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler.egg-info/dependency_links.txt +0 -0
  31. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler.egg-info/requires.txt +0 -0
  32. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/ssh_handler.egg-info/top_level.txt +0 -0
  33. {ssh_handler-1.5.0 → ssh_handler-1.6.0}/tests/test_offline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ssh-handler
3
- Version: 1.5.0
3
+ Version: 1.6.0
4
4
  Summary: Extensive SSH/SFTP/SCP/FTP handler built on Paramiko, for test automation, CLIs and PyQt5 tools.
5
5
  Author: ssh-handler contributors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ssh-handler"
7
- version = "1.5.0"
7
+ version = "1.6.0"
8
8
  description = "Extensive SSH/SFTP/SCP/FTP handler built on Paramiko, for test automation, CLIs and PyQt5 tools."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -36,9 +36,13 @@ all = ["PyQt5>=5.15"]
36
36
  [project.scripts]
37
37
  ssh-handler = "ssh_handler.cli:main"
38
38
  ssh-handler-setup = "ssh_handler.cli:setup_server_main"
39
+ ssh-handler-docs = "ssh_handler.cli:open_docs"
40
+
41
+ [project.gui-scripts]
42
+ ssh-handler-gui = "ssh_handler.cli:launch_gui"
39
43
 
40
44
  [tool.setuptools]
41
45
  packages = ["ssh_handler"]
42
46
 
43
47
  [tool.setuptools.package-data]
44
- ssh_handler = ["*.ps1", "openssh/*.zip"]
48
+ ssh_handler = ["*.ps1", "*.md", "openssh/*.zip", "assets/*.ico", "assets/*.png"]
@@ -0,0 +1,536 @@
1
+ # ssh-handler
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/ssh-handler.svg)](https://pypi.org/project/ssh-handler/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/ssh-handler.svg)](https://pypi.org/project/ssh-handler/)
5
+
6
+ An extensive **SSH / SFTP / SCP / FTP** automation handler built on
7
+ [Paramiko](https://www.paramiko.org/). One package, three ways to use it:
8
+
9
+ - **Test-automation framework** — raise-on-error API, pytest fixtures, parallel fleet ops.
10
+ - **Standalone CLI** — `python -m ssh_handler …`, fully argument-driven.
11
+ - **PyQt5 tool** — safe mode + log streaming over Qt signals, runs off the GUI thread.
12
+
13
+ Passwords are wrapped in a `Secret` and stored in the **OS credential vault** — they
14
+ never appear in logs, reprs, tracebacks, or on disk in plaintext.
15
+
16
+ ```bash
17
+ pip install ssh-handler
18
+ ```
19
+
20
+ ---
21
+
22
+ ## Table of contents
23
+
24
+ - [Why this package](#why-this-package)
25
+ - [Install](#install)
26
+ - [Quick start](#quick-start)
27
+ - [What you can do](#what-you-can-do) — full capability list
28
+ - [Domain / Windows (RDP) hosts](#domain--windows-rdp-hosts)
29
+ - [Confidential credentials](#confidential-credentials)
30
+ - [Performance](#performance)
31
+ - [Error handling: two styles](#error-handling-two-styles)
32
+ - [Result objects](#result-objects)
33
+ - [CLI reference](#cli-reference)
34
+ - [PyQt5 integration](#pyqt5-integration)
35
+ - [Parallel fleet operations](#parallel-fleet-operations)
36
+ - [FTP / FTPS](#ftp--ftps)
37
+ - [API map](#api-map)
38
+ - [Releasing](#releasing)
39
+
40
+ ---
41
+
42
+ ## Why this package
43
+
44
+ Paramiko is powerful but low-level: you manage clients, transports, channels,
45
+ SFTP sessions, timeouts, retries, host-key policies and error handling yourself,
46
+ and you repeat that boilerplate in every project. `ssh-handler` wraps all of it
47
+ behind one object that:
48
+
49
+ - auto-selects the right authentication strategy (password, key, agent, empty password),
50
+ - retries connections and transparently reconnects dropped sessions,
51
+ - returns **structured results** for every action instead of raw strings,
52
+ - keeps **passwords confidential** end-to-end,
53
+ - and exposes the same surface whether you're in a test, a CLI, or a GUI.
54
+
55
+ ## Install
56
+
57
+ ```bash
58
+ pip install ssh-handler # everything: SSH, SFTP, SCP, FTP, serial,
59
+ # credential vault, WinRM bootstrap
60
+ pip install "ssh-handler[gui]" # also installs PyQt5 for the GUI worker
61
+ ```
62
+
63
+ **Batteries included.** A plain `pip install ssh-handler` pulls in `paramiko`,
64
+ `scp`, `pyserial`, `keyring`, and `pywinrm`, so SSH, SFTP/SCP/FTP transfers,
65
+ serial/COM ports, confidential credential storage, and the WinRM bootstrap all
66
+ work out of the box. Only **PyQt5** is optional (`[gui]`), because it's a large
67
+ GUI toolkit you only need when building a GUI — forcing it would bloat headless
68
+ and CI installs.
69
+
70
+ ## Quick start
71
+
72
+ ```python
73
+ from ssh_handler import SSHHandler, SSHConfig
74
+
75
+ with SSHHandler(SSHConfig(host="10.0.0.5", username="root", password="pw")) as ssh:
76
+ print(ssh.run("uptime").stdout)
77
+ ssh.run("systemctl restart nginx", check=True) # raises on non-zero exit
78
+ ssh.push("local.txt", "/tmp/remote.txt") # SFTP upload
79
+ ssh.pull("/etc/nginx", "./backup", recursive=True) # recursive download
80
+ ```
81
+
82
+ ## What you can do
83
+
84
+ **Connection & session**
85
+ - Connect with password, private key (+ passphrase), SSH agent, auto-discovered
86
+ keys, or an **empty-password** account — all auto-tried in a smart order.
87
+ - **Auto-retry** connects with backoff; **auto-reconnect** if a session drops.
88
+ - Keepalives, per-command and connection timeouts, optional compression.
89
+ - **Jump host / bastion** chaining (ProxyJump-style) via `SSHConfig(jump_host=…)`.
90
+ - Host-key policy: `auto` / `reject` / `warn`, with an optional `known_hosts` file.
91
+ - Remote-OS awareness (`detect_os()`, `is_windows`) for Linux **and** Windows targets.
92
+ - Raw escape hatch: `ssh.client` and `ssh.transport` expose the underlying Paramiko objects.
93
+
94
+ **Command execution**
95
+ - `run()` — timeout, `check` (raise on non-zero), PTY allocation, custom environment.
96
+ - `run_many()` — batch with stop-on-error.
97
+ - `sudo()` — runs `sudo -S` and feeds the password on stdin.
98
+ - `open_shell()` — a persistent interactive `ShellSession` with `send` /
99
+ `read_until` (send-expect) / `read_available`.
100
+
101
+ **Continuous / streaming output (logs)**
102
+ - `iter_lines(cmd)` — generator yielding a never-ending command's output **line
103
+ by line, live** (`slog2info -w`, `tail -f`, `journalctl -f`, `dmesg -w`).
104
+ - `stream(cmd, on_line=, match=, save_to=, stop_on_match=, stop_event=)` — stream
105
+ with **live regex matching**, a per-line/per-match callback, and **tee to a
106
+ local file**, all built in.
107
+
108
+ **Serial / COM ports** (`SerialHandler`, included by default)
109
+ - `list_serial_ports()`, `open`/`close`, `write` / `write_line`.
110
+ - `iter_lines()` and `stream(...)` — same live streaming + match + save-to-file
111
+ model as SSH, for device consoles.
112
+
113
+ **File operations (SFTP) — full Paramiko parity**
114
+ - Transfers: `push` / `pull` (single file **or** recursive directory, with progress
115
+ callbacks and transfer statistics), plus `scp_push` / `scp_pull` (SCP protocol).
116
+ - Listing & metadata: `listdir`, `listdir_attr`, `stat`, `lstat`, `exists`, `isdir`, `walk`.
117
+ - Directories: `mkdir`, `makedirs` (recursive `mkdir -p`), `rmdir`.
118
+ - Files: `remove`, `rename`, `open` (remote file object), `read_text`, `write_text`.
119
+ - Permissions & links: `chmod`, `chown`, `symlink`, `readlink`.
120
+
121
+ **Other protocols**
122
+ - **FTP / FTPS** via `FTPHandler` (standard-library `ftplib`, no extra dependency):
123
+ connect, login, TLS, `push`, `pull`, `listdir`, `cwd`, `pwd`, `mkdir`, `rmdir`,
124
+ `remove`, `rename`, `size`, `exists`.
125
+
126
+ **Scale & integration**
127
+ - `SSHPool` — run the same command/transfer across many hosts in parallel threads.
128
+ - Safe mode + log callback for GUIs; structured result objects everywhere.
129
+ - Confidential credential storage in the OS vault (`CredentialStore`, `Secret`, `mask`).
130
+
131
+ ## Domain / Windows (RDP) hosts
132
+
133
+ The Windows machines you normally RDP into can be driven over SSH once **OpenSSH
134
+ Server** is enabled on them:
135
+
136
+ ```powershell
137
+ Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
138
+ Start-Service sshd ; Set-Service -Name sshd -StartupType Automatic
139
+ ```
140
+
141
+ Then log in with your normal domain credentials. **Pass `domain` and `username`
142
+ separately** — never hard-code a single `"DOMAIN\user"` Python string, because a
143
+ backslash escape (e.g. `\n`, `\t`) silently becomes a control character. The
144
+ handler builds the `DOMAIN\user` login string for you:
145
+
146
+ ```python
147
+ from ssh_handler import SSHHandler, SSHConfig, CredentialStore
148
+
149
+ store = CredentialStore(service="my_test_lab")
150
+ cfg = SSHConfig(
151
+ host="10.20.30.40",
152
+ domain="CORP", username="myuser", # -> login "CORP\myuser"
153
+ password=store.get("CORP\\myuser"), # a Secret pulled from the OS vault
154
+ remote_os="windows", # skip the OS probe
155
+ fast_auth=True, # skip key probing -> faster login
156
+ )
157
+ with SSHHandler(cfg) as ssh:
158
+ print(ssh.run("whoami").stdout) # CORP\myuser
159
+ print(ssh.run("powershell Get-Service sshd").stdout)
160
+ ssh.push("report.xlsx", "C:/Users/myuser/Desktop/report.xlsx")
161
+ ```
162
+
163
+ Store the password **once** so it never appears in code again:
164
+
165
+ ```python
166
+ from ssh_handler import CredentialStore, prompt_password
167
+ CredentialStore("my_test_lab").set("CORP\\myuser", prompt_password())
168
+ ```
169
+ ```bash
170
+ # …or from the CLI:
171
+ python -m ssh_handler store-credential --user myuser --domain CORP --service my_test_lab
172
+ ```
173
+
174
+ ## RDP-only Windows hosts: auto-enable SSH once (WinRM bootstrap)
175
+
176
+ A freshly imaged corporate Windows box often has **RDP and WinRM open but no SSH
177
+ server** (port 22 closed). You can't start sshd *over* SSH when SSH is down — but
178
+ if WinRM is reachable, the handler can use it as a one-time bootstrap channel.
179
+
180
+ Set one flag and connect normally:
181
+
182
+ ```python
183
+ from ssh_handler import SSHHandler, SSHConfig
184
+
185
+ cfg = SSHConfig(
186
+ host="10.232.9.22", domain="CORP", username="myuser", password="pw",
187
+ auto_bootstrap_via_winrm=True, # if SSH is down but WinRM is up, enable sshd, then retry
188
+ )
189
+ with SSHHandler(cfg) as ssh: # 1st run: enables sshd over WinRM, then connects
190
+ print(ssh.run("whoami").stdout) # every later run: connects straight over SSH
191
+ ```
192
+
193
+ **It's genuinely one-time.** The bootstrap installs the OpenSSH Server capability,
194
+ starts `sshd` with **Automatic** startup, and adds a **persistent** firewall rule —
195
+ so it survives reboots. After the first run, port 22 is already open and the
196
+ handler connects directly over SSH; WinRM is never touched again.
197
+
198
+ Do it explicitly instead of automatically if you prefer:
199
+
200
+ ```python
201
+ ssh = SSHHandler(cfg)
202
+ ssh.bootstrap_sshd_via_winrm() # one-time setup
203
+ ssh.connect()
204
+ ```
205
+
206
+ Requirements: `pip install "ssh-handler[winrm]"` (pulls in `pywinrm`; uses NTLM so
207
+ domain creds work without Kerberos), and the account must be a **local
208
+ administrator** on the target. If SSH already works, this code path never runs.
209
+
210
+ ### Set up OpenSSH Server on a machine (bundled installer)
211
+
212
+ Installing the package also gives you a one-command, **fully offline** setup for
213
+ the local Windows machine. The OpenSSH ZIPs (ARM64 / Win64 / Win32) ship *inside*
214
+ the wheel, so the installer needs **no internet and no Windows Update** — it picks
215
+ the ZIP matching the CPU architecture, self-elevates to Administrator, installs &
216
+ starts OpenSSH Server, and opens the firewall. (This avoids the
217
+ `Add-WindowsCapability` hang common on locked-down corporate networks.)
218
+
219
+ ```powershell
220
+ pip install ssh-handler
221
+ ssh-handler-setup # offline install + start sshd + firewall (self-elevates)
222
+ ssh-handler-setup --install-pip # also (re)install the package as admin
223
+ ssh-handler-setup --force # reinstall even if sshd already exists
224
+ # equivalent: python -m ssh_handler setup-server
225
+ ```
226
+
227
+ Run it on whichever machine you want to reach over SSH (e.g. the RDP jump box).
228
+
229
+ When a connection just fails, the error now self-diagnoses — it probes the SSH and
230
+ RDP ports and tells you *why* (e.g. "Port 22 is closed but RDP (3389) is open … no
231
+ SSH server listening"). Call `ssh.diagnose()` for a pre-flight reachability check.
232
+
233
+ ## Continuous logs & live pattern matching
234
+
235
+ Stream a long-running remote command and react to lines as they arrive — match a
236
+ pattern, save to a file, or stop when something appears. Works through the jump
237
+ host too.
238
+
239
+ ```python
240
+ from ssh_handler import SSHHandler, SSHConfig
241
+
242
+ with SSHHandler(SSHConfig(host="10.120.1.91", username="root", password="pw",
243
+ jump_host=rdp_box), quiet=True) as ssh:
244
+
245
+ # (a) simplest: iterate lines live
246
+ for line in ssh.iter_lines("slog2info -w"):
247
+ print(line)
248
+ if "FATAL" in line:
249
+ break
250
+
251
+ # (b) full: match + tee to a local file + callback, stop on a pattern
252
+ result = ssh.stream(
253
+ "tail -f /var/log/messages",
254
+ on_line=print, # called for every line
255
+ match=r"error|fail", # regex; matching lines collected
256
+ on_match=lambda l: print("HIT:", l),
257
+ save_to="device.log", # tee every line to this local file
258
+ stop_on_match=False, # set True to stop at the first match
259
+ timeout=60, # optional overall time limit
260
+ )
261
+ print(result["lines"], "lines,", len(result["matches"]), "matched")
262
+ ```
263
+
264
+ To stop a stream from another thread (e.g. a GUI Stop button), pass a
265
+ `threading.Event` as `stop_event=` and `.set()` it.
266
+
267
+ ## Serial / COM ports
268
+
269
+ Same streaming + match + save model for device serial consoles (included by
270
+ default — no extra install).
271
+
272
+ ### Local serial (device plugged into the machine running the code)
273
+ ```python
274
+ from ssh_handler import SerialHandler, list_serial_ports
275
+ print(list_serial_ports())
276
+ with SerialHandler("COM5", baudrate=115200, quiet=True) as ser:
277
+ ser.write_line("version")
278
+ ser.stream(on_line=print, match=r"login:", stop_on_match=True, save_to="console.log")
279
+ ```
280
+
281
+ ### Serial via RDP / SSH (port on a *remote* machine)
282
+ `pyserial` only opens a *local* port, so when the serial port is on a remote
283
+ machine, stream it **over SSH** with `serial_stream()` — same live match + save.
284
+ It auto-detects the OS from the device name: `COM*` → Windows (PowerShell
285
+ SerialPort reader), `/dev/tty*` → Linux (`stty` + `cat`).
286
+
287
+ **Windows COM port on the remote machine** (connect SSH straight to that machine
288
+ — it has sshd from `ssh-handler-setup`):
289
+
290
+ ```python
291
+ cfg = SSHConfig(host="10.232.9.22", domain="CORP", username="myuser",
292
+ password="pw", host_key_policy="ignore")
293
+ with SSHHandler(cfg, quiet=True) as ssh:
294
+ ssh.serial_write("COM5", "version", baudrate=115200) # write a line
295
+ ssh.serial_stream("COM5", baudrate=115200, # read it live
296
+ on_line=print, match=r"login:|ERROR",
297
+ save_to="com5.log", timeout=120)
298
+ ```
299
+
300
+ **Linux device file on a target reached through the jump:**
301
+
302
+ ```python
303
+ target = SSHConfig(host="10.120.1.91", username="root", password="pw",
304
+ jump_host=rdp_box, host_key_policy="ignore")
305
+ with SSHHandler(target, quiet=True) as ssh:
306
+ ssh.serial_stream("/dev/ttyUSB0", baudrate=115200,
307
+ on_line=print, match=r"login:", save_to="ttyusb0.log")
308
+ ```
309
+
310
+ > Note: on Windows a COM port can't be shared — don't run `serial_write` while a
311
+ > `serial_stream` on the same port is open (`serial_write` opens/writes/closes).
312
+ > If the port is on **your own laptop**, use the local `SerialHandler("COM5")`
313
+ > above instead — no SSH needed.
314
+
315
+ ## File transfer (SFTP / SCP / FTP) via RDP
316
+
317
+ **SFTP and SCP already work through the jump host** — no special setup. Once you
318
+ pass `jump_host=`, every transfer runs over that tunnel (laptop → RDP → target):
319
+
320
+ ```python
321
+ with SSHHandler(target, quiet=True) as ssh: # target has jump_host=rdp_box
322
+ ssh.push("firmware.bin", "/tmp/firmware.bin") # SFTP, through the jump
323
+ ssh.pull("/var/log/messages", "messages.log") # SFTP, through the jump
324
+ ssh.scp_push("img.tar", "/tmp/img.tar") # SCP, through the jump
325
+ print(ssh.read_text("/etc/os-release"))
326
+ ```
327
+
328
+ **FTP via RDP:** FTP is a separate protocol (its data channel can't ride an SSH
329
+ tunnel cleanly), so prefer **SFTP through the jump** as shown above — it does the
330
+ same job better and is already routed via RDP. If you specifically need a real
331
+ FTP *server* on the target, run `FTPHandler` on the RDP machine itself (where it
332
+ can reach that server directly).
333
+
334
+ ```python
335
+ from ssh_handler import SerialHandler, list_serial_ports
336
+
337
+ print(list_serial_ports()) # [{'device':'COM5','description':...}, ...]
338
+
339
+ with SerialHandler("COM5", baudrate=115200, quiet=True) as ser:
340
+ ser.write_line("version") # send a command
341
+ res = ser.stream(
342
+ on_line=print,
343
+ match=r"login:", # wait for the login prompt
344
+ stop_on_match=True,
345
+ save_to="serial_console.log", # tee to file
346
+ timeout=120,
347
+ )
348
+ print("matched:", res["matched"])
349
+ ```
350
+
351
+ `write_line(..., eol="\r\n")` for consoles that need CRLF. Everything returns the
352
+ same `OperationResult` in safe mode and raises `SerialError` otherwise.
353
+
354
+ ## Confidential credentials
355
+
356
+ | Mechanism | What it does |
357
+ |-----------|--------------|
358
+ | `Secret` | wraps a password; `str()`/`repr()`/logs show `********`; only `.reveal()` exposes it |
359
+ | `mask()` | redacts secret values from any string (applied automatically to all logging) |
360
+ | `CredentialStore` | stores/reads passwords in the OS vault (Windows Credential Manager / macOS Keychain / Secret Service) via `keyring` — **no plaintext on disk** |
361
+ | `prompt_password()` | hidden terminal input, returns a `Secret` |
362
+
363
+ Pass a `Secret` (or a plain string, which is wrapped automatically) anywhere a
364
+ password is accepted. It stays redacted across the whole stack.
365
+
366
+ ## Performance
367
+
368
+ - **`fast_auth`** (default on): when a password is supplied, the slow key/agent
369
+ probing is skipped — faster logins and no "Too many authentication failures"
370
+ from the server's `MaxAuthTries`.
371
+ - One SFTP channel is opened lazily and **reused** across operations.
372
+ - SFTP downloads use Paramiko **prefetch** for high throughput.
373
+ - `remote_os="windows"|"linux"` skips the one-time OS-detection probe.
374
+ - `compress=True` for slow/high-latency links; keepalives keep long sessions alive.
375
+ - `SSHPool` parallelizes across hosts with a thread pool.
376
+
377
+ ## Error handling: two styles
378
+
379
+ **Raise (default)** — best for tests/scripts. Typed exceptions, all subclasses of
380
+ `SSHError`:
381
+
382
+ ```
383
+ SSHConnectionError SSHAuthenticationError SSHTimeoutError
384
+ SSHCommandError SSHTransferError SSHNotConnectedError
385
+ FTPError CredentialError
386
+ ```
387
+
388
+ **Safe mode** (`SSHHandler(cfg, safe=True)`) — best for GUIs. Every call returns an
389
+ `OperationResult` instead of raising, so an event loop never dies:
390
+
391
+ ```python
392
+ res = ssh.connect()
393
+ if not res: # OperationResult is falsy on failure
394
+ show_error(res.error)
395
+ else:
396
+ data = res.value # or res.unwrap() to re-raise on failure
397
+ ```
398
+
399
+ Override per call with `safe=True` / `safe=False`.
400
+
401
+ ## Result objects
402
+
403
+ Every action returns structured data, not bare strings:
404
+
405
+ - **`CommandResult`** — `exit_code`, `stdout`, `stderr`, `duration`, `host`, `.ok`, `.as_dict()`
406
+ - **`TransferResult`** — `size_bytes`, `duration`, `speed_bps`, `human_speed`, `human_size`, `files`
407
+ - **`ShellResult`** — `output`, `matched`, `timed_out`, `duration`
408
+ - **`OperationResult`** — safe-mode wrapper: `bool(res)`, `res.value`, `res.error`, `res.unwrap()`
409
+
410
+ ## CLI reference
411
+
412
+ ```bash
413
+ python -m ssh_handler run --host H --user U --domain CORP uptime
414
+ python -m ssh_handler push --host H --user U ./build /tmp/build --recursive
415
+ python -m ssh_handler pull --host H --user U /var/log ./logs --recursive
416
+ python -m ssh_handler info --host H --user U --json
417
+ python -m ssh_handler store-credential --user U --domain CORP --service my_test_lab
418
+
419
+ # continuous logs over SSH, with live matching + save:
420
+ python -m ssh_handler stream --host H --user U --match "error|fail" \
421
+ --save run.log -- slog2info -w
422
+
423
+ # serial / COM ports:
424
+ python -m ssh_handler list-serial
425
+ python -m ssh_handler serial-monitor --port COM5 --baud 115200 \
426
+ --match "login:" --stop-on-match --save console.log
427
+
428
+ # install OpenSSH Server on THIS Windows machine (offline, self-elevates):
429
+ ssh-handler-setup
430
+ ```
431
+
432
+ Password options: `--password` (hidden prompt), `--use-stored` (read from the OS
433
+ vault), `--key FILE` (private key). Add `--json` for machine-readable output.
434
+ Put `--match`/`--save` *before* the streamed command. After `pip install`, the
435
+ `ssh-handler` and `ssh-handler-setup` console scripts are also available.
436
+
437
+ ## PyQt5 integration
438
+
439
+ `ssh_handler.pyqt_worker.SSHWorker` is a `QObject` wrapping the handler in **safe
440
+ mode**. Move it to a `QThread`, connect its signals, and drive it from the GUI:
441
+
442
+ ```python
443
+ from PyQt5.QtCore import QThread
444
+ from ssh_handler import SSHConfig
445
+ from ssh_handler.pyqt_worker import SSHWorker
446
+
447
+ worker = SSHWorker(SSHConfig(host="10.0.0.5", username="root", password=secret))
448
+ thread = QThread(); worker.moveToThread(thread)
449
+ worker.log.connect(text_edit.append) # live, secret-masked log lines
450
+ worker.command_done.connect(on_command_done)
451
+ worker.progress.connect(progress_bar.setValue) # bytes_done, bytes_total
452
+ thread.started.connect(lambda: worker.run_command("uptime"))
453
+ thread.start()
454
+ ```
455
+
456
+ Signals: `log`, `connected`, `command_done`, `transfer_done`, `progress`,
457
+ `stream_line`, `stream_match`, `stream_done`, `error`, `finished`. The import is
458
+ lazy, so the rest of the package works where PyQt5 isn't installed.
459
+
460
+ **Streaming logs into the GUI** — drive `start_stream` in the worker thread and
461
+ wire the per-line signals to your widgets; `stop_stream()` ends it cleanly:
462
+
463
+ ```python
464
+ worker.stream_line.connect(log_view.append) # every live line
465
+ worker.stream_match.connect(lambda l: alerts.append(l)) # only matching lines
466
+ thread.started.connect(lambda: worker.start_stream("slog2info -w",
467
+ match="error|fail",
468
+ save_to="device.log"))
469
+ # later, from a Stop button:
470
+ worker.stop_stream()
471
+ ```
472
+
473
+ ## Parallel fleet operations
474
+
475
+ ```python
476
+ from ssh_handler import SSHPool, SSHConfig
477
+
478
+ configs = [SSHConfig(host=h, username="root", password="pw")
479
+ for h in ("10.0.0.1", "10.0.0.2", "10.0.0.3")]
480
+
481
+ with SSHPool(configs, max_workers=8) as pool:
482
+ for host, res in pool.run("uptime").items():
483
+ print(host, res.value.stdout.strip() if res else res.error)
484
+ pool.pull("/var/log/syslog", "logs/{host}_syslog.txt") # {host} avoids collisions
485
+ ```
486
+
487
+ ## FTP / FTPS
488
+
489
+ ```python
490
+ from ssh_handler import FTPHandler, FTPConfig
491
+
492
+ with FTPHandler(FTPConfig(host="ftp.example.com", username="u",
493
+ password="p", use_tls=True)) as ftp:
494
+ ftp.push("local.txt", "remote.txt")
495
+ ftp.pull("remote.txt", "copy.txt")
496
+ print(ftp.listdir("/"))
497
+ ```
498
+
499
+ ## API map
500
+
501
+ ```
502
+ ssh_handler/
503
+ config.py SSHConfig, FTPConfig
504
+ credentials.py Secret, CredentialStore, mask, prompt_password
505
+ core.py SSHHandler, ShellSession (SSH + SFTP + SCP + stream + diagnose)
506
+ ftp.py FTPHandler (FTP / FTPS)
507
+ serial_handler.py SerialHandler, list_serial_ports (serial / COM ports)
508
+ winrm_bootstrap.py enable_openssh_via_winrm (one-time sshd enable over WinRM)
509
+ pool.py SSHPool (parallel multi-host)
510
+ cli.py argparse entry point (python -m ssh_handler / ssh-handler)
511
+ pyqt_worker.py SSHWorker (PyQt5, lazy import)
512
+ results.py CommandResult, TransferResult, ShellResult, OperationResult
513
+ exceptions.py SSHError hierarchy
514
+ examples/examples.py copy-paste recipes
515
+ tests/test_offline.py offline checks (no network needed)
516
+ ```
517
+
518
+ ## Releasing
519
+
520
+ Maintainers: use the helper to build and publish a new version.
521
+
522
+ ```bash
523
+ python scripts/release.py 1.0.1 # bump -> build -> twine check -> upload
524
+ python scripts/release.py 1.0.1 --dry-run # build + check only, no upload
525
+ python scripts/release.py patch # auto-bump patch/minor/major
526
+ ```
527
+
528
+ The token is read from the `TWINE_PASSWORD` environment variable (username
529
+ `__token__`), never hard-coded. See [`scripts/release.py`](scripts/release.py) and
530
+ the optional [GitHub Actions workflow](.github/workflows/publish.yml) (publishes on
531
+ a `v*` tag). PyPI permanently forbids re-uploading an existing version, so each
532
+ release must use a new version number.
533
+
534
+ ## License
535
+
536
+ MIT
@@ -22,7 +22,7 @@ dependencies (PyQt5 / scp) don't break the core package.
22
22
 
23
23
  from __future__ import annotations
24
24
 
25
- __version__ = "1.5.0"
25
+ __version__ = "1.6.0"
26
26
 
27
27
  from .config import SSHConfig, FTPConfig
28
28
  from .core import SSHHandler, ShellSession
@@ -33,6 +33,34 @@ def _setup_script_path() -> str:
33
33
  return os.path.join(os.path.dirname(__file__), "setup_openssh_server.ps1")
34
34
 
35
35
 
36
+ DOCS_URL = "https://pypi.org/project/ssh-handler/"
37
+
38
+
39
+ def open_docs(argv=None) -> int:
40
+ """Console entry point (`ssh-handler-docs`): open the rendered docs in a
41
+ browser, falling back to the README bundled in the package."""
42
+ import webbrowser
43
+ try:
44
+ if webbrowser.open(DOCS_URL):
45
+ print(f"Opened docs: {DOCS_URL}")
46
+ return 0
47
+ except Exception:
48
+ pass
49
+ readme = os.path.join(os.path.dirname(__file__), "README.md")
50
+ if os.path.exists(readme):
51
+ webbrowser.open("file://" + readme)
52
+ print(f"Opened bundled README: {readme}")
53
+ else:
54
+ print(f"Docs: {DOCS_URL}")
55
+ return 0
56
+
57
+
58
+ def launch_gui(argv=None) -> int:
59
+ """Console entry point (`ssh-handler-gui`): launch the PyQt5 application."""
60
+ from .gui_app import main as gui_main
61
+ return gui_main()
62
+
63
+
36
64
  def setup_server_main(argv=None) -> int:
37
65
  """
38
66
  Console entry point (`ssh-handler-setup`): run the bundled PowerShell script
@@ -0,0 +1,402 @@
1
+ """
2
+ ssh-handler GUI — a PyQt5 application that performs every operation with a
3
+ button and shows commands, results, and live logs in one pane.
4
+
5
+ Launch:
6
+ ssh-handler-gui (console script after `pip install`)
7
+ python -m ssh_handler.gui_app
8
+
9
+ Build a standalone .exe with the bundled icon:
10
+ python scripts/build_exe.py (uses PyInstaller)
11
+
12
+ Capabilities exposed: connect (with optional jump host / RDP), run commands,
13
+ SFTP push/pull, serial (COM/device) monitoring with match + save, and live SSH
14
+ log streaming (slog2info / tail -f) with match + save.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import sys
21
+ import queue
22
+ import threading
23
+ import webbrowser
24
+
25
+ try:
26
+ from PyQt5.QtCore import QThread, pyqtSignal, Qt
27
+ from PyQt5.QtGui import QIcon, QTextCursor, QFont
28
+ from PyQt5.QtWidgets import (
29
+ QApplication, QWidget, QMainWindow, QGridLayout, QVBoxLayout, QHBoxLayout,
30
+ QFormLayout, QLabel, QLineEdit, QPushButton, QPlainTextEdit, QSpinBox,
31
+ QCheckBox, QTabWidget, QGroupBox, QFileDialog, QComboBox, QStatusBar,
32
+ )
33
+ except Exception as exc: # pragma: no cover
34
+ raise ImportError(
35
+ "The GUI needs PyQt5. Install it with: pip install \"ssh-handler[gui]\""
36
+ ) from exc
37
+
38
+ from .config import SSHConfig
39
+ from .core import SSHHandler
40
+ from .results import OperationResult
41
+
42
+ ICON_PATH = os.path.join(os.path.dirname(__file__), "assets", "icon.ico")
43
+ DOCS_URL = "https://pypi.org/project/ssh-handler/"
44
+
45
+
46
+ # --------------------------------------------------------------------------- #
47
+ # Background worker: one thread owns the SSHHandler; jobs are queued callables.
48
+ # --------------------------------------------------------------------------- #
49
+ class Worker(QThread):
50
+ log = pyqtSignal(str)
51
+ status = pyqtSignal(str, bool) # message, connected?
52
+ done = pyqtSignal(str, object) # label, value
53
+
54
+ def __init__(self):
55
+ super().__init__()
56
+ self._jobs: "queue.Queue" = queue.Queue()
57
+ self._alive = True
58
+ self.ssh: SSHHandler | None = None
59
+ self.stream_stop = threading.Event()
60
+
61
+ def run(self):
62
+ while self._alive:
63
+ job = self._jobs.get()
64
+ if job is None:
65
+ break
66
+ label, fn = job
67
+ try:
68
+ fn()
69
+ except Exception as exc: # never kill the thread
70
+ self.log.emit(f"[ERROR] {label}: {exc}")
71
+
72
+ def submit(self, label, fn):
73
+ self._jobs.put((label, fn))
74
+
75
+ def shutdown(self):
76
+ self._alive = False
77
+ self.stream_stop.set()
78
+ self._jobs.put(None)
79
+
80
+ # ---- jobs (run inside the worker thread) ----
81
+ def do_connect(self, cfg: SSHConfig):
82
+ def _job():
83
+ self.ssh = SSHHandler(cfg, log_callback=self.log.emit, safe=True)
84
+ res = self.ssh.connect()
85
+ ok = bool(res)
86
+ if ok:
87
+ self.status.emit(f"Connected to {cfg.host}", True)
88
+ else:
89
+ self.status.emit(f"Connect failed: {getattr(res,'error','')}", False)
90
+ self.submit("connect", _job)
91
+
92
+ def do_disconnect(self):
93
+ def _job():
94
+ self.stream_stop.set()
95
+ if self.ssh:
96
+ self.ssh.disconnect()
97
+ self.status.emit("Disconnected", False)
98
+ self.submit("disconnect", _job)
99
+
100
+ def _need(self) -> bool:
101
+ if not self.ssh or not self.ssh.is_connected:
102
+ self.log.emit("[ERROR] Not connected. Click Connect first.")
103
+ return False
104
+ return True
105
+
106
+ def do_run(self, command: str):
107
+ def _job():
108
+ if not self._need():
109
+ return
110
+ res = self.ssh.run(command)
111
+ if isinstance(res, OperationResult) and res.success:
112
+ r = res.value
113
+ if r.stdout:
114
+ self.log.emit(r.stdout.rstrip())
115
+ if r.stderr:
116
+ self.log.emit("[stderr] " + r.stderr.rstrip())
117
+ self.log.emit(f"[exit {r.exit_code}, {r.duration:.2f}s]")
118
+ self.submit("run", _job)
119
+
120
+ def do_push(self, local, remote, recursive):
121
+ def _job():
122
+ if not self._need():
123
+ return
124
+ res = self.ssh.push(local, remote, recursive=recursive)
125
+ if isinstance(res, OperationResult) and res.success:
126
+ self.log.emit(str(res.value))
127
+ self.submit("push", _job)
128
+
129
+ def do_pull(self, remote, local, recursive):
130
+ def _job():
131
+ if not self._need():
132
+ return
133
+ res = self.ssh.pull(remote, local, recursive=recursive)
134
+ if isinstance(res, OperationResult) and res.success:
135
+ self.log.emit(str(res.value))
136
+ self.submit("pull", _job)
137
+
138
+ def do_serial(self, device, baud, match, save_to):
139
+ def _job():
140
+ if not self._need():
141
+ return
142
+ self.stream_stop.clear()
143
+ self.log.emit(f"--- serial {device} @ {baud} (Stop to end) ---")
144
+ self.ssh.serial_stream(device, baudrate=baud, on_line=self.log.emit,
145
+ match=(match or None), save_to=(save_to or None),
146
+ stop_event=self.stream_stop)
147
+ self.log.emit("--- serial stopped ---")
148
+ self.submit("serial", _job)
149
+
150
+ def do_stream(self, command, match, save_to):
151
+ def _job():
152
+ if not self._need():
153
+ return
154
+ self.stream_stop.clear()
155
+ self.log.emit(f"--- streaming '{command}' (Stop to end) ---")
156
+ self.ssh.stream(command, on_line=self.log.emit, match=(match or None),
157
+ save_to=(save_to or None), stop_event=self.stream_stop)
158
+ self.log.emit("--- stream stopped ---")
159
+ self.submit("stream", _job)
160
+
161
+ def stop_stream(self):
162
+ self.stream_stop.set()
163
+
164
+
165
+ # --------------------------------------------------------------------------- #
166
+ # Main window
167
+ # --------------------------------------------------------------------------- #
168
+ class MainWindow(QMainWindow):
169
+ def __init__(self):
170
+ super().__init__()
171
+ self.setWindowTitle("ssh-handler — Automotive SSH toolkit")
172
+ if os.path.exists(ICON_PATH):
173
+ self.setWindowIcon(QIcon(ICON_PATH))
174
+ self.resize(900, 680)
175
+
176
+ self.worker = Worker()
177
+ self.worker.log.connect(self._append_log)
178
+ self.worker.status.connect(self._on_status)
179
+ self.worker.start()
180
+
181
+ central = QWidget()
182
+ self.setCentralWidget(central)
183
+ root = QVBoxLayout(central)
184
+ root.addWidget(self._build_connection_group())
185
+ root.addWidget(self._build_tabs())
186
+ root.addWidget(self._build_log_group(), stretch=1)
187
+ self.setStatusBar(QStatusBar())
188
+ self.statusBar().showMessage("Not connected")
189
+
190
+ # ---- connection panel ----
191
+ def _build_connection_group(self):
192
+ g = QGroupBox("Connection")
193
+ lay = QGridLayout(g)
194
+ self.host = QLineEdit(); self.host.setPlaceholderText("target host / IP")
195
+ self.port = QSpinBox(); self.port.setRange(1, 65535); self.port.setValue(22)
196
+ self.user = QLineEdit()
197
+ self.domain = QLineEdit(); self.domain.setPlaceholderText("optional domain")
198
+ self.password = QLineEdit(); self.password.setEchoMode(QLineEdit.Password)
199
+ self.ignore_hostkey = QCheckBox("Ignore host key (lab)")
200
+ self.ignore_hostkey.setChecked(True)
201
+
202
+ self.use_jump = QCheckBox("Via jump host (RDP machine)")
203
+ self.jhost = QLineEdit(); self.jhost.setPlaceholderText("jump host / IP")
204
+ self.juser = QLineEdit(); self.juser.setPlaceholderText("jump user")
205
+ self.jdomain = QLineEdit(); self.jdomain.setPlaceholderText("jump domain")
206
+ self.jpass = QLineEdit(); self.jpass.setEchoMode(QLineEdit.Password)
207
+ self.jpass.setPlaceholderText("jump password")
208
+
209
+ self.btn_connect = QPushButton("Connect")
210
+ self.btn_disconnect = QPushButton("Disconnect")
211
+ self.btn_disconnect.setEnabled(False)
212
+ self.btn_connect.clicked.connect(self._connect)
213
+ self.btn_disconnect.clicked.connect(self._disconnect)
214
+
215
+ lay.addWidget(QLabel("Host"), 0, 0); lay.addWidget(self.host, 0, 1)
216
+ lay.addWidget(QLabel("Port"), 0, 2); lay.addWidget(self.port, 0, 3)
217
+ lay.addWidget(QLabel("User"), 1, 0); lay.addWidget(self.user, 1, 1)
218
+ lay.addWidget(QLabel("Domain"), 1, 2); lay.addWidget(self.domain, 1, 3)
219
+ lay.addWidget(QLabel("Password"), 2, 0); lay.addWidget(self.password, 2, 1)
220
+ lay.addWidget(self.ignore_hostkey, 2, 2, 1, 2)
221
+ lay.addWidget(self.use_jump, 3, 0, 1, 4)
222
+ lay.addWidget(self.jhost, 4, 0, 1, 2); lay.addWidget(self.juser, 4, 2, 1, 2)
223
+ lay.addWidget(self.jdomain, 5, 0, 1, 2); lay.addWidget(self.jpass, 5, 2, 1, 2)
224
+ lay.addWidget(self.btn_connect, 6, 0, 1, 2)
225
+ lay.addWidget(self.btn_disconnect, 6, 2, 1, 2)
226
+ return g
227
+
228
+ # ---- operation tabs ----
229
+ def _build_tabs(self):
230
+ tabs = QTabWidget()
231
+
232
+ # Command
233
+ cmd = QWidget(); cl = QHBoxLayout(cmd)
234
+ self.cmd_in = QLineEdit("uname -a")
235
+ run_btn = QPushButton("Run")
236
+ run_btn.clicked.connect(lambda: self.worker.do_run(self.cmd_in.text().strip()))
237
+ self.cmd_in.returnPressed.connect(run_btn.click)
238
+ cl.addWidget(QLabel("Command")); cl.addWidget(self.cmd_in, 1); cl.addWidget(run_btn)
239
+ tabs.addTab(cmd, "Command")
240
+
241
+ # Files
242
+ files = QWidget(); fl = QGridLayout(files)
243
+ self.local_path = QLineEdit(); self.remote_path = QLineEdit()
244
+ lb = QPushButton("Browse"); lb.clicked.connect(self._browse_local)
245
+ self.recursive = QCheckBox("Recursive (folder)")
246
+ push_btn = QPushButton("Push (upload)")
247
+ pull_btn = QPushButton("Pull (download)")
248
+ push_btn.clicked.connect(lambda: self.worker.do_push(
249
+ self.local_path.text().strip(), self.remote_path.text().strip(),
250
+ self.recursive.isChecked()))
251
+ pull_btn.clicked.connect(lambda: self.worker.do_pull(
252
+ self.remote_path.text().strip(), self.local_path.text().strip(),
253
+ self.recursive.isChecked()))
254
+ fl.addWidget(QLabel("Local"), 0, 0); fl.addWidget(self.local_path, 0, 1)
255
+ fl.addWidget(lb, 0, 2)
256
+ fl.addWidget(QLabel("Remote"), 1, 0); fl.addWidget(self.remote_path, 1, 1, 1, 2)
257
+ fl.addWidget(self.recursive, 2, 0)
258
+ fl.addWidget(push_btn, 2, 1); fl.addWidget(pull_btn, 2, 2)
259
+ tabs.addTab(files, "Files (SFTP)")
260
+
261
+ # Serial
262
+ ser = QWidget(); sl = QGridLayout(ser)
263
+ self.ser_dev = QLineEdit("COM5")
264
+ self.ser_baud = QSpinBox(); self.ser_baud.setRange(300, 4000000)
265
+ self.ser_baud.setValue(115200)
266
+ self.ser_match = QLineEdit(); self.ser_match.setPlaceholderText("regex to match (optional)")
267
+ self.ser_save = QLineEdit(); self.ser_save.setPlaceholderText("save to file (optional)")
268
+ sb = QPushButton("Browse"); sb.clicked.connect(lambda: self._browse_save(self.ser_save))
269
+ ser_start = QPushButton("Start"); ser_stop = QPushButton("Stop")
270
+ ser_start.clicked.connect(lambda: self.worker.do_serial(
271
+ self.ser_dev.text().strip(), self.ser_baud.value(),
272
+ self.ser_match.text().strip(), self.ser_save.text().strip()))
273
+ ser_stop.clicked.connect(self.worker.stop_stream)
274
+ sl.addWidget(QLabel("Device"), 0, 0); sl.addWidget(self.ser_dev, 0, 1)
275
+ sl.addWidget(QLabel("Baud"), 0, 2); sl.addWidget(self.ser_baud, 0, 3)
276
+ sl.addWidget(QLabel("Match"), 1, 0); sl.addWidget(self.ser_match, 1, 1, 1, 3)
277
+ sl.addWidget(QLabel("Save"), 2, 0); sl.addWidget(self.ser_save, 2, 1, 1, 2)
278
+ sl.addWidget(sb, 2, 3)
279
+ sl.addWidget(ser_start, 3, 1); sl.addWidget(ser_stop, 3, 2)
280
+ tabs.addTab(ser, "Serial")
281
+
282
+ # Log stream
283
+ strm = QWidget(); stl = QGridLayout(strm)
284
+ self.strm_cmd = QLineEdit("slog2info -w")
285
+ self.strm_match = QLineEdit(); self.strm_match.setPlaceholderText("regex to match (optional)")
286
+ self.strm_save = QLineEdit(); self.strm_save.setPlaceholderText("save to file (optional)")
287
+ stb = QPushButton("Browse"); stb.clicked.connect(lambda: self._browse_save(self.strm_save))
288
+ strm_start = QPushButton("Start"); strm_stop = QPushButton("Stop")
289
+ strm_start.clicked.connect(lambda: self.worker.do_stream(
290
+ self.strm_cmd.text().strip(), self.strm_match.text().strip(),
291
+ self.strm_save.text().strip()))
292
+ strm_stop.clicked.connect(self.worker.stop_stream)
293
+ stl.addWidget(QLabel("Command"), 0, 0); stl.addWidget(self.strm_cmd, 0, 1, 1, 3)
294
+ stl.addWidget(QLabel("Match"), 1, 0); stl.addWidget(self.strm_match, 1, 1, 1, 3)
295
+ stl.addWidget(QLabel("Save"), 2, 0); stl.addWidget(self.strm_save, 2, 1, 1, 2)
296
+ stl.addWidget(stb, 2, 3)
297
+ stl.addWidget(strm_start, 3, 1); stl.addWidget(strm_stop, 3, 2)
298
+ tabs.addTab(strm, "Log stream")
299
+
300
+ return tabs
301
+
302
+ # ---- log pane ----
303
+ def _build_log_group(self):
304
+ g = QGroupBox("Log")
305
+ lay = QVBoxLayout(g)
306
+ self.log_view = QPlainTextEdit(); self.log_view.setReadOnly(True)
307
+ self.log_view.setFont(QFont("Consolas", 9))
308
+ self.log_view.setMaximumBlockCount(20000)
309
+ row = QHBoxLayout()
310
+ clear = QPushButton("Clear"); clear.clicked.connect(self.log_view.clear)
311
+ savelog = QPushButton("Save log…"); savelog.clicked.connect(self._save_log)
312
+ docs = QPushButton("Help / Docs"); docs.clicked.connect(lambda: webbrowser.open(DOCS_URL))
313
+ row.addWidget(clear); row.addWidget(savelog); row.addStretch(1); row.addWidget(docs)
314
+ lay.addWidget(self.log_view, 1); lay.addLayout(row)
315
+ return g
316
+
317
+ # ---- actions ----
318
+ def _build_config(self) -> SSHConfig:
319
+ policy = "ignore" if self.ignore_hostkey.isChecked() else "auto"
320
+ jump = None
321
+ if self.use_jump.isChecked() and self.jhost.text().strip():
322
+ jump = SSHConfig(host=self.jhost.text().strip(),
323
+ username=self.juser.text().strip() or None,
324
+ domain=self.jdomain.text().strip() or None,
325
+ password=self.jpass.text(), host_key_policy=policy)
326
+ return SSHConfig(
327
+ host=self.host.text().strip(), port=self.port.value(),
328
+ username=self.user.text().strip() or None,
329
+ domain=self.domain.text().strip() or None,
330
+ password=self.password.text(), jump_host=jump,
331
+ host_key_policy=policy,
332
+ )
333
+
334
+ def _connect(self):
335
+ if not self.host.text().strip():
336
+ self._append_log("[ERROR] Enter a host first.")
337
+ return
338
+ self._append_log(f"Connecting to {self.host.text().strip()}…")
339
+ self.worker.do_connect(self._build_config())
340
+
341
+ def _disconnect(self):
342
+ self.worker.do_disconnect()
343
+
344
+ def _on_status(self, msg, connected):
345
+ self.statusBar().showMessage(msg)
346
+ self._append_log(msg)
347
+ self.btn_connect.setEnabled(not connected)
348
+ self.btn_disconnect.setEnabled(connected)
349
+
350
+ def _append_log(self, text):
351
+ self.log_view.appendPlainText(text)
352
+ self.log_view.moveCursor(QTextCursor.End)
353
+
354
+ def _browse_local(self):
355
+ path, _ = QFileDialog.getOpenFileName(self, "Choose local file")
356
+ if path:
357
+ self.local_path.setText(path)
358
+
359
+ def _browse_save(self, target):
360
+ path, _ = QFileDialog.getSaveFileName(self, "Save to file")
361
+ if path:
362
+ target.setText(path)
363
+
364
+ def _save_log(self):
365
+ path, _ = QFileDialog.getSaveFileName(self, "Save log", "session.log")
366
+ if path:
367
+ with open(path, "w", encoding="utf-8") as fh:
368
+ fh.write(self.log_view.toPlainText())
369
+ self._append_log(f"Log saved to {path}")
370
+
371
+ def closeEvent(self, event):
372
+ self.worker.shutdown()
373
+ self.worker.wait(2000)
374
+ super().closeEvent(event)
375
+
376
+
377
+ def _maybe_open_docs_first_run():
378
+ """Open the docs in a browser the first time the app is launched."""
379
+ try:
380
+ flag_dir = os.path.join(os.path.expanduser("~"), ".ssh-handler")
381
+ flag = os.path.join(flag_dir, "docs-opened")
382
+ if not os.path.exists(flag):
383
+ os.makedirs(flag_dir, exist_ok=True)
384
+ webbrowser.open(DOCS_URL)
385
+ with open(flag, "w") as fh:
386
+ fh.write("1")
387
+ except Exception:
388
+ pass
389
+
390
+
391
+ def main():
392
+ app = QApplication(sys.argv)
393
+ if os.path.exists(ICON_PATH):
394
+ app.setWindowIcon(QIcon(ICON_PATH))
395
+ _maybe_open_docs_first_run()
396
+ win = MainWindow()
397
+ win.show()
398
+ return app.exec_()
399
+
400
+
401
+ if __name__ == "__main__":
402
+ raise SystemExit(main())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ssh-handler
3
- Version: 1.5.0
3
+ Version: 1.6.0
4
4
  Summary: Extensive SSH/SFTP/SCP/FTP handler built on Paramiko, for test automation, CLIs and PyQt5 tools.
5
5
  Author: ssh-handler contributors
6
6
  License-Expression: MIT
@@ -1,6 +1,7 @@
1
1
  LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
+ ssh_handler/README.md
4
5
  ssh_handler/__init__.py
5
6
  ssh_handler/__main__.py
6
7
  ssh_handler/cli.py
@@ -9,6 +10,7 @@ ssh_handler/core.py
9
10
  ssh_handler/credentials.py
10
11
  ssh_handler/exceptions.py
11
12
  ssh_handler/ftp.py
13
+ ssh_handler/gui_app.py
12
14
  ssh_handler/pool.py
13
15
  ssh_handler/pyqt_worker.py
14
16
  ssh_handler/results.py
@@ -21,6 +23,8 @@ ssh_handler.egg-info/dependency_links.txt
21
23
  ssh_handler.egg-info/entry_points.txt
22
24
  ssh_handler.egg-info/requires.txt
23
25
  ssh_handler.egg-info/top_level.txt
26
+ ssh_handler/assets/icon.ico
27
+ ssh_handler/assets/icon.png
24
28
  ssh_handler/openssh/OpenSSH-ARM64.zip
25
29
  ssh_handler/openssh/OpenSSH-Win32.zip
26
30
  ssh_handler/openssh/OpenSSH-Win64.zip
@@ -1,3 +1,7 @@
1
1
  [console_scripts]
2
2
  ssh-handler = ssh_handler.cli:main
3
+ ssh-handler-docs = ssh_handler.cli:open_docs
3
4
  ssh-handler-setup = ssh_handler.cli:setup_server_main
5
+
6
+ [gui_scripts]
7
+ ssh-handler-gui = ssh_handler.cli:launch_gui
File without changes
File without changes
File without changes