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
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"]
|