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/shell/_common.py
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
"""Target-shell command construction independent of the host transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import abc
|
|
6
|
+
import collections.abc
|
|
7
|
+
import copy
|
|
8
|
+
import dataclasses
|
|
9
|
+
import enum
|
|
10
|
+
import inspect
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import typing
|
|
14
|
+
import types
|
|
15
|
+
from pathlib import Path, PurePath
|
|
16
|
+
|
|
17
|
+
from ..executor import (
|
|
18
|
+
CommandArgument,
|
|
19
|
+
Executor,
|
|
20
|
+
ExecutorCapability,
|
|
21
|
+
ExecutorCommand,
|
|
22
|
+
)
|
|
23
|
+
from ..host._common import (
|
|
24
|
+
CaptureOutput,
|
|
25
|
+
Command,
|
|
26
|
+
Environment,
|
|
27
|
+
FileHandle,
|
|
28
|
+
Input,
|
|
29
|
+
PathLike,
|
|
30
|
+
)
|
|
31
|
+
from ..process import Process, TerminalRequest
|
|
32
|
+
|
|
33
|
+
_Result = typing.TypeVar("_Result", covariant=True)
|
|
34
|
+
_ENVIRONMENT_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
35
|
+
|
|
36
|
+
if typing.TYPE_CHECKING:
|
|
37
|
+
from ..host._common import Host
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ShellOperator(enum.Enum):
|
|
41
|
+
PIPE = "pipe"
|
|
42
|
+
AND = "and"
|
|
43
|
+
OR = "or"
|
|
44
|
+
REDIRECT = "redirect"
|
|
45
|
+
APPEND = "append"
|
|
46
|
+
SEQUENCE = "sequence"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
ShellToken = typing.Union[Command, ShellOperator]
|
|
50
|
+
|
|
51
|
+
#: What a caller may pass for `env` on a `Shell` call. A mapping merges over
|
|
52
|
+
#: the shell's default per key; the default `{}` merges nothing and therefore
|
|
53
|
+
#: inherits it; `None` declines the shell's defaults, leaving whatever
|
|
54
|
+
#: environment the host itself provides (login profile, rc files, service
|
|
55
|
+
#: environment) untouched.
|
|
56
|
+
EnvironmentSelection = typing.Optional[Environment]
|
|
57
|
+
|
|
58
|
+
#: The `env` default: an empty mapping, meaning "merge nothing, inherit the
|
|
59
|
+
#: shell's environment". A module-level immutable value rather than a literal
|
|
60
|
+
#: `{}` in each signature, so the default can never be mutated by a caller.
|
|
61
|
+
_INHERIT_ENV: Environment = types.MappingProxyType({})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclasses.dataclass(frozen=True)
|
|
65
|
+
class ShellCommand:
|
|
66
|
+
"""A transport-ready command and any environment sent out of band."""
|
|
67
|
+
|
|
68
|
+
command: str
|
|
69
|
+
environment: typing.Optional[Environment]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ShellSession(Process):
|
|
73
|
+
"""A persistent process with shell-aware command submission."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, flavour: ShellFlavour, process: Process) -> None:
|
|
76
|
+
self.flavour = flavour
|
|
77
|
+
self.process = process
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def returncode(self) -> typing.Optional[int]:
|
|
81
|
+
return self.process.returncode
|
|
82
|
+
|
|
83
|
+
def send(
|
|
84
|
+
self,
|
|
85
|
+
*cmds: ShellToken,
|
|
86
|
+
cwd: typing.Optional[PathLike] = None,
|
|
87
|
+
env: typing.Optional[Environment] = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Render and submit commands using this session's shell language."""
|
|
90
|
+
if not cmds and cwd is None and not env:
|
|
91
|
+
raise ValueError("send requires commands, cwd, or env")
|
|
92
|
+
script = self.flavour.script(cmds, cwd=cwd, env=env, for_session=True)
|
|
93
|
+
self.process.write(
|
|
94
|
+
script + self.flavour.command_separator + self.flavour.line_terminator
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def write(self, data):
|
|
98
|
+
self.process.write(data)
|
|
99
|
+
|
|
100
|
+
def read(self, size: int = -1):
|
|
101
|
+
return self.process.read(size)
|
|
102
|
+
|
|
103
|
+
def read_stderr(self, size: int = -1):
|
|
104
|
+
return self.process.read_stderr(size)
|
|
105
|
+
|
|
106
|
+
def send_eof(self) -> None:
|
|
107
|
+
self.process.send_eof()
|
|
108
|
+
|
|
109
|
+
def resize(
|
|
110
|
+
self,
|
|
111
|
+
columns: int,
|
|
112
|
+
rows: int,
|
|
113
|
+
pixel_width: int = 0,
|
|
114
|
+
pixel_height: int = 0,
|
|
115
|
+
) -> None:
|
|
116
|
+
self.process.resize(columns, rows, pixel_width, pixel_height)
|
|
117
|
+
|
|
118
|
+
def wait(self, timeout: typing.Optional[float] = None) -> int:
|
|
119
|
+
return self.process.wait(timeout)
|
|
120
|
+
|
|
121
|
+
def terminate(self) -> None:
|
|
122
|
+
self.process.terminate()
|
|
123
|
+
|
|
124
|
+
def kill(self) -> None:
|
|
125
|
+
self.process.kill()
|
|
126
|
+
|
|
127
|
+
def close(self) -> None:
|
|
128
|
+
self.process.close()
|
|
129
|
+
|
|
130
|
+
def __enter__(self) -> ShellSession:
|
|
131
|
+
self.process.__enter__()
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def __exit__(
|
|
135
|
+
self,
|
|
136
|
+
exc_type: typing.Optional[typing.Type[BaseException]],
|
|
137
|
+
exc_value: typing.Optional[BaseException],
|
|
138
|
+
traceback: typing.Optional[types.TracebackType],
|
|
139
|
+
) -> bool:
|
|
140
|
+
return self.process.__exit__(exc_type, exc_value, traceback)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ShellFlavour(abc.ABC):
|
|
144
|
+
"""Construct scripts and commands for one explicitly selected target shell."""
|
|
145
|
+
|
|
146
|
+
name: str
|
|
147
|
+
default_executable: str
|
|
148
|
+
info_script: str
|
|
149
|
+
command_separator: str
|
|
150
|
+
line_terminator: str = "\n"
|
|
151
|
+
context_order = ("env", "cwd", "command")
|
|
152
|
+
structured_command_prefix = ""
|
|
153
|
+
path_flavor: type[PurePath] = PurePath
|
|
154
|
+
|
|
155
|
+
def command_path(self, value: PathLike) -> PurePath:
|
|
156
|
+
"""Return a direct-command marker using the target shell's path syntax."""
|
|
157
|
+
return self.path_flavor(value)
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _text(value: object) -> str:
|
|
161
|
+
"""Normalize values and reject shell control characters."""
|
|
162
|
+
if isinstance(value, os.PathLike):
|
|
163
|
+
value = os.fspath(value)
|
|
164
|
+
elif isinstance(value, bytes):
|
|
165
|
+
value = value.decode("utf-8", "surrogateescape")
|
|
166
|
+
text = str(value)
|
|
167
|
+
if any(ord(char) < 32 or ord(char) == 127 for char in text):
|
|
168
|
+
raise ValueError("shell values cannot contain control characters")
|
|
169
|
+
return text
|
|
170
|
+
|
|
171
|
+
@abc.abstractmethod
|
|
172
|
+
def quote(self, value: object) -> str:
|
|
173
|
+
"""Quote one structured argument for this shell."""
|
|
174
|
+
|
|
175
|
+
@abc.abstractmethod
|
|
176
|
+
def operator(self, value: ShellOperator) -> str:
|
|
177
|
+
"""Render one supported command operator."""
|
|
178
|
+
|
|
179
|
+
def structured_command(self, values: typing.Iterable[object]) -> str:
|
|
180
|
+
return self.structured_command_prefix + " ".join(
|
|
181
|
+
self.quote(value) for value in values
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
@abc.abstractmethod
|
|
185
|
+
def environment_assignment(self, key: str, value: object) -> str:
|
|
186
|
+
"""Render one validated environment assignment."""
|
|
187
|
+
|
|
188
|
+
def environment_script(self, env: Environment) -> str:
|
|
189
|
+
"""Convert an environment mapping into a standalone shell script."""
|
|
190
|
+
assignments = []
|
|
191
|
+
for key, value in env.items():
|
|
192
|
+
if isinstance(key, bytes):
|
|
193
|
+
key = key.decode("utf-8", "surrogateescape")
|
|
194
|
+
if not isinstance(key, str) or not _ENVIRONMENT_KEY.fullmatch(key):
|
|
195
|
+
raise ValueError(f"invalid environment variable name: {key!r}")
|
|
196
|
+
assignments.append(self.environment_assignment(key, value))
|
|
197
|
+
return self.command_separator.join(assignments)
|
|
198
|
+
|
|
199
|
+
def command_text(self, value: Command) -> str:
|
|
200
|
+
if isinstance(value, (bytes, PurePath, Path, os.PathLike)):
|
|
201
|
+
return self.quote(value)
|
|
202
|
+
if isinstance(value, str):
|
|
203
|
+
return self._text(value)
|
|
204
|
+
if isinstance(value, collections.abc.Iterable):
|
|
205
|
+
values = tuple(value)
|
|
206
|
+
if not values:
|
|
207
|
+
raise ValueError("structured command must not be empty")
|
|
208
|
+
return self.structured_command(values)
|
|
209
|
+
return self._text(value)
|
|
210
|
+
|
|
211
|
+
def join(self, values: typing.Iterable[ShellToken]) -> str:
|
|
212
|
+
"""Join commands, preserving raw strings and explicit operators."""
|
|
213
|
+
result = []
|
|
214
|
+
pending = self.command_separator
|
|
215
|
+
has_command = False
|
|
216
|
+
expecting_command = False
|
|
217
|
+
for value in values:
|
|
218
|
+
if isinstance(value, ShellOperator):
|
|
219
|
+
if not has_command or expecting_command:
|
|
220
|
+
raise ValueError("shell operator must appear between commands")
|
|
221
|
+
pending = self.operator(value)
|
|
222
|
+
expecting_command = True
|
|
223
|
+
continue
|
|
224
|
+
command = self.command_text(value)
|
|
225
|
+
if not command:
|
|
226
|
+
continue
|
|
227
|
+
if has_command:
|
|
228
|
+
result.append(pending)
|
|
229
|
+
result.append(command)
|
|
230
|
+
has_command = True
|
|
231
|
+
expecting_command = False
|
|
232
|
+
pending = self.command_separator
|
|
233
|
+
if expecting_command:
|
|
234
|
+
raise ValueError("shell operator must be followed by a command")
|
|
235
|
+
return "".join(result)
|
|
236
|
+
|
|
237
|
+
def script(
|
|
238
|
+
self,
|
|
239
|
+
cmds: typing.Iterable[ShellToken],
|
|
240
|
+
*,
|
|
241
|
+
cwd: typing.Optional[PathLike] = None,
|
|
242
|
+
env: typing.Optional[Environment] = None,
|
|
243
|
+
for_session: bool = False,
|
|
244
|
+
) -> str:
|
|
245
|
+
"""Build a script, applying environment and cwd consistently."""
|
|
246
|
+
command = self.join(cmds)
|
|
247
|
+
changed = self.change_directory(cwd) if cwd is not None else ""
|
|
248
|
+
rendered = {
|
|
249
|
+
"env": self.environment_script(env) if env else "",
|
|
250
|
+
"cwd": changed,
|
|
251
|
+
"command": command,
|
|
252
|
+
}
|
|
253
|
+
if (
|
|
254
|
+
"cwd" in self.context_order
|
|
255
|
+
and "command" in self.context_order
|
|
256
|
+
and cwd is not None
|
|
257
|
+
and command
|
|
258
|
+
):
|
|
259
|
+
cwd_index = self.context_order.index("cwd")
|
|
260
|
+
command_index = self.context_order.index("command")
|
|
261
|
+
if command_index == cwd_index + 1:
|
|
262
|
+
rendered["cwd"] = self.join_cwd(changed, command)
|
|
263
|
+
rendered["command"] = ""
|
|
264
|
+
parts = [rendered[name] for name in self.context_order if rendered[name]]
|
|
265
|
+
script = self.command_separator.join(parts)
|
|
266
|
+
epilogue = getattr(self, "execution_epilogue", "")
|
|
267
|
+
if script and epilogue and not for_session:
|
|
268
|
+
script += epilogue
|
|
269
|
+
return script
|
|
270
|
+
|
|
271
|
+
@abc.abstractmethod
|
|
272
|
+
def change_directory(self, cwd: PathLike) -> str:
|
|
273
|
+
"""Render a directory change which fails if the directory is absent."""
|
|
274
|
+
|
|
275
|
+
def join_cwd(self, changed: str, command: str) -> str:
|
|
276
|
+
"""Join cwd setup and payload with the shell's AND operator."""
|
|
277
|
+
return f"{changed}{self.operator(ShellOperator.AND)}{command}"
|
|
278
|
+
|
|
279
|
+
@abc.abstractmethod
|
|
280
|
+
def command(
|
|
281
|
+
self,
|
|
282
|
+
cmds: typing.Iterable[ShellToken],
|
|
283
|
+
*,
|
|
284
|
+
executable: typing.Optional[str] = None,
|
|
285
|
+
cwd: typing.Optional[PathLike] = None,
|
|
286
|
+
env: typing.Optional[Environment] = None,
|
|
287
|
+
) -> ShellCommand:
|
|
288
|
+
"""Build the command submitted to an SSH exec channel."""
|
|
289
|
+
|
|
290
|
+
@abc.abstractmethod
|
|
291
|
+
def invocation(
|
|
292
|
+
self,
|
|
293
|
+
script: str,
|
|
294
|
+
*,
|
|
295
|
+
executable: typing.Optional[str] = None,
|
|
296
|
+
) -> typing.Sequence[str]:
|
|
297
|
+
"""Build local-process argv which invokes this shell for one script."""
|
|
298
|
+
|
|
299
|
+
def __str__(self) -> str:
|
|
300
|
+
return self.name
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class Shell(Executor[_Result], typing.Generic[_Result]):
|
|
304
|
+
"""Bind one shell language to a one-string callable or host executor."""
|
|
305
|
+
|
|
306
|
+
def __init__(
|
|
307
|
+
self,
|
|
308
|
+
flavour: ShellFlavour,
|
|
309
|
+
executor: typing.Union[Executor[_Result], Host],
|
|
310
|
+
*,
|
|
311
|
+
cwd: typing.Optional[PathLike] = None,
|
|
312
|
+
env: typing.Optional[Environment] = None,
|
|
313
|
+
encoding: typing.Optional[str] = None,
|
|
314
|
+
errors: typing.Optional[str] = None,
|
|
315
|
+
) -> None:
|
|
316
|
+
self.flavour = flavour
|
|
317
|
+
#: Defaults applied to every `run`, `execute`, and `session` call that
|
|
318
|
+
#: does not pass its own value. `cwd`, `encoding`, and `errors`
|
|
319
|
+
#: override wholesale; `env` merges per key so a call can change one
|
|
320
|
+
#: variable without restating the rest (see `_resolve_env`).
|
|
321
|
+
self.cwd = cwd
|
|
322
|
+
self.env = dict(env) if env is not None else None
|
|
323
|
+
self.encoding = encoding
|
|
324
|
+
self.errors = errors
|
|
325
|
+
run = getattr(executor, "run", None)
|
|
326
|
+
spawn = getattr(executor, "spawn", None)
|
|
327
|
+
self._spawn = spawn if callable(spawn) else None
|
|
328
|
+
self._session: typing.Optional[ShellSession] = None
|
|
329
|
+
if callable(executor):
|
|
330
|
+
self._execute = executor
|
|
331
|
+
elif callable(run):
|
|
332
|
+
self._execute = run
|
|
333
|
+
else:
|
|
334
|
+
raise TypeError("executor must be callable or provide run(command)")
|
|
335
|
+
try:
|
|
336
|
+
self._executor_parameters = inspect.signature(self._execute).parameters
|
|
337
|
+
except (TypeError, ValueError):
|
|
338
|
+
self._executor_parameters = {}
|
|
339
|
+
self._executor_accepts_options = any(
|
|
340
|
+
item.kind is inspect.Parameter.VAR_KEYWORD
|
|
341
|
+
for item in self._executor_parameters.values()
|
|
342
|
+
)
|
|
343
|
+
published = getattr(executor, "executor_capabilities", None)
|
|
344
|
+
if published is None:
|
|
345
|
+
inferred = set()
|
|
346
|
+
if self._accepts_keyword("cwd"):
|
|
347
|
+
inferred.add(ExecutorCapability.CWD)
|
|
348
|
+
if self._accepts_keyword("env"):
|
|
349
|
+
inferred.add(ExecutorCapability.ENV)
|
|
350
|
+
if any(
|
|
351
|
+
item.kind is inspect.Parameter.VAR_POSITIONAL
|
|
352
|
+
for item in self._executor_parameters.values()
|
|
353
|
+
):
|
|
354
|
+
inferred.add(ExecutorCapability.ARGS)
|
|
355
|
+
published = frozenset(inferred)
|
|
356
|
+
# One capability vocabulary, strings -- see `ExecutorCapability`.
|
|
357
|
+
# Its members subclass `str`, so a set published as enum members by a
|
|
358
|
+
# raw `Executor` and one published as plain strings by a `Host` (via
|
|
359
|
+
# `ExecutorProvider`) compare and hash identically. No conversion
|
|
360
|
+
# happens here, and none is needed at any other boundary.
|
|
361
|
+
self.executor_capabilities = frozenset(published)
|
|
362
|
+
self._executor_accepts_cwd = (
|
|
363
|
+
ExecutorCapability.CWD in self.executor_capabilities
|
|
364
|
+
)
|
|
365
|
+
self._executor_accepts_env = (
|
|
366
|
+
ExecutorCapability.ENV in self.executor_capabilities
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
def _accepts_keyword(self, name: str) -> bool:
|
|
370
|
+
parameter = self._executor_parameters.get(name)
|
|
371
|
+
return parameter is not None and parameter.kind in (
|
|
372
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
373
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def _resolve_env(self, env: EnvironmentSelection) -> typing.Optional[Environment]:
|
|
377
|
+
"""Merge a per-call environment over this shell's default.
|
|
378
|
+
|
|
379
|
+
The default `{}` merges nothing, so a call that says nothing about the
|
|
380
|
+
environment inherits the shell's. A mapping merges over that default
|
|
381
|
+
per key: the call wins for keys it names, and default-only keys
|
|
382
|
+
survive, so one variable can change without restating the rest.
|
|
383
|
+
|
|
384
|
+
`None` declines the shell's configured defaults. It does **not** mean
|
|
385
|
+
an empty environment: the command still runs with whatever the host
|
|
386
|
+
provides on its own -- a login profile, rc files, the service
|
|
387
|
+
environment -- because nothing is sent to override it. Requesting a
|
|
388
|
+
genuinely empty environment is a separate feature that does not exist
|
|
389
|
+
yet (see `.agents/plans/env_clear_semantics.md`).
|
|
390
|
+
"""
|
|
391
|
+
if env is None:
|
|
392
|
+
return None
|
|
393
|
+
if self.env is None:
|
|
394
|
+
return dict(env) if env else None
|
|
395
|
+
merged = dict(self.env)
|
|
396
|
+
merged.update(env)
|
|
397
|
+
return merged
|
|
398
|
+
|
|
399
|
+
def _resolve_cwd(self, cwd: typing.Optional[PathLike]) -> typing.Optional[PathLike]:
|
|
400
|
+
return self.cwd if cwd is None else cwd
|
|
401
|
+
|
|
402
|
+
def execute(
|
|
403
|
+
self,
|
|
404
|
+
command: ExecutorCommand,
|
|
405
|
+
*args: CommandArgument,
|
|
406
|
+
stdin: typing.Optional[FileHandle] = None,
|
|
407
|
+
stdout: typing.Optional[FileHandle] = None,
|
|
408
|
+
stderr: typing.Optional[FileHandle] = None,
|
|
409
|
+
cwd: typing.Optional[PathLike] = None,
|
|
410
|
+
env: typing.Optional[Environment] = None,
|
|
411
|
+
capture_output: typing.Optional[CaptureOutput] = None,
|
|
412
|
+
check: typing.Optional[bool] = None,
|
|
413
|
+
encoding: typing.Optional[str] = None,
|
|
414
|
+
errors: typing.Optional[str] = None,
|
|
415
|
+
input: Input = None,
|
|
416
|
+
timeout: typing.Optional[float] = None,
|
|
417
|
+
text: typing.Optional[bool] = None,
|
|
418
|
+
) -> _Result:
|
|
419
|
+
"""Execute one command and forward supported execution context."""
|
|
420
|
+
executor_args = args
|
|
421
|
+
if args and ExecutorCapability.ARGS not in self.executor_capabilities:
|
|
422
|
+
command = self.flavour.structured_command((command, *args))
|
|
423
|
+
executor_args = ()
|
|
424
|
+
# Apply the shell's cwd/env defaults only where the executor can carry
|
|
425
|
+
# them natively. Where it cannot, the caller renders them into the
|
|
426
|
+
# script instead -- `run` does exactly that and then passes
|
|
427
|
+
# cwd=None/env=None here, so resolving again would forward a value this
|
|
428
|
+
# executor rejects. `execute` used directly against a capability-less
|
|
429
|
+
# executor therefore ignores the defaults by design: it dispatches one
|
|
430
|
+
# opaque command rather than building a script.
|
|
431
|
+
if self._executor_accepts_cwd:
|
|
432
|
+
cwd = self._resolve_cwd(cwd)
|
|
433
|
+
if self._executor_accepts_env:
|
|
434
|
+
env = self._resolve_env(env)
|
|
435
|
+
if encoding is None:
|
|
436
|
+
encoding = self.encoding
|
|
437
|
+
if errors is None:
|
|
438
|
+
errors = self.errors
|
|
439
|
+
options = {
|
|
440
|
+
name: value
|
|
441
|
+
for name, value in (
|
|
442
|
+
("stdin", stdin),
|
|
443
|
+
("stdout", stdout),
|
|
444
|
+
("stderr", stderr),
|
|
445
|
+
("capture_output", capture_output),
|
|
446
|
+
("check", check),
|
|
447
|
+
("encoding", encoding),
|
|
448
|
+
("errors", errors),
|
|
449
|
+
("input", input),
|
|
450
|
+
("timeout", timeout),
|
|
451
|
+
("text", text),
|
|
452
|
+
)
|
|
453
|
+
if value is not None
|
|
454
|
+
}
|
|
455
|
+
if cwd is not None:
|
|
456
|
+
if not self._executor_accepts_cwd:
|
|
457
|
+
raise TypeError("executor does not accept cwd")
|
|
458
|
+
options["cwd"] = cwd
|
|
459
|
+
if env is not None:
|
|
460
|
+
if not self._executor_accepts_env:
|
|
461
|
+
raise TypeError("executor does not accept env")
|
|
462
|
+
options["env"] = env
|
|
463
|
+
unsupported = [
|
|
464
|
+
name
|
|
465
|
+
for name in options
|
|
466
|
+
if not self._accepts_keyword(name) and not self._executor_accepts_options
|
|
467
|
+
]
|
|
468
|
+
if unsupported:
|
|
469
|
+
raise TypeError(f"executor does not accept {sorted(unsupported)[0]}")
|
|
470
|
+
return self._execute(command, *executor_args, **options)
|
|
471
|
+
|
|
472
|
+
__call__ = execute
|
|
473
|
+
|
|
474
|
+
def run(
|
|
475
|
+
self,
|
|
476
|
+
*cmds: ShellToken,
|
|
477
|
+
stdin: typing.Optional[FileHandle] = None,
|
|
478
|
+
stdout: typing.Optional[FileHandle] = None,
|
|
479
|
+
stderr: typing.Optional[FileHandle] = None,
|
|
480
|
+
cwd: typing.Optional[PathLike] = None,
|
|
481
|
+
env: EnvironmentSelection = _INHERIT_ENV,
|
|
482
|
+
capture_output: typing.Optional[CaptureOutput] = None,
|
|
483
|
+
check: typing.Optional[bool] = None,
|
|
484
|
+
encoding: typing.Optional[str] = None,
|
|
485
|
+
errors: typing.Optional[str] = None,
|
|
486
|
+
input: Input = None,
|
|
487
|
+
timeout: typing.Optional[float] = None,
|
|
488
|
+
text: typing.Optional[bool] = None,
|
|
489
|
+
) -> _Result:
|
|
490
|
+
"""Build one script from all commands and pass it to the executor.
|
|
491
|
+
|
|
492
|
+
`env` merges over the shell's default per key; the default merges
|
|
493
|
+
nothing and so inherits it. Pass `None` to run without the shell's
|
|
494
|
+
configured environment, keeping whatever the host provides itself.
|
|
495
|
+
"""
|
|
496
|
+
# Resolve defaults here rather than in `execute`: the script is
|
|
497
|
+
# rendered before dispatch, so an embedded `cd`/env assignment has to
|
|
498
|
+
# see the shell's defaults too.
|
|
499
|
+
cwd = self._resolve_cwd(cwd)
|
|
500
|
+
env = self._resolve_env(env)
|
|
501
|
+
script = self.flavour.script(
|
|
502
|
+
cmds,
|
|
503
|
+
cwd=None if self._executor_accepts_cwd else cwd,
|
|
504
|
+
env=None if self._executor_accepts_env else env,
|
|
505
|
+
)
|
|
506
|
+
return self.execute(
|
|
507
|
+
script,
|
|
508
|
+
cwd=cwd if self._executor_accepts_cwd else None,
|
|
509
|
+
env=env if self._executor_accepts_env else None,
|
|
510
|
+
stdin=stdin,
|
|
511
|
+
stdout=stdout,
|
|
512
|
+
stderr=stderr,
|
|
513
|
+
capture_output=capture_output,
|
|
514
|
+
check=check,
|
|
515
|
+
encoding=encoding,
|
|
516
|
+
errors=errors,
|
|
517
|
+
input=input,
|
|
518
|
+
timeout=timeout,
|
|
519
|
+
text=text,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
def session(
|
|
523
|
+
self,
|
|
524
|
+
*cmds: ShellToken,
|
|
525
|
+
executable: typing.Optional[str] = None,
|
|
526
|
+
cwd: typing.Optional[PathLike] = None,
|
|
527
|
+
env: EnvironmentSelection = _INHERIT_ENV,
|
|
528
|
+
terminal: TerminalRequest = None,
|
|
529
|
+
encoding: typing.Optional[str] = None,
|
|
530
|
+
errors: typing.Optional[str] = None,
|
|
531
|
+
) -> ShellSession:
|
|
532
|
+
"""Open a persistent process using this shell language.
|
|
533
|
+
|
|
534
|
+
`env` merges over the shell's default per key; the default merges
|
|
535
|
+
nothing and so inherits it. Pass `None` to open the session without
|
|
536
|
+
the shell's configured environment, keeping whatever the host
|
|
537
|
+
provides itself.
|
|
538
|
+
"""
|
|
539
|
+
if self._spawn is None:
|
|
540
|
+
raise NotImplementedError("executor does not provide persistent sessions")
|
|
541
|
+
cwd = self._resolve_cwd(cwd)
|
|
542
|
+
env = self._resolve_env(env)
|
|
543
|
+
session = ShellSession(
|
|
544
|
+
self.flavour,
|
|
545
|
+
self._spawn(
|
|
546
|
+
executable=executable,
|
|
547
|
+
terminal=terminal,
|
|
548
|
+
encoding=self.encoding if encoding is None else encoding,
|
|
549
|
+
errors=self.errors if errors is None else errors,
|
|
550
|
+
),
|
|
551
|
+
)
|
|
552
|
+
# A shell default cwd/env applies to the session too: it is submitted
|
|
553
|
+
# once here so it persists for every later `send` in that shell.
|
|
554
|
+
if cmds or cwd is not None or env:
|
|
555
|
+
session.send(
|
|
556
|
+
*cmds,
|
|
557
|
+
cwd=cwd,
|
|
558
|
+
env=env,
|
|
559
|
+
)
|
|
560
|
+
return session
|
|
561
|
+
|
|
562
|
+
def configure(
|
|
563
|
+
self,
|
|
564
|
+
*,
|
|
565
|
+
cwd: typing.Optional[PathLike] = None,
|
|
566
|
+
env: EnvironmentSelection = _INHERIT_ENV,
|
|
567
|
+
encoding: typing.Optional[str] = None,
|
|
568
|
+
errors: typing.Optional[str] = None,
|
|
569
|
+
) -> "Shell[_Result]":
|
|
570
|
+
"""Return a copy of this shell with additional defaults applied.
|
|
571
|
+
|
|
572
|
+
`env` merges over this shell's default the same way a per-call `env`
|
|
573
|
+
does, so configuring twice layers rather than replaces, and `None`
|
|
574
|
+
produces a copy carrying no environment default at all. The original
|
|
575
|
+
shell is left unchanged, which keeps `host.shell` -- a fresh object
|
|
576
|
+
per access -- safe to configure without surprising another caller.
|
|
577
|
+
"""
|
|
578
|
+
clone = copy.copy(self)
|
|
579
|
+
clone._session = None
|
|
580
|
+
clone.cwd = self.cwd if cwd is None else cwd
|
|
581
|
+
clone.env = self._resolve_env(env)
|
|
582
|
+
clone.encoding = self.encoding if encoding is None else encoding
|
|
583
|
+
clone.errors = self.errors if errors is None else errors
|
|
584
|
+
return clone
|
|
585
|
+
|
|
586
|
+
def __enter__(self) -> ShellSession:
|
|
587
|
+
"""Open a default session, so ``with host.shell as session:`` works.
|
|
588
|
+
|
|
589
|
+
The session is closed on exit. `session(...)` remains the way to pass
|
|
590
|
+
a command, cwd, env, terminal, or encoding; this is the no-argument
|
|
591
|
+
shorthand. A `Shell` is not reusable as a context manager while a
|
|
592
|
+
session it opened is still active -- each `with` opens its own.
|
|
593
|
+
"""
|
|
594
|
+
if self._session is not None:
|
|
595
|
+
raise RuntimeError("shell already has an active session")
|
|
596
|
+
session = self.session()
|
|
597
|
+
try:
|
|
598
|
+
# Enter the session so the underlying process sees a balanced
|
|
599
|
+
# __enter__/__exit__ pair; `session.__exit__` delegates to it.
|
|
600
|
+
session.__enter__()
|
|
601
|
+
except BaseException:
|
|
602
|
+
session.close()
|
|
603
|
+
raise
|
|
604
|
+
self._session = session
|
|
605
|
+
return session
|
|
606
|
+
|
|
607
|
+
def __exit__(
|
|
608
|
+
self,
|
|
609
|
+
exc_type: typing.Optional[typing.Type[BaseException]],
|
|
610
|
+
exc_value: typing.Optional[BaseException],
|
|
611
|
+
traceback: typing.Optional[types.TracebackType],
|
|
612
|
+
) -> bool:
|
|
613
|
+
session, self._session = self._session, None
|
|
614
|
+
if session is None:
|
|
615
|
+
return False
|
|
616
|
+
return session.__exit__(exc_type, exc_value, traceback)
|