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
hostctl/host/serial.py ADDED
@@ -0,0 +1,401 @@
1
+ """Serial-console host and opaque serial connection configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import shlex
7
+ import subprocess
8
+ import typing
9
+ from urllib.parse import quote, unquote, urlencode
10
+
11
+ from ..executor import (
12
+ CaptureOutput,
13
+ Environment,
14
+ FileHandle,
15
+ Input,
16
+ SerialExecutor,
17
+ SerialFactory,
18
+ SerialLike,
19
+ SerialSettings,
20
+ capture_streams,
21
+ )
22
+ from ..executor._common import write_output
23
+ from ..process import Process, SerialConsoleProcess, terminal_options
24
+ from ..serial import PromptConsoleProfile, RawConsoleProfile, SerialConsoleProtocol
25
+ from ._common import (
26
+ Command,
27
+ Host,
28
+ HostConfig,
29
+ HostInfo,
30
+ PathLike,
31
+ starts_direct_command,
32
+ strict_uri_credentials,
33
+ strict_uri_query,
34
+ )
35
+
36
+
37
+ def _bool(value: str, name: str) -> bool:
38
+ normalized = value.casefold()
39
+ if normalized not in ("true", "false"):
40
+ raise ValueError(f"{name} must be true or false")
41
+ return normalized == "true"
42
+
43
+
44
+ def _optional_float(value: str, name: str) -> typing.Optional[float]:
45
+ if value == "":
46
+ return None
47
+ try:
48
+ return float(value)
49
+ except ValueError as exc:
50
+ raise ValueError(f"{name} must be numeric") from exc
51
+
52
+
53
+ def _format_number(value: typing.Optional[float]) -> str:
54
+ if value is None:
55
+ return ""
56
+ return str(int(value)) if float(value).is_integer() else str(value)
57
+
58
+
59
+ @dataclasses.dataclass
60
+ class SerialConfig(HostConfig, schemes=("serial",)):
61
+ """Serial transport settings; credentials and profiles stay out of URIs."""
62
+
63
+ port: str
64
+ baudrate: int = 115200
65
+ bytesize: int = 8
66
+ parity: str = "N"
67
+ stopbits: float = 1
68
+ xonxoff: bool = False
69
+ rtscts: bool = False
70
+ dsrdtr: bool = False
71
+ read_timeout: typing.Optional[float] = 0.1
72
+ write_timeout: typing.Optional[float] = 10
73
+ inter_byte_timeout: typing.Optional[float] = None
74
+ exclusive: typing.Optional[bool] = None
75
+ protocol: SerialConsoleProtocol = dataclasses.field(
76
+ default_factory=RawConsoleProfile, repr=False, compare=False
77
+ )
78
+ username: typing.Optional[str] = dataclasses.field(default=None, repr=False)
79
+ password: typing.Optional[str] = dataclasses.field(default=None, repr=False)
80
+ serial_factory: typing.Optional[SerialFactory] = dataclasses.field(
81
+ default=None, repr=False, compare=False
82
+ )
83
+ serial_port: typing.Optional[SerialLike] = dataclasses.field(
84
+ default=None, repr=False, compare=False
85
+ )
86
+
87
+ def __post_init__(self) -> None:
88
+ HostConfig.__init__(self)
89
+ settings = SerialSettings(
90
+ self.port,
91
+ baudrate=self.baudrate,
92
+ bytesize=self.bytesize,
93
+ parity=self.parity,
94
+ stopbits=self.stopbits,
95
+ xonxoff=self.xonxoff,
96
+ rtscts=self.rtscts,
97
+ dsrdtr=self.dsrdtr,
98
+ read_timeout=self.read_timeout,
99
+ write_timeout=self.write_timeout,
100
+ inter_byte_timeout=self.inter_byte_timeout,
101
+ exclusive=self.exclusive,
102
+ )
103
+ self.port = settings.port
104
+ self.baudrate, self.bytesize, self.parity, self.stopbits = (
105
+ settings.baudrate,
106
+ settings.bytesize,
107
+ settings.parity,
108
+ settings.stopbits,
109
+ )
110
+ if not isinstance(self.protocol, SerialConsoleProtocol):
111
+ raise TypeError("protocol must implement SerialConsoleProtocol")
112
+
113
+ @property
114
+ def connection_uri(self) -> str:
115
+ values: dict[str, object] = {
116
+ "baudrate": self.baudrate,
117
+ "bytesize": self.bytesize,
118
+ "parity": self.parity,
119
+ "stopbits": _format_number(self.stopbits),
120
+ "xonxoff": str(self.xonxoff).lower(),
121
+ "rtscts": str(self.rtscts).lower(),
122
+ "dsrdtr": str(self.dsrdtr).lower(),
123
+ "read_timeout": _format_number(self.read_timeout),
124
+ "write_timeout": _format_number(self.write_timeout),
125
+ "inter_byte_timeout": _format_number(self.inter_byte_timeout),
126
+ "exclusive": "" if self.exclusive is None else str(self.exclusive).lower(),
127
+ }
128
+ return f"serial:///{quote(self.port, safe='')}?{urlencode(values)}"
129
+
130
+ @classmethod
131
+ def _from_parsed_uri(cls, parsed, **credentials: object) -> "SerialConfig":
132
+ strict_uri_credentials(
133
+ credentials,
134
+ ("protocol", "username", "password", "serial_factory", "serial_port"),
135
+ )
136
+ port = unquote(parsed.path.lstrip("/"))
137
+ if parsed.netloc:
138
+ raise ValueError("serial URI must not contain an authority")
139
+ if not port:
140
+ raise ValueError("serial URI requires a port")
141
+ query = strict_uri_query(
142
+ parsed,
143
+ (
144
+ "baudrate",
145
+ "bytesize",
146
+ "parity",
147
+ "stopbits",
148
+ "xonxoff",
149
+ "rtscts",
150
+ "dsrdtr",
151
+ "read_timeout",
152
+ "write_timeout",
153
+ "inter_byte_timeout",
154
+ "exclusive",
155
+ ),
156
+ )
157
+
158
+ def integer(name: str, default: int) -> int:
159
+ try:
160
+ return int(query.get(name, default))
161
+ except ValueError as exc:
162
+ raise ValueError(f"{name} must be an integer") from exc
163
+
164
+ return cls(
165
+ port,
166
+ baudrate=integer("baudrate", 115200),
167
+ bytesize=integer("bytesize", 8),
168
+ parity=query.get("parity", "N"),
169
+ stopbits=float(query.get("stopbits", "1")),
170
+ xonxoff=_bool(query.get("xonxoff", "false"), "xonxoff"),
171
+ rtscts=_bool(query.get("rtscts", "false"), "rtscts"),
172
+ dsrdtr=_bool(query.get("dsrdtr", "false"), "dsrdtr"),
173
+ read_timeout=_optional_float(
174
+ query.get("read_timeout", "0.1"), "read_timeout"
175
+ ),
176
+ write_timeout=_optional_float(
177
+ query.get("write_timeout", "10"), "write_timeout"
178
+ ),
179
+ inter_byte_timeout=_optional_float(
180
+ query.get("inter_byte_timeout", ""), "inter_byte_timeout"
181
+ ),
182
+ exclusive=(
183
+ None
184
+ if query.get("exclusive", "") == ""
185
+ else _bool(query["exclusive"], "exclusive")
186
+ ),
187
+ protocol=typing.cast(
188
+ SerialConsoleProtocol, credentials.get("protocol", RawConsoleProfile())
189
+ ),
190
+ username=typing.cast(typing.Optional[str], credentials.get("username")),
191
+ password=typing.cast(typing.Optional[str], credentials.get("password")),
192
+ serial_factory=typing.cast(
193
+ typing.Optional[SerialFactory], credentials.get("serial_factory")
194
+ ),
195
+ serial_port=typing.cast(
196
+ typing.Optional[SerialLike], credentials.get("serial_port")
197
+ ),
198
+ )
199
+
200
+ def _create_host(self) -> "SerialHost":
201
+ return SerialHost(self)
202
+
203
+
204
+ class SerialHost(Host):
205
+ """Host facade over one exclusive serial console byte stream."""
206
+
207
+ def __init__(self, config: SerialConfig) -> None:
208
+ self.config = config
209
+ settings = SerialSettings(
210
+ config.port,
211
+ config.baudrate,
212
+ config.bytesize,
213
+ config.parity,
214
+ config.stopbits,
215
+ config.xonxoff,
216
+ config.rtscts,
217
+ config.dsrdtr,
218
+ config.read_timeout,
219
+ config.write_timeout,
220
+ config.inter_byte_timeout,
221
+ config.exclusive,
222
+ )
223
+ self._executor = SerialExecutor(
224
+ settings,
225
+ serial_factory=config.serial_factory,
226
+ serial_port=config.serial_port,
227
+ owns_serial_port=False if config.serial_port is not None else True,
228
+ )
229
+ self._negotiated = False
230
+
231
+ @property
232
+ def executor(self) -> SerialExecutor:
233
+ return self._executor
234
+
235
+ @property
236
+ def capabilities(self) -> typing.FrozenSet[str]:
237
+ values = {"session"}
238
+ if getattr(self.config.protocol, "can_run", False):
239
+ values.add("run")
240
+ return frozenset(values)
241
+
242
+ @property
243
+ def shell_flavour(self):
244
+ raise NotImplementedError("serial consoles do not identify a shell flavour")
245
+
246
+ @property
247
+ def shell(self):
248
+ return _SerialShell(self)
249
+
250
+ def connect(self) -> None:
251
+ if self._negotiated:
252
+ return
253
+ self._executor.connect()
254
+ process = self._executor.open()
255
+ try:
256
+ self.config.protocol.negotiate(process)
257
+ self._negotiated = True
258
+ finally:
259
+ process.close()
260
+
261
+ def close(self) -> None:
262
+ self._negotiated = False
263
+ self._executor.close()
264
+
265
+ def info(self) -> HostInfo:
266
+ return HostInfo()
267
+
268
+ def path(self, *segments: PathLike, backend: typing.Optional[str] = None):
269
+ raise NotImplementedError("serial consoles do not provide filesystem paths")
270
+
271
+ def spawn(
272
+ self,
273
+ *cmds: Command,
274
+ executable: typing.Optional[str] = None,
275
+ cwd: typing.Optional[PathLike] = None,
276
+ env: typing.Optional[Environment] = None,
277
+ terminal=None,
278
+ encoding: typing.Optional[str] = None,
279
+ errors: typing.Optional[str] = None,
280
+ ) -> Process:
281
+ if cmds:
282
+ raise NotImplementedError("serial sessions do not accept startup commands")
283
+ if executable is not None or cwd is not None or env is not None:
284
+ raise NotImplementedError(
285
+ "serial sessions do not support executable/cwd/env"
286
+ )
287
+ selected_terminal = terminal_options(terminal)
288
+ if selected_terminal is not None and not callable(
289
+ getattr(self.config.protocol, "terminal_setup", None)
290
+ ):
291
+ raise NotImplementedError("serial connections cannot allocate a PTY")
292
+ self.connect()
293
+ raw = self._executor.open()
294
+ if selected_terminal is not None:
295
+ try:
296
+ self.config.protocol.resize(
297
+ raw, selected_terminal.columns, selected_terminal.rows
298
+ )
299
+ except Exception:
300
+ raw.close()
301
+ raise
302
+ return SerialConsoleProcess(
303
+ raw,
304
+ self.config.protocol,
305
+ encoding or getattr(self.config.protocol, "encoding", "utf-8"),
306
+ )
307
+
308
+ def run(
309
+ self,
310
+ *cmds: Command,
311
+ bufsize: int = -1,
312
+ executable: typing.Optional[str] = None,
313
+ stdin: typing.Optional[FileHandle] = None,
314
+ stdout: typing.Optional[FileHandle] = None,
315
+ stderr: typing.Optional[FileHandle] = None,
316
+ cwd: typing.Optional[PathLike] = None,
317
+ env: typing.Optional[Environment] = None,
318
+ capture_output: CaptureOutput = True,
319
+ check: bool = True,
320
+ encoding: typing.Optional[str] = None,
321
+ errors: typing.Optional[str] = None,
322
+ input: Input = None,
323
+ timeout: typing.Optional[float] = None,
324
+ text: typing.Optional[bool] = None,
325
+ ) -> subprocess.CompletedProcess:
326
+ if not self.config.protocol.can_run:
327
+ raise NotImplementedError(
328
+ "serial console profile does not provide reliable run()"
329
+ )
330
+ if starts_direct_command(cmds) is not None:
331
+ raise NotImplementedError(
332
+ "serial consoles do not provide native argv execution"
333
+ )
334
+ if executable is not None:
335
+ raise NotImplementedError("serial consoles do not accept executable")
336
+ if cwd is not None or env is not None:
337
+ raise NotImplementedError("serial console profile does not support cwd/env")
338
+ if input is not None:
339
+ raise NotImplementedError("serial framed run does not support stdin input")
340
+ command = ";".join(
341
+ (
342
+ shlex.join([str(item) for item in value])
343
+ if isinstance(value, (tuple, list))
344
+ else str(value)
345
+ )
346
+ for value in cmds
347
+ )
348
+ self.connect()
349
+ process = self._executor.open()
350
+ try:
351
+ output, returncode = self.config.protocol.run(
352
+ process, command, timeout=timeout
353
+ )
354
+ except TimeoutError as exc:
355
+ raise subprocess.TimeoutExpired(
356
+ command, timeout, output=getattr(exc, "output", None)
357
+ ) from exc
358
+ finally:
359
+ process.close()
360
+ output_stream, _error_stream = capture_streams(capture_output, stdout, stderr)
361
+ use_text = bool(text) if text is not None else encoding is not None
362
+ if use_text:
363
+ output_value: typing.Union[str, bytes] = output.decode(
364
+ encoding or "utf-8", errors or "strict"
365
+ )
366
+ else:
367
+ output_value = output
368
+ captured = output_value if output_stream == subprocess.PIPE else None
369
+ if output_stream not in (None, subprocess.PIPE, subprocess.DEVNULL):
370
+ write_output(
371
+ output_stream,
372
+ output_value,
373
+ encoding=encoding,
374
+ errors=errors,
375
+ )
376
+ result = subprocess.CompletedProcess(command, returncode, captured, None)
377
+ if check and returncode:
378
+ raise subprocess.CalledProcessError(
379
+ returncode, command, output=captured, stderr=None
380
+ )
381
+ return result
382
+
383
+
384
+ class _SerialShell:
385
+ """Minimal shell binding for consoles with no declared OS shell."""
386
+
387
+ def __init__(self, host: SerialHost) -> None:
388
+ self.host = host
389
+
390
+ def run(self, *cmds, **options):
391
+ return self.host.run(*cmds, **options)
392
+
393
+ def session(self, *cmds, **options):
394
+ process = self.host.spawn(**options)
395
+ if cmds:
396
+ for command in cmds:
397
+ process.send_command(command)
398
+ return process
399
+
400
+
401
+ __all__ = ["SerialConfig", "SerialHost"]