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,234 @@
1
+ """Transport-independent command executor contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import typing
10
+ from pathlib import PurePath
11
+
12
+ from pathlib_next import Pathname
13
+
14
+ _Result = typing.TypeVar("_Result", covariant=True)
15
+
16
+ FileHandle = typing.Union[int, typing.BinaryIO, typing.TextIO]
17
+ Input = typing.Optional[typing.Union[bytes, str]]
18
+ PathLike = typing.Union[str, os.PathLike[str]]
19
+ Environment = typing.Mapping[typing.Union[str, bytes], object]
20
+ CaptureOutput = typing.Literal[True, False, "stdout", "stderr"]
21
+ ExecutorCommand = typing.Union[str, PurePath, Pathname]
22
+ CommandArgument = typing.Union[str, bytes, PurePath, Pathname]
23
+
24
+
25
+ def capture_streams(
26
+ capture_output: CaptureOutput,
27
+ stdout: typing.Optional[FileHandle],
28
+ stderr: typing.Optional[FileHandle],
29
+ ) -> typing.Tuple[typing.Optional[FileHandle], typing.Optional[FileHandle]]:
30
+ """Apply hostctl's extended capture_output convention."""
31
+ if capture_output not in (True, False, "stdout", "stderr"):
32
+ raise ValueError("capture_output must be True, False, 'stdout', or 'stderr'")
33
+ if capture_output == "stdout":
34
+ if stdout not in (None, subprocess.PIPE):
35
+ raise ValueError(
36
+ "stdout argument cannot be used with capture_output='stdout'"
37
+ )
38
+ stdout = subprocess.PIPE
39
+ elif capture_output == "stderr":
40
+ if stderr not in (None, subprocess.PIPE):
41
+ raise ValueError(
42
+ "stderr argument cannot be used with capture_output='stderr'"
43
+ )
44
+ stderr = subprocess.PIPE
45
+ elif capture_output is True:
46
+ if stdout is None:
47
+ stdout = subprocess.PIPE
48
+ if stderr is None:
49
+ stderr = subprocess.PIPE
50
+ return stdout, stderr
51
+
52
+
53
+ def reject_stdin_conflict(input: Input, stdin: typing.Optional[FileHandle]) -> None:
54
+ if input is not None and stdin is not None:
55
+ raise ValueError("stdin and input arguments may not both be used")
56
+
57
+
58
+ def normalize_input(
59
+ input: Input,
60
+ *,
61
+ text_mode: bool,
62
+ encoding: typing.Optional[str] = None,
63
+ errors: typing.Optional[str] = None,
64
+ ) -> Input:
65
+ """Match `input` to the stream mode the executor is about to use.
66
+
67
+ A mismatch here is not a clean error. `subprocess` writes `input` on a
68
+ daemon writer thread; handing `bytes` to a text-mode stdin raises
69
+ `TypeError` *inside that thread*, which dies without closing the pipe, so
70
+ the child never sees EOF and the call blocks forever -- `timeout=` does not
71
+ fire, because nothing is waiting on the child. Verified on CPython 3.14;
72
+ 3.9 happens to surface the `TypeError` instead, so the failure mode is
73
+ interpreter-dependent, which is worse than either outcome alone.
74
+
75
+ Every executor needs this, not just the local one, and they must agree:
76
+ a `SystemHost` can dispatch the same call through different providers on
77
+ different attempts, so a caller cannot write one correct invocation if
78
+ each provider normalizes differently. `text_mode` is a parameter rather
79
+ than inferred because the sinks differ -- `subprocess` wants `str` under a
80
+ text mode, AsyncSSH wants `bytes` when given no encoding.
81
+
82
+ Passing `bytes` under a text mode decodes them; passing `str` under a
83
+ binary mode encodes them. `encoding`/`errors` default to UTF-8/strict,
84
+ matching `subprocess`.
85
+ """
86
+ if input is None or isinstance(input, (int,)):
87
+ return input
88
+ if text_mode:
89
+ if isinstance(input, bytes):
90
+ return input.decode(encoding or "utf-8", errors or "strict")
91
+ return input
92
+ if isinstance(input, str):
93
+ return input.encode(encoding or "utf-8", errors or "strict")
94
+ return input
95
+
96
+
97
+ def normalize_environment(
98
+ env: typing.Optional[Environment],
99
+ ) -> typing.Optional[typing.Dict[str, str]]:
100
+ if env is None:
101
+ return None
102
+ return {
103
+ key.decode() if isinstance(key, bytes) else str(key): (
104
+ value.decode() if isinstance(value, bytes) else str(value)
105
+ )
106
+ for key, value in env.items()
107
+ }
108
+
109
+
110
+ def write_output(
111
+ stream: FileHandle,
112
+ value: typing.Optional[typing.Union[str, bytes]],
113
+ *,
114
+ encoding: typing.Optional[str],
115
+ errors: typing.Optional[str],
116
+ ) -> None:
117
+ """Write completed buffered output without taking ownership of the stream."""
118
+ if value is None or stream == subprocess.DEVNULL:
119
+ return
120
+ close = False
121
+ if isinstance(stream, int):
122
+ stream = os.fdopen(os.dup(stream), "wb", closefd=True)
123
+ close = True
124
+ try:
125
+ try:
126
+ stream.write(value)
127
+ except TypeError:
128
+ if isinstance(value, bytes):
129
+ stream.write(value.decode(encoding or "utf-8", errors or "strict"))
130
+ else:
131
+ stream.write(value.encode(encoding or "utf-8", errors or "strict"))
132
+ flush = getattr(stream, "flush", None)
133
+ if flush is not None:
134
+ flush()
135
+ finally:
136
+ if close:
137
+ stream.close()
138
+
139
+
140
+ def dispatch_output(
141
+ stdout_target: typing.Optional[FileHandle],
142
+ stderr_target: typing.Optional[FileHandle],
143
+ stdout: typing.Optional[typing.Union[str, bytes]],
144
+ stderr: typing.Optional[typing.Union[str, bytes]],
145
+ *,
146
+ encoding: typing.Optional[str],
147
+ errors: typing.Optional[str],
148
+ ) -> typing.Tuple[
149
+ typing.Optional[typing.Union[str, bytes]],
150
+ typing.Optional[typing.Union[str, bytes]],
151
+ ]:
152
+ if stdout_target != subprocess.PIPE:
153
+ write_output(
154
+ sys.stdout if stdout_target is None else stdout_target,
155
+ stdout,
156
+ encoding=encoding,
157
+ errors=errors,
158
+ )
159
+ stdout = None
160
+ if stderr_target not in (subprocess.PIPE, subprocess.STDOUT):
161
+ write_output(
162
+ sys.stderr if stderr_target is None else stderr_target,
163
+ stderr,
164
+ encoding=encoding,
165
+ errors=errors,
166
+ )
167
+ stderr = None
168
+ return stdout, stderr
169
+
170
+
171
+ class ExecutorCapability(str, enum.Enum):
172
+ """Native executor features, spelled as strings.
173
+
174
+ There is exactly one capability vocabulary in hostctl: **strings**. These
175
+ members subclass :class:`str` so ``ExecutorCapability.CWD == "cwd"`` and
176
+ both hash alike -- a set of members and a set of their spellings are
177
+ interchangeable, with no conversion step at any boundary.
178
+
179
+ Strings are the vocabulary rather than opaque enum members because
180
+ provider capability sets legitimately carry transport-specific tokens with
181
+ no member here ("runspace", plus whatever a third-party provider
182
+ declares). An enum-only vocabulary could not express those; a mixed one
183
+ would need coercion at every comparison, which is precisely the bug this
184
+ shape removes. `str, enum.Enum` (not `enum.StrEnum`) keeps the 3.9 floor.
185
+ """
186
+
187
+ ARGS = "args"
188
+ CWD = "cwd"
189
+ ENV = "env"
190
+ SCRIPT = "script"
191
+ MANAGES_STATUS = "manages_status"
192
+
193
+
194
+ class ExecutionOptions(typing.TypedDict, total=False):
195
+ """Shell-agnostic subprocess-style options understood by executors."""
196
+
197
+ stdin: typing.Optional[FileHandle]
198
+ stdout: typing.Optional[FileHandle]
199
+ stderr: typing.Optional[FileHandle]
200
+ cwd: typing.Optional[PathLike]
201
+ env: typing.Optional[Environment]
202
+ capture_output: CaptureOutput
203
+ check: bool
204
+ encoding: typing.Optional[str]
205
+ errors: typing.Optional[str]
206
+ input: Input
207
+ timeout: typing.Optional[float]
208
+ text: bool
209
+
210
+
211
+ @typing.runtime_checkable
212
+ class Executor(typing.Protocol[_Result]):
213
+ """Callable executor receiving one command string and execution options."""
214
+
215
+ executor_capabilities: typing.FrozenSet[ExecutorCapability]
216
+
217
+ def __call__(
218
+ self,
219
+ command: ExecutorCommand,
220
+ *args: CommandArgument,
221
+ stdin: typing.Optional[FileHandle] = None,
222
+ stdout: typing.Optional[FileHandle] = None,
223
+ stderr: typing.Optional[FileHandle] = None,
224
+ cwd: typing.Optional[PathLike] = None,
225
+ env: typing.Optional[Environment] = None,
226
+ capture_output: typing.Optional[CaptureOutput] = None,
227
+ check: typing.Optional[bool] = None,
228
+ encoding: typing.Optional[str] = None,
229
+ errors: typing.Optional[str] = None,
230
+ input: Input = None,
231
+ timeout: typing.Optional[float] = None,
232
+ text: typing.Optional[bool] = None,
233
+ **options: object,
234
+ ) -> _Result: ...