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
|
@@ -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: ...
|