ssh-handler 1.4.1__tar.gz → 1.5.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.
- {ssh_handler-1.4.1/ssh_handler.egg-info → ssh_handler-1.5.0}/PKG-INFO +1 -1
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/pyproject.toml +1 -1
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler/__init__.py +1 -1
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler/core.py +25 -6
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler/results.py +24 -9
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler/serial_handler.py +8 -2
- {ssh_handler-1.4.1 → ssh_handler-1.5.0/ssh_handler.egg-info}/PKG-INFO +1 -1
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/LICENSE +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/README.md +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/setup.cfg +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler/__main__.py +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler/cli.py +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler/config.py +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler/credentials.py +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler/exceptions.py +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler/ftp.py +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler/openssh/OpenSSH-ARM64.zip +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler/openssh/OpenSSH-Win32.zip +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler/openssh/OpenSSH-Win64.zip +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler/pool.py +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler/pyqt_worker.py +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler/setup_openssh_server.ps1 +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler/winrm_bootstrap.py +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler.egg-info/SOURCES.txt +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler.egg-info/dependency_links.txt +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler.egg-info/entry_points.txt +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler.egg-info/requires.txt +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/ssh_handler.egg-info/top_level.txt +0 -0
- {ssh_handler-1.4.1 → ssh_handler-1.5.0}/tests/test_offline.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ssh-handler"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.5.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"
|
|
@@ -603,7 +603,7 @@ class SSHHandler:
|
|
|
603
603
|
# Continuous / streaming output (tail -f, slog2info -w, journalctl -f…)
|
|
604
604
|
# ------------------------------------------------------------------ #
|
|
605
605
|
def iter_lines(self, command: str, *, timeout: Optional[float] = None,
|
|
606
|
-
stop_event=None, get_pty: bool =
|
|
606
|
+
stop_event=None, get_pty: bool = False, encoding: str = "utf-8",
|
|
607
607
|
idle_poll: float = 0.4):
|
|
608
608
|
"""
|
|
609
609
|
Run a (possibly never-ending) command and yield its stdout **line by
|
|
@@ -655,7 +655,8 @@ class SSHHandler:
|
|
|
655
655
|
|
|
656
656
|
def stream(self, command: str, *, on_line=None, on_match=None, match=None,
|
|
657
657
|
stop_on_match: bool = False, save_to: Optional[str] = None,
|
|
658
|
-
append: bool = True,
|
|
658
|
+
append: bool = True, clean: bool = True,
|
|
659
|
+
timeout: Optional[float] = None,
|
|
659
660
|
stop_event=None, encoding: str = "utf-8", safe: Optional[bool] = None):
|
|
660
661
|
"""
|
|
661
662
|
Consume a streaming command with built-in matching and file logging.
|
|
@@ -669,6 +670,7 @@ class SSHHandler:
|
|
|
669
670
|
:returns: dict with 'lines' (count) and 'matches' (list).
|
|
670
671
|
"""
|
|
671
672
|
import re
|
|
673
|
+
from .results import strip_ansi
|
|
672
674
|
|
|
673
675
|
def _do():
|
|
674
676
|
pat = re.compile(match) if isinstance(match, str) else match
|
|
@@ -678,6 +680,8 @@ class SSHHandler:
|
|
|
678
680
|
try:
|
|
679
681
|
for line in self.iter_lines(command, timeout=timeout,
|
|
680
682
|
stop_event=stop_event, encoding=encoding):
|
|
683
|
+
if clean:
|
|
684
|
+
line = strip_ansi(line)
|
|
681
685
|
count += 1
|
|
682
686
|
if fh:
|
|
683
687
|
fh.write(line + "\n")
|
|
@@ -708,8 +712,9 @@ class SSHHandler:
|
|
|
708
712
|
def serial_stream(self, device: str = "/dev/ttyUSB0", *, baudrate: int = 115200,
|
|
709
713
|
mode: str = "auto", on_line=None, on_match=None, match=None,
|
|
710
714
|
stop_on_match: bool = False, save_to: Optional[str] = None,
|
|
711
|
-
timeout: Optional[float] = None,
|
|
712
|
-
configure: bool = True,
|
|
715
|
+
clean: bool = True, timeout: Optional[float] = None,
|
|
716
|
+
stop_event=None, configure: bool = True,
|
|
717
|
+
safe: Optional[bool] = None):
|
|
713
718
|
"""
|
|
714
719
|
Stream a serial port attached to the **remote** host, over SSH — so it
|
|
715
720
|
works through a jump host (laptop -> RDP machine -> target). Same live
|
|
@@ -753,7 +758,7 @@ class SSHHandler:
|
|
|
753
758
|
else:
|
|
754
759
|
cmd = f"cat {dev}"
|
|
755
760
|
return self.stream(cmd, on_line=on_line, on_match=on_match, match=match,
|
|
756
|
-
stop_on_match=stop_on_match, save_to=save_to,
|
|
761
|
+
stop_on_match=stop_on_match, save_to=save_to, clean=clean,
|
|
757
762
|
timeout=timeout, stop_event=stop_event, safe=safe)
|
|
758
763
|
|
|
759
764
|
def serial_write(self, device: str, data: str, *, baudrate: int = 115200,
|
|
@@ -1027,6 +1032,10 @@ class SSHHandler:
|
|
|
1027
1032
|
local_path = os.path.expanduser(local_path)
|
|
1028
1033
|
sftp = self.sftp()
|
|
1029
1034
|
start = time.time()
|
|
1035
|
+
# Verify the remote path exists FIRST, so a missing source never leaves
|
|
1036
|
+
# behind an empty local file (sftp.get opens the local file before fetch).
|
|
1037
|
+
if not self._remote_exists(sftp, remote_path):
|
|
1038
|
+
raise SSHTransferError(f"Remote path does not exist: {remote_path}")
|
|
1030
1039
|
try:
|
|
1031
1040
|
if self._remote_is_dir(sftp, remote_path):
|
|
1032
1041
|
if not recursive:
|
|
@@ -1039,7 +1048,17 @@ class SSHHandler:
|
|
|
1039
1048
|
if parent and not os.path.exists(parent):
|
|
1040
1049
|
os.makedirs(parent, exist_ok=True)
|
|
1041
1050
|
self._emit(logging.INFO, f"PULL {remote_path} -> {local_path}")
|
|
1042
|
-
|
|
1051
|
+
existed = os.path.exists(local_path)
|
|
1052
|
+
try:
|
|
1053
|
+
sftp.get(remote_path, local_path, callback=callback)
|
|
1054
|
+
except BaseException:
|
|
1055
|
+
# don't leave a partial/empty local file behind on failure
|
|
1056
|
+
if not existed and os.path.exists(local_path):
|
|
1057
|
+
try:
|
|
1058
|
+
os.remove(local_path)
|
|
1059
|
+
except OSError:
|
|
1060
|
+
pass
|
|
1061
|
+
raise
|
|
1043
1062
|
size, count = os.path.getsize(local_path), 1
|
|
1044
1063
|
except SSHTransferError:
|
|
1045
1064
|
raise
|
|
@@ -2,11 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import re
|
|
5
6
|
import time
|
|
6
7
|
from dataclasses import dataclass, field, asdict
|
|
7
8
|
from typing import Optional
|
|
8
9
|
|
|
9
10
|
|
|
11
|
+
# ANSI/VT escape sequences (CSI like ESC[23;80H, OSC like ESC]0;title BEL, etc.)
|
|
12
|
+
_ANSI_RE = re.compile(r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))")
|
|
13
|
+
# control chars except tab(09), newline(0a), carriage-return(0d handled separately)
|
|
14
|
+
_CTRL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def strip_ansi(text: str) -> str:
|
|
18
|
+
"""Remove ANSI/VT escape codes, carriage returns, and other control chars,
|
|
19
|
+
so streamed console output is clean to save, match, and read."""
|
|
20
|
+
if not text:
|
|
21
|
+
return text
|
|
22
|
+
text = _ANSI_RE.sub("", text)
|
|
23
|
+
text = text.replace("\r", "")
|
|
24
|
+
return _CTRL_RE.sub("", text)
|
|
25
|
+
|
|
26
|
+
|
|
10
27
|
def _human_size(num: float) -> str:
|
|
11
28
|
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
12
29
|
if abs(num) < 1024.0:
|
|
@@ -43,10 +60,8 @@ class CommandResult:
|
|
|
43
60
|
return asdict(self)
|
|
44
61
|
|
|
45
62
|
def __str__(self) -> str:
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
f"ok={self.ok} dur={self.duration:.2f}s cmd={self.command!r}>"
|
|
49
|
-
)
|
|
63
|
+
status = "ok" if self.ok else f"FAILED (exit {self.exit_code})"
|
|
64
|
+
return f"$ {self.command} [{status}, {self.duration:.2f}s]"
|
|
50
65
|
|
|
51
66
|
|
|
52
67
|
@dataclass
|
|
@@ -79,11 +94,11 @@ class TransferResult:
|
|
|
79
94
|
return d
|
|
80
95
|
|
|
81
96
|
def __str__(self) -> str:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
97
|
+
verb = "Uploaded" if self.direction == "push" else "Downloaded"
|
|
98
|
+
files = f"{self.files} files, " if self.files != 1 else ""
|
|
99
|
+
return (f"{verb} {self.source} -> {self.dest} "
|
|
100
|
+
f"({files}{self.human_size} in {self.duration:.2f}s, "
|
|
101
|
+
f"{self.human_speed})")
|
|
87
102
|
|
|
88
103
|
|
|
89
104
|
@dataclass
|
|
@@ -153,12 +153,16 @@ class SerialHandler:
|
|
|
153
153
|
|
|
154
154
|
def stream(self, *, on_line=None, on_match=None, match=None,
|
|
155
155
|
stop_on_match: bool = False, save_to: Optional[str] = None,
|
|
156
|
-
append: bool = True,
|
|
156
|
+
append: bool = True, clean: bool = True,
|
|
157
|
+
timeout: Optional[float] = None, stop_event=None,
|
|
157
158
|
encoding: str = "utf-8", safe=None):
|
|
158
159
|
"""
|
|
159
160
|
Read the serial console continuously with built-in matching + file
|
|
160
|
-
logging. Same signature/semantics as SSHHandler.stream.
|
|
161
|
+
logging. Same signature/semantics as SSHHandler.stream. ``clean=True``
|
|
162
|
+
strips ANSI escape codes and control chars from each line.
|
|
161
163
|
"""
|
|
164
|
+
from .results import strip_ansi
|
|
165
|
+
|
|
162
166
|
def _do():
|
|
163
167
|
pat = re.compile(match) if isinstance(match, str) else match
|
|
164
168
|
matches, count = [], 0
|
|
@@ -167,6 +171,8 @@ class SerialHandler:
|
|
|
167
171
|
try:
|
|
168
172
|
for line in self.iter_lines(stop_event=stop_event, timeout=timeout,
|
|
169
173
|
encoding=encoding):
|
|
174
|
+
if clean:
|
|
175
|
+
line = strip_ansi(line)
|
|
170
176
|
count += 1
|
|
171
177
|
if fh:
|
|
172
178
|
fh.write(line + "\n")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|