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,247 @@
1
+ """Explicit raw QEMU/libvirt console stream sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+ import subprocess
7
+ import threading
8
+ import types
9
+ import typing
10
+
11
+ from ._common import IncrementalTextDecoder, Process, ProcessData, raise_normalized
12
+
13
+
14
+ class QemuConsoleStream(typing.Protocol):
15
+ """Minimal libvirt-stream-like console channel."""
16
+
17
+ def send(self, data: bytes) -> int: ...
18
+
19
+ def recv(self, size: int) -> bytes: ...
20
+
21
+ def finish(self) -> object: ...
22
+
23
+ def abort(self) -> object: ...
24
+
25
+
26
+ ConsoleStreamFactory = typing.Callable[[], QemuConsoleStream]
27
+ ConsoleResize = typing.Callable[[int, int, int, int], object]
28
+
29
+
30
+ def normalize_qemu_console_error(exc: Exception) -> Exception:
31
+ """Normalize libvirt-like stream failures without importing libvirt."""
32
+ if isinstance(exc, (ConnectionError, PermissionError, TimeoutError)):
33
+ return exc
34
+ if isinstance(exc, OSError):
35
+ if exc.errno in (errno.EACCES, errno.EPERM):
36
+ return PermissionError(exc.errno, exc.strerror, exc.filename)
37
+ if exc.errno in (errno.ETIMEDOUT,):
38
+ return TimeoutError(str(exc))
39
+ return ConnectionError(str(exc))
40
+ message = str(exc)
41
+ folded = message.casefold()
42
+ if "permission denied" in folded or "access denied" in folded:
43
+ return PermissionError(message)
44
+ if "timed out" in folded or "timeout" in folded:
45
+ return TimeoutError(message)
46
+ if type(exc).__name__ in ("libvirtError", "StreamError"):
47
+ return ConnectionError(message)
48
+ return exc
49
+
50
+
51
+ class QemuSerialConsole:
52
+ """Grant one exclusive raw process lease over a console stream."""
53
+
54
+ def __init__(
55
+ self,
56
+ *,
57
+ stream_factory: typing.Optional[ConsoleStreamFactory] = None,
58
+ stream: typing.Optional[QemuConsoleStream] = None,
59
+ owns_stream: bool = False,
60
+ resize: typing.Optional[ConsoleResize] = None,
61
+ ) -> None:
62
+ if (stream_factory is None) == (stream is None):
63
+ raise ValueError("provide exactly one of stream_factory or stream")
64
+ self._factory = stream_factory
65
+ self._stream = stream
66
+ self._owns_stream = owns_stream if stream is not None else True
67
+ self._resize = resize
68
+ self._lease = threading.Lock()
69
+ self._io_lock = threading.RLock()
70
+
71
+ def open(self) -> QemuSerialProcess:
72
+ if not self._lease.acquire(blocking=False):
73
+ raise RuntimeError("QEMU console already has an active process")
74
+ try:
75
+ stream = self._stream
76
+ if stream is None:
77
+ assert self._factory is not None
78
+ stream = self._factory()
79
+ self._stream = stream
80
+ except Exception as exc:
81
+ self._lease.release()
82
+ normalized = normalize_qemu_console_error(exc)
83
+ if normalized is exc:
84
+ raise
85
+ raise normalized from exc
86
+ return QemuSerialProcess(
87
+ stream,
88
+ io_lock=self._io_lock,
89
+ release=self._release,
90
+ close_stream=self._owns_stream,
91
+ resize=self._resize,
92
+ )
93
+
94
+ def _release(self) -> None:
95
+ if self._owns_stream:
96
+ self._stream = None
97
+ self._lease.release()
98
+
99
+
100
+ class QemuSerialProcess(Process):
101
+ """Raw merged byte I/O over one libvirt console/channel stream."""
102
+
103
+ def __init__(
104
+ self,
105
+ stream: QemuConsoleStream,
106
+ *,
107
+ io_lock: typing.Optional[threading.RLock] = None,
108
+ release: typing.Callable[[], None],
109
+ close_stream: bool = True,
110
+ resize: typing.Optional[ConsoleResize] = None,
111
+ encoding: typing.Optional[str] = None,
112
+ errors: typing.Optional[str] = None,
113
+ ) -> None:
114
+ self._stream = stream
115
+ self._io_lock = io_lock or threading.RLock()
116
+ self._release = release
117
+ self._close_stream = close_stream
118
+ self._resize = resize
119
+ self._encoding = encoding
120
+ self._errors = errors or "strict"
121
+ self._closed = threading.Event()
122
+ self._decoder = (
123
+ IncrementalTextDecoder(encoding, self._errors)
124
+ if encoding is not None
125
+ else None
126
+ )
127
+
128
+ @property
129
+ def returncode(self) -> typing.Optional[int]:
130
+ return 0 if self._closed.is_set() else None
131
+
132
+ def _require_open(self) -> None:
133
+ if self._closed.is_set():
134
+ raise ConnectionError("QEMU console process is closed")
135
+
136
+ def write(self, data: ProcessData) -> None:
137
+ if isinstance(data, str):
138
+ if self._encoding is None:
139
+ raise TypeError("raw QEMU console writes require bytes")
140
+ data = data.encode(self._encoding, self._errors)
141
+ self._require_open()
142
+ offset = 0
143
+ try:
144
+ with self._io_lock:
145
+ while offset < len(data):
146
+ sent = self._stream.send(data[offset:])
147
+ if sent <= 0:
148
+ raise ConnectionError("QEMU console write made no progress")
149
+ offset += sent
150
+ except Exception as exc:
151
+ raise_normalized(exc, normalize_qemu_console_error)
152
+
153
+ def read(self, size: int = -1) -> ProcessData:
154
+ self._require_open()
155
+ if size < -1:
156
+ raise ValueError("read size must be -1 or non-negative")
157
+ if size == 0:
158
+ return b""
159
+ request_size = 64 * 1024 if size == -1 else size
160
+ try:
161
+ with self._io_lock:
162
+ value = self._stream.recv(request_size)
163
+ except Exception as exc:
164
+ raise_normalized(exc, normalize_qemu_console_error)
165
+ if not value:
166
+ self._finish(closing_stream=self._close_stream)
167
+ if self._decoder is not None:
168
+ return self._decoder.decode(value, final=not value)
169
+ return value
170
+
171
+ def read_stderr(self, size: int = -1) -> bytes:
172
+ raise NotImplementedError("QEMU serial consoles have one merged byte stream")
173
+
174
+ def send_eof(self) -> None:
175
+ raise NotImplementedError(
176
+ "QEMU console streams do not support a generic half-close"
177
+ )
178
+
179
+ def resize(
180
+ self,
181
+ columns: int,
182
+ rows: int,
183
+ pixel_width: int = 0,
184
+ pixel_height: int = 0,
185
+ ) -> None:
186
+ self._require_open()
187
+ if self._resize is None:
188
+ raise NotImplementedError(
189
+ "QEMU console backend does not support terminal resize"
190
+ )
191
+ if columns <= 0 or rows <= 0:
192
+ raise ValueError("terminal columns and rows must be positive")
193
+ if pixel_width < 0 or pixel_height < 0:
194
+ raise ValueError("terminal pixel dimensions must not be negative")
195
+ try:
196
+ self._resize(columns, rows, pixel_width, pixel_height)
197
+ except Exception as exc:
198
+ raise_normalized(exc, normalize_qemu_console_error)
199
+
200
+ def wait(self, timeout: typing.Optional[float] = None) -> int:
201
+ if timeout is not None and timeout < 0:
202
+ raise ValueError("timeout must not be negative")
203
+ if not self._closed.wait(timeout):
204
+ raise subprocess.TimeoutExpired("QEMU serial console", timeout)
205
+ return 0
206
+
207
+ def terminate(self) -> None:
208
+ raise NotImplementedError(
209
+ "QEMU serial consoles have no generic terminate signal"
210
+ )
211
+
212
+ def kill(self) -> None:
213
+ raise NotImplementedError("QEMU serial consoles have no generic kill signal")
214
+
215
+ def close(self) -> None:
216
+ self._finish(closing_stream=self._close_stream)
217
+
218
+ def _finish(self, *, closing_stream: bool) -> None:
219
+ if self._closed.is_set():
220
+ return
221
+ error: typing.Optional[Exception] = None
222
+ if closing_stream:
223
+ try:
224
+ with self._io_lock:
225
+ self._stream.finish()
226
+ except Exception as exc:
227
+ error = exc
228
+ try:
229
+ self._stream.abort()
230
+ except Exception:
231
+ pass
232
+ self._closed.set()
233
+ self._release()
234
+ if error is not None:
235
+ raise_normalized(error, normalize_qemu_console_error)
236
+
237
+ def __enter__(self) -> QemuSerialProcess:
238
+ return self
239
+
240
+ def __exit__(
241
+ self,
242
+ exc_type: typing.Optional[typing.Type[BaseException]],
243
+ exc_value: typing.Optional[BaseException],
244
+ traceback: typing.Optional[types.TracebackType],
245
+ ) -> bool:
246
+ self.close()
247
+ return False
@@ -0,0 +1,257 @@
1
+ """Raw byte-stream process facade for a serial connection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import threading
7
+ import types
8
+ import typing
9
+
10
+ from ._common import Process, ProcessData, raise_normalized
11
+ from ..executor.serial import SerialLike, normalize_serial_error
12
+
13
+
14
+ class SerialProcess(Process):
15
+ """Exclusive raw access to one serial connection."""
16
+
17
+ def __init__(
18
+ self,
19
+ serial_port: SerialLike,
20
+ *,
21
+ io_lock: threading.RLock,
22
+ release: typing.Callable[[], None],
23
+ ) -> None:
24
+ self._serial = serial_port
25
+ self._io_lock = io_lock
26
+ self._release = release
27
+ self._closed = threading.Event()
28
+
29
+ @property
30
+ def returncode(self) -> typing.Optional[int]:
31
+ return 0 if self._closed.is_set() else None
32
+
33
+ def _require_open(self) -> None:
34
+ if self._closed.is_set() or not self._serial.is_open:
35
+ raise ConnectionError("serial process is closed")
36
+
37
+ def write(self, data: ProcessData) -> None:
38
+ if isinstance(data, str):
39
+ raise TypeError("raw serial process writes require bytes")
40
+ self._require_open()
41
+ offset = 0
42
+ try:
43
+ with self._io_lock:
44
+ while offset < len(data):
45
+ written = self._serial.write(data[offset:])
46
+ if written <= 0:
47
+ raise TimeoutError("serial write made no progress")
48
+ offset += written
49
+ self._serial.flush()
50
+ except Exception as exc:
51
+ normalized = normalize_serial_error(exc)
52
+ if normalized is exc:
53
+ raise
54
+ raise normalized from exc
55
+
56
+ def read(self, size: int = -1) -> bytes:
57
+ self._require_open()
58
+ if size < -1:
59
+ raise ValueError("read size must be -1 or non-negative")
60
+ if size == -1:
61
+ available = getattr(self._serial, "in_waiting", None)
62
+ if available is not None:
63
+ size = min(max(int(available), 0), 64 * 1024)
64
+ if size == 0:
65
+ return b""
66
+ else:
67
+ # A generic injected serial object may not expose an
68
+ # availability count. Read at most one byte rather than
69
+ # blocking for an arbitrary 64 KiB request.
70
+ size = 1
71
+ try:
72
+ with self._io_lock:
73
+ return self._serial.read(size)
74
+ except Exception as exc:
75
+ raise_normalized(exc, normalize_serial_error)
76
+
77
+ def read_stderr(self, size: int = -1) -> bytes:
78
+ raise NotImplementedError("serial has one merged byte stream")
79
+
80
+ def send_eof(self) -> None:
81
+ raise NotImplementedError("serial connections do not support half-close")
82
+
83
+ def resize(
84
+ self,
85
+ columns: int,
86
+ rows: int,
87
+ pixel_width: int = 0,
88
+ pixel_height: int = 0,
89
+ ) -> None:
90
+ raise NotImplementedError("serial terminals cannot be resized generically")
91
+
92
+ def wait(self, timeout: typing.Optional[float] = None) -> int:
93
+ if timeout is not None and timeout < 0:
94
+ raise ValueError("timeout must not be negative")
95
+ if not self._closed.wait(timeout):
96
+ raise subprocess.TimeoutExpired("serial session", timeout)
97
+ return 0
98
+
99
+ def terminate(self) -> None:
100
+ raise NotImplementedError("serial has no generic terminate signal")
101
+
102
+ def kill(self) -> None:
103
+ raise NotImplementedError("serial has no generic kill signal")
104
+
105
+ def send_break(self, duration: float = 0.25) -> None:
106
+ if duration < 0:
107
+ raise ValueError("break duration must not be negative")
108
+ self._require_open()
109
+ try:
110
+ with self._io_lock:
111
+ self._serial.send_break(duration)
112
+ except Exception as exc:
113
+ normalized = normalize_serial_error(exc)
114
+ if normalized is exc:
115
+ raise
116
+ raise normalized from exc
117
+
118
+ def reset_input_buffer(self) -> None:
119
+ """Discard stale bytes before a protocol negotiation or command."""
120
+ self._require_open()
121
+ reset = getattr(self._serial, "reset_input_buffer", None)
122
+ if not callable(reset):
123
+ raise NotImplementedError("serial backend cannot reset its input buffer")
124
+ try:
125
+ with self._io_lock:
126
+ reset()
127
+ except Exception as exc:
128
+ raise_normalized(exc, normalize_serial_error)
129
+
130
+ @property
131
+ def dtr(self) -> bool:
132
+ self._require_open()
133
+ return self._serial.dtr
134
+
135
+ @dtr.setter
136
+ def dtr(self, value: bool) -> None:
137
+ self._require_open()
138
+ self._serial.dtr = bool(value)
139
+
140
+ @property
141
+ def rts(self) -> bool:
142
+ self._require_open()
143
+ return self._serial.rts
144
+
145
+ @rts.setter
146
+ def rts(self, value: bool) -> None:
147
+ self._require_open()
148
+ self._serial.rts = bool(value)
149
+
150
+ def close(self) -> None:
151
+ if self._closed.is_set():
152
+ return
153
+ self._closed.set()
154
+ self._release()
155
+
156
+ def __enter__(self) -> SerialProcess:
157
+ return self
158
+
159
+ def __exit__(
160
+ self,
161
+ exc_type: typing.Optional[typing.Type[BaseException]],
162
+ exc_value: typing.Optional[BaseException],
163
+ traceback: typing.Optional[types.TracebackType],
164
+ ) -> bool:
165
+ self.close()
166
+ return False
167
+
168
+
169
+ class SerialConsoleProcess(Process):
170
+ """Console-aware wrapper used by :class:`hostctl.host.SerialHost`.
171
+
172
+ It keeps the raw process lease while allowing shell sessions to submit
173
+ text. Reads remain merged bytes; framing and prompt interpretation belong
174
+ to the selected profile.
175
+ """
176
+
177
+ def __init__(
178
+ self, process: SerialProcess, profile: object, encoding: str = "utf-8"
179
+ ) -> None:
180
+ self.process = process
181
+ self.profile = profile
182
+ self.encoding = encoding
183
+
184
+ @property
185
+ def returncode(self):
186
+ return self.process.returncode
187
+
188
+ def write(self, data: ProcessData) -> None:
189
+ if isinstance(data, str):
190
+ data = data.encode(self.encoding)
191
+ self.process.write(data)
192
+
193
+ def read(self, size: int = -1) -> bytes:
194
+ value = self.process.read(size)
195
+ return typing.cast(bytes, value)
196
+
197
+ def read_stderr(self, size: int = -1) -> bytes:
198
+ return self.process.read_stderr(size)
199
+
200
+ def send_eof(self) -> None:
201
+ self.process.send_eof()
202
+
203
+ def resize(
204
+ self, columns: int, rows: int, pixel_width: int = 0, pixel_height: int = 0
205
+ ) -> None:
206
+ self.process.resize(columns, rows, pixel_width, pixel_height)
207
+
208
+ def wait(self, timeout: typing.Optional[float] = None) -> int:
209
+ return self.process.wait(timeout)
210
+
211
+ def terminate(self) -> None:
212
+ self.process.terminate()
213
+
214
+ def kill(self) -> None:
215
+ self.process.kill()
216
+
217
+ def close(self) -> None:
218
+ self.process.close()
219
+
220
+ def send_break(self, duration: float = 0.25) -> None:
221
+ self.process.send_break(duration)
222
+
223
+ @property
224
+ def dtr(self) -> bool:
225
+ return self.process.dtr
226
+
227
+ @dtr.setter
228
+ def dtr(self, value: bool) -> None:
229
+ self.process.dtr = value
230
+
231
+ @property
232
+ def rts(self) -> bool:
233
+ return self.process.rts
234
+
235
+ @rts.setter
236
+ def rts(self, value: bool) -> None:
237
+ self.process.rts = value
238
+
239
+ def send_command(self, command: str | bytes) -> None:
240
+ sender = getattr(self.profile, "send", None)
241
+ if not callable(sender):
242
+ raise NotImplementedError("console profile cannot send commands")
243
+ sender(self.process, command)
244
+
245
+ def send(self, *commands: str | bytes) -> None:
246
+ """Submit one or more profile-framed console commands."""
247
+ if not commands:
248
+ raise ValueError("send requires at least one command")
249
+ for command in commands:
250
+ self.send_command(command)
251
+
252
+ def __enter__(self) -> "SerialConsoleProcess":
253
+ self.process.__enter__()
254
+ return self
255
+
256
+ def __exit__(self, exc_type, exc_value, traceback) -> bool:
257
+ return self.process.__exit__(exc_type, exc_value, traceback)
hostctl/process/ssh.py ADDED
@@ -0,0 +1,165 @@
1
+ """Persistent process adapter for AsyncSSH channels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import types
6
+ import typing
7
+ import inspect
8
+
9
+ from ._common import Process, ProcessData
10
+
11
+
12
+ class _Reader(typing.Protocol):
13
+ def read(self, size: int = -1) -> typing.Awaitable[ProcessData]: ...
14
+
15
+
16
+ class _Writer(typing.Protocol):
17
+ def write(self, data: ProcessData) -> None: ...
18
+
19
+ def drain(self) -> typing.Awaitable[None]: ...
20
+
21
+ def write_eof(self) -> None: ...
22
+
23
+
24
+ class _Completed(typing.Protocol):
25
+ returncode: int
26
+
27
+
28
+ class _AsyncsshProcess(typing.Protocol):
29
+ returncode: typing.Optional[int]
30
+ stdin: _Writer
31
+ stdout: _Reader
32
+ stderr: _Reader
33
+
34
+ def change_terminal_size(
35
+ self, width: int, height: int, pixwidth: int, pixheight: int
36
+ ) -> None: ...
37
+
38
+ def close(self) -> None: ...
39
+
40
+ def kill(self) -> None: ...
41
+
42
+ def terminate(self) -> None: ...
43
+
44
+ def wait(
45
+ self, check: bool = False, timeout: typing.Optional[float] = None
46
+ ) -> typing.Awaitable[_Completed]: ...
47
+
48
+ def wait_closed(self) -> typing.Awaitable[None]: ...
49
+
50
+
51
+ class SshProcess(Process):
52
+ """Synchronous facade over a process owned by hostctl's AsyncSSH loop."""
53
+
54
+ def __init__(
55
+ self, process: _AsyncsshProcess, command: typing.Optional[str]
56
+ ) -> None:
57
+ self._process = process
58
+ self._command = command
59
+ self._closed = False
60
+
61
+ @property
62
+ def returncode(self) -> typing.Optional[int]:
63
+ return self._process.returncode
64
+
65
+ def _call(self, function: typing.Callable[[], object]) -> object:
66
+ from .. import _async
67
+
68
+ async def invoke() -> object:
69
+ result = function()
70
+ if inspect.isawaitable(result):
71
+ return await result
72
+ return result
73
+
74
+ try:
75
+ return _async.async_to_sync(invoke())
76
+ except Exception as exc:
77
+ normalized = _async.normalize_asyncssh_error(exc, command=self._command)
78
+ if normalized is exc:
79
+ raise
80
+ raise normalized from exc
81
+
82
+ def write(self, data: ProcessData) -> None:
83
+ async def operation() -> None:
84
+ writer = self._process.stdin
85
+ writer.write(data)
86
+ await writer.drain()
87
+
88
+ self._call(operation)
89
+
90
+ def read(self, size: int = -1) -> ProcessData:
91
+ return self._call(lambda: self._process.stdout.read(size))
92
+
93
+ def read_stderr(self, size: int = -1) -> ProcessData:
94
+ return self._call(lambda: self._process.stderr.read(size))
95
+
96
+ def send_eof(self) -> None:
97
+ def operation() -> None:
98
+ self._process.stdin.write_eof()
99
+
100
+ self._call(operation)
101
+
102
+ def resize(
103
+ self,
104
+ columns: int,
105
+ rows: int,
106
+ pixel_width: int = 0,
107
+ pixel_height: int = 0,
108
+ ) -> None:
109
+ if columns <= 0 or rows <= 0:
110
+ raise ValueError("terminal columns and rows must be positive")
111
+ if pixel_width < 0 or pixel_height < 0:
112
+ raise ValueError("terminal pixel dimensions must not be negative")
113
+ self._call(
114
+ lambda: self._process.change_terminal_size(
115
+ columns, rows, pixel_width, pixel_height
116
+ )
117
+ )
118
+
119
+ def wait(self, timeout: typing.Optional[float] = None) -> int:
120
+ try:
121
+ from .. import _async
122
+
123
+ result = _async.async_to_sync(
124
+ self._process.wait(check=False, timeout=timeout)
125
+ )
126
+ except Exception as exc:
127
+ normalized = _async.normalize_asyncssh_error(
128
+ exc, command=self._command, timeout=timeout
129
+ )
130
+ if normalized is exc:
131
+ raise
132
+ raise normalized from exc
133
+ return -1 if result.returncode is None else result.returncode
134
+
135
+ def terminate(self) -> None:
136
+ self._call(self._process.terminate)
137
+
138
+ def kill(self) -> None:
139
+ self._call(self._process.kill)
140
+
141
+ def close(self) -> None:
142
+ if self._closed:
143
+ return
144
+ from .. import _async
145
+
146
+ try:
147
+ self._call(self._process.close)
148
+ self._call(lambda: self._process.wait_closed())
149
+ except Exception:
150
+ # A failed close remains retryable; callers must not lose the
151
+ # channel reference merely because the first close attempt failed.
152
+ raise
153
+ self._closed = True
154
+
155
+ def __enter__(self) -> SshProcess:
156
+ return self
157
+
158
+ def __exit__(
159
+ self,
160
+ exc_type: typing.Optional[typing.Type[BaseException]],
161
+ exc_value: typing.Optional[BaseException],
162
+ traceback: typing.Optional[types.TracebackType],
163
+ ) -> bool:
164
+ self.close()
165
+ return False
@@ -0,0 +1,33 @@
1
+ """Composable execution and filesystem provider contracts.
2
+
3
+ The names exported here are the *authoring* surface: the base classes an
4
+ integration instantiates or subclasses to reach a new transport, the probe and
5
+ selection records it exchanges with a host, and the one exception that permits
6
+ fallback. See the "Systems and providers" guide for the authoring contract.
7
+
8
+ The built-in transport adapters in :mod:`hostctl.provider.transports` are how
9
+ ``LocalHost``, ``ContainerHost``, and ``QemuHost`` assemble themselves. They
10
+ are importable from that module but deliberately absent from ``__all__``: no
11
+ caller constructs them, and their capability frozensets describe backends
12
+ hostctl owns rather than a contract a caller implements against.
13
+ """
14
+
15
+ from ._common import (
16
+ ExecutorProvider,
17
+ OperationNotStarted,
18
+ PathProvider,
19
+ ProviderProbe,
20
+ ProviderSelection,
21
+ ProviderSelector,
22
+ SessionInitializer,
23
+ )
24
+
25
+ __all__ = [
26
+ "ExecutorProvider",
27
+ "OperationNotStarted",
28
+ "PathProvider",
29
+ "ProviderProbe",
30
+ "ProviderSelection",
31
+ "ProviderSelector",
32
+ "SessionInitializer",
33
+ ]