hostctl 0.1.0__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.0 → hostctl-0.2.0}/CHANGELOG.md +89 -1
- {hostctl-0.1.0 → hostctl-0.2.0}/PKG-INFO +17 -1
- {hostctl-0.1.0 → hostctl-0.2.0}/README.md +16 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/docs/api/reference.md +2 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/docs/guide/run.md +105 -2
- {hostctl-0.1.0 → hostctl-0.2.0}/docs/guide/transfer.md +4 -7
- {hostctl-0.1.0 → hostctl-0.2.0}/pyproject.toml +1 -1
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/AGENTS.md +14 -3
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/__init__.py +4 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/_cli.py +5 -8
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/host/__init__.py +2 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/host/_common.py +97 -18
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/host/_ssh.py +2 -1
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/host/_winrm.py +2 -1
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/host/qemu.py +2 -1
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/shell/_common.py +37 -1
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/conformance/test_live.py +3 -1
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/conformance/test_process_contract.py +7 -1
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/conformance/test_run_contract.py +15 -17
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_cli.py +6 -4
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_composite_path_propagation.py +1 -1
- hostctl-0.2.0/tests/test_exec_command.py +122 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_host_api.py +39 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_host_container.py +2 -2
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_system_hosts.py +3 -2
- {hostctl-0.1.0 → hostctl-0.2.0}/.gitignore +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/LICENSE +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/docs/changelog.md +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/docs/guide/cli.md +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/docs/guide/contracts.md +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/docs/guide/extending.md +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/docs/guide/path.md +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/docs/guide/providers.md +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/docs/index.md +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/examples/application_provider.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/examples/copy_between_hosts.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/examples/local_run.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/examples/remote_run.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/mkdocs.yml +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/__main__.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/_async.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/executor/__init__.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/executor/_common.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/executor/_qga.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/executor/container.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/executor/local.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/executor/psrp.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/executor/qemu.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/executor/serial.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/executor/ssh.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/executor/winrm.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/host/_local.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/host/composite_path.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/host/container.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/host/container_path.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/host/serial.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/host/system.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/process/__init__.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/process/_common.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/process/container.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/process/psrp.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/process/qemu_serial.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/process/serial.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/process/ssh.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/provider/__init__.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/provider/_common.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/provider/transports.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/py.typed +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/serial/__init__.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/shell/__init__.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/shell/cmd.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/shell/fish.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/shell/posix.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/shell/powershell.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/src/hostctl/sync.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/conformance/__init__.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/conformance/path_fakes.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/conformance/providers.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/conformance/test_composite_path_routing.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/conformance/test_path_contract.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/conformance/test_sync_contract.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_application_provider.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_asyncssh_errors.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_container_path.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_container_process.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_host_local.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_host_module_layout.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_host_qemu.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_host_remote.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_host_winrm.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_observability.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_process.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_provider_fault_injection.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_psrp.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_qemu_executor.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_qemu_path.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_qemu_serial.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_qemu_serial_process.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_qga_ssh_transport.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_qga_transport.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_serial_executor.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_serial_host.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_serial_live.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_shell.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_ssh_process.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_sync_helpers.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_system_host_fidelity.py +0 -0
- {hostctl-0.1.0 → hostctl-0.2.0}/tests/test_winrm_path.py +0 -0
|
@@ -7,6 +7,92 @@ 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
|
+
|
|
54
|
+
## [0.1.2] - 2026-07-28
|
|
55
|
+
|
|
56
|
+
Supersedes 0.1.1, which was tagged but never published: a release-gate test
|
|
57
|
+
timed out on the Windows/Python 3.14 runner, so nothing reached PyPI. There is
|
|
58
|
+
no 0.1.1 release; its fix is included here.
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- `uri_hostname(parsed)` returns the host as it was written in a URI, rather
|
|
63
|
+
than the case-folded spelling `urlsplit().hostname` produces. Use it in
|
|
64
|
+
`_from_parsed_uri` wherever a host is stored; presence checks can keep using
|
|
65
|
+
`.hostname`, since emptiness does not depend on case.
|
|
66
|
+
|
|
67
|
+
### Changed
|
|
68
|
+
|
|
69
|
+
- A config built from a URI now stores the hostname as typed, so
|
|
70
|
+
`HostConfig("ssh://nasA")` keeps `nasA` in `.host`, in `connection_uri`, and
|
|
71
|
+
in the `HostInfo.hostname` a system host reports without connecting. It
|
|
72
|
+
previously stored `urlsplit`'s lowercased form, which meant the library
|
|
73
|
+
echoed a spelling the operator never wrote and left downstream code to
|
|
74
|
+
recover the original from the URI itself.
|
|
75
|
+
|
|
76
|
+
Consequently `.host` is the spelling that was given, not a canonical form:
|
|
77
|
+
`HostConfig("ssh://nasA")` and `HostConfig("ssh://nasa")` no longer compare
|
|
78
|
+
equal, so code using a config or its host as a dict key or for
|
|
79
|
+
deduplication should casefold explicitly. Resolution is unaffected — DNS,
|
|
80
|
+
SSH, and WinRM all treat the two spellings as one name.
|
|
81
|
+
|
|
82
|
+
### Fixed
|
|
83
|
+
|
|
84
|
+
- `redact_uri()` no longer case-folds the hostname. Only the branch that
|
|
85
|
+
rebuilt the authority — the one taken when a password was present — adopted
|
|
86
|
+
`urlsplit`'s lowercased `hostname`, so `nasA` rendered as `nasa` with a
|
|
87
|
+
credential and as `nasA` without one. The same host now renders one way
|
|
88
|
+
whichever branch runs, and log records stay greppable by the name the
|
|
89
|
+
operator typed. Redaction removes a credential; normalizing a host is a
|
|
90
|
+
separate concern and is left to the transport that resolves it.
|
|
91
|
+
- The spawn conformance test no longer fails on a slow runner. It waited 10s
|
|
92
|
+
for a real `powershell.exe -NoProfile` to start, exit, and be reaped, which
|
|
93
|
+
a cold Windows CI runner can exceed. The wait is a hang guard rather than a
|
|
94
|
+
performance assertion, so it is now 60s. Test-only; no library change.
|
|
95
|
+
|
|
10
96
|
## [0.1.0] - 2026-07-27
|
|
11
97
|
|
|
12
98
|
First release. Alpha: the public surface is deliberately small (66 exported
|
|
@@ -144,5 +230,7 @@ test suite on Python 3.9 through 3.14.
|
|
|
144
230
|
assigned to it; a config-less host now builds its own family configuration
|
|
145
231
|
instead.
|
|
146
232
|
|
|
147
|
-
[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
|
|
235
|
+
[0.1.2]: https://github.com/jose-pr/hostctl/compare/v0.1.0...v0.1.2
|
|
148
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
|
|
@@ -39,6 +39,7 @@ without a deprecation cycle.
|
|
|
39
39
|
|
|
40
40
|
## Executors and processes
|
|
41
41
|
|
|
42
|
+
::: hostctl.Exec
|
|
42
43
|
::: hostctl.Executor
|
|
43
44
|
::: hostctl.ExecutionOptions
|
|
44
45
|
::: hostctl.LocalExecutor
|
|
@@ -84,6 +85,7 @@ without a deprecation cycle.
|
|
|
84
85
|
::: hostctl.strict_uri_credentials
|
|
85
86
|
::: hostctl.strict_uri_query
|
|
86
87
|
::: hostctl.uri_host
|
|
88
|
+
::: hostctl.uri_hostname
|
|
87
89
|
|
|
88
90
|
## Composing transports
|
|
89
91
|
|
|
@@ -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
|
|
@@ -116,11 +116,8 @@ with ProgressReader(source_path.open("rb"), report, total=size) as reader:
|
|
|
116
116
|
though their read sides stream.
|
|
117
117
|
|
|
118
118
|
Three gaps are tracked as `pathlib_next` requests rather than hostctl features,
|
|
119
|
-
because each one lives inside machinery hostctl deliberately does not fork
|
|
120
|
-
Drafts are kept in the repository under `.agents/upstream/`:
|
|
119
|
+
because each one lives inside machinery hostctl deliberately does not fork:
|
|
121
120
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
| Symlink handling in `PathSyncer` (plus its unreachable `ignore_error`) | `pathlib_next-symlink-sync.md` |
|
|
126
|
-
| Optional backend-native checksum protocol | `pathlib_next-backend-checksum-protocol.md` |
|
|
121
|
+
- Byte-progress hook in `BinaryOpen.copy` / `Path.copy()`
|
|
122
|
+
- Symlink handling in `PathSyncer` (plus its unreachable `ignore_error`)
|
|
123
|
+
- Optional backend-native checksum protocol
|
|
@@ -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,
|
|
@@ -46,6 +47,7 @@ from .host import (
|
|
|
46
47
|
strict_uri_credentials as strict_uri_credentials,
|
|
47
48
|
strict_uri_query as strict_uri_query,
|
|
48
49
|
uri_host as uri_host,
|
|
50
|
+
uri_hostname as uri_hostname,
|
|
49
51
|
winrm_providers as winrm_providers,
|
|
50
52
|
QemuConfig as QemuConfig,
|
|
51
53
|
QemuHost as QemuHost,
|
|
@@ -125,6 +127,7 @@ __all__ = [
|
|
|
125
127
|
"strict_uri_credentials",
|
|
126
128
|
"strict_uri_query",
|
|
127
129
|
"uri_host",
|
|
130
|
+
"uri_hostname",
|
|
128
131
|
"winrm_providers",
|
|
129
132
|
"SshConfig",
|
|
130
133
|
"WinRMConfig",
|
|
@@ -144,6 +147,7 @@ __all__ = [
|
|
|
144
147
|
"IosConfig",
|
|
145
148
|
"IosHost",
|
|
146
149
|
# Executors and processes
|
|
150
|
+
"Exec",
|
|
147
151
|
"Executor",
|
|
148
152
|
"ExecutionOptions",
|
|
149
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
|
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Host contracts and built-in host implementations."""
|
|
2
2
|
|
|
3
3
|
from ._common import (
|
|
4
|
+
Exec as Exec,
|
|
4
5
|
Host as Host,
|
|
5
6
|
HostConfig as HostConfig,
|
|
6
7
|
HostInfo as HostInfo,
|
|
@@ -10,6 +11,7 @@ from ._common import (
|
|
|
10
11
|
strict_uri_credentials as strict_uri_credentials,
|
|
11
12
|
strict_uri_query as strict_uri_query,
|
|
12
13
|
uri_host as uri_host,
|
|
14
|
+
uri_hostname as uri_hostname,
|
|
13
15
|
)
|
|
14
16
|
from ._ssh import ssh_providers as ssh_providers
|
|
15
17
|
from ._winrm import winrm_providers as winrm_providers
|
|
@@ -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)
|
|
@@ -160,9 +211,37 @@ def _reject_authority_control_characters(parsed: _SplitResult) -> None:
|
|
|
160
211
|
)
|
|
161
212
|
|
|
162
213
|
|
|
214
|
+
def uri_hostname(parsed: _SplitResult) -> str:
|
|
215
|
+
"""The host as it was written, not as `urlsplit` case-folded it.
|
|
216
|
+
|
|
217
|
+
`urlsplit().hostname` is lowercased by design -- DNS is case-insensitive,
|
|
218
|
+
so folding is right for *resolution*. It is wrong for text a caller will
|
|
219
|
+
see again. A config built from a URI stores this value and renders it back
|
|
220
|
+
through `connection_uri`, so reading `.hostname` there would make the
|
|
221
|
+
library echo a spelling the operator never typed, and `nasA` would reach
|
|
222
|
+
logs and `HostInfo` as `nasa`.
|
|
223
|
+
|
|
224
|
+
Use this in `_from_parsed_uri` wherever a host is stored. Presence checks
|
|
225
|
+
(`if not parsed.hostname`) can keep using `.hostname` -- emptiness does not
|
|
226
|
+
depend on case. Resolution is unaffected either way: DNS, SSH, and WinRM
|
|
227
|
+
all treat the two spellings as one name.
|
|
228
|
+
|
|
229
|
+
Returns `""` for a URI with no host, matching `hostname or ""`.
|
|
230
|
+
|
|
231
|
+
`hostname` is a case-folded substring of the authority (after any
|
|
232
|
+
userinfo), so locating it recovers the original text.
|
|
233
|
+
"""
|
|
234
|
+
hostname = parsed.hostname or ""
|
|
235
|
+
authority = parsed.netloc.rpartition("@")[2]
|
|
236
|
+
index = authority.casefold().find(hostname)
|
|
237
|
+
if index < 0:
|
|
238
|
+
return hostname
|
|
239
|
+
return authority[index : index + len(hostname)]
|
|
240
|
+
|
|
241
|
+
|
|
163
242
|
def _rebuild_authority(parsed: _SplitResult, password: _ty.Optional[str]) -> str:
|
|
164
243
|
"""Rebuild a URI authority with `password` in place of the original."""
|
|
165
|
-
host = uri_host(parsed
|
|
244
|
+
host = uri_host(uri_hostname(parsed))
|
|
166
245
|
if parsed.port is not None:
|
|
167
246
|
host = f"{host}:{parsed.port}"
|
|
168
247
|
if not parsed.username:
|
|
@@ -40,6 +40,7 @@ from ._common import (
|
|
|
40
40
|
strict_uri_credentials,
|
|
41
41
|
strict_uri_query,
|
|
42
42
|
uri_host,
|
|
43
|
+
uri_hostname,
|
|
43
44
|
reject_stdin_conflict,
|
|
44
45
|
)
|
|
45
46
|
from ..shell import (
|
|
@@ -147,7 +148,7 @@ class SshConfig(HostConfig, schemes=("ssh",)):
|
|
|
147
148
|
if not parsed.hostname or parsed.path not in ("", "/"):
|
|
148
149
|
raise ValueError("SSH URI requires a host and no path")
|
|
149
150
|
return cls(
|
|
150
|
-
host=parsed
|
|
151
|
+
host=uri_hostname(parsed),
|
|
151
152
|
port=parsed.port or 22,
|
|
152
153
|
username=unquote(parsed.username or "") or "root",
|
|
153
154
|
password=typing.cast(typing.Optional[str], credentials.get("password")),
|
|
@@ -50,6 +50,7 @@ from ._common import (
|
|
|
50
50
|
strict_uri_credentials,
|
|
51
51
|
strict_uri_query,
|
|
52
52
|
uri_host,
|
|
53
|
+
uri_hostname,
|
|
53
54
|
)
|
|
54
55
|
from ..shell import POWERSHELL, ShellFlavour
|
|
55
56
|
|
|
@@ -155,7 +156,7 @@ class WinRMConfig(HostConfig, schemes=("winrm", "winrms")):
|
|
|
155
156
|
if not parsed.username:
|
|
156
157
|
raise ValueError("WinRM URI requires a username")
|
|
157
158
|
return cls(
|
|
158
|
-
host=parsed
|
|
159
|
+
host=uri_hostname(parsed),
|
|
159
160
|
username=unquote(parsed.username),
|
|
160
161
|
password=typing.cast(typing.Optional[str], credentials.get("password")),
|
|
161
162
|
port=parsed.port,
|
|
@@ -48,6 +48,7 @@ from ._common import (
|
|
|
48
48
|
strict_uri_credentials,
|
|
49
49
|
strict_uri_query,
|
|
50
50
|
uri_host,
|
|
51
|
+
uri_hostname,
|
|
51
52
|
)
|
|
52
53
|
from ._ssh import SshConfig, _SshTransport
|
|
53
54
|
|
|
@@ -222,7 +223,7 @@ class QemuConfig(HostConfig, schemes=("qemu+libvirt", "qga+unix", "qga+ssh")):
|
|
|
222
223
|
if not parsed.hostname or not parsed.username or not parsed.path.strip("/"):
|
|
223
224
|
raise ValueError("SSH QGA URI requires user, host, and domain")
|
|
224
225
|
ssh = SshConfig(
|
|
225
|
-
parsed
|
|
226
|
+
uri_hostname(parsed),
|
|
226
227
|
port=parsed.port or 22,
|
|
227
228
|
username=unquote(parsed.username),
|
|
228
229
|
password=typing.cast(typing.Optional[str], credentials.get("password")),
|