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/executor/qemu.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Buffered command execution through the QEMU Guest Agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
import typing
|
|
10
|
+
|
|
11
|
+
from ._qga import GuestAgentTransport, QgaProtocolError
|
|
12
|
+
from ._common import (
|
|
13
|
+
CaptureOutput,
|
|
14
|
+
CommandArgument,
|
|
15
|
+
Environment,
|
|
16
|
+
Executor,
|
|
17
|
+
ExecutorCapability,
|
|
18
|
+
ExecutorCommand,
|
|
19
|
+
FileHandle,
|
|
20
|
+
Input,
|
|
21
|
+
PathLike,
|
|
22
|
+
dispatch_output,
|
|
23
|
+
normalize_environment,
|
|
24
|
+
normalize_input,
|
|
25
|
+
capture_streams,
|
|
26
|
+
reject_stdin_conflict,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GuestAgentProtocolError(QgaProtocolError):
|
|
31
|
+
"""A guest-agent response did not satisfy the QGA command contract."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_input(
|
|
35
|
+
stream: FileHandle,
|
|
36
|
+
*,
|
|
37
|
+
encoding: typing.Optional[str],
|
|
38
|
+
errors: typing.Optional[str],
|
|
39
|
+
) -> bytes:
|
|
40
|
+
if stream == subprocess.DEVNULL:
|
|
41
|
+
return b""
|
|
42
|
+
if stream == subprocess.PIPE:
|
|
43
|
+
raise ValueError("stdin=subprocess.PIPE requires input")
|
|
44
|
+
if isinstance(stream, int):
|
|
45
|
+
with os.fdopen(os.dup(stream), "rb") as duplicate:
|
|
46
|
+
value = duplicate.read()
|
|
47
|
+
else:
|
|
48
|
+
value = stream.read()
|
|
49
|
+
if isinstance(value, str):
|
|
50
|
+
return value.encode(encoding or "utf-8", errors or "strict")
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _decode_output(value: object, field: str) -> bytes:
|
|
55
|
+
if value is None:
|
|
56
|
+
return b""
|
|
57
|
+
if not isinstance(value, str):
|
|
58
|
+
raise GuestAgentProtocolError(f"{field} must be Base64 text")
|
|
59
|
+
try:
|
|
60
|
+
return base64.b64decode(value, validate=True)
|
|
61
|
+
except (ValueError, TypeError) as exc:
|
|
62
|
+
raise GuestAgentProtocolError(f"{field} is not valid Base64") from exc
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class QemuExecutor(Executor[subprocess.CompletedProcess]):
|
|
66
|
+
"""Execute direct argv through QGA's buffered ``guest-exec`` RPC."""
|
|
67
|
+
|
|
68
|
+
executor_capabilities = frozenset((ExecutorCapability.ARGS, ExecutorCapability.ENV))
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
transport: typing.Callable[[], GuestAgentTransport],
|
|
73
|
+
*,
|
|
74
|
+
poll_interval: float = 0.01,
|
|
75
|
+
max_poll_interval: float = 0.25,
|
|
76
|
+
clock: typing.Callable[[], float] = time.monotonic,
|
|
77
|
+
sleep: typing.Callable[[float], None] = time.sleep,
|
|
78
|
+
) -> None:
|
|
79
|
+
if poll_interval <= 0:
|
|
80
|
+
raise ValueError("poll_interval must be positive")
|
|
81
|
+
if max_poll_interval < poll_interval:
|
|
82
|
+
raise ValueError("max_poll_interval must be at least poll_interval")
|
|
83
|
+
self._transport = transport
|
|
84
|
+
self._poll_interval = poll_interval
|
|
85
|
+
self._max_poll_interval = max_poll_interval
|
|
86
|
+
self._clock = clock
|
|
87
|
+
self._sleep = sleep
|
|
88
|
+
|
|
89
|
+
def __call__(
|
|
90
|
+
self,
|
|
91
|
+
command: ExecutorCommand,
|
|
92
|
+
*args: CommandArgument,
|
|
93
|
+
bufsize: int = -1,
|
|
94
|
+
stdin: typing.Optional[FileHandle] = None,
|
|
95
|
+
stdout: typing.Optional[FileHandle] = None,
|
|
96
|
+
stderr: typing.Optional[FileHandle] = None,
|
|
97
|
+
cwd: typing.Optional[PathLike] = None,
|
|
98
|
+
env: typing.Optional[Environment] = None,
|
|
99
|
+
capture_output: CaptureOutput = True,
|
|
100
|
+
check: bool = True,
|
|
101
|
+
encoding: typing.Optional[str] = None,
|
|
102
|
+
errors: typing.Optional[str] = None,
|
|
103
|
+
input: Input = None,
|
|
104
|
+
timeout: typing.Optional[float] = None,
|
|
105
|
+
text: typing.Optional[bool] = None,
|
|
106
|
+
**options: object,
|
|
107
|
+
) -> subprocess.CompletedProcess:
|
|
108
|
+
del bufsize
|
|
109
|
+
if options:
|
|
110
|
+
raise TypeError(f"unsupported QEMU executor option: {sorted(options)[0]}")
|
|
111
|
+
if cwd is not None:
|
|
112
|
+
raise NotImplementedError(
|
|
113
|
+
"QGA guest-exec has no native working-directory support"
|
|
114
|
+
)
|
|
115
|
+
reject_stdin_conflict(input, stdin)
|
|
116
|
+
if not str(command):
|
|
117
|
+
raise ValueError("QGA guest-exec path must not be empty")
|
|
118
|
+
if timeout is not None and timeout < 0:
|
|
119
|
+
raise ValueError("timeout must not be negative")
|
|
120
|
+
if text and encoding is None:
|
|
121
|
+
encoding = "utf-8"
|
|
122
|
+
|
|
123
|
+
stdout, stderr = capture_streams(capture_output, stdout, stderr)
|
|
124
|
+
argv = [
|
|
125
|
+
(
|
|
126
|
+
value.decode(encoding or "utf-8", errors or "strict")
|
|
127
|
+
if isinstance(value, bytes)
|
|
128
|
+
else str(value)
|
|
129
|
+
)
|
|
130
|
+
for value in args
|
|
131
|
+
]
|
|
132
|
+
arguments: typing.Dict[str, object] = {
|
|
133
|
+
"path": str(command),
|
|
134
|
+
"arg": argv,
|
|
135
|
+
"capture-output": True,
|
|
136
|
+
}
|
|
137
|
+
normalized_env = normalize_environment(env)
|
|
138
|
+
if normalized_env is not None:
|
|
139
|
+
arguments["env"] = [
|
|
140
|
+
f"{key}={value}" for key, value in normalized_env.items()
|
|
141
|
+
]
|
|
142
|
+
if input is not None:
|
|
143
|
+
# QGA carries stdin base64-encoded, so this leg is always binary.
|
|
144
|
+
payload = normalize_input(
|
|
145
|
+
input, text_mode=False, encoding=encoding, errors=errors
|
|
146
|
+
)
|
|
147
|
+
arguments["input-data"] = base64.b64encode(
|
|
148
|
+
typing.cast(bytes, payload)
|
|
149
|
+
).decode("ascii")
|
|
150
|
+
elif stdin is not None:
|
|
151
|
+
payload = _read_input(
|
|
152
|
+
stdin,
|
|
153
|
+
encoding=encoding,
|
|
154
|
+
errors=errors,
|
|
155
|
+
)
|
|
156
|
+
arguments["input-data"] = base64.b64encode(payload).decode("ascii")
|
|
157
|
+
|
|
158
|
+
deadline = None if timeout is None else self._clock() + timeout
|
|
159
|
+
command_display = [str(command), *argv]
|
|
160
|
+
started = self._execute(
|
|
161
|
+
{"execute": "guest-exec", "arguments": arguments},
|
|
162
|
+
deadline,
|
|
163
|
+
command_display,
|
|
164
|
+
timeout=timeout,
|
|
165
|
+
)
|
|
166
|
+
if not isinstance(started, typing.Mapping):
|
|
167
|
+
raise GuestAgentProtocolError("guest-exec returned a non-object result")
|
|
168
|
+
pid = started.get("pid")
|
|
169
|
+
if not isinstance(pid, int) or isinstance(pid, bool):
|
|
170
|
+
raise GuestAgentProtocolError("guest-exec did not return an integer PID")
|
|
171
|
+
|
|
172
|
+
status = self._wait(pid, deadline, command_display, timeout)
|
|
173
|
+
out = _decode_output(status.get("out-data"), "out-data")
|
|
174
|
+
err = _decode_output(status.get("err-data"), "err-data")
|
|
175
|
+
returncode = status.get("exitcode")
|
|
176
|
+
if not isinstance(returncode, int) or isinstance(returncode, bool):
|
|
177
|
+
signal = status.get("signal")
|
|
178
|
+
if isinstance(signal, int) and not isinstance(signal, bool):
|
|
179
|
+
returncode = -signal
|
|
180
|
+
else:
|
|
181
|
+
raise GuestAgentProtocolError(
|
|
182
|
+
"completed guest-exec status has no integer exit code or signal"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if text or encoding is not None or errors is not None:
|
|
186
|
+
codec = encoding or "utf-8"
|
|
187
|
+
out = out.decode(codec, errors or "strict")
|
|
188
|
+
err = err.decode(codec, errors or "strict")
|
|
189
|
+
if stderr is subprocess.STDOUT:
|
|
190
|
+
out += err
|
|
191
|
+
err = None
|
|
192
|
+
out, err = dispatch_output(
|
|
193
|
+
stdout,
|
|
194
|
+
stderr,
|
|
195
|
+
out,
|
|
196
|
+
err,
|
|
197
|
+
encoding=encoding,
|
|
198
|
+
errors=errors,
|
|
199
|
+
)
|
|
200
|
+
completed = subprocess.CompletedProcess(
|
|
201
|
+
command_display,
|
|
202
|
+
returncode,
|
|
203
|
+
out,
|
|
204
|
+
err,
|
|
205
|
+
)
|
|
206
|
+
completed.pid = pid
|
|
207
|
+
completed.stdout_truncated = bool(status.get("out-truncated", False))
|
|
208
|
+
completed.stderr_truncated = bool(status.get("err-truncated", False))
|
|
209
|
+
if check:
|
|
210
|
+
completed.check_returncode()
|
|
211
|
+
return completed
|
|
212
|
+
|
|
213
|
+
def _execute(
|
|
214
|
+
self,
|
|
215
|
+
request: typing.Mapping[str, object],
|
|
216
|
+
deadline: typing.Optional[float],
|
|
217
|
+
command: typing.Sequence[str],
|
|
218
|
+
timeout: typing.Optional[float] = None,
|
|
219
|
+
) -> object:
|
|
220
|
+
remaining = self._remaining(deadline, command, timeout=timeout)
|
|
221
|
+
try:
|
|
222
|
+
return self._transport().execute(request, timeout=remaining)
|
|
223
|
+
except (TimeoutError, subprocess.TimeoutExpired) as exc:
|
|
224
|
+
expired = subprocess.TimeoutExpired(command, remaining)
|
|
225
|
+
expired.orphaned = request.get("execute") == "guest-exec"
|
|
226
|
+
raise expired from exc
|
|
227
|
+
|
|
228
|
+
def _wait(
|
|
229
|
+
self,
|
|
230
|
+
pid: int,
|
|
231
|
+
deadline: typing.Optional[float],
|
|
232
|
+
command: typing.Sequence[str],
|
|
233
|
+
timeout: typing.Optional[float],
|
|
234
|
+
) -> typing.Mapping[str, object]:
|
|
235
|
+
interval = self._poll_interval
|
|
236
|
+
partial_out = b""
|
|
237
|
+
partial_err = b""
|
|
238
|
+
while True:
|
|
239
|
+
try:
|
|
240
|
+
result = self._execute(
|
|
241
|
+
{
|
|
242
|
+
"execute": "guest-exec-status",
|
|
243
|
+
"arguments": {"pid": pid},
|
|
244
|
+
},
|
|
245
|
+
deadline,
|
|
246
|
+
command,
|
|
247
|
+
timeout=timeout,
|
|
248
|
+
)
|
|
249
|
+
except subprocess.TimeoutExpired as exc:
|
|
250
|
+
self._annotate_timeout(exc, pid, timeout, partial_out, partial_err)
|
|
251
|
+
raise
|
|
252
|
+
if not isinstance(result, typing.Mapping):
|
|
253
|
+
raise GuestAgentProtocolError(
|
|
254
|
+
"guest-exec-status returned a non-object result"
|
|
255
|
+
)
|
|
256
|
+
if not isinstance(result.get("exited"), bool):
|
|
257
|
+
raise GuestAgentProtocolError(
|
|
258
|
+
"guest-exec-status has no boolean exited field"
|
|
259
|
+
)
|
|
260
|
+
if "out-data" in result:
|
|
261
|
+
partial_out = _decode_output(result.get("out-data"), "out-data")
|
|
262
|
+
if "err-data" in result:
|
|
263
|
+
partial_err = _decode_output(result.get("err-data"), "err-data")
|
|
264
|
+
if result.get("exited") is True:
|
|
265
|
+
return result
|
|
266
|
+
try:
|
|
267
|
+
remaining = self._remaining(deadline, command, pid, timeout)
|
|
268
|
+
except subprocess.TimeoutExpired as exc:
|
|
269
|
+
self._annotate_timeout(exc, pid, timeout, partial_out, partial_err)
|
|
270
|
+
raise
|
|
271
|
+
delay = interval if remaining is None else min(interval, remaining)
|
|
272
|
+
self._sleep(delay)
|
|
273
|
+
interval = min(interval * 2, self._max_poll_interval)
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def _annotate_timeout(
|
|
277
|
+
exc: subprocess.TimeoutExpired,
|
|
278
|
+
pid: int,
|
|
279
|
+
timeout: typing.Optional[float],
|
|
280
|
+
stdout: bytes,
|
|
281
|
+
stderr: bytes,
|
|
282
|
+
) -> None:
|
|
283
|
+
exc.timeout = timeout
|
|
284
|
+
exc.pid = pid
|
|
285
|
+
exc.orphaned = True
|
|
286
|
+
exc.output = stdout
|
|
287
|
+
exc.stderr = stderr
|
|
288
|
+
|
|
289
|
+
def _remaining(
|
|
290
|
+
self,
|
|
291
|
+
deadline: typing.Optional[float],
|
|
292
|
+
command: typing.Sequence[str],
|
|
293
|
+
pid: typing.Optional[int] = None,
|
|
294
|
+
timeout: typing.Optional[float] = None,
|
|
295
|
+
) -> typing.Optional[float]:
|
|
296
|
+
if deadline is None:
|
|
297
|
+
return None
|
|
298
|
+
remaining = deadline - self._clock()
|
|
299
|
+
if remaining > 0:
|
|
300
|
+
return remaining
|
|
301
|
+
expired = subprocess.TimeoutExpired(command, timeout)
|
|
302
|
+
expired.orphaned = pid is not None
|
|
303
|
+
if pid is not None:
|
|
304
|
+
expired.pid = pid
|
|
305
|
+
raise expired
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Raw serial transport ownership without console or shell assumptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import errno
|
|
7
|
+
import contextlib
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import types
|
|
11
|
+
import typing
|
|
12
|
+
|
|
13
|
+
if typing.TYPE_CHECKING:
|
|
14
|
+
from ..process.serial import SerialProcess
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SerialLike(typing.Protocol):
|
|
18
|
+
is_open: bool
|
|
19
|
+
dtr: bool
|
|
20
|
+
rts: bool
|
|
21
|
+
timeout: typing.Optional[float]
|
|
22
|
+
write_timeout: typing.Optional[float]
|
|
23
|
+
|
|
24
|
+
def close(self) -> None: ...
|
|
25
|
+
|
|
26
|
+
def flush(self) -> None: ...
|
|
27
|
+
|
|
28
|
+
def read(self, size: int = 1) -> bytes: ...
|
|
29
|
+
|
|
30
|
+
def send_break(self, duration: float = 0.25) -> None: ...
|
|
31
|
+
|
|
32
|
+
def write(self, data: bytes) -> int: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
SerialFactory = typing.Callable[..., SerialLike]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SerialTransport:
|
|
39
|
+
"""Locked byte transport used by protocol profiles and tests."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
serial_port: SerialLike,
|
|
44
|
+
*,
|
|
45
|
+
lock: typing.Optional[threading.RLock] = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
self.serial = serial_port
|
|
48
|
+
self.lock = lock or threading.RLock()
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def connected(self) -> bool:
|
|
52
|
+
return bool(self.serial.is_open)
|
|
53
|
+
|
|
54
|
+
@contextlib.contextmanager
|
|
55
|
+
def _deadline_timeout(
|
|
56
|
+
self, attribute: str, deadline: typing.Optional[float]
|
|
57
|
+
) -> typing.Iterator[None]:
|
|
58
|
+
if deadline is None:
|
|
59
|
+
yield
|
|
60
|
+
return
|
|
61
|
+
if not hasattr(self.serial, attribute):
|
|
62
|
+
raise NotImplementedError(
|
|
63
|
+
f"serial backend cannot enforce a finite {attribute.replace('_', ' ')}"
|
|
64
|
+
)
|
|
65
|
+
previous = getattr(self.serial, attribute)
|
|
66
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
67
|
+
setattr(self.serial, attribute, remaining)
|
|
68
|
+
try:
|
|
69
|
+
yield
|
|
70
|
+
finally:
|
|
71
|
+
setattr(self.serial, attribute, previous)
|
|
72
|
+
|
|
73
|
+
def read(
|
|
74
|
+
self, size: int = 4096, *, timeout: typing.Optional[float] = None
|
|
75
|
+
) -> bytes:
|
|
76
|
+
if size < 0:
|
|
77
|
+
raise ValueError("read size must not be negative")
|
|
78
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
79
|
+
if timeout is not None and timeout < 0:
|
|
80
|
+
raise ValueError("timeout must not be negative")
|
|
81
|
+
with self.lock:
|
|
82
|
+
while True:
|
|
83
|
+
with self._deadline_timeout("timeout", deadline):
|
|
84
|
+
value = self.serial.read(size)
|
|
85
|
+
if value or deadline is None or time.monotonic() >= deadline:
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
def write(self, data: bytes, *, timeout: typing.Optional[float] = None) -> None:
|
|
89
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
90
|
+
if timeout is not None and timeout < 0:
|
|
91
|
+
raise ValueError("timeout must not be negative")
|
|
92
|
+
offset = 0
|
|
93
|
+
with self.lock:
|
|
94
|
+
while offset < len(data):
|
|
95
|
+
with self._deadline_timeout("write_timeout", deadline):
|
|
96
|
+
written = self.serial.write(data[offset:])
|
|
97
|
+
if written <= 0:
|
|
98
|
+
raise TimeoutError("serial write made no progress")
|
|
99
|
+
offset += written
|
|
100
|
+
if (
|
|
101
|
+
deadline is not None
|
|
102
|
+
and time.monotonic() >= deadline
|
|
103
|
+
and offset < len(data)
|
|
104
|
+
):
|
|
105
|
+
raise TimeoutError("serial write timed out")
|
|
106
|
+
self.serial.flush()
|
|
107
|
+
|
|
108
|
+
def reset_input_buffer(self) -> None:
|
|
109
|
+
reset = getattr(self.serial, "reset_input_buffer", None)
|
|
110
|
+
if callable(reset):
|
|
111
|
+
with self.lock:
|
|
112
|
+
reset()
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def dtr(self) -> bool:
|
|
116
|
+
return bool(self.serial.dtr)
|
|
117
|
+
|
|
118
|
+
@dtr.setter
|
|
119
|
+
def dtr(self, value: bool) -> None:
|
|
120
|
+
with self.lock:
|
|
121
|
+
self.serial.dtr = bool(value)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def rts(self) -> bool:
|
|
125
|
+
return bool(self.serial.rts)
|
|
126
|
+
|
|
127
|
+
@rts.setter
|
|
128
|
+
def rts(self, value: bool) -> None:
|
|
129
|
+
with self.lock:
|
|
130
|
+
self.serial.rts = bool(value)
|
|
131
|
+
|
|
132
|
+
def send_break(self, duration: float = 0.25) -> None:
|
|
133
|
+
with self.lock:
|
|
134
|
+
self.serial.send_break(duration)
|
|
135
|
+
|
|
136
|
+
def close(self) -> None:
|
|
137
|
+
with self.lock:
|
|
138
|
+
self.serial.close()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclasses.dataclass(frozen=True)
|
|
142
|
+
class SerialSettings:
|
|
143
|
+
"""Validated settings passed unchanged to ``serial_for_url``."""
|
|
144
|
+
|
|
145
|
+
port: str
|
|
146
|
+
baudrate: int = 115200
|
|
147
|
+
bytesize: int = 8
|
|
148
|
+
parity: str = "N"
|
|
149
|
+
stopbits: float = 1
|
|
150
|
+
xonxoff: bool = False
|
|
151
|
+
rtscts: bool = False
|
|
152
|
+
dsrdtr: bool = False
|
|
153
|
+
read_timeout: typing.Optional[float] = 0.1
|
|
154
|
+
write_timeout: typing.Optional[float] = 10
|
|
155
|
+
inter_byte_timeout: typing.Optional[float] = None
|
|
156
|
+
exclusive: typing.Optional[bool] = None
|
|
157
|
+
|
|
158
|
+
def __post_init__(self) -> None:
|
|
159
|
+
if not self.port:
|
|
160
|
+
raise ValueError("serial port must not be empty")
|
|
161
|
+
if self.baudrate <= 0:
|
|
162
|
+
raise ValueError("baudrate must be positive")
|
|
163
|
+
if self.bytesize not in (5, 6, 7, 8):
|
|
164
|
+
raise ValueError("bytesize must be 5, 6, 7, or 8")
|
|
165
|
+
if self.parity.upper() not in ("N", "E", "O", "M", "S"):
|
|
166
|
+
raise ValueError("parity must be N, E, O, M, or S")
|
|
167
|
+
object.__setattr__(self, "parity", self.parity.upper())
|
|
168
|
+
if self.stopbits not in (1, 1.5, 2):
|
|
169
|
+
raise ValueError("stopbits must be 1, 1.5, or 2")
|
|
170
|
+
for name in ("read_timeout", "write_timeout", "inter_byte_timeout"):
|
|
171
|
+
value = getattr(self, name)
|
|
172
|
+
if value is not None and value < 0:
|
|
173
|
+
raise ValueError(f"{name} must not be negative")
|
|
174
|
+
|
|
175
|
+
def factory_options(self) -> typing.Dict[str, object]:
|
|
176
|
+
options: typing.Dict[str, object] = {
|
|
177
|
+
"baudrate": self.baudrate,
|
|
178
|
+
"bytesize": self.bytesize,
|
|
179
|
+
"parity": self.parity,
|
|
180
|
+
"stopbits": self.stopbits,
|
|
181
|
+
"xonxoff": self.xonxoff,
|
|
182
|
+
"rtscts": self.rtscts,
|
|
183
|
+
"dsrdtr": self.dsrdtr,
|
|
184
|
+
"timeout": self.read_timeout,
|
|
185
|
+
"write_timeout": self.write_timeout,
|
|
186
|
+
"inter_byte_timeout": self.inter_byte_timeout,
|
|
187
|
+
}
|
|
188
|
+
if self.exclusive is not None:
|
|
189
|
+
options["exclusive"] = self.exclusive
|
|
190
|
+
return options
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _default_serial_factory(port: str, **options: object) -> SerialLike:
|
|
194
|
+
try:
|
|
195
|
+
import serial
|
|
196
|
+
except ImportError as exc:
|
|
197
|
+
raise ImportError(
|
|
198
|
+
"serial support requires the 'serial' extra: " "pip install hostctl[serial]"
|
|
199
|
+
) from exc
|
|
200
|
+
return typing.cast(SerialLike, serial.serial_for_url(port, **options))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def normalize_serial_error(exc: Exception) -> Exception:
|
|
204
|
+
"""Normalize pyserial/native failures without requiring pyserial at import."""
|
|
205
|
+
if isinstance(exc, (FileNotFoundError, PermissionError, TimeoutError)):
|
|
206
|
+
return exc
|
|
207
|
+
if isinstance(exc, OSError):
|
|
208
|
+
if exc.errno == errno.ENOENT:
|
|
209
|
+
return FileNotFoundError(exc.errno, exc.strerror, exc.filename)
|
|
210
|
+
if exc.errno in (errno.EACCES, errno.EPERM):
|
|
211
|
+
return PermissionError(exc.errno, exc.strerror, exc.filename)
|
|
212
|
+
name = type(exc).__name__
|
|
213
|
+
if name == "SerialTimeoutException":
|
|
214
|
+
return TimeoutError(str(exc))
|
|
215
|
+
if name in ("SerialException", "PortNotOpenError"):
|
|
216
|
+
return ConnectionError(str(exc))
|
|
217
|
+
return exc
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class SerialExecutor:
|
|
221
|
+
"""Own one raw serial connection and grant one exclusive process lease."""
|
|
222
|
+
|
|
223
|
+
def __init__(
|
|
224
|
+
self,
|
|
225
|
+
settings: SerialSettings,
|
|
226
|
+
*,
|
|
227
|
+
serial_factory: typing.Optional[SerialFactory] = None,
|
|
228
|
+
serial_port: typing.Optional[SerialLike] = None,
|
|
229
|
+
owns_serial_port: bool = False,
|
|
230
|
+
) -> None:
|
|
231
|
+
if serial_factory is not None and serial_port is not None:
|
|
232
|
+
raise ValueError("serial_factory and serial_port are mutually exclusive")
|
|
233
|
+
self.settings = settings
|
|
234
|
+
self._factory = serial_factory or _default_serial_factory
|
|
235
|
+
self._serial = serial_port
|
|
236
|
+
self._injected = serial_port is not None
|
|
237
|
+
self._owns_serial = owns_serial_port if serial_port is not None else True
|
|
238
|
+
self._lease = threading.Lock()
|
|
239
|
+
self._io_lock = threading.RLock()
|
|
240
|
+
self._connect_lock = threading.Lock()
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def connected(self) -> bool:
|
|
244
|
+
return self._serial is not None and self._serial.is_open
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def transport(self) -> SerialTransport:
|
|
248
|
+
"""Return the locked transport for protocol/profile integrations."""
|
|
249
|
+
serial_port = self.connect()
|
|
250
|
+
return SerialTransport(serial_port, lock=self._io_lock)
|
|
251
|
+
|
|
252
|
+
def connect(self) -> SerialLike:
|
|
253
|
+
with self._connect_lock:
|
|
254
|
+
if self.connected:
|
|
255
|
+
return typing.cast(SerialLike, self._serial)
|
|
256
|
+
if self._injected:
|
|
257
|
+
raise ConnectionError("injected serial port is closed")
|
|
258
|
+
try:
|
|
259
|
+
self._serial = self._factory(
|
|
260
|
+
self.settings.port, **self.settings.factory_options()
|
|
261
|
+
)
|
|
262
|
+
except Exception as exc:
|
|
263
|
+
normalized = normalize_serial_error(exc)
|
|
264
|
+
if normalized is exc:
|
|
265
|
+
raise
|
|
266
|
+
raise normalized from exc
|
|
267
|
+
return typing.cast(SerialLike, self._serial)
|
|
268
|
+
|
|
269
|
+
def open(self) -> SerialProcess:
|
|
270
|
+
"""Acquire the connection for one raw byte-stream process."""
|
|
271
|
+
if not self._lease.acquire(blocking=False):
|
|
272
|
+
raise RuntimeError("serial connection already has an active process")
|
|
273
|
+
try:
|
|
274
|
+
serial_port = self.connect()
|
|
275
|
+
except Exception:
|
|
276
|
+
self._lease.release()
|
|
277
|
+
raise
|
|
278
|
+
from ..process.serial import SerialProcess
|
|
279
|
+
|
|
280
|
+
return SerialProcess(
|
|
281
|
+
serial_port,
|
|
282
|
+
io_lock=self._io_lock,
|
|
283
|
+
release=self._release,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def _release(self) -> None:
|
|
287
|
+
self._lease.release()
|
|
288
|
+
|
|
289
|
+
def close(self) -> None:
|
|
290
|
+
serial_port = self._serial
|
|
291
|
+
if serial_port is not None and self._owns_serial:
|
|
292
|
+
self._serial = None
|
|
293
|
+
try:
|
|
294
|
+
serial_port.close()
|
|
295
|
+
except Exception as exc:
|
|
296
|
+
normalized = normalize_serial_error(exc)
|
|
297
|
+
if normalized is exc:
|
|
298
|
+
raise
|
|
299
|
+
raise normalized from exc
|
|
300
|
+
|
|
301
|
+
def __enter__(self) -> SerialExecutor:
|
|
302
|
+
self.connect()
|
|
303
|
+
return self
|
|
304
|
+
|
|
305
|
+
def __exit__(
|
|
306
|
+
self,
|
|
307
|
+
exc_type: typing.Optional[typing.Type[BaseException]],
|
|
308
|
+
exc_value: typing.Optional[BaseException],
|
|
309
|
+
traceback: typing.Optional[types.TracebackType],
|
|
310
|
+
) -> bool:
|
|
311
|
+
self.close()
|
|
312
|
+
return False
|