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.
Files changed (51) hide show
  1. hostctl/AGENTS.md +292 -0
  2. hostctl/README.md +248 -0
  3. hostctl/__init__.py +194 -0
  4. hostctl/__main__.py +6 -0
  5. hostctl/_async.py +169 -0
  6. hostctl/_cli.py +232 -0
  7. hostctl/executor/__init__.py +82 -0
  8. hostctl/executor/_common.py +234 -0
  9. hostctl/executor/_qga.py +646 -0
  10. hostctl/executor/container.py +171 -0
  11. hostctl/executor/local.py +91 -0
  12. hostctl/executor/psrp.py +145 -0
  13. hostctl/executor/qemu.py +305 -0
  14. hostctl/executor/serial.py +312 -0
  15. hostctl/executor/ssh.py +226 -0
  16. hostctl/executor/winrm.py +255 -0
  17. hostctl/host/__init__.py +93 -0
  18. hostctl/host/_common.py +804 -0
  19. hostctl/host/_local.py +258 -0
  20. hostctl/host/_ssh.py +587 -0
  21. hostctl/host/_winrm.py +1002 -0
  22. hostctl/host/composite_path.py +567 -0
  23. hostctl/host/container.py +606 -0
  24. hostctl/host/container_path.py +664 -0
  25. hostctl/host/qemu.py +1078 -0
  26. hostctl/host/serial.py +401 -0
  27. hostctl/host/system.py +809 -0
  28. hostctl/process/__init__.py +49 -0
  29. hostctl/process/_common.py +113 -0
  30. hostctl/process/container.py +265 -0
  31. hostctl/process/psrp.py +208 -0
  32. hostctl/process/qemu_serial.py +247 -0
  33. hostctl/process/serial.py +257 -0
  34. hostctl/process/ssh.py +165 -0
  35. hostctl/provider/__init__.py +33 -0
  36. hostctl/provider/_common.py +452 -0
  37. hostctl/provider/transports.py +303 -0
  38. hostctl/py.typed +0 -0
  39. hostctl/serial/__init__.py +285 -0
  40. hostctl/shell/__init__.py +123 -0
  41. hostctl/shell/_common.py +616 -0
  42. hostctl/shell/cmd.py +167 -0
  43. hostctl/shell/fish.py +63 -0
  44. hostctl/shell/posix.py +83 -0
  45. hostctl/shell/powershell.py +120 -0
  46. hostctl/sync.py +245 -0
  47. hostctl-0.1.0.dist-info/METADATA +302 -0
  48. hostctl-0.1.0.dist-info/RECORD +51 -0
  49. hostctl-0.1.0.dist-info/WHEEL +4 -0
  50. hostctl-0.1.0.dist-info/entry_points.txt +2 -0
  51. 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
+ ]