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,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