hostctl 0.1.2__tar.gz → 0.2.0__tar.gz
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-0.1.2 → hostctl-0.2.0}/CHANGELOG.md +46 -1
- {hostctl-0.1.2 → hostctl-0.2.0}/PKG-INFO +17 -1
- {hostctl-0.1.2 → hostctl-0.2.0}/README.md +16 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/docs/api/reference.md +1 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/docs/guide/run.md +105 -2
- {hostctl-0.1.2 → hostctl-0.2.0}/pyproject.toml +1 -1
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/AGENTS.md +14 -3
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/__init__.py +2 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/_cli.py +5 -8
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/__init__.py +1 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/_common.py +68 -17
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/shell/_common.py +36 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/conformance/test_live.py +3 -1
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/conformance/test_run_contract.py +15 -17
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_cli.py +6 -4
- hostctl-0.2.0/tests/test_exec_command.py +122 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_host_container.py +2 -2
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_system_hosts.py +3 -2
- {hostctl-0.1.2 → hostctl-0.2.0}/.gitignore +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/LICENSE +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/docs/changelog.md +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/docs/guide/cli.md +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/docs/guide/contracts.md +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/docs/guide/extending.md +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/docs/guide/path.md +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/docs/guide/providers.md +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/docs/guide/transfer.md +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/docs/index.md +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/examples/application_provider.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/examples/copy_between_hosts.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/examples/local_run.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/examples/remote_run.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/mkdocs.yml +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/__main__.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/_async.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/__init__.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/_common.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/_qga.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/container.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/local.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/psrp.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/qemu.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/serial.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/ssh.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/winrm.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/_local.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/_ssh.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/_winrm.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/composite_path.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/container.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/container_path.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/qemu.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/serial.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/system.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/process/__init__.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/process/_common.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/process/container.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/process/psrp.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/process/qemu_serial.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/process/serial.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/process/ssh.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/provider/__init__.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/provider/_common.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/provider/transports.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/py.typed +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/serial/__init__.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/shell/__init__.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/shell/cmd.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/shell/fish.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/shell/posix.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/shell/powershell.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/sync.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/conformance/__init__.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/conformance/path_fakes.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/conformance/providers.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/conformance/test_composite_path_routing.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/conformance/test_path_contract.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/conformance/test_process_contract.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/conformance/test_sync_contract.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_application_provider.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_asyncssh_errors.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_composite_path_propagation.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_container_path.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_container_process.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_host_api.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_host_local.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_host_module_layout.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_host_qemu.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_host_remote.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_host_winrm.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_observability.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_process.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_provider_fault_injection.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_psrp.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_qemu_executor.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_qemu_path.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_qemu_serial.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_qemu_serial_process.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_qga_ssh_transport.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_qga_transport.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_serial_executor.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_serial_host.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_serial_live.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_shell.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_ssh_process.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_sync_helpers.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_system_host_fidelity.py +0 -0
- {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_winrm_path.py +0 -0
|
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-07-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `Exec(program, *args)` marks one command for direct execution: the program
|
|
15
|
+
runs with that argv and no shell layer renders. The program and every
|
|
16
|
+
argument may be a `str`, `bytes`, or a path object — all reach the transport
|
|
17
|
+
as text, so the spelling records how the caller held the value rather than
|
|
18
|
+
changing what runs. A **bare program name is now possible**
|
|
19
|
+
(`Exec("ls", "-l")`), resolved through the target's `PATH`; it previously had
|
|
20
|
+
no spelling at all, because a plain string command is always shell text.
|
|
21
|
+
- The full `run()` command syntax is documented in the "Running commands"
|
|
22
|
+
guide: the varargs rule, the three command forms, operators, validation, and
|
|
23
|
+
the PowerShell rendering.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **Breaking.** Direct execution is marked by `Exec`, not by position. A path
|
|
28
|
+
in the first argument used to mean "this is the executable, the rest is
|
|
29
|
+
argv"; a path is now an ordinary value that stringifies wherever it appears.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
host.run(Path("/bin/ls"), "-l") # before: direct execution
|
|
33
|
+
host.run(Exec("/bin/ls", "-l")) # now
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
This is a **silent** change for the old spelling: `run(Path(...), *args)`
|
|
37
|
+
still succeeds, but the values become several `;`-joined shell commands
|
|
38
|
+
instead of one program and its argv, so arguments are subject to shell
|
|
39
|
+
quoting and word-splitting. Anything passing a leading path must be updated;
|
|
40
|
+
there is no deprecation shim.
|
|
41
|
+
|
|
42
|
+
What it buys, both unspellable before: several path commands in one call
|
|
43
|
+
(`run(p1, p2)` is two commands), and direct execution of a `PATH`-resolved
|
|
44
|
+
name.
|
|
45
|
+
|
|
46
|
+
An `Exec` cannot be combined with other commands in one call — there is no
|
|
47
|
+
shell to join them with, and running only one would silently drop the rest.
|
|
48
|
+
It raises `TypeError` rather than choosing.
|
|
49
|
+
- `hostctl run` passes its operand as a single `Exec`, so the program is used
|
|
50
|
+
verbatim. It no longer converts the operand through the target shell's path
|
|
51
|
+
flavour, which means a bare name now resolves through the target's `PATH`
|
|
52
|
+
instead of being treated as a relative path.
|
|
53
|
+
|
|
10
54
|
## [0.1.2] - 2026-07-28
|
|
11
55
|
|
|
12
56
|
Supersedes 0.1.1, which was tagged but never published: a release-gate test
|
|
@@ -186,6 +230,7 @@ test suite on Python 3.9 through 3.14.
|
|
|
186
230
|
assigned to it; a config-less host now builds its own family configuration
|
|
187
231
|
instead.
|
|
188
232
|
|
|
189
|
-
[Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.
|
|
233
|
+
[Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.0...HEAD
|
|
234
|
+
[0.2.0]: https://github.com/jose-pr/hostctl/compare/v0.1.2...v0.2.0
|
|
190
235
|
[0.1.2]: https://github.com/jose-pr/hostctl/compare/v0.1.0...v0.1.2
|
|
191
236
|
[0.1.0]: https://github.com/jose-pr/hostctl/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hostctl
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Run commands and access files on a host, local or remote, protocol-agnostic
|
|
5
5
|
Project-URL: Homepage, https://github.com/jose-pr/hostctl/
|
|
6
6
|
Project-URL: Documentation, https://jose-pr.github.io/hostctl/
|
|
@@ -172,6 +172,22 @@ with Host("ssh://admin:secret@nas.example.com") as same_host:
|
|
|
172
172
|
same_host.run("uptime")
|
|
173
173
|
```
|
|
174
174
|
|
|
175
|
+
`run()` takes one positional argument per command, so a `list` is a single
|
|
176
|
+
command whose elements are each quoted, not a list of commands:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
host.run(["chmod", "755", target]) # one command
|
|
180
|
+
host.run(["chmod", "755", target], ["chown", "u", target]) # two commands
|
|
181
|
+
host.run("chmod", "755", target) # WRONG: three commands
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
A raw string is verbatim shell text, and `Exec(program, *args)` is direct
|
|
185
|
+
execution — one program plus argv, with no shell layer, where the program may
|
|
186
|
+
be an absolute path or a bare name resolved through the target's `PATH`. A
|
|
187
|
+
`ShellOperator` between two commands joins them conditionally. See the
|
|
188
|
+
[running commands guide](https://jose-pr.github.io/hostctl/guide/run/) for the
|
|
189
|
+
full rules.
|
|
190
|
+
|
|
175
191
|
A URI may carry `user:password@host` on the way *in* — it is a valid URI, so
|
|
176
192
|
hostctl accepts it, extracts the password into the credentials, and keeps it
|
|
177
193
|
out of every rendered form. Passing the same password both in the URI and as an
|
|
@@ -118,6 +118,22 @@ with Host("ssh://admin:secret@nas.example.com") as same_host:
|
|
|
118
118
|
same_host.run("uptime")
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
+
`run()` takes one positional argument per command, so a `list` is a single
|
|
122
|
+
command whose elements are each quoted, not a list of commands:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
host.run(["chmod", "755", target]) # one command
|
|
126
|
+
host.run(["chmod", "755", target], ["chown", "u", target]) # two commands
|
|
127
|
+
host.run("chmod", "755", target) # WRONG: three commands
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
A raw string is verbatim shell text, and `Exec(program, *args)` is direct
|
|
131
|
+
execution — one program plus argv, with no shell layer, where the program may
|
|
132
|
+
be an absolute path or a bare name resolved through the target's `PATH`. A
|
|
133
|
+
`ShellOperator` between two commands joins them conditionally. See the
|
|
134
|
+
[running commands guide](https://jose-pr.github.io/hostctl/guide/run/) for the
|
|
135
|
+
full rules.
|
|
136
|
+
|
|
121
137
|
A URI may carry `user:password@host` on the way *in* — it is a valid URI, so
|
|
122
138
|
hostctl accepts it, extracts the password into the credentials, and keeps it
|
|
123
139
|
out of every rendered form. Passing the same password both in the URI and as an
|
|
@@ -12,10 +12,113 @@ result = host.run("echo hello")
|
|
|
12
12
|
print(result.stdout)
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
## Command syntax
|
|
16
|
+
|
|
17
|
+
`run(*cmds, **options)` takes **one positional argument per command** — it is
|
|
18
|
+
varargs, not a list of commands. This is the distinction to get right:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
host.run(["chmod", "755", target]) # ONE command
|
|
22
|
+
host.run(["chmod", "755", target], ["chown", "u", target]) # TWO commands
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
A list is *one* command whose elements are quoted individually. Wrapping
|
|
26
|
+
commands in an outer list does not produce several commands — the inner lists
|
|
27
|
+
are stringified into a single nonsensical argument:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
host.run([["chmod", "755", target]]) # WRONG: one command, mangled
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The mirror mistake is passing an argv as separate positionals, which makes each
|
|
34
|
+
element its own command:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
host.run("chmod", "755", target) # WRONG: runs chmod;755;/path
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### The three forms a command may take
|
|
41
|
+
|
|
42
|
+
| Form | Example | Result |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| Structured `list`/`tuple` | `["chmod", "755", p]` | `chmod 755 '/tmp/a b'` — every element quoted as data |
|
|
45
|
+
| Raw `str` | `"echo $HOME"` | verbatim shell text; globs, pipes, and `$VAR` are interpreted |
|
|
46
|
+
| `Exec(...)` | `Exec("/bin/ls", "-l", p)` | direct execution: one program plus argv, no shell |
|
|
47
|
+
|
|
48
|
+
Prefer the structured form. It quotes each element, so a value containing a
|
|
49
|
+
space, quote, `$`, or `;` is passed as data instead of changing the command.
|
|
50
|
+
Use a raw string only when you want the shell to interpret the text.
|
|
51
|
+
|
|
52
|
+
Elements may be `str`, `bytes`, `int`, or path objects; all are normalized and
|
|
53
|
+
quoted. `["echo", 42, b"by", PurePosixPath("/p q")]` renders as
|
|
54
|
+
`echo 42 by '/p q'`.
|
|
55
|
+
|
|
56
|
+
### Joining and operators
|
|
57
|
+
|
|
58
|
+
Commands are joined with the flavour's separator (`;` on POSIX). Pass a
|
|
59
|
+
`ShellOperator` *between* two commands to join them conditionally instead:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from hostctl import ShellOperator
|
|
63
|
+
|
|
64
|
+
host.run(["test", "-f", target], ShellOperator.AND, ["rm", target])
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`ShellOperator` provides `AND`, `OR`, `PIPE`, `REDIRECT`, `APPEND`, and
|
|
68
|
+
`SEQUENCE`. An operator that is leading, trailing, or adjacent to another
|
|
69
|
+
raises `ValueError`, and a flavour may reject one it cannot represent
|
|
70
|
+
portably — PowerShell 5 rejects `AND`/`OR`, while PowerShell 7 accepts them.
|
|
71
|
+
|
|
72
|
+
### `Exec` is direct execution
|
|
73
|
+
|
|
74
|
+
`Exec(program, *args)` runs one program with an argv, with no shell layer
|
|
75
|
+
rendered — nothing quotes, splits, or interprets the values:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from hostctl import Exec
|
|
79
|
+
|
|
80
|
+
host.run(Exec("/bin/ls", "-l", target)) # absolute path
|
|
81
|
+
host.run(Exec("ls", "-l")) # resolved through the target's PATH
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The program and each argument may be a `str`, `bytes`, or a path object. All
|
|
85
|
+
of them reach the transport as text, so the spelling records how you happened
|
|
86
|
+
to hold the value rather than changing what runs. A bare name is only possible
|
|
87
|
+
here: a plain string command is always shell text.
|
|
88
|
+
|
|
89
|
+
Arguments are argv values, never nested commands — a list or tuple raises
|
|
90
|
+
`TypeError` rather than blurring the two.
|
|
91
|
+
|
|
92
|
+
Direct execution replaces the whole call, so an `Exec` cannot be combined with
|
|
93
|
+
other commands: there is no shell to join them with, and running just one
|
|
94
|
+
would silently drop the rest. Give each its own call, or use structured
|
|
95
|
+
commands if you want them joined.
|
|
96
|
+
|
|
97
|
+
Everything else is an ordinary value. A path is just a value that
|
|
98
|
+
stringifies, wherever it appears:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
host.run(["chmod", "755", Path("/srv/app")]) # a quoted argument
|
|
102
|
+
host.run(Path("/bin/a"), Path("/bin/b")) # two shell commands
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Validation
|
|
106
|
+
|
|
107
|
+
An empty structured command and any control character (including a newline
|
|
108
|
+
inside a value, which could otherwise smuggle in a second command) raise
|
|
109
|
+
`ValueError`. An empty raw string is skipped when joining.
|
|
110
|
+
|
|
111
|
+
### PowerShell targets
|
|
112
|
+
|
|
113
|
+
PowerShell flavours render a structured command through the `&` call operator
|
|
114
|
+
and append `; exit $LASTEXITCODE` so the remote status propagates:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
& 'Get-Item' 'C:/a b'; exit $LASTEXITCODE
|
|
118
|
+
```
|
|
119
|
+
|
|
15
120
|
`run()` returns a `subprocess.CompletedProcess`. Highlights:
|
|
16
121
|
|
|
17
|
-
- Multiple positional commands are joined with `;`. A command given as a
|
|
18
|
-
`list`/`tuple` is shell-quoted piece by piece.
|
|
19
122
|
- `capture_output` may be `True` (both streams), `"stdout"`, `"stderr"`, or
|
|
20
123
|
`False`.
|
|
21
124
|
- SSH `input=`, `cwd=`, `env=`, `check=`, `timeout=`, and
|
|
@@ -57,9 +57,20 @@ shared protocol implementations can be added once; `Shell.__call__` delegates
|
|
|
57
57
|
to `Shell.execute()`.
|
|
58
58
|
`Host` itself supplies the multi-command `run()` provider contract; there is no
|
|
59
59
|
duplicate host-executor protocol or unused host-options wrapper.
|
|
60
|
-
`
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
`Exec(program, *args)` is the direct-execution marker for `Host.run()`: one
|
|
61
|
+
program plus argv, no shell layer. The program may be a `str`, `bytes`, or a
|
|
62
|
+
path — all reach the transport as text, so a bare name resolves through the
|
|
63
|
+
target's `PATH`. Direct execution replaces the whole call, so an `Exec`
|
|
64
|
+
combined with any other command raises. Everything else is an ordinary value:
|
|
65
|
+
a path is a value that stringifies wherever it appears, so `run(p1, p2)` is two
|
|
66
|
+
shell commands. `Exec` is a deliberately non-iterable frozen dataclass --
|
|
67
|
+
`ShellFlavour.command_text` dispatches structured commands on `Iterable`, and
|
|
68
|
+
an iterable marker would be quoted into an argv instead of taking the direct
|
|
69
|
+
branch; `command_text` raises if an `Exec` reaches it.
|
|
70
|
+
`Shell.execute(command, *args)` keeps its own program-plus-argv signature and
|
|
71
|
+
wraps into an `Exec` only when dispatching to a host `run(*cmds)` and only when
|
|
72
|
+
argv values are present -- `Shell.run` renders every command into one script
|
|
73
|
+
and dispatches it with no args, and that script is shell text, not a program.
|
|
63
74
|
|
|
64
75
|
## Configuration and lifecycle
|
|
65
76
|
|
|
@@ -34,6 +34,7 @@ from .executor import (
|
|
|
34
34
|
from .host import (
|
|
35
35
|
ContainerConfig as ContainerConfig,
|
|
36
36
|
ContainerHost as ContainerHost,
|
|
37
|
+
Exec as Exec,
|
|
37
38
|
Host as Host,
|
|
38
39
|
HostConfig as HostConfig,
|
|
39
40
|
HostInfo as HostInfo,
|
|
@@ -146,6 +147,7 @@ __all__ = [
|
|
|
146
147
|
"IosConfig",
|
|
147
148
|
"IosHost",
|
|
148
149
|
# Executors and processes
|
|
150
|
+
"Exec",
|
|
149
151
|
"Executor",
|
|
150
152
|
"ExecutionOptions",
|
|
151
153
|
"ExecutorCapability",
|
|
@@ -8,7 +8,6 @@ import dataclasses
|
|
|
8
8
|
import getpass
|
|
9
9
|
import json
|
|
10
10
|
import os
|
|
11
|
-
from pathlib import PurePath
|
|
12
11
|
import re
|
|
13
12
|
import subprocess
|
|
14
13
|
import sys
|
|
@@ -17,7 +16,7 @@ import typing
|
|
|
17
16
|
|
|
18
17
|
from pathlib_next import Path as NextPath
|
|
19
18
|
|
|
20
|
-
from .host import Host, HostPath
|
|
19
|
+
from .host import Exec, Host, HostPath
|
|
21
20
|
|
|
22
21
|
_URI_OPERAND = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
|
23
22
|
|
|
@@ -81,13 +80,11 @@ def _command_run(args: argparse.Namespace, stdout, stderr) -> int:
|
|
|
81
80
|
if not command:
|
|
82
81
|
raise ValueError("run requires a command after --")
|
|
83
82
|
with Host(args.uri, **_credentials(args)) as host:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
command_path = PurePath(command[0])
|
|
83
|
+
# `Exec` marks direct execution and takes the program verbatim, so the
|
|
84
|
+
# operand needs no path flavour: a bare name resolves through the
|
|
85
|
+
# target's PATH and an absolute path is used as given.
|
|
88
86
|
result = host.run(
|
|
89
|
-
|
|
90
|
-
*command[1:],
|
|
87
|
+
Exec(command[0], *command[1:]),
|
|
91
88
|
check=False,
|
|
92
89
|
capture_output=True,
|
|
93
90
|
)
|
|
@@ -35,30 +35,81 @@ if _ty.TYPE_CHECKING:
|
|
|
35
35
|
from ..process import Process, TerminalRequest
|
|
36
36
|
from ..shell import Shell, ShellFlavour
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
#: One argv value. Everything reaching a transport is text, so a program or
|
|
39
|
+
#: argument may be spelled as a string, bytes, or a path object
|
|
40
|
+
#: interchangeably -- the spelling records how the caller held the value, not
|
|
41
|
+
#: what crosses the wire.
|
|
42
|
+
ExecValue = _ty.Union[str, bytes, _PurePath, _Pathname]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@_dc.dataclass(frozen=True)
|
|
46
|
+
class Exec:
|
|
47
|
+
"""One command executed directly, with no shell layer.
|
|
48
|
+
|
|
49
|
+
``Exec(program, *args)`` names a program and its argv. The program may be
|
|
50
|
+
an absolute path or a bare name the target resolves through ``PATH``; a
|
|
51
|
+
bare name has no other spelling, since a plain string is always shell text.
|
|
52
|
+
|
|
53
|
+
This is the explicit marker for direct execution. A path used anywhere
|
|
54
|
+
else is an ordinary value that stringifies, so several commands can be
|
|
55
|
+
written without one of them silently becoming an executable::
|
|
56
|
+
|
|
57
|
+
host.run(Exec("/bin/ls", "-l")) # one direct command
|
|
58
|
+
host.run(Exec("ls", "-l")) # PATH-resolved, no shell
|
|
59
|
+
host.run(Exec("/bin/a"), Exec("/bin/b")) # two direct commands
|
|
60
|
+
host.run(PurePosixPath("/bin/a"), PurePosixPath("/bin/b"))
|
|
61
|
+
# two ordinary shell commands
|
|
62
|
+
|
|
63
|
+
Arguments are argv values, never nested commands: a list or tuple raises
|
|
64
|
+
``TypeError`` rather than blurring argv and shell semantics.
|
|
65
|
+
|
|
66
|
+
A deliberately non-iterable container. `ShellFlavour.command_text`
|
|
67
|
+
dispatches structured commands on ``Iterable``, so an iterable marker
|
|
68
|
+
would be quoted into an argv string instead of taking the direct branch.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
program: ExecValue
|
|
72
|
+
args: _ty.Tuple[ExecValue, ...]
|
|
73
|
+
|
|
74
|
+
def __init__(self, program: ExecValue, *args: ExecValue) -> None:
|
|
75
|
+
if not isinstance(program, (str, bytes, _PurePath, _Pathname)):
|
|
76
|
+
raise TypeError("Exec program must be a str, bytes, or path value")
|
|
77
|
+
for value in args:
|
|
78
|
+
if isinstance(value, (tuple, list)):
|
|
79
|
+
raise TypeError("direct command arguments must be scalar values")
|
|
80
|
+
if not isinstance(value, (str, bytes, _PurePath, _Pathname)):
|
|
81
|
+
raise TypeError(
|
|
82
|
+
"direct command arguments must be str, bytes, or path values"
|
|
83
|
+
)
|
|
84
|
+
object.__setattr__(self, "program", program)
|
|
85
|
+
object.__setattr__(self, "args", tuple(args))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
Command = _ty.Union[str, PathLike, Exec, _ty.Sequence[object]]
|
|
39
89
|
|
|
40
90
|
|
|
41
91
|
def starts_direct_command(
|
|
42
92
|
cmds: _ty.Sequence[Command],
|
|
43
|
-
) -> _ty.Optional[_ty.Tuple[
|
|
44
|
-
"""Split
|
|
93
|
+
) -> _ty.Optional[_ty.Tuple[ExecValue, _ty.Tuple[object, ...]]]:
|
|
94
|
+
"""Split an :class:`Exec` call into one executable and its argv arguments.
|
|
45
95
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
96
|
+
Returns ``None`` unless the call is exactly one `Exec`. Direct execution
|
|
97
|
+
replaces the whole command list rather than joining with anything, so an
|
|
98
|
+
`Exec` alongside other commands is rejected: there is no shell to join
|
|
99
|
+
them with, and silently running only one would lose the rest.
|
|
49
100
|
"""
|
|
50
|
-
if not cmds
|
|
101
|
+
if not cmds:
|
|
51
102
|
return None
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return command,
|
|
103
|
+
marked = [value for value in cmds if isinstance(value, Exec)]
|
|
104
|
+
if not marked:
|
|
105
|
+
return None
|
|
106
|
+
if len(cmds) > 1:
|
|
107
|
+
raise TypeError(
|
|
108
|
+
"a direct Exec command cannot be combined with other commands; "
|
|
109
|
+
"run it in its own call"
|
|
110
|
+
)
|
|
111
|
+
command = marked[0]
|
|
112
|
+
return command.program, command.args
|
|
62
113
|
|
|
63
114
|
|
|
64
115
|
@_dc.dataclass(frozen=True)
|
|
@@ -24,6 +24,7 @@ from ..host._common import (
|
|
|
24
24
|
CaptureOutput,
|
|
25
25
|
Command,
|
|
26
26
|
Environment,
|
|
27
|
+
Exec,
|
|
27
28
|
FileHandle,
|
|
28
29
|
Input,
|
|
29
30
|
PathLike,
|
|
@@ -197,6 +198,15 @@ class ShellFlavour(abc.ABC):
|
|
|
197
198
|
return self.command_separator.join(assignments)
|
|
198
199
|
|
|
199
200
|
def command_text(self, value: Command) -> str:
|
|
201
|
+
if isinstance(value, Exec):
|
|
202
|
+
# Direct execution bypasses the shell entirely, so an `Exec` that
|
|
203
|
+
# reaches here means a transport did not take its direct branch.
|
|
204
|
+
# Rendering it as a quoted argv would silently reintroduce the
|
|
205
|
+
# shell layer the caller asked to skip.
|
|
206
|
+
raise TypeError(
|
|
207
|
+
"Exec is executed directly and cannot be rendered into a shell "
|
|
208
|
+
"script; this host does not support direct execution"
|
|
209
|
+
)
|
|
200
210
|
if isinstance(value, (bytes, PurePath, Path, os.PathLike)):
|
|
201
211
|
return self.quote(value)
|
|
202
212
|
if isinstance(value, str):
|
|
@@ -340,6 +350,24 @@ class Shell(Executor[_Result], typing.Generic[_Result]):
|
|
|
340
350
|
item.kind is inspect.Parameter.VAR_KEYWORD
|
|
341
351
|
for item in self._executor_parameters.values()
|
|
342
352
|
)
|
|
353
|
+
# A host `run(*cmds)` takes several *commands*; an executor
|
|
354
|
+
# `__call__(command, *args)` takes one command and its argv. Only the
|
|
355
|
+
# former needs the program and argv bundled into one `Exec`, since
|
|
356
|
+
# otherwise each argument would become a separate command. The shapes
|
|
357
|
+
# differ in their first parameter: VAR_POSITIONAL vs a named one.
|
|
358
|
+
positional = [
|
|
359
|
+
item
|
|
360
|
+
for item in self._executor_parameters.values()
|
|
361
|
+
if item.kind
|
|
362
|
+
in (
|
|
363
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
364
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
365
|
+
inspect.Parameter.VAR_POSITIONAL,
|
|
366
|
+
)
|
|
367
|
+
]
|
|
368
|
+
self._execute_takes_commands = bool(positional) and (
|
|
369
|
+
positional[0].kind is inspect.Parameter.VAR_POSITIONAL
|
|
370
|
+
)
|
|
343
371
|
published = getattr(executor, "executor_capabilities", None)
|
|
344
372
|
if published is None:
|
|
345
373
|
inferred = set()
|
|
@@ -467,6 +495,14 @@ class Shell(Executor[_Result], typing.Generic[_Result]):
|
|
|
467
495
|
]
|
|
468
496
|
if unsupported:
|
|
469
497
|
raise TypeError(f"executor does not accept {sorted(unsupported)[0]}")
|
|
498
|
+
if executor_args and self._execute_takes_commands:
|
|
499
|
+
# A host `run(*cmds)` treats several positionals as several
|
|
500
|
+
# commands, so a program and its argv must arrive as one `Exec`
|
|
501
|
+
# rather than relying on position to mark direct execution.
|
|
502
|
+
# Only when there *are* argv values: `Shell.run` renders every
|
|
503
|
+
# command into one script and dispatches it here with no args, and
|
|
504
|
+
# that script is shell text to interpret, not a program to exec.
|
|
505
|
+
return self._execute(Exec(command, *executor_args), **options)
|
|
470
506
|
return self._execute(command, *executor_args, **options)
|
|
471
507
|
|
|
472
508
|
__call__ = execute
|
|
@@ -13,6 +13,8 @@ from pathlib import Path
|
|
|
13
13
|
|
|
14
14
|
import pytest
|
|
15
15
|
|
|
16
|
+
from hostctl import Exec
|
|
17
|
+
|
|
16
18
|
from .providers import live_providers, provider_context
|
|
17
19
|
|
|
18
20
|
|
|
@@ -22,7 +24,7 @@ def test_live_provider_direct_command(provider):
|
|
|
22
24
|
pytest.skip(f"{provider.name} does not advertise run")
|
|
23
25
|
with provider_context(provider) as host:
|
|
24
26
|
if provider.name == "local":
|
|
25
|
-
result = host.run(
|
|
27
|
+
result = host.run(Exec(sys.executable, "-c", "print('live')"))
|
|
26
28
|
else:
|
|
27
29
|
command = (
|
|
28
30
|
"Write-Output live"
|
|
@@ -10,6 +10,8 @@ from pathlib import Path
|
|
|
10
10
|
|
|
11
11
|
import pytest
|
|
12
12
|
|
|
13
|
+
from hostctl import Exec
|
|
14
|
+
|
|
13
15
|
from .providers import fake_providers, live_providers, provider_context
|
|
14
16
|
|
|
15
17
|
|
|
@@ -24,10 +26,12 @@ def test_direct_argv_and_capture(provider):
|
|
|
24
26
|
pytest.skip(f"{provider.name} has no run capability")
|
|
25
27
|
with provider_context(provider) as host:
|
|
26
28
|
result = host.run(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
Exec(
|
|
30
|
+
sys.executable,
|
|
31
|
+
"-c",
|
|
32
|
+
"import sys; print(sys.argv[1]); print('err', file=sys.stderr)",
|
|
33
|
+
"a & b",
|
|
34
|
+
)
|
|
31
35
|
)
|
|
32
36
|
assert result.returncode == 0
|
|
33
37
|
assert result.stdout == b"a & b\r\n" if os.name == "nt" else b"a & b\n"
|
|
@@ -48,19 +52,17 @@ def test_text_env_and_nonzero_check(provider):
|
|
|
48
52
|
environment["SystemRoot"] = os.environ["SystemRoot"]
|
|
49
53
|
with provider_context(provider) as host:
|
|
50
54
|
result = host.run(
|
|
51
|
-
|
|
52
|
-
"-c",
|
|
53
|
-
code,
|
|
55
|
+
Exec(sys.executable, "-c", code),
|
|
54
56
|
env=environment,
|
|
55
57
|
text=True,
|
|
56
58
|
)
|
|
57
59
|
assert result.stdout.splitlines()[0] == "42"
|
|
58
60
|
failed = host.run(
|
|
59
|
-
|
|
61
|
+
Exec(sys.executable, "-c", "raise SystemExit(3)"), check=False
|
|
60
62
|
)
|
|
61
63
|
assert failed.returncode == 3
|
|
62
64
|
with pytest.raises(subprocess.CalledProcessError):
|
|
63
|
-
host.run(
|
|
65
|
+
host.run(Exec(sys.executable, "-c", "raise SystemExit(4)"))
|
|
64
66
|
|
|
65
67
|
|
|
66
68
|
@pytest.mark.parametrize("provider", fake_providers(), ids=lambda p: p.name)
|
|
@@ -71,9 +73,7 @@ def test_cwd_is_applied_only_when_supported(provider, tmp_path):
|
|
|
71
73
|
pytest.skip(f"{provider.name} does not support cwd")
|
|
72
74
|
with provider_context(provider) as host:
|
|
73
75
|
result = host.run(
|
|
74
|
-
|
|
75
|
-
"-c",
|
|
76
|
-
"import pathlib; print(pathlib.Path.cwd())",
|
|
76
|
+
Exec(sys.executable, "-c", "import pathlib; print(pathlib.Path.cwd())"),
|
|
77
77
|
cwd=tmp_path,
|
|
78
78
|
text=True,
|
|
79
79
|
)
|
|
@@ -85,7 +85,7 @@ def test_silent_capture_is_empty_bytes(provider):
|
|
|
85
85
|
if "run" not in provider.capabilities:
|
|
86
86
|
pytest.skip(f"{provider.name} has no run capability")
|
|
87
87
|
with provider_context(provider) as host:
|
|
88
|
-
result = host.run(
|
|
88
|
+
result = host.run(Exec(sys.executable, "-c", "pass"))
|
|
89
89
|
assert result.stdout == b""
|
|
90
90
|
assert result.stderr == b""
|
|
91
91
|
|
|
@@ -118,14 +118,12 @@ def test_timeout_and_input_are_subprocess_compatible(provider):
|
|
|
118
118
|
pytest.skip(f"{provider.name} does not support {', '.join(sorted(missing))}")
|
|
119
119
|
with provider_context(provider) as host:
|
|
120
120
|
result = host.run(
|
|
121
|
-
|
|
122
|
-
"-c",
|
|
123
|
-
"import sys; print(sys.stdin.read())",
|
|
121
|
+
Exec(sys.executable, "-c", "import sys; print(sys.stdin.read())"),
|
|
124
122
|
input="payload",
|
|
125
123
|
text=True,
|
|
126
124
|
)
|
|
127
125
|
assert result.stdout.strip() == "payload"
|
|
128
126
|
with pytest.raises(subprocess.TimeoutExpired):
|
|
129
127
|
host.run(
|
|
130
|
-
|
|
128
|
+
Exec(sys.executable, "-c", "import time; time.sleep(2)"), timeout=0.01
|
|
131
129
|
)
|
|
@@ -2,10 +2,9 @@ import io
|
|
|
2
2
|
import json
|
|
3
3
|
import subprocess
|
|
4
4
|
import types
|
|
5
|
-
from pathlib import PurePosixPath
|
|
6
|
-
|
|
7
5
|
import pytest
|
|
8
6
|
|
|
7
|
+
from hostctl import Exec
|
|
9
8
|
from hostctl._cli import _parser, main
|
|
10
9
|
|
|
11
10
|
|
|
@@ -36,7 +35,7 @@ def test_run_propagates_status_and_streams():
|
|
|
36
35
|
assert stderr.getvalue().strip() == b"err"
|
|
37
36
|
|
|
38
37
|
|
|
39
|
-
def
|
|
38
|
+
def test_run_marks_the_operand_as_one_direct_command(monkeypatch):
|
|
40
39
|
seen = []
|
|
41
40
|
|
|
42
41
|
class _Host:
|
|
@@ -56,8 +55,11 @@ def test_run_marks_direct_command_with_target_shell_path_flavour(monkeypatch):
|
|
|
56
55
|
|
|
57
56
|
monkeypatch.setattr("hostctl._cli.Host", lambda *args, **kwargs: _Host())
|
|
58
57
|
|
|
58
|
+
# One `Exec`, not a leading path plus positionals: the operand is a single
|
|
59
|
+
# direct command, and the program is passed verbatim so a bare name
|
|
60
|
+
# resolves through the target's PATH.
|
|
59
61
|
assert main(["run", "ssh://host", "--", "/bin/sh", "-c", "true"]) == 0
|
|
60
|
-
assert seen == [(
|
|
62
|
+
assert seen == [(Exec("/bin/sh", "-c", "true"),)]
|
|
61
63
|
|
|
62
64
|
|
|
63
65
|
def test_cat_ls_cp_and_info(tmp_path):
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""`Exec` marks direct execution explicitly, rather than by position."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import PurePosixPath
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from pathlib_next import PosixPathname
|
|
11
|
+
|
|
12
|
+
from hostctl import Exec, LocalHost, POSIX_SHELL, POWERSHELL
|
|
13
|
+
from hostctl.host._common import starts_direct_command
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_exec_accepts_str_bytes_and_path_programs():
|
|
17
|
+
# Everything reaching a transport is text, so how the caller held the
|
|
18
|
+
# value is not meant to change what runs.
|
|
19
|
+
for program in (
|
|
20
|
+
"/bin/ls",
|
|
21
|
+
b"/bin/ls",
|
|
22
|
+
PurePosixPath("/bin/ls"),
|
|
23
|
+
PosixPathname("/bin/ls"),
|
|
24
|
+
):
|
|
25
|
+
command = Exec(program, "-l")
|
|
26
|
+
assert command.program == program
|
|
27
|
+
assert command.args == ("-l",)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_exec_is_not_iterable_so_a_shell_cannot_quote_it_as_argv():
|
|
31
|
+
# `ShellFlavour.command_text` dispatches structured commands on Iterable.
|
|
32
|
+
# An iterable marker would be silently rendered into a quoted argv,
|
|
33
|
+
# reintroducing the shell layer the caller asked to skip.
|
|
34
|
+
import collections.abc
|
|
35
|
+
|
|
36
|
+
assert not isinstance(Exec("ls"), collections.abc.Iterable)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_exec_reaching_a_shell_script_raises_rather_than_being_rendered():
|
|
40
|
+
with pytest.raises(TypeError, match="executed directly"):
|
|
41
|
+
POSIX_SHELL.script([Exec("ls", "-l")])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_exec_rejects_nested_commands_and_non_scalar_values():
|
|
45
|
+
with pytest.raises(TypeError, match="scalar values"):
|
|
46
|
+
Exec("ls", ["nested"])
|
|
47
|
+
with pytest.raises(TypeError, match="program must be"):
|
|
48
|
+
Exec(["ls"])
|
|
49
|
+
with pytest.raises(TypeError, match="program must be"):
|
|
50
|
+
Exec(42)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_exec_dispatches_as_one_direct_command():
|
|
54
|
+
assert starts_direct_command([Exec("/bin/ls", "-l")]) == ("/bin/ls", ("-l",))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_exec_cannot_be_combined_with_other_commands():
|
|
58
|
+
# There is no shell to join them with, and running only one would
|
|
59
|
+
# silently drop the rest.
|
|
60
|
+
with pytest.raises(TypeError, match="cannot be combined"):
|
|
61
|
+
starts_direct_command([Exec("/bin/a"), Exec("/bin/b")])
|
|
62
|
+
with pytest.raises(TypeError, match="cannot be combined"):
|
|
63
|
+
starts_direct_command([Exec("/bin/a"), "echo hi"])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_a_bare_path_is_an_ordinary_value_not_an_executable():
|
|
67
|
+
# The change this makes possible: several paths are several commands,
|
|
68
|
+
# because no position is special any more.
|
|
69
|
+
assert (
|
|
70
|
+
starts_direct_command([PurePosixPath("/bin/a"), PurePosixPath("/bin/b")])
|
|
71
|
+
is None
|
|
72
|
+
)
|
|
73
|
+
assert (
|
|
74
|
+
POSIX_SHELL.script([PurePosixPath("/bin/a"), PurePosixPath("/bin/b")])
|
|
75
|
+
== "/bin/a;/bin/b"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_a_path_inside_a_structured_command_stays_a_quoted_argument():
|
|
80
|
+
assert (
|
|
81
|
+
POSIX_SHELL.script([["chmod", "755", PurePosixPath("/srv/a b")]])
|
|
82
|
+
== "chmod 755 '/srv/a b'"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_local_exec_runs_the_program_without_a_shell():
|
|
87
|
+
result = LocalHost().run(
|
|
88
|
+
Exec(sys.executable, "-c", "import sys; print(sys.argv[1])", "a & b"),
|
|
89
|
+
text=True,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# `a & b` survives verbatim: no shell interpreted the `&`.
|
|
93
|
+
assert result.stdout.strip() == "a & b"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_local_exec_resolves_a_bare_program_name_through_path():
|
|
97
|
+
# Previously unspellable: a bare string is always shell text, so direct
|
|
98
|
+
# execution required knowing an absolute path.
|
|
99
|
+
name = "cmd.exe" if sys.platform == "win32" else "echo"
|
|
100
|
+
args = ("/c", "echo", "hi") if sys.platform == "win32" else ("hi",)
|
|
101
|
+
|
|
102
|
+
result = LocalHost().run(Exec(name, *args), text=True)
|
|
103
|
+
|
|
104
|
+
assert result.stdout.strip() == "hi"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_local_exec_reports_status_like_subprocess():
|
|
108
|
+
failed = LocalHost().run(
|
|
109
|
+
Exec(sys.executable, "-c", "raise SystemExit(3)"), check=False
|
|
110
|
+
)
|
|
111
|
+
assert failed.returncode == 3
|
|
112
|
+
|
|
113
|
+
with pytest.raises(subprocess.CalledProcessError):
|
|
114
|
+
LocalHost().run(Exec(sys.executable, "-c", "raise SystemExit(4)"))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_powershell_structured_command_still_renders_through_the_call_operator():
|
|
118
|
+
# `Exec` changes direct execution only; structured commands are untouched.
|
|
119
|
+
assert (
|
|
120
|
+
POWERSHELL.script([["Get-Item", "C:/a b"]])
|
|
121
|
+
== "& 'Get-Item' 'C:/a b'; exit $LASTEXITCODE"
|
|
122
|
+
)
|
|
@@ -8,6 +8,7 @@ from pathlib import PurePosixPath
|
|
|
8
8
|
|
|
9
9
|
import pytest
|
|
10
10
|
|
|
11
|
+
from hostctl import Exec
|
|
11
12
|
from hostctl.host._common import HostConfig
|
|
12
13
|
from hostctl.executor.container import normalize_container_error
|
|
13
14
|
from hostctl.host.container import ContainerConfig, ContainerHost
|
|
@@ -124,8 +125,7 @@ def test_container_auto_shell_uses_inspected_os(os_name, flavour):
|
|
|
124
125
|
def test_container_direct_argv_preserves_arguments_and_context():
|
|
125
126
|
host, _, container = _host(user="1000", workdir="/default")
|
|
126
127
|
result = host.run(
|
|
127
|
-
PurePosixPath("/opt/my tool"),
|
|
128
|
-
"value with spaces",
|
|
128
|
+
Exec(PurePosixPath("/opt/my tool"), "value with spaces"),
|
|
129
129
|
cwd="/override",
|
|
130
130
|
env={"COUNT": 3},
|
|
131
131
|
text=True,
|
|
@@ -5,6 +5,7 @@ import pytest
|
|
|
5
5
|
from pathlib_next.mempath import MemPath, MemPathBackend
|
|
6
6
|
|
|
7
7
|
from hostctl import (
|
|
8
|
+
Exec,
|
|
8
9
|
HostConfig,
|
|
9
10
|
OperationNotStarted,
|
|
10
11
|
PathProvider,
|
|
@@ -168,7 +169,7 @@ def test_direct_path_command_without_argv_capability_renders_one_quoted_command(
|
|
|
168
169
|
return subprocess.CompletedProcess((command, *args), 0, b"ok", b"")
|
|
169
170
|
|
|
170
171
|
host = PosixHost(executor_providers=(ExecutorProvider("shell", execute),))
|
|
171
|
-
host.run(
|
|
172
|
+
host.run(Exec("printf", "a & b"), check=False)
|
|
172
173
|
|
|
173
174
|
assert len(calls) == 1
|
|
174
175
|
rendered, args = calls[0]
|
|
@@ -191,7 +192,7 @@ def test_executor_fallback_replans_capabilities_before_retrying():
|
|
|
191
192
|
first = ExecutorProvider("first", declined, capabilities=("args", "cwd"))
|
|
192
193
|
second = ExecutorProvider("second", selected)
|
|
193
194
|
host = PosixHost(executor_providers=(first, second))
|
|
194
|
-
host.run(
|
|
195
|
+
host.run(Exec("printf", "a & b"), cwd="/tmp", check=False)
|
|
195
196
|
|
|
196
197
|
assert calls[0][0] == "first"
|
|
197
198
|
assert calls[1][0] == "second"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|