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,49 @@
1
+ """Persistent process contracts and provider adapters."""
2
+
3
+ from ._common import (
4
+ Process as Process,
5
+ ProcessData as ProcessData,
6
+ TerminalOptions as TerminalOptions,
7
+ TerminalRequest as TerminalRequest,
8
+ terminal_options as terminal_options,
9
+ )
10
+ from .ssh import SshProcess as SshProcess
11
+ from .container import ContainerProcess as ContainerProcess
12
+ from .serial import (
13
+ SerialConsoleProcess as SerialConsoleProcess,
14
+ SerialProcess as SerialProcess,
15
+ )
16
+ from .qemu_serial import (
17
+ ConsoleResize as ConsoleResize,
18
+ ConsoleStreamFactory as ConsoleStreamFactory,
19
+ QemuConsoleStream as QemuConsoleStream,
20
+ QemuSerialConsole as QemuSerialConsole,
21
+ QemuSerialProcess as QemuSerialProcess,
22
+ normalize_qemu_console_error as normalize_qemu_console_error,
23
+ )
24
+ from .psrp import (
25
+ PipelineResult as PipelineResult,
26
+ PipelineStreams as PipelineStreams,
27
+ RunspaceSession as RunspaceSession,
28
+ )
29
+
30
+ __all__ = (
31
+ "Process",
32
+ "ProcessData",
33
+ "ConsoleResize",
34
+ "ConsoleStreamFactory",
35
+ "QemuConsoleStream",
36
+ "QemuSerialConsole",
37
+ "QemuSerialProcess",
38
+ "ContainerProcess",
39
+ "SshProcess",
40
+ "SerialProcess",
41
+ "SerialConsoleProcess",
42
+ "TerminalOptions",
43
+ "TerminalRequest",
44
+ "terminal_options",
45
+ "normalize_qemu_console_error",
46
+ "PipelineResult",
47
+ "PipelineStreams",
48
+ "RunspaceSession",
49
+ )
@@ -0,0 +1,113 @@
1
+ """Contracts for persistent child processes and terminal allocation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import typing
7
+ import types
8
+ import codecs
9
+
10
+ ProcessData = typing.Union[str, bytes]
11
+
12
+
13
+ def raise_normalized(
14
+ exc: Exception, normalizer: typing.Callable[[Exception], Exception]
15
+ ) -> typing.NoReturn:
16
+ """Raise a normalized transport error while preserving its cause."""
17
+ normalized = normalizer(exc)
18
+ if normalized is exc:
19
+ raise exc
20
+ raise normalized from exc
21
+
22
+
23
+ class IncrementalTextDecoder:
24
+ """Decode arbitrary byte chunks without splitting multibyte characters."""
25
+
26
+ def __init__(self, encoding: str, errors: str = "strict") -> None:
27
+ self._decoder = codecs.getincrementaldecoder(encoding)(errors)
28
+
29
+ def decode(self, data: bytes, *, final: bool = False) -> str:
30
+ return self._decoder.decode(data, final=final)
31
+
32
+
33
+ TerminalRequest = typing.Optional[typing.Union[bool, "TerminalOptions"]]
34
+
35
+
36
+ @dataclasses.dataclass(frozen=True)
37
+ class TerminalOptions:
38
+ """Requested pseudo-terminal type and initial dimensions."""
39
+
40
+ term_type: str = "xterm-256color"
41
+ columns: int = 80
42
+ rows: int = 24
43
+ pixel_width: int = 0
44
+ pixel_height: int = 0
45
+
46
+ def __post_init__(self) -> None:
47
+ if not self.term_type:
48
+ raise ValueError("term_type must not be empty")
49
+ if self.columns <= 0 or self.rows <= 0:
50
+ raise ValueError("terminal columns and rows must be positive")
51
+ if self.pixel_width < 0 or self.pixel_height < 0:
52
+ raise ValueError("terminal pixel dimensions must not be negative")
53
+
54
+ @property
55
+ def size(self) -> typing.Tuple[int, int, int, int]:
56
+ return (
57
+ self.columns,
58
+ self.rows,
59
+ self.pixel_width,
60
+ self.pixel_height,
61
+ )
62
+
63
+
64
+ def terminal_options(value: TerminalRequest) -> typing.Optional[TerminalOptions]:
65
+ """Normalize a convenient boolean terminal request to concrete options."""
66
+ if value is True:
67
+ return TerminalOptions()
68
+ if value in (False, None):
69
+ return None
70
+ if not isinstance(value, TerminalOptions):
71
+ raise TypeError("terminal must be bool, TerminalOptions, or None")
72
+ return value
73
+
74
+
75
+ @typing.runtime_checkable
76
+ class Process(typing.Protocol):
77
+ """Synchronous control surface for a persistent child process."""
78
+
79
+ @property
80
+ def returncode(self) -> typing.Optional[int]: ...
81
+
82
+ def write(self, data: ProcessData) -> None: ...
83
+
84
+ def read(self, size: int = -1) -> ProcessData: ...
85
+
86
+ def read_stderr(self, size: int = -1) -> ProcessData: ...
87
+
88
+ def send_eof(self) -> None: ...
89
+
90
+ def resize(
91
+ self,
92
+ columns: int,
93
+ rows: int,
94
+ pixel_width: int = 0,
95
+ pixel_height: int = 0,
96
+ ) -> None: ...
97
+
98
+ def wait(self, timeout: typing.Optional[float] = None) -> int: ...
99
+
100
+ def terminate(self) -> None: ...
101
+
102
+ def kill(self) -> None: ...
103
+
104
+ def close(self) -> None: ...
105
+
106
+ def __enter__(self) -> Process: ...
107
+
108
+ def __exit__(
109
+ self,
110
+ exc_type: typing.Optional[typing.Type[BaseException]],
111
+ exc_value: typing.Optional[BaseException],
112
+ traceback: typing.Optional[types.TracebackType],
113
+ ) -> bool: ...
@@ -0,0 +1,265 @@
1
+ """Persistent process adapter for Docker Engine exec sockets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import collections
6
+ import codecs
7
+ import socket
8
+ import subprocess
9
+ import time
10
+ import types
11
+ import typing
12
+
13
+ from ._common import Process, ProcessData
14
+
15
+
16
+ class ContainerExecApi(typing.Protocol):
17
+ def exec_inspect(self, exec_id: str) -> typing.Mapping[str, object]: ...
18
+
19
+ def exec_resize(self, exec_id: str, *, height: int, width: int) -> object: ...
20
+
21
+
22
+ class _Socket(typing.Protocol):
23
+ def recv(self, size: int) -> bytes: ...
24
+
25
+ def sendall(self, value: bytes) -> None: ...
26
+
27
+ def shutdown(self, how: int) -> None: ...
28
+
29
+ def close(self) -> None: ...
30
+
31
+
32
+ class ContainerProcess(Process):
33
+ """Synchronous Docker exec channel, with framed non-TTY output."""
34
+
35
+ def __init__(
36
+ self,
37
+ api: ContainerExecApi,
38
+ exec_id: str,
39
+ stream: object,
40
+ *,
41
+ tty: bool,
42
+ command: typing.Sequence[str],
43
+ encoding: typing.Optional[str] = None,
44
+ errors: typing.Optional[str] = None,
45
+ ) -> None:
46
+ self._api = api
47
+ self._exec_id = exec_id
48
+ self._socket = typing.cast(_Socket, getattr(stream, "_sock", stream))
49
+ self._tty = tty
50
+ self._command = list(command)
51
+ self._encoding = encoding
52
+ self._errors = errors or "strict"
53
+ self._buffers = {
54
+ 1: collections.deque(), # type: typing.Deque[bytes]
55
+ 2: collections.deque(), # type: typing.Deque[bytes]
56
+ }
57
+ self._eof = False
58
+ self._closed = False
59
+ self._returncode: typing.Optional[int] = None
60
+ self._wire_buffer = bytearray()
61
+ self._decoders = {
62
+ 1: (
63
+ codecs.getincrementaldecoder(encoding)(self._errors)
64
+ if encoding
65
+ else None
66
+ ),
67
+ 2: (
68
+ codecs.getincrementaldecoder(encoding)(self._errors)
69
+ if encoding
70
+ else None
71
+ ),
72
+ }
73
+ settimeout = getattr(self._socket, "settimeout", None)
74
+ if settimeout is not None:
75
+ settimeout(None)
76
+
77
+ @property
78
+ def returncode(self) -> typing.Optional[int]:
79
+ if self._returncode is not None:
80
+ return self._returncode
81
+ state = self._api.exec_inspect(self._exec_id)
82
+ if state.get("Running"):
83
+ return None
84
+ value = state.get("ExitCode")
85
+ if value is None:
86
+ return None
87
+ self._returncode = int(value)
88
+ return self._returncode
89
+
90
+ def _encode(self, value: ProcessData) -> bytes:
91
+ if isinstance(value, bytes):
92
+ return value
93
+ return value.encode(self._encoding or "utf-8", self._errors)
94
+
95
+ def _decode(
96
+ self, value: bytes, stream: int = 1, *, final: bool = False
97
+ ) -> ProcessData:
98
+ if self._encoding is None:
99
+ return value
100
+ decoder = self._decoders[stream]
101
+ return typing.cast(codecs.IncrementalDecoder, decoder).decode(
102
+ value, final=final
103
+ )
104
+
105
+ def write(self, data: ProcessData) -> None:
106
+ if self._closed:
107
+ raise ValueError("process is closed")
108
+ self._socket.sendall(self._encode(data))
109
+
110
+ def _receive(self) -> None:
111
+ if self._eof:
112
+ return
113
+ if self._tty:
114
+ value = self._socket.recv(64 * 1024)
115
+ if value:
116
+ self._buffers[1].append(value)
117
+ else:
118
+ self._eof = True
119
+ return
120
+ while not self._consume_frames():
121
+ chunk = self._socket.recv(64 * 1024)
122
+ if not chunk:
123
+ self._finish_wire()
124
+ return
125
+ self._wire_buffer.extend(chunk)
126
+
127
+ def _consume_frames(self) -> bool:
128
+ """Consume every complete Docker multiplex frame already buffered."""
129
+ consumed = False
130
+ while len(self._wire_buffer) >= 8:
131
+ length = int.from_bytes(self._wire_buffer[4:8], "big")
132
+ frame_size = 8 + length
133
+ if len(self._wire_buffer) < frame_size:
134
+ break
135
+ stream = self._wire_buffer[0]
136
+ payload = bytes(self._wire_buffer[8:frame_size])
137
+ del self._wire_buffer[:frame_size]
138
+ if stream in self._buffers and payload:
139
+ self._buffers[stream].append(payload)
140
+ consumed = True
141
+ return consumed
142
+
143
+ def _finish_wire(self) -> None:
144
+ self._eof = True
145
+ if self._wire_buffer:
146
+ raise ConnectionError("connection dropped mid-frame")
147
+
148
+ def _read_stream(self, stream: int, size: int) -> ProcessData:
149
+ if self._tty and stream == 2:
150
+ raise NotImplementedError("TTY sessions combine stdout and stderr")
151
+ if size == 0:
152
+ return self._decode(b"")
153
+ output = bytearray()
154
+ while size < 0 or len(output) < size:
155
+ while not self._buffers[stream] and not self._eof:
156
+ self._receive()
157
+ if not self._buffers[stream]:
158
+ break
159
+ chunk = self._buffers[stream].popleft()
160
+ if size >= 0 and len(output) + len(chunk) > size:
161
+ take = size - len(output)
162
+ output.extend(chunk[:take])
163
+ self._buffers[stream].appendleft(chunk[take:])
164
+ else:
165
+ output.extend(chunk)
166
+ # read(n) returns as soon as data is available, like SSH and
167
+ # socket streams; callers needing exactly n bytes can loop.
168
+ if size >= 0 and output:
169
+ break
170
+ return self._decode(bytes(output), stream)
171
+
172
+ def read(self, size: int = -1) -> ProcessData:
173
+ return self._read_stream(1, size)
174
+
175
+ def read_stderr(self, size: int = -1) -> ProcessData:
176
+ return self._read_stream(2, size)
177
+
178
+ def send_eof(self) -> None:
179
+ shutdown = getattr(self._socket, "shutdown", None)
180
+ if shutdown is None:
181
+ raise NotImplementedError("exec transport cannot half-close stdin")
182
+ try:
183
+ shutdown(socket.SHUT_WR)
184
+ except (OSError, ValueError) as exc:
185
+ raise NotImplementedError("exec transport cannot half-close stdin") from exc
186
+
187
+ def resize(
188
+ self,
189
+ columns: int,
190
+ rows: int,
191
+ pixel_width: int = 0,
192
+ pixel_height: int = 0,
193
+ ) -> None:
194
+ if not self._tty:
195
+ raise NotImplementedError("process does not have a terminal")
196
+ if columns <= 0 or rows <= 0:
197
+ raise ValueError("terminal columns and rows must be positive")
198
+ if pixel_width < 0 or pixel_height < 0:
199
+ raise ValueError("terminal pixel dimensions must not be negative")
200
+ self._api.exec_resize(self._exec_id, height=rows, width=columns)
201
+
202
+ def wait(self, timeout: typing.Optional[float] = None) -> int:
203
+ deadline = None if timeout is None else time.monotonic() + timeout
204
+ while True:
205
+ # Drain one available frame before polling. Non-blocking Docker
206
+ # sockets raise timeout/BlockingIOError when no data is ready.
207
+ self._receive_available()
208
+ returncode = self.returncode
209
+ if returncode is not None:
210
+ return returncode
211
+ if deadline is not None and time.monotonic() >= deadline:
212
+ raise subprocess.TimeoutExpired(self._command, timeout)
213
+ time.sleep(0.01)
214
+
215
+ def _receive_available(self) -> None:
216
+ settimeout = getattr(self._socket, "settimeout", None)
217
+ gettimeout = getattr(self._socket, "gettimeout", None)
218
+ if settimeout is None:
219
+ # There is no portable way to prove that recv() will not block.
220
+ # wait() must keep polling process state rather than deadlock.
221
+ return
222
+ previous = gettimeout() if gettimeout is not None else None
223
+ try:
224
+ settimeout(0.0)
225
+ while not self._eof:
226
+ try:
227
+ value = self._socket.recv(64 * 1024)
228
+ except (socket.timeout, BlockingIOError):
229
+ break
230
+ if not value:
231
+ if self._tty:
232
+ self._eof = True
233
+ else:
234
+ self._finish_wire()
235
+ break
236
+ if self._tty:
237
+ self._buffers[1].append(value)
238
+ else:
239
+ self._wire_buffer.extend(value)
240
+ self._consume_frames()
241
+ finally:
242
+ settimeout(previous)
243
+
244
+ def terminate(self) -> None:
245
+ raise NotImplementedError("Docker Engine cannot signal one exec process")
246
+
247
+ def kill(self) -> None:
248
+ raise NotImplementedError("Docker Engine cannot signal one exec process")
249
+
250
+ def close(self) -> None:
251
+ if not self._closed:
252
+ self._closed = True
253
+ self._socket.close()
254
+
255
+ def __enter__(self) -> ContainerProcess:
256
+ return self
257
+
258
+ def __exit__(
259
+ self,
260
+ exc_type: typing.Optional[typing.Type[BaseException]],
261
+ exc_value: typing.Optional[BaseException],
262
+ traceback: typing.Optional[types.TracebackType],
263
+ ) -> bool:
264
+ self.close()
265
+ return False
@@ -0,0 +1,208 @@
1
+ """Persistent PowerShell Remoting Protocol runspace sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import types
7
+ import typing
8
+
9
+ from ..executor.psrp import require_pypsrp
10
+
11
+
12
+ @dataclasses.dataclass(frozen=True)
13
+ class PipelineStreams:
14
+ """Typed PowerShell streams returned by one pipeline invocation."""
15
+
16
+ error: tuple[object, ...] = ()
17
+ warning: tuple[object, ...] = ()
18
+ verbose: tuple[object, ...] = ()
19
+ debug: tuple[object, ...] = ()
20
+ information: tuple[object, ...] = ()
21
+ progress: tuple[object, ...] = ()
22
+
23
+
24
+ @dataclasses.dataclass(frozen=True)
25
+ class PipelineResult:
26
+ """Result of a PSRP pipeline, retaining objects and stream records."""
27
+
28
+ output: tuple[object, ...]
29
+ streams: PipelineStreams
30
+ state: str
31
+ had_errors: bool
32
+ returncode: int
33
+
34
+
35
+ def _stream_values(streams: object, name: str) -> tuple[object, ...]:
36
+ value = getattr(streams, name, ())
37
+ try:
38
+ return tuple(value)
39
+ except TypeError:
40
+ return (value,) if value is not None else ()
41
+
42
+
43
+ def _pipeline_state_name(value: object) -> str:
44
+ """Normalize pypsrp's integer states and compatible enum-like doubles."""
45
+ value = getattr(value, "name", getattr(value, "state", value))
46
+ if isinstance(value, int):
47
+ return {
48
+ 0: "NotStarted",
49
+ 1: "Running",
50
+ 2: "Stopping",
51
+ 3: "Stopped",
52
+ 4: "Completed",
53
+ 5: "Failed",
54
+ 6: "Disconnected",
55
+ }.get(value, f"Unknown({value})")
56
+ return str(value)
57
+
58
+
59
+ class RunspaceSession:
60
+ """A persistent PSRP runspace with typed PowerShell pipeline streams.
61
+
62
+ ``RunspaceSession`` intentionally does not implement the byte-oriented
63
+ ``Process``/``ShellSession`` contract: a runspace is a PowerShell object
64
+ pipeline, not a TTY and has no raw stdin or resize operations.
65
+ """
66
+
67
+ def __init__(
68
+ self, config: object | None = None, *, pool: object | None = None
69
+ ) -> None:
70
+ self._config = config
71
+ self._pool = pool
72
+ self._owns_pool = pool is None
73
+ self._open = False
74
+
75
+ def _make_pool(self) -> object:
76
+ if self._pool is not None:
77
+ return self._pool
78
+ require_pypsrp()
79
+ from pypsrp.powershell import RunspacePool
80
+ from pypsrp.wsman import WSMan
81
+
82
+ config = self._config
83
+ if config is None:
84
+ raise ValueError("a WinRMConfig is required when pool is not supplied")
85
+ ssl = bool(getattr(config, "ssl", False))
86
+ endpoint_port = getattr(config, "port", None)
87
+ kwargs = {
88
+ "server": getattr(config, "host"),
89
+ "port": endpoint_port or (5986 if ssl else 5985),
90
+ "username": getattr(config, "username"),
91
+ "password": getattr(config, "password", None),
92
+ "ssl": ssl,
93
+ "auth": "negotiate",
94
+ "cert_validation": getattr(config, "server_cert_validation", "validate")
95
+ == "validate",
96
+ "encryption": getattr(config, "message_encryption", "auto"),
97
+ "connection_timeout": getattr(config, "read_timeout_sec", 30),
98
+ "operation_timeout": getattr(config, "operation_timeout_sec", 20),
99
+ "read_timeout": getattr(config, "read_timeout_sec", 30),
100
+ }
101
+ try:
102
+ wsman = WSMan(**kwargs)
103
+ except TypeError:
104
+ # pypsrp releases have renamed timeout parameters; retry with the
105
+ # stable connection options and let transport errors surface.
106
+ kwargs.pop("operation_timeout")
107
+ kwargs.pop("read_timeout")
108
+ wsman = WSMan(**kwargs)
109
+ return RunspacePool(wsman)
110
+
111
+ def connect(self) -> None:
112
+ if self._open:
113
+ return
114
+ self._pool = self._make_pool()
115
+ open_method = getattr(self._pool, "open", None)
116
+ if open_method is not None:
117
+ open_method()
118
+ self._open = True
119
+
120
+ def close(self) -> None:
121
+ if not self._open and self._pool is None:
122
+ return
123
+ pool, self._pool = self._pool, None
124
+ self._open = False
125
+ if pool is not None:
126
+ close = getattr(pool, "close", None)
127
+ if close is not None:
128
+ close()
129
+
130
+ def __enter__(self) -> RunspaceSession:
131
+ self.connect()
132
+ return self
133
+
134
+ def __exit__(
135
+ self,
136
+ exc_type: typing.Optional[typing.Type[BaseException]],
137
+ exc_value: typing.Optional[BaseException],
138
+ traceback: typing.Optional[types.TracebackType],
139
+ ) -> bool:
140
+ self.close()
141
+ return False
142
+
143
+ def invoke(
144
+ self,
145
+ script: object,
146
+ *args: object,
147
+ raw: bool = False,
148
+ capture_exit: bool = False,
149
+ ) -> PipelineResult:
150
+ """Invoke a script or executable with persistent runspace state."""
151
+ self.connect()
152
+ from ..shell import POWERSHELL
153
+
154
+ if args:
155
+ # PowerShell's call operator preserves executable semantics while
156
+ # still allowing arguments to be represented as object literals.
157
+ command = POWERSHELL.quote(script)
158
+ values = " ".join(POWERSHELL.quote(value) for value in args)
159
+ script_text = "& {} {}".format(command, values)
160
+ else:
161
+ script_text = str(script)
162
+ marker = "__HOSTCTL_LASTEXITCODE__"
163
+ if capture_exit:
164
+ script_text = (
165
+ f"{script_text}; Write-Output ('{marker}:' + "
166
+ "[string]([int]$LASTEXITCODE))"
167
+ )
168
+ from pypsrp.powershell import PowerShell
169
+
170
+ pipeline = PowerShell(self._pool)
171
+ pipeline.add_script(script_text)
172
+ output = pipeline.invoke()
173
+ streams_obj = getattr(pipeline, "streams", object())
174
+ streams = PipelineStreams(
175
+ error=_stream_values(streams_obj, "error"),
176
+ warning=_stream_values(streams_obj, "warning"),
177
+ verbose=_stream_values(streams_obj, "verbose"),
178
+ debug=_stream_values(streams_obj, "debug"),
179
+ information=_stream_values(streams_obj, "information"),
180
+ progress=_stream_values(streams_obj, "progress"),
181
+ )
182
+ state = _pipeline_state_name(getattr(pipeline, "state", "Completed"))
183
+ had_errors = bool(getattr(pipeline, "had_errors", bool(streams.error)))
184
+ values = tuple(output or ())
185
+ returncode = 0 if state.casefold() == "completed" and not had_errors else 1
186
+ if capture_exit:
187
+ retained = []
188
+ for value in values:
189
+ text_value = str(value)
190
+ if text_value.startswith(marker + ":"):
191
+ try:
192
+ parsed_returncode = int(text_value.split(":", 1)[1])
193
+ returncode = (
194
+ 1
195
+ if had_errors and parsed_returncode == 0
196
+ else parsed_returncode
197
+ )
198
+ except ValueError:
199
+ had_errors = True
200
+ returncode = 1
201
+ else:
202
+ retained.append(value)
203
+ values = tuple(retained)
204
+ if raw:
205
+ values = tuple(values)
206
+ else:
207
+ values = tuple(str(value) for value in values)
208
+ return PipelineResult(values, streams, state, had_errors, returncode)