hostctl 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hostctl/AGENTS.md +292 -0
- hostctl/README.md +248 -0
- hostctl/__init__.py +194 -0
- hostctl/__main__.py +6 -0
- hostctl/_async.py +169 -0
- hostctl/_cli.py +232 -0
- hostctl/executor/__init__.py +82 -0
- hostctl/executor/_common.py +234 -0
- hostctl/executor/_qga.py +646 -0
- hostctl/executor/container.py +171 -0
- hostctl/executor/local.py +91 -0
- hostctl/executor/psrp.py +145 -0
- hostctl/executor/qemu.py +305 -0
- hostctl/executor/serial.py +312 -0
- hostctl/executor/ssh.py +226 -0
- hostctl/executor/winrm.py +255 -0
- hostctl/host/__init__.py +93 -0
- hostctl/host/_common.py +804 -0
- hostctl/host/_local.py +258 -0
- hostctl/host/_ssh.py +587 -0
- hostctl/host/_winrm.py +1002 -0
- hostctl/host/composite_path.py +567 -0
- hostctl/host/container.py +606 -0
- hostctl/host/container_path.py +664 -0
- hostctl/host/qemu.py +1078 -0
- hostctl/host/serial.py +401 -0
- hostctl/host/system.py +809 -0
- hostctl/process/__init__.py +49 -0
- hostctl/process/_common.py +113 -0
- hostctl/process/container.py +265 -0
- hostctl/process/psrp.py +208 -0
- hostctl/process/qemu_serial.py +247 -0
- hostctl/process/serial.py +257 -0
- hostctl/process/ssh.py +165 -0
- hostctl/provider/__init__.py +33 -0
- hostctl/provider/_common.py +452 -0
- hostctl/provider/transports.py +303 -0
- hostctl/py.typed +0 -0
- hostctl/serial/__init__.py +285 -0
- hostctl/shell/__init__.py +123 -0
- hostctl/shell/_common.py +616 -0
- hostctl/shell/cmd.py +167 -0
- hostctl/shell/fish.py +63 -0
- hostctl/shell/posix.py +83 -0
- hostctl/shell/powershell.py +120 -0
- hostctl/sync.py +245 -0
- hostctl-0.1.0.dist-info/METADATA +302 -0
- hostctl-0.1.0.dist-info/RECORD +51 -0
- hostctl-0.1.0.dist-info/WHEEL +4 -0
- hostctl-0.1.0.dist-info/entry_points.txt +2 -0
- hostctl-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Path and executor provider adapters for the built-in transports.
|
|
2
|
+
|
|
3
|
+
The SSH/SFTP and WinRM adapters live beside their private transport services
|
|
4
|
+
(``host/_ssh.py``, ``host/_winrm.py``) because they own that transport's
|
|
5
|
+
lifecycle. The adapters here wrap backends that are *not* lifecycle owners:
|
|
6
|
+
the local filesystem, the Docker archive API, and the QEMU Guest Agent file
|
|
7
|
+
RPCs. Each one declares the operations its backend can genuinely perform, so
|
|
8
|
+
:class:`~hostctl.host.composite_path._CompositePathMixin` rejects an
|
|
9
|
+
unsupported mutation outright instead of falling through to another provider.
|
|
10
|
+
|
|
11
|
+
None of these adapters retries. A backend failure is reported as-is; only a
|
|
12
|
+
proven pre-dispatch refusal is raised as
|
|
13
|
+
:class:`~hostctl.provider.OperationNotStarted`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import typing
|
|
21
|
+
|
|
22
|
+
from pathlib_next import Path
|
|
23
|
+
|
|
24
|
+
from ._common import (
|
|
25
|
+
ExecutorProvider,
|
|
26
|
+
OperationNotStarted,
|
|
27
|
+
PathProvider,
|
|
28
|
+
ProviderProbe,
|
|
29
|
+
ProviderSelector,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger("hostctl.provider.transports")
|
|
33
|
+
|
|
34
|
+
#: Operations a POSIX-complete filesystem backend performs.
|
|
35
|
+
FULL_PATH_OPERATIONS = frozenset(
|
|
36
|
+
(
|
|
37
|
+
"stat",
|
|
38
|
+
"scandir",
|
|
39
|
+
"open",
|
|
40
|
+
"open_read",
|
|
41
|
+
"open_write",
|
|
42
|
+
"read",
|
|
43
|
+
"write",
|
|
44
|
+
"exists",
|
|
45
|
+
"is_file",
|
|
46
|
+
"is_dir",
|
|
47
|
+
"mkdir",
|
|
48
|
+
"chmod",
|
|
49
|
+
"unlink",
|
|
50
|
+
"rmdir",
|
|
51
|
+
"rename",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
#: Content and metadata reads plus whole-file writes, with no namespace
|
|
56
|
+
#: mutations. The Docker archive API can replace a file's bytes but cannot
|
|
57
|
+
#: create a directory, change a mode, or remove/rename an entry.
|
|
58
|
+
ARCHIVE_PATH_OPERATIONS = frozenset(
|
|
59
|
+
(
|
|
60
|
+
"stat",
|
|
61
|
+
"scandir",
|
|
62
|
+
"open",
|
|
63
|
+
"open_read",
|
|
64
|
+
"open_write",
|
|
65
|
+
"read",
|
|
66
|
+
"write",
|
|
67
|
+
"exists",
|
|
68
|
+
"is_file",
|
|
69
|
+
"is_dir",
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
#: QGA file RPCs without a guest helper: content only. ``stat``/``scandir``
|
|
74
|
+
#: and every namespace mutation require a positively probed helper.
|
|
75
|
+
QGA_FILE_OPERATIONS = frozenset(("open", "open_read", "open_write", "read", "write"))
|
|
76
|
+
|
|
77
|
+
#: Operations a positively probed guest helper adds on top of the file RPCs.
|
|
78
|
+
QGA_HELPER_OPERATIONS = frozenset(
|
|
79
|
+
(
|
|
80
|
+
"stat",
|
|
81
|
+
"scandir",
|
|
82
|
+
"exists",
|
|
83
|
+
"is_file",
|
|
84
|
+
"is_dir",
|
|
85
|
+
"mkdir",
|
|
86
|
+
"chmod",
|
|
87
|
+
"unlink",
|
|
88
|
+
"rmdir",
|
|
89
|
+
"rename",
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class LocalExecutorProvider(ExecutorProvider):
|
|
95
|
+
"""Local subprocess execution with native argv, cwd, and env support."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, executor=None):
|
|
98
|
+
if executor is None:
|
|
99
|
+
from ..executor import LocalExecutor
|
|
100
|
+
|
|
101
|
+
executor = LocalExecutor()
|
|
102
|
+
self.transport = executor
|
|
103
|
+
super().__init__("local", executor)
|
|
104
|
+
|
|
105
|
+
def probe(self) -> ProviderProbe:
|
|
106
|
+
return ProviderProbe(
|
|
107
|
+
"available", capabilities=self.capabilities, system_hint=os.name
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class LocalPathProvider(PathProvider):
|
|
112
|
+
"""Local filesystem paths; the process's own filesystem is always usable."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, factory: typing.Optional[typing.Callable[..., Path]] = None):
|
|
115
|
+
super().__init__(
|
|
116
|
+
"local",
|
|
117
|
+
factory if factory is not None else (lambda *segments: Path(*segments)),
|
|
118
|
+
capabilities=FULL_PATH_OPERATIONS,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def probe(self) -> ProviderProbe:
|
|
122
|
+
return ProviderProbe(
|
|
123
|
+
"available", capabilities=self.capabilities, system_hint=os.name
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ContainerExecutorProvider(ExecutorProvider):
|
|
128
|
+
"""Docker ``exec`` command provider owning the container's inspection.
|
|
129
|
+
|
|
130
|
+
``connect`` inspects the container before any command is dispatched, so a
|
|
131
|
+
missing or stopped container is a proven pre-dispatch refusal rather than a
|
|
132
|
+
command whose outcome is unknown.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(self, executor, *, connect=None, close=None, info=None):
|
|
136
|
+
self.transport = executor
|
|
137
|
+
self._connect = connect
|
|
138
|
+
self._close = close
|
|
139
|
+
self._info = info
|
|
140
|
+
super().__init__("container", executor)
|
|
141
|
+
|
|
142
|
+
def probe(self) -> ProviderProbe:
|
|
143
|
+
return ProviderProbe("available", capabilities=self.capabilities)
|
|
144
|
+
|
|
145
|
+
def connect(self):
|
|
146
|
+
if self._connect is None:
|
|
147
|
+
return
|
|
148
|
+
log.debug("inspecting container before dispatch")
|
|
149
|
+
try:
|
|
150
|
+
self._connect()
|
|
151
|
+
except (ConnectionError, TimeoutError) as exc:
|
|
152
|
+
log.debug(
|
|
153
|
+
"container provider declining before dispatch: %s: %s",
|
|
154
|
+
type(exc).__name__,
|
|
155
|
+
ProviderSelector.redact(exc),
|
|
156
|
+
)
|
|
157
|
+
raise OperationNotStarted(
|
|
158
|
+
"container is unavailable before dispatch", cause=exc
|
|
159
|
+
) from exc
|
|
160
|
+
log.debug("container is running and ready for dispatch")
|
|
161
|
+
|
|
162
|
+
def close(self):
|
|
163
|
+
if self._close is not None:
|
|
164
|
+
log.debug("releasing container provider resources")
|
|
165
|
+
self._close()
|
|
166
|
+
|
|
167
|
+
def info(self):
|
|
168
|
+
if self._info is None:
|
|
169
|
+
return None
|
|
170
|
+
return self._info()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ContainerArchivePathProvider(PathProvider):
|
|
174
|
+
"""Docker archive (``get_archive``/``put_archive``) path provider.
|
|
175
|
+
|
|
176
|
+
The archive API has no namespace mutations. Declaring only the operations
|
|
177
|
+
it implements makes ``mkdir``/``chmod``/``unlink``/``rmdir``/``rename``
|
|
178
|
+
fail with ``NotImplementedError`` at selection time, which is the plan's
|
|
179
|
+
"read-only providers explicitly reject mutations instead of falling
|
|
180
|
+
through" rule applied to a partially writable backend.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(self, factory, *, name="archive", probe=None):
|
|
184
|
+
self._probe_hook = probe
|
|
185
|
+
super().__init__(name, factory, capabilities=ARCHIVE_PATH_OPERATIONS)
|
|
186
|
+
|
|
187
|
+
def probe(self) -> ProviderProbe:
|
|
188
|
+
if self._probe_hook is None:
|
|
189
|
+
return ProviderProbe("available", capabilities=self.capabilities)
|
|
190
|
+
try:
|
|
191
|
+
usable = self._probe_hook()
|
|
192
|
+
except (ConnectionError, TimeoutError, OSError) as exc:
|
|
193
|
+
return ProviderProbe("unavailable", type(exc).__name__)
|
|
194
|
+
if isinstance(usable, ProviderProbe):
|
|
195
|
+
return usable
|
|
196
|
+
return ProviderProbe(
|
|
197
|
+
"available" if usable else "unavailable",
|
|
198
|
+
"" if usable else "container archive API is unavailable",
|
|
199
|
+
capabilities=self.capabilities,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class QgaPathProvider(PathProvider):
|
|
204
|
+
"""QEMU Guest Agent path provider gated on genuinely probed RPCs.
|
|
205
|
+
|
|
206
|
+
QGA file RPCs give content access. Metadata and namespace mutations exist
|
|
207
|
+
only when a guest helper was positively probed, so this provider reports
|
|
208
|
+
``degraded`` when it can move bytes but cannot describe or restructure the
|
|
209
|
+
guest filesystem.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def __init__(self, factory, backend, *, name="qga"):
|
|
213
|
+
self.backend = backend
|
|
214
|
+
capabilities = self._backend_capabilities(backend)
|
|
215
|
+
super().__init__(name, factory, capabilities=capabilities)
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _backend_capabilities(backend) -> frozenset:
|
|
219
|
+
supported = frozenset(getattr(backend, "supported_commands", ()) or ())
|
|
220
|
+
values = set()
|
|
221
|
+
read_commands = getattr(
|
|
222
|
+
type(backend),
|
|
223
|
+
"_READ_COMMANDS",
|
|
224
|
+
frozenset(("guest-file-open", "guest-file-read", "guest-file-close")),
|
|
225
|
+
)
|
|
226
|
+
write_commands = getattr(
|
|
227
|
+
type(backend),
|
|
228
|
+
"_WRITE_COMMANDS",
|
|
229
|
+
frozenset(
|
|
230
|
+
(
|
|
231
|
+
"guest-file-open",
|
|
232
|
+
"guest-file-write",
|
|
233
|
+
"guest-file-flush",
|
|
234
|
+
"guest-file-close",
|
|
235
|
+
)
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
if read_commands <= supported:
|
|
239
|
+
values.update(("read", "open", "open_read"))
|
|
240
|
+
if write_commands <= supported:
|
|
241
|
+
values.update(("write", "open_write"))
|
|
242
|
+
if getattr(backend, "helper", None) is not None:
|
|
243
|
+
values.update(QGA_HELPER_OPERATIONS)
|
|
244
|
+
return frozenset(values)
|
|
245
|
+
|
|
246
|
+
def probe(self) -> ProviderProbe:
|
|
247
|
+
if not self.capabilities:
|
|
248
|
+
return ProviderProbe(
|
|
249
|
+
"unavailable", "guest agent provides no usable file RPCs"
|
|
250
|
+
)
|
|
251
|
+
if getattr(self.backend, "helper", None) is None:
|
|
252
|
+
# Warning, not debug: the provider stays usable, so the operation
|
|
253
|
+
# proceeds and nothing surfaces an error -- but the caller silently
|
|
254
|
+
# loses stat/scandir and every namespace mutation. Degrading
|
|
255
|
+
# without saying so is exactly the case a log is for.
|
|
256
|
+
log.warning(
|
|
257
|
+
"QGA provider %s is degraded: no guest helper was probed, so "
|
|
258
|
+
"metadata and namespace mutations are unavailable",
|
|
259
|
+
ProviderSelector.redact(self.name),
|
|
260
|
+
)
|
|
261
|
+
return ProviderProbe(
|
|
262
|
+
"degraded",
|
|
263
|
+
"guest helper was not probed; metadata and mutations are unavailable",
|
|
264
|
+
capabilities=self.capabilities,
|
|
265
|
+
)
|
|
266
|
+
return ProviderProbe("available", capabilities=self.capabilities)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class DownloadPathProvider(PathProvider):
|
|
270
|
+
"""Read-only content provider for an application download endpoint.
|
|
271
|
+
|
|
272
|
+
Mutating operations are absent from the declared capability set, so the
|
|
273
|
+
composite path rejects them rather than silently routing the mutation to
|
|
274
|
+
another provider that might apply it to different state.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
READ_OPERATIONS = frozenset(("stat", "read", "open", "open_read", "exists"))
|
|
278
|
+
|
|
279
|
+
def __init__(self, factory, *, name="download", available=True, reason=""):
|
|
280
|
+
self._available = bool(available)
|
|
281
|
+
self._reason = reason
|
|
282
|
+
super().__init__(name, factory, capabilities=self.READ_OPERATIONS)
|
|
283
|
+
|
|
284
|
+
def probe(self) -> ProviderProbe:
|
|
285
|
+
if not self._available:
|
|
286
|
+
return ProviderProbe(
|
|
287
|
+
"unavailable", self._reason or "download endpoint is unavailable"
|
|
288
|
+
)
|
|
289
|
+
return ProviderProbe("available", capabilities=self.capabilities)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
__all__ = [
|
|
293
|
+
"ARCHIVE_PATH_OPERATIONS",
|
|
294
|
+
"ContainerArchivePathProvider",
|
|
295
|
+
"ContainerExecutorProvider",
|
|
296
|
+
"DownloadPathProvider",
|
|
297
|
+
"FULL_PATH_OPERATIONS",
|
|
298
|
+
"LocalExecutorProvider",
|
|
299
|
+
"LocalPathProvider",
|
|
300
|
+
"QGA_FILE_OPERATIONS",
|
|
301
|
+
"QGA_HELPER_OPERATIONS",
|
|
302
|
+
"QgaPathProvider",
|
|
303
|
+
]
|
hostctl/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Console protocols for serial transports.
|
|
2
|
+
|
|
3
|
+
The wire is deliberately kept separate from shell and operating-system
|
|
4
|
+
assumptions. Profiles negotiate a console, optionally frame commands, and
|
|
5
|
+
never claim filesystem or process semantics which the device cannot provide.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import abc
|
|
11
|
+
import dataclasses
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
import typing
|
|
15
|
+
|
|
16
|
+
from ..process.serial import SerialProcess
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@typing.runtime_checkable
|
|
20
|
+
class SerialConsoleProtocol(typing.Protocol):
|
|
21
|
+
"""Runtime contract implemented by serial console profiles."""
|
|
22
|
+
|
|
23
|
+
can_run: bool
|
|
24
|
+
line_terminator: bytes
|
|
25
|
+
encoding: str
|
|
26
|
+
|
|
27
|
+
def negotiate(self, process: SerialProcess) -> None: ...
|
|
28
|
+
|
|
29
|
+
def send(self, process: SerialProcess, command: str | bytes) -> None: ...
|
|
30
|
+
|
|
31
|
+
def run(
|
|
32
|
+
self,
|
|
33
|
+
process: SerialProcess,
|
|
34
|
+
command: str | bytes,
|
|
35
|
+
*,
|
|
36
|
+
timeout: float | None = None,
|
|
37
|
+
) -> tuple[bytes, int]: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ConsoleProtocolError(ConnectionError):
|
|
41
|
+
"""The console did not complete the expected protocol exchange."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclasses.dataclass(frozen=True, repr=False)
|
|
45
|
+
class LoginStep:
|
|
46
|
+
"""One bounded expect/send step used by :class:`PromptConsoleProfile`."""
|
|
47
|
+
|
|
48
|
+
expect: bytes | str
|
|
49
|
+
send: bytes | str
|
|
50
|
+
secret: bool = False
|
|
51
|
+
|
|
52
|
+
def __post_init__(self) -> None:
|
|
53
|
+
expect = self.expect.encode() if isinstance(self.expect, str) else self.expect
|
|
54
|
+
send = self.send.encode() if isinstance(self.send, str) else self.send
|
|
55
|
+
if not expect:
|
|
56
|
+
raise ValueError("login expect expression must not be empty")
|
|
57
|
+
object.__setattr__(self, "expect", bytes(expect))
|
|
58
|
+
object.__setattr__(self, "send", bytes(send))
|
|
59
|
+
|
|
60
|
+
def __repr__(self) -> str:
|
|
61
|
+
value = "<redacted>" if self.secret else repr(self.send)
|
|
62
|
+
return (
|
|
63
|
+
f"LoginStep(expect={self.expect!r}, send={value}, secret={self.secret!r})"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RawConsoleProfile:
|
|
68
|
+
"""Raw byte console; it supports interactive sessions but not ``run``."""
|
|
69
|
+
|
|
70
|
+
can_run = False
|
|
71
|
+
line_terminator = b"\r\n"
|
|
72
|
+
encoding = "utf-8"
|
|
73
|
+
max_buffer = 64 * 1024
|
|
74
|
+
|
|
75
|
+
def negotiate(self, process: SerialProcess) -> None:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
def send(self, process: SerialProcess, command: str | bytes) -> None:
|
|
79
|
+
data = (
|
|
80
|
+
command.encode(self.encoding)
|
|
81
|
+
if isinstance(command, str)
|
|
82
|
+
else bytes(command)
|
|
83
|
+
)
|
|
84
|
+
process.write(data + self.line_terminator)
|
|
85
|
+
|
|
86
|
+
def run(self, process: SerialProcess, command: str | bytes, *, timeout=None):
|
|
87
|
+
raise NotImplementedError("raw serial consoles do not provide framed run()")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class PromptConsoleProfile:
|
|
91
|
+
"""Configurable prompt/login framing for terminal-like devices.
|
|
92
|
+
|
|
93
|
+
A profile only advertises ``run`` when ``reliable_status`` is explicitly
|
|
94
|
+
enabled. Without a real status marker a prompt can delimit output, but it
|
|
95
|
+
cannot truthfully represent a process exit status.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
prompt: bytes | str,
|
|
101
|
+
*,
|
|
102
|
+
login: typing.Iterable[LoginStep | tuple[bytes | str, bytes | str]] = (),
|
|
103
|
+
line_terminator: bytes | str = b"\r\n",
|
|
104
|
+
error_patterns: typing.Iterable[bytes | str] = (),
|
|
105
|
+
status_marker: bytes | str | None = None,
|
|
106
|
+
reliable_status: bool = False,
|
|
107
|
+
max_buffer: int = 64 * 1024,
|
|
108
|
+
read_size: int = 4096,
|
|
109
|
+
wakeup: bytes = b"\r",
|
|
110
|
+
echo: bool = True,
|
|
111
|
+
paging_prompt: bytes | str | None = None,
|
|
112
|
+
paging_continue: bytes | str = b" ",
|
|
113
|
+
paging_disable: bytes | str | None = None,
|
|
114
|
+
max_paging_pages: int = 32,
|
|
115
|
+
terminal_setup: typing.Callable[[SerialProcess, int, int], None] | None = None,
|
|
116
|
+
status_parser: typing.Callable[[re.Match[bytes]], int] | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
self._prompt = self._compile(prompt)
|
|
119
|
+
self.login = tuple(
|
|
120
|
+
step if isinstance(step, LoginStep) else LoginStep(step[0], step[1])
|
|
121
|
+
for step in login
|
|
122
|
+
)
|
|
123
|
+
self._login_patterns = tuple(self._compile(step.expect) for step in self.login)
|
|
124
|
+
self.line_terminator = (
|
|
125
|
+
line_terminator.encode()
|
|
126
|
+
if isinstance(line_terminator, str)
|
|
127
|
+
else bytes(line_terminator)
|
|
128
|
+
)
|
|
129
|
+
if not self.line_terminator:
|
|
130
|
+
raise ValueError("line_terminator must not be empty")
|
|
131
|
+
self.error_patterns = tuple(self._compile(value) for value in error_patterns)
|
|
132
|
+
self.status_marker = (
|
|
133
|
+
self._compile(status_marker) if status_marker is not None else None
|
|
134
|
+
)
|
|
135
|
+
self.reliable_status = bool(reliable_status)
|
|
136
|
+
self.can_run = self.reliable_status and self.status_marker is not None
|
|
137
|
+
self.max_buffer = max(1024, int(max_buffer))
|
|
138
|
+
self.read_size = max(1, int(read_size))
|
|
139
|
+
self.wakeup = bytes(wakeup)
|
|
140
|
+
self.echo = bool(echo)
|
|
141
|
+
self._paging_prompt = self._compile(paging_prompt) if paging_prompt else None
|
|
142
|
+
self.paging_continue = (
|
|
143
|
+
paging_continue.encode()
|
|
144
|
+
if isinstance(paging_continue, str)
|
|
145
|
+
else bytes(paging_continue)
|
|
146
|
+
)
|
|
147
|
+
self.paging_disable = (
|
|
148
|
+
paging_disable.encode()
|
|
149
|
+
if isinstance(paging_disable, str)
|
|
150
|
+
else (bytes(paging_disable) if paging_disable is not None else None)
|
|
151
|
+
)
|
|
152
|
+
if max_paging_pages < 0:
|
|
153
|
+
raise ValueError("max_paging_pages must not be negative")
|
|
154
|
+
self.max_paging_pages = int(max_paging_pages)
|
|
155
|
+
self.terminal_setup = terminal_setup
|
|
156
|
+
self.status_parser = status_parser
|
|
157
|
+
self.encoding = "utf-8"
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _compile(value: bytes | str) -> typing.Pattern[bytes]:
|
|
161
|
+
raw = value.encode() if isinstance(value, str) else bytes(value)
|
|
162
|
+
if not raw:
|
|
163
|
+
raise ValueError("console expressions must not be empty")
|
|
164
|
+
try:
|
|
165
|
+
return re.compile(raw)
|
|
166
|
+
except re.error as exc:
|
|
167
|
+
raise ValueError(f"invalid console expression: {exc}") from exc
|
|
168
|
+
|
|
169
|
+
def _read_until(
|
|
170
|
+
self,
|
|
171
|
+
process: SerialProcess,
|
|
172
|
+
expression: typing.Pattern[bytes],
|
|
173
|
+
*,
|
|
174
|
+
timeout: float | None,
|
|
175
|
+
initial: bytes = b"",
|
|
176
|
+
paging_process: SerialProcess | None = None,
|
|
177
|
+
) -> bytes:
|
|
178
|
+
started = time.monotonic()
|
|
179
|
+
buffer = bytearray(initial)
|
|
180
|
+
pages = 0
|
|
181
|
+
while True:
|
|
182
|
+
if paging_process is not None and self._paging_prompt is not None:
|
|
183
|
+
paging = self._paging_prompt.search(buffer)
|
|
184
|
+
if paging is not None:
|
|
185
|
+
if pages >= self.max_paging_pages:
|
|
186
|
+
raise ConsoleProtocolError(
|
|
187
|
+
"serial console paging limit exceeded"
|
|
188
|
+
)
|
|
189
|
+
pages += 1
|
|
190
|
+
del buffer[: paging.end()]
|
|
191
|
+
paging_process.write(self.paging_continue)
|
|
192
|
+
continue
|
|
193
|
+
match = expression.search(buffer)
|
|
194
|
+
if match:
|
|
195
|
+
return bytes(buffer)
|
|
196
|
+
if len(buffer) > self.max_buffer:
|
|
197
|
+
del buffer[: -self.max_buffer]
|
|
198
|
+
if timeout is not None and time.monotonic() - started >= timeout:
|
|
199
|
+
error = TimeoutError("serial console prompt timed out")
|
|
200
|
+
error.output = bytes(buffer) # type: ignore[attr-defined]
|
|
201
|
+
raise error
|
|
202
|
+
chunk = process.read(self.read_size)
|
|
203
|
+
if chunk:
|
|
204
|
+
buffer.extend(chunk)
|
|
205
|
+
continue
|
|
206
|
+
# pyserial timed reads return b""; avoid a busy loop while still
|
|
207
|
+
# respecting a monotonic deadline.
|
|
208
|
+
time.sleep(0.001)
|
|
209
|
+
|
|
210
|
+
def negotiate(self, process: SerialProcess) -> None:
|
|
211
|
+
transcript = b""
|
|
212
|
+
reset = getattr(process, "reset_input_buffer", None)
|
|
213
|
+
if callable(reset):
|
|
214
|
+
try:
|
|
215
|
+
reset()
|
|
216
|
+
except NotImplementedError:
|
|
217
|
+
pass
|
|
218
|
+
if self.wakeup:
|
|
219
|
+
process.write(self.wakeup)
|
|
220
|
+
for step in self.login:
|
|
221
|
+
transcript = self._read_until(
|
|
222
|
+
process, self._compile(step.expect), timeout=10, initial=transcript
|
|
223
|
+
)
|
|
224
|
+
process.write(step.send + self.line_terminator)
|
|
225
|
+
self._read_until(process, self._prompt, timeout=10, initial=transcript)
|
|
226
|
+
if self.paging_disable is not None:
|
|
227
|
+
process.write(self.paging_disable + self.line_terminator)
|
|
228
|
+
|
|
229
|
+
def resize(self, process: SerialProcess, columns: int, rows: int) -> None:
|
|
230
|
+
if self.terminal_setup is None:
|
|
231
|
+
raise NotImplementedError("console profile does not support terminal setup")
|
|
232
|
+
if columns <= 0 or rows <= 0:
|
|
233
|
+
raise ValueError("terminal columns and rows must be positive")
|
|
234
|
+
self.terminal_setup(process, columns, rows)
|
|
235
|
+
|
|
236
|
+
def send(self, process: SerialProcess, command: str | bytes) -> None:
|
|
237
|
+
data = (
|
|
238
|
+
command.encode(self.encoding)
|
|
239
|
+
if isinstance(command, str)
|
|
240
|
+
else bytes(command)
|
|
241
|
+
)
|
|
242
|
+
process.write(data + self.line_terminator)
|
|
243
|
+
|
|
244
|
+
def run(self, process: SerialProcess, command: str | bytes, *, timeout=None):
|
|
245
|
+
if not self.can_run:
|
|
246
|
+
raise NotImplementedError("console profile does not provide reliable run()")
|
|
247
|
+
self.send(process, command)
|
|
248
|
+
transcript = self._read_until(
|
|
249
|
+
process, self._prompt, timeout=timeout, paging_process=process
|
|
250
|
+
)
|
|
251
|
+
if any(pattern.search(transcript) for pattern in self._login_patterns):
|
|
252
|
+
raise ConsoleProtocolError("serial console requested login again")
|
|
253
|
+
marker = self.status_marker.search(transcript) if self.status_marker else None
|
|
254
|
+
if marker is None:
|
|
255
|
+
raise ConsoleProtocolError("command completion marker missing")
|
|
256
|
+
body = transcript[: marker.start()]
|
|
257
|
+
# Remove a simple echoed command without attempting to parse vendor
|
|
258
|
+
# terminal editing rules.
|
|
259
|
+
if self.echo:
|
|
260
|
+
encoded = (
|
|
261
|
+
command.encode(self.encoding)
|
|
262
|
+
if isinstance(command, str)
|
|
263
|
+
else bytes(command)
|
|
264
|
+
)
|
|
265
|
+
if body.startswith(encoded):
|
|
266
|
+
body = body[len(encoded) :].lstrip(b"\r\n")
|
|
267
|
+
status = (
|
|
268
|
+
self.status_parser(marker)
|
|
269
|
+
if self.status_parser
|
|
270
|
+
else (
|
|
271
|
+
1 if any(pattern.search(body) for pattern in self.error_patterns) else 0
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
if not isinstance(status, int) or status < 0:
|
|
275
|
+
raise ConsoleProtocolError("console status parser returned an invalid code")
|
|
276
|
+
return body, status
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
__all__ = [
|
|
280
|
+
"ConsoleProtocolError",
|
|
281
|
+
"LoginStep",
|
|
282
|
+
"PromptConsoleProfile",
|
|
283
|
+
"RawConsoleProfile",
|
|
284
|
+
"SerialConsoleProtocol",
|
|
285
|
+
]
|