hostctl 0.2.7__tar.gz → 0.3.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.3.0/.gitignore +51 -0
- hostctl-0.3.0/AGENTS.md +72 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/CHANGELOG.md +212 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/PKG-INFO +48 -17
- {hostctl-0.2.7 → hostctl-0.3.0}/README.md +36 -11
- hostctl-0.3.0/benchmarks/README.md +80 -0
- hostctl-0.3.0/benchmarks/results/0.2.7-py3.14.json +99 -0
- hostctl-0.3.0/benchmarks/results/0.2.7-py3.9.json +99 -0
- hostctl-0.3.0/benchmarks/results/0.3.0-py3.14.json +99 -0
- hostctl-0.3.0/benchmarks/results/0.3.0-py3.9.json +99 -0
- hostctl-0.3.0/benchmarks/run.py +175 -0
- hostctl-0.3.0/docs/guide/cli.md +60 -0
- hostctl-0.3.0/docs/guide/contracts.md +107 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/docs/guide/extending.md +12 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/docs/guide/path.md +13 -3
- {hostctl-0.2.7 → hostctl-0.3.0}/docs/guide/providers.md +24 -12
- {hostctl-0.2.7 → hostctl-0.3.0}/docs/guide/run.md +110 -15
- {hostctl-0.2.7 → hostctl-0.3.0}/docs/guide/transfer.md +7 -7
- {hostctl-0.2.7 → hostctl-0.3.0}/mkdocs.yml +4 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/pyproject.toml +49 -15
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/AGENTS.md +243 -24
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/_async.py +58 -3
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/_cli.py +146 -41
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/executor/__init__.py +4 -2
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/executor/_common.py +111 -7
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/executor/_qga.py +248 -34
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/executor/container.py +9 -6
- hostctl-0.3.0/src/hostctl/executor/local.py +276 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/executor/qemu.py +87 -12
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/executor/serial.py +28 -119
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/executor/ssh.py +77 -51
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/executor/winrm.py +33 -10
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/__init__.py +3 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/_common.py +288 -151
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/_connection.py +121 -14
- hostctl-0.3.0/src/hostctl/host/_local.py +156 -0
- hostctl-0.3.0/src/hostctl/host/_qga_helper.py +345 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/_ssh.py +181 -37
- hostctl-0.3.0/src/hostctl/host/_staged_io.py +385 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/_winrm.py +178 -32
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/composite_path.py +329 -70
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/container.py +80 -26
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/container_path.py +286 -83
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/qemu.py +342 -73
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/serial.py +155 -46
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/system.py +478 -107
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/process/_common.py +48 -23
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/process/container.py +76 -12
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/process/psrp.py +66 -11
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/process/qemu_serial.py +54 -15
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/process/serial.py +88 -20
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/process/ssh.py +61 -2
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/provider/_common.py +157 -17
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/provider/transports.py +57 -9
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/serial/__init__.py +176 -24
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/shell/__init__.py +2 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/shell/_common.py +215 -24
- hostctl-0.3.0/src/hostctl/shell/_grammar.py +97 -0
- hostctl-0.3.0/src/hostctl/shell/cmd.py +308 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/shell/fish.py +23 -3
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/shell/posix.py +21 -3
- hostctl-0.3.0/src/hostctl/shell/powershell.py +258 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/sync.py +129 -17
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/conformance/path_fakes.py +88 -13
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/conformance/providers.py +274 -57
- hostctl-0.3.0/tests/conformance/test_composite_path_routing.py +244 -0
- hostctl-0.3.0/tests/conformance/test_live.py +157 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/conformance/test_path_contract.py +11 -2
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/conformance/test_process_contract.py +14 -0
- hostctl-0.3.0/tests/conformance/test_registry.py +49 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/conformance/test_run_contract.py +128 -7
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/conformance/test_sync_contract.py +11 -6
- hostctl-0.3.0/tests/conftest.py +25 -0
- hostctl-0.3.0/tests/test_asyncssh_errors.py +166 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_cli.py +23 -0
- hostctl-0.3.0/tests/test_cli_console.py +131 -0
- hostctl-0.3.0/tests/test_cmd_live_effects.py +82 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_composite_path_backend_kwargs.py +3 -9
- hostctl-0.3.0/tests/test_composite_path_inherited_operations.py +286 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_composite_path_propagation.py +120 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_connection_string.py +92 -0
- hostctl-0.3.0/tests/test_container_path.py +564 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_container_process.py +136 -0
- hostctl-0.3.0/tests/test_docs_snippets.py +98 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_exec_command.py +30 -1
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_host_api.py +358 -3
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_host_container.py +63 -0
- hostctl-0.3.0/tests/test_host_local.py +257 -0
- hostctl-0.3.0/tests/test_host_qemu.py +251 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_host_remote.py +246 -9
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_host_winrm.py +192 -22
- hostctl-0.3.0/tests/test_login_shell.py +158 -0
- hostctl-0.3.0/tests/test_module_hygiene.py +105 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_observability.py +33 -0
- hostctl-0.3.0/tests/test_packaging.py +186 -0
- hostctl-0.3.0/tests/test_powershell_status_live.py +74 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_process.py +28 -2
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_provider_fault_injection.py +314 -1
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_psrp.py +84 -3
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_qemu_executor.py +55 -7
- hostctl-0.3.0/tests/test_qemu_guest_semantics.py +204 -0
- hostctl-0.3.0/tests/test_qemu_lifecycle.py +198 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_qemu_path.py +62 -2
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_qemu_serial_process.py +60 -0
- hostctl-0.3.0/tests/test_qga_errors.py +257 -0
- hostctl-0.3.0/tests/test_qga_path_helper.py +388 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_qga_ssh_transport.py +44 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_qga_transport.py +116 -0
- hostctl-0.3.0/tests/test_serial_contract.py +254 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_serial_executor.py +16 -17
- hostctl-0.3.0/tests/test_serial_framing.py +234 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_serial_host.py +56 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_serial_live.py +5 -1
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_shell.py +252 -5
- hostctl-0.3.0/tests/test_shell_rendering.py +315 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_ssh_process.py +63 -5
- hostctl-0.3.0/tests/test_staged_io.py +348 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_sync_helpers.py +238 -2
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_system_host_fidelity.py +4 -1
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_system_hosts.py +452 -2
- hostctl-0.3.0/tests/test_uri_hygiene.py +68 -0
- hostctl-0.3.0/tests/test_winrm_path.py +420 -0
- hostctl-0.3.0/tests/test_winrm_powershell_live.py +149 -0
- hostctl-0.2.7/.gitignore +0 -26
- hostctl-0.2.7/docs/guide/cli.md +0 -34
- hostctl-0.2.7/docs/guide/contracts.md +0 -79
- hostctl-0.2.7/src/hostctl/executor/local.py +0 -79
- hostctl-0.2.7/src/hostctl/host/_local.py +0 -258
- hostctl-0.2.7/src/hostctl/host/_staged_io.py +0 -132
- hostctl-0.2.7/src/hostctl/shell/cmd.py +0 -166
- hostctl-0.2.7/src/hostctl/shell/powershell.py +0 -119
- hostctl-0.2.7/tests/conformance/test_composite_path_routing.py +0 -75
- hostctl-0.2.7/tests/conformance/test_live.py +0 -80
- hostctl-0.2.7/tests/test_asyncssh_errors.py +0 -81
- hostctl-0.2.7/tests/test_composite_path_inherited_operations.py +0 -124
- hostctl-0.2.7/tests/test_container_path.py +0 -248
- hostctl-0.2.7/tests/test_host_local.py +0 -124
- hostctl-0.2.7/tests/test_host_qemu.py +0 -126
- hostctl-0.2.7/tests/test_staged_io.py +0 -143
- hostctl-0.2.7/tests/test_winrm_path.py +0 -233
- {hostctl-0.2.7 → hostctl-0.3.0}/LICENSE +0 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/docs/api/reference.md +0 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/docs/changelog.md +0 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/docs/index.md +0 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/examples/application_provider.py +0 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/examples/copy_between_hosts.py +0 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/examples/local_run.py +0 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/examples/remote_run.py +0 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/__init__.py +0 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/__main__.py +0 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/executor/psrp.py +0 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/process/__init__.py +0 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/provider/__init__.py +0 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/py.typed +0 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/conformance/__init__.py +0 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_application_provider.py +0 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_executor_exports.py +0 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_host_module_layout.py +0 -0
- {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_qemu_serial.py +0 -0
hostctl-0.3.0/.gitignore
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
.pytest_cache/
|
|
4
|
+
.hypothesis/
|
|
5
|
+
.mypy_cache/
|
|
6
|
+
.ruff_cache/
|
|
7
|
+
.coverage*
|
|
8
|
+
htmlcov/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
.venv/
|
|
11
|
+
dist/
|
|
12
|
+
build/
|
|
13
|
+
site/
|
|
14
|
+
|
|
15
|
+
# Private agent working area (dotagents link is a symlink; a directory-only
|
|
16
|
+
# `.agents/` pattern would not match it, so this must stay slashless).
|
|
17
|
+
.agents
|
|
18
|
+
CLAUDE*
|
|
19
|
+
.claude
|
|
20
|
+
|
|
21
|
+
# Unshared per-machine overrides. `AGENTS.local.md` is the dangerous one: it
|
|
22
|
+
# sits beside an `AGENTS.md` that is *supposed* to ship, under a nearly
|
|
23
|
+
# identical name and with the opposite destination, and it carries local
|
|
24
|
+
# paths, hostnames and sometimes credentials. The manifest excludes the same
|
|
25
|
+
# pattern; both teeth are needed, and neither is automatic.
|
|
26
|
+
*.local.*
|
|
27
|
+
|
|
28
|
+
# Root dotfiles only. Unanchored, `.*` also matched every nested dotfile and
|
|
29
|
+
# dot-directory -- a `tests/.golden/` fixture or a vendored `.config` would
|
|
30
|
+
# have been ignored with nothing to say so. The slashless `.agents`,
|
|
31
|
+
# `CLAUDE*` and `.claude` rules above are deliberately NOT anchored, because
|
|
32
|
+
# a nested copy of those is exactly what must not be committed.
|
|
33
|
+
/.*
|
|
34
|
+
!/.gitignore
|
|
35
|
+
!/.gitattributes
|
|
36
|
+
# Directory form: git cannot re-include a file below an excluded directory,
|
|
37
|
+
# so `.github/` itself has to be un-ignored, not just the files under it.
|
|
38
|
+
!/.github/
|
|
39
|
+
|
|
40
|
+
# Run logs. `summarize_run.py` writes `run.log` into the working directory,
|
|
41
|
+
# and the sdist target excludes only dotfiles -- so a test run from the repo
|
|
42
|
+
# root followed by `python -m build` published that log once already.
|
|
43
|
+
run.log
|
|
44
|
+
*.log
|
|
45
|
+
|
|
46
|
+
# OS and editor droppings.
|
|
47
|
+
.DS_Store
|
|
48
|
+
Thumbs.db
|
|
49
|
+
*.swp
|
|
50
|
+
*~
|
|
51
|
+
|
hostctl-0.3.0/AGENTS.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Working in this checkout
|
|
2
|
+
|
|
3
|
+
Contributor orientation. The **shipped API header** is
|
|
4
|
+
[`src/hostctl/AGENTS.md`](src/hostctl/AGENTS.md) — that one goes in the wheel and
|
|
5
|
+
describes the public surface for a consumer. This one describes the repository,
|
|
6
|
+
and is not published anywhere.
|
|
7
|
+
|
|
8
|
+
## Layout
|
|
9
|
+
|
|
10
|
+
| path | what lives there |
|
|
11
|
+
| ---- | ---------------- |
|
|
12
|
+
| `src/hostctl/host/` | `Host`, `HostConfig`, URI dispatch, per-transport hosts, composite paths |
|
|
13
|
+
| `src/hostctl/executor/` | one buffered command executor per transport |
|
|
14
|
+
| `src/hostctl/process/` | persistent process adapters (`spawn`, sessions, consoles) |
|
|
15
|
+
| `src/hostctl/shell/` | shell flavours: quoting, script assembly, invocation |
|
|
16
|
+
| `src/hostctl/provider/` | the provider/selector contracts every transport plugs into |
|
|
17
|
+
| `src/hostctl/serial/` | console profiles (prompt, login, status framing) |
|
|
18
|
+
| `src/hostctl/sync/` | checksum and byte-progress helpers for `pathlib_next` |
|
|
19
|
+
| `tests/conformance/` | the cross-transport battery: one contract, every provider |
|
|
20
|
+
| `examples/` | runnable examples; deliberately not packaged |
|
|
21
|
+
| `docs/` | the guide published to GitHub Pages |
|
|
22
|
+
|
|
23
|
+
`tests/conformance/` is where a behaviour that must hold **everywhere** belongs.
|
|
24
|
+
A test there is parametrized over every registered provider and skips itself
|
|
25
|
+
when a provider does not advertise the capability, so a transport whose
|
|
26
|
+
capabilities drift silently stops being tested — which is why
|
|
27
|
+
`tests/conformance/test_registry.py` exists to check the registry itself.
|
|
28
|
+
|
|
29
|
+
## Environments
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
py -3.14 -m venv .venv/3.14-nt-amd64
|
|
33
|
+
.venv/3.14-nt-amd64/Scripts/python -m pip install -e ".[dev,docs]"
|
|
34
|
+
.venv/3.14-nt-amd64/Scripts/python -m pytest -q
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
3.14 is the development interpreter; **3.9 is the supported floor** and a change
|
|
38
|
+
is not done until it passes there too (`.venv/3.9-nt-amd64`). The `dev` extra
|
|
39
|
+
pulls every transport, so `".[dev]"` is the whole list — do not re-spell the
|
|
40
|
+
extras anywhere.
|
|
41
|
+
|
|
42
|
+
Before committing: `python -m black src tests` (CI checks it) and
|
|
43
|
+
`python -m mkdocs build --strict` if you touched docs.
|
|
44
|
+
|
|
45
|
+
## CI — three workflows, one per concern
|
|
46
|
+
|
|
47
|
+
- `test.yml` — `workflow_dispatch` (with a `ref` input) and throwaway `ci-*`
|
|
48
|
+
tags, plus a Linux job that runs the conformance battery against **live**
|
|
49
|
+
sshd, Docker and serial loopback legs.
|
|
50
|
+
- `release.yml` — on a `v*` tag: test gate → build → GitHub release → PyPI,
|
|
51
|
+
plus a strict docs **build** that gates the release without deploying.
|
|
52
|
+
- `docs.yml` — owns every Pages deploy: on a published release, on a push to
|
|
53
|
+
`main` touching docs sources, and on manual dispatch.
|
|
54
|
+
|
|
55
|
+
Pushing a `ci-*` tag is routine; delete it afterwards. **Pushing a `v*` tag is
|
|
56
|
+
a release and needs the maintainer's explicit consent for that version** —
|
|
57
|
+
publishing is irreversible.
|
|
58
|
+
|
|
59
|
+
## Conventions that are not obvious from the source
|
|
60
|
+
|
|
61
|
+
- **Files are LF-only**, every file, including on Windows.
|
|
62
|
+
- **A divergence is documented or it is a bug.** Where a transport cannot
|
|
63
|
+
honour the common contract, add a row to `docs/guide/contracts.md`'s ledger
|
|
64
|
+
saying what differs and *why the transport cannot do otherwise* — not a
|
|
65
|
+
sentence saying it differs.
|
|
66
|
+
- **Composite paths forward the method that was called.** Adding an operation
|
|
67
|
+
is a row in `_FORWARDED`, not a method body; decomposing a call into
|
|
68
|
+
primitives discards transport-native implementations.
|
|
69
|
+
- **Capabilities are strings**, and a provider may declare one this package's
|
|
70
|
+
enum does not know.
|
|
71
|
+
- Measure before claiming. Several comments in this tree cite a measured
|
|
72
|
+
number because the intuitive answer was wrong.
|
|
@@ -7,6 +7,216 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-09-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **QGA paths have metadata and namespace operations.** `QemuHost.path()`
|
|
15
|
+
now supports `stat`, `iterdir`, `mkdir`, `unlink`, `rename` and `chmod`
|
|
16
|
+
through a guest-side helper built during discovery -- hostctl previously
|
|
17
|
+
shipped none, so a caller had to write one and every QGA path reported
|
|
18
|
+
`degraded`. The helper is POSITIVELY PROBED: GNU `stat -c`/`find -printf`
|
|
19
|
+
on a POSIX guest, PowerShell on a Windows one. A guest that fails the
|
|
20
|
+
probe keeps content access and loses only what genuinely needs a helper.
|
|
21
|
+
`QemuConfig(path_helper=...)` still overrides.
|
|
22
|
+
- **`SshConfig(login_shell="auto"|"cmd"|"powershell"|"posix")`** says what
|
|
23
|
+
parses the command string on arrival. Windows OpenSSH's stock server
|
|
24
|
+
hands it to `cmd.exe /c`, so a `cmd`-dialect command line is parsed by cmd
|
|
25
|
+
twice; unescaped, 4 of 8 adversarial values survived and two of the
|
|
26
|
+
failures were command injection. `auto` reads the dialect.
|
|
27
|
+
- **`ShellFlavour.argument(value, *, target=ShellTarget...)`** separates
|
|
28
|
+
quoting for the shell's parser from quoting for whatever reads the token
|
|
29
|
+
next -- a cmd builtin, a child's argv parser, PowerShell's binder, or the
|
|
30
|
+
program slot. `quote()` remains the syntactic layer.
|
|
31
|
+
|
|
32
|
+
### Removed
|
|
33
|
+
|
|
34
|
+
- **`SerialTransport`** and its `hostctl.executor` export. It duplicated
|
|
35
|
+
`SerialProcess`'s locked, deadline-clamping byte path and had no caller
|
|
36
|
+
outside its own tests, so the two could drift with nothing to notice.
|
|
37
|
+
`SerialProcess` keeps the job -- it is what every console profile reads
|
|
38
|
+
through -- and gained the `write(timeout=)` it lacked.
|
|
39
|
+
- **`SerialConfig.username` and `SerialConfig.password`.** They were stored,
|
|
40
|
+
whitelisted for URI dispatch, and read by nothing: console credentials live
|
|
41
|
+
in the profile's `login=` steps. An explicit one is now refused rather than
|
|
42
|
+
silently ignored, and `HOSTCTL_PASSWORD` is no longer offered to a `serial:`
|
|
43
|
+
URI.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- The `pathlib_next` floor moves to `>=0.9.10` in both `dependencies` and
|
|
48
|
+
the `ssh` extra. **0.9.4 through 0.9.9 cannot be used**: those releases
|
|
49
|
+
decide "same file" and "overlapping trees" by falling back to path
|
|
50
|
+
equality, which compares `(type, segments)` and knows nothing about
|
|
51
|
+
hosts, so copying `/etc/app.conf` from one host to the same path on
|
|
52
|
+
another was refused with `OSError [Errno 22] Source and target are the
|
|
53
|
+
same file` (and `PathSyncer` with "source and target overlap"). 0.9.10
|
|
54
|
+
adds the `_same_filesystem()` hook this release answers.
|
|
55
|
+
- **`LocalExecutor` merges `env` over the inherited environment** instead of
|
|
56
|
+
replacing it, so the documented cross-transport rule -- "`env` is additive
|
|
57
|
+
to the provider's environment" -- now holds locally too. A child no longer
|
|
58
|
+
starts without `PATH`, `HOME` or `SystemRoot` because one variable was set.
|
|
59
|
+
- **A serial `run()` frames each command as its own profile exchange** and
|
|
60
|
+
refuses a structured (argv) command: a serial host names no shell flavour,
|
|
61
|
+
so it has no quoting rule to apply, and joining commands with `;` put two
|
|
62
|
+
on one line where most device consoles read a single malformed one.
|
|
63
|
+
`stdin=` and `bufsize=` raise instead of being ignored, as `input=` already
|
|
64
|
+
did.
|
|
65
|
+
- **A serial `spawn(encoding=...)` reads text**, matching the SSH and QEMU
|
|
66
|
+
console adapters; without either text keyword it still reads bytes.
|
|
67
|
+
- **QEMU `dialect="auto"`/`path_flavor="auto"` need positive evidence** of the
|
|
68
|
+
guest's family -- `guest-get-osinfo`, or a family-exclusive command in
|
|
69
|
+
`guest-info` -- and raise naming the explicit settings when neither is
|
|
70
|
+
available. An agent with `guest-get-osinfo` blocklisted used to be read as
|
|
71
|
+
POSIX, silently, including on Windows.
|
|
72
|
+
- **`QemuHost.info().os_family` is a family** (`linux`, `windows`,
|
|
73
|
+
`freebsd`), taken from `kernel-name`. It carried `guest-get-osinfo`'s `id`,
|
|
74
|
+
so the same machine reported `linux` over SSH and `ubuntu` over QGA.
|
|
75
|
+
- **Each guest-agent request is bounded by `agent_timeout`**, separately from
|
|
76
|
+
a command's own `timeout=`: an agent that stops answering is a
|
|
77
|
+
`ConnectionError` saying so, not a command timeout ten minutes later.
|
|
78
|
+
- **A truncated QGA `run()` result warns.** `stdout_truncated`/
|
|
79
|
+
`stderr_truncated` were set and documented nowhere, so a shortened
|
|
80
|
+
transcript looked complete.
|
|
81
|
+
- **`hostctl shell` opens a console that cannot allocate a terminal**, so a
|
|
82
|
+
`serial:` URI works; **`hostctl cp local:/tmp/x`** is accepted, the
|
|
83
|
+
spelling every other subcommand documents; and `hostctl --help` carries the
|
|
84
|
+
URI forms, the `URI:PATH` grammar, the `HOSTCTL_PASSWORD` rule and the exit
|
|
85
|
+
codes.
|
|
86
|
+
- **`asyncssh` and `libvirt-python` are bounded** (`>=2.14,<3`, `>=9.0,<12`);
|
|
87
|
+
the `dev` extra now pulls every transport, so `pip install -e ".[dev]"` is
|
|
88
|
+
the whole development environment.
|
|
89
|
+
- **`LocalHost` is a `SystemHost`.** It duplicated the provider selection,
|
|
90
|
+
failover loop, capability computation and dispatch ladder, and the two
|
|
91
|
+
copies diverged. Two consequences for callers:
|
|
92
|
+
**`LocalHost().path()` with no segments no longer returns the absolute
|
|
93
|
+
working directory** -- it returns a path with no segments added, like
|
|
94
|
+
every other transport, which still resolves against the process's working
|
|
95
|
+
directory. A local path is still a plain `pathlib_next` path, not a
|
|
96
|
+
composite.
|
|
97
|
+
|
|
98
|
+
### Fixed
|
|
99
|
+
|
|
100
|
+
- **A transfer between two hosts is no longer mistaken for a file copied
|
|
101
|
+
onto itself.** `CompositePosixPath`/`CompositeWindowsPath` now answer
|
|
102
|
+
`pathlib_next`'s `_same_filesystem()` with the host's provider set, so
|
|
103
|
+
two hosts are two namespaces even when a path is spelled identically on
|
|
104
|
+
both, while two paths on one host still are one — which keeps the guard
|
|
105
|
+
that refuses `copy()` of a file onto itself. A `.via()` pin selects a
|
|
106
|
+
route to the host, not a different host, and answers accordingly.
|
|
107
|
+
- **The SFTP leg verifies the server's host key.** `SshConfig.connect_opts()`
|
|
108
|
+
omitted `known_hosts` whenever it held its `()` default. asyncssh reads a
|
|
109
|
+
missing key as "resolve known_hosts the usual way", so `run()` verified the
|
|
110
|
+
server — but `pathlib_next`'s SFTP connect seeds `known_hosts=None`
|
|
111
|
+
(verification off) for options it is not handed, so `path()` accepted any
|
|
112
|
+
host key, including an actively substituted one, and sent it the configured
|
|
113
|
+
password. Opting out is still possible and now explicit: `known_hosts=None`.
|
|
114
|
+
- **`ConnectionString` parses `user:password@host`.** A scheme-shaped username
|
|
115
|
+
made `urlsplit` read `root:hunter2@nas` as the scheme `root`, so the password
|
|
116
|
+
was never recognised: it stayed in the path, where `str()` and `repr()`
|
|
117
|
+
rendered it verbatim, the host came out empty, and `is_local` reported a
|
|
118
|
+
remote machine as local. Without a scheme to assume, a target carrying
|
|
119
|
+
credentials is now refused rather than guessed at, and both error messages
|
|
120
|
+
redact (`root:<redacted>@nas`).
|
|
121
|
+
- **An args-capable executor gets one shell layer, not two.** `SystemHost`
|
|
122
|
+
fed a finished shell command line back into `flavour.invocation()`, which
|
|
123
|
+
takes a script. On Windows the outer PowerShell re-parsed the inner
|
|
124
|
+
`-Command` string, so `windows://node?executor=local` reported 0 for
|
|
125
|
+
`cmd /c exit 3` — `check=True` passed for a failing command — and quoted
|
|
126
|
+
text and variables came back mangled or empty.
|
|
127
|
+
- **cmd.exe builtin operands are quoted, and `set` no longer escapes inside
|
|
128
|
+
quotes.** cmd splits a builtin's operands on `,`, `;` and `=` as well as
|
|
129
|
+
whitespace, so `del /q a b.txt` deleted `a` and `b.txt` and left the named
|
|
130
|
+
file. Separately, `set "KEY=VALUE"` caret-escaped inside a quoted span,
|
|
131
|
+
where cmd does not process carets: `100%` reached the child as `100^%`,
|
|
132
|
+
`%OS%` still expanded, and a `"` in the value ended the assignment early.
|
|
133
|
+
An empty value remains inexpressible in cmd, and is now documented as such.
|
|
134
|
+
- **PowerShell 5 native arguments survive the C runtime.** PS 5.1 rebuilds the
|
|
135
|
+
command line for a native program and leaves embedded quotes alone, so
|
|
136
|
+
`run(("robocopy", src, dst, name))` with `name = 'my file" /MIR "z'` handed
|
|
137
|
+
robocopy `/MIR` — mirror mode, which deletes files in the destination. Empty
|
|
138
|
+
arguments were dropped and a trailing backslash swallowed the next argument.
|
|
139
|
+
PowerShell 7 is unaffected and keeps the plain literal.
|
|
140
|
+
- **A transfer to another backend no longer renames inside the source host.**
|
|
141
|
+
`ssh_host.path('/srv/export.csv').move(LocalPath('/home/op/export.csv'))`
|
|
142
|
+
issued an SFTP rename on the server: the file left `/srv`, never arrived,
|
|
143
|
+
and no error was raised. A `str` destination — documented by `pathlib_next`
|
|
144
|
+
— also raised `TypeError` on every transport, and a cross-provider `move()`
|
|
145
|
+
aborted instead of falling back to copy + remove. All three now work.
|
|
146
|
+
- **Docker file modes are translated.** The archive stat header carries Go's
|
|
147
|
+
`os.FileMode`, used verbatim as a POSIX `st_mode`: `is_file()` was False for
|
|
148
|
+
every ordinary file and `is_dir()` raised `OverflowError: mode out of range`
|
|
149
|
+
for a directory, which also killed any recursive copy.
|
|
150
|
+
- **An unreachable QGA socket is reported as a transport failure.** It escaped
|
|
151
|
+
as a bare `FileNotFoundError`, indistinguishable from the guest file being
|
|
152
|
+
absent — so an append across a guest reboot staged an empty buffer and
|
|
153
|
+
truncated the guest file when the agent came back.
|
|
154
|
+
- **Native WinRM reports the remote exit status.** The provider did not
|
|
155
|
+
declare `manages_status`, so PowerShell's `; exit $LASTEXITCODE` epilogue was
|
|
156
|
+
appended and its `exit` ended the remote pipeline before the
|
|
157
|
+
`__HOSTCTL_LASTEXITCODE__` marker could be emitted. A command that failed by
|
|
158
|
+
exit status reported 0 and `check=True` passed.
|
|
159
|
+
- **One unsupported operation no longer takes a path provider out of
|
|
160
|
+
service.** A `NotImplementedError` from a retry-safe call was recorded as
|
|
161
|
+
a decline on the host's shared provider selector, which every later
|
|
162
|
+
operation then consulted: after a backend refused `samefile()` — it needs
|
|
163
|
+
`st_dev`/`st_ino`, which many remote stats lack — the next read on any
|
|
164
|
+
path of that host failed with `no path provider supports open_read`. The
|
|
165
|
+
refusal is now scoped to the call that raised it. `OperationNotStarted`
|
|
166
|
+
still declines the provider for the generation, as before.
|
|
167
|
+
- **A `cmd.exe` at a path containing a space is launchable.** The program
|
|
168
|
+
slot of a Windows command line is read by `CreateProcess`, not by cmd, and
|
|
169
|
+
rendering it with cmd's caret quoting made Windows look for a program named
|
|
170
|
+
`^` -- measured as `FileNotFoundError: [WinError 2]`.
|
|
171
|
+
- **A structured PowerShell command can bind a named parameter.**
|
|
172
|
+
`["Remove-Item", "-LiteralPath", path]` rendered every token as a quoted
|
|
173
|
+
string, which PowerShell's binder reads positionally: measured against a
|
|
174
|
+
real `powershell.exe`, it returned 1 and left the file in place. A token
|
|
175
|
+
that is exactly a parameter name is now unquoted.
|
|
176
|
+
- **`ShellSession.send()` does not append a separator the text already has**,
|
|
177
|
+
so `send("echo hi;")` is not `echo hi;;` and `send("sleep 5 &")` is not
|
|
178
|
+
`sleep 5 &;`.
|
|
179
|
+
- **`zsh` quoting covers a leading `=`**, which zsh expands to a program path
|
|
180
|
+
where POSIX `sh` leaves it alone.
|
|
181
|
+
- **`PowerShellFlavour(7, executable=...)` is named `pwsh`.** The name was
|
|
182
|
+
derived only when no executable was given, so an explicit path left the
|
|
183
|
+
flavour identifying as `powershell`.
|
|
184
|
+
- **`Shell(..., env={})` forwards no environment**, rather than an empty one
|
|
185
|
+
that starts a child with nothing.
|
|
186
|
+
- **`normalize_input` covers `bytearray` and `memoryview`** -- the deadlock
|
|
187
|
+
it exists to prevent, for the two buffer types it did not check.
|
|
188
|
+
- **Container archive reads no longer lie about links, sizes or errors**: a
|
|
189
|
+
single-member hardlink archive raises `OSError(ENOENT)` instead of
|
|
190
|
+
`tarfile.StreamError`, a hardlinked file in a listing reports its real
|
|
191
|
+
size, the archive root (`"."`) is listable, and every Docker status code
|
|
192
|
+
maps through one helper that populates `errno`.
|
|
193
|
+
- **A container exec stream no longer returns `''` mid-character**, and a
|
|
194
|
+
closed container process reads EOF instead of raising the operating
|
|
195
|
+
system's raw socket error.
|
|
196
|
+
- **QGA failures speak one error vocabulary**: a guest error is classified on
|
|
197
|
+
the `strerror` tail (a file named `notfound.log` was reported missing
|
|
198
|
+
whatever actually failed), `host.run()` of a missing guest program raises
|
|
199
|
+
`FileNotFoundError`, libvirt-relayed guest errors stay guest errors, and a
|
|
200
|
+
guest error no longer tears down and re-handshakes the connection.
|
|
201
|
+
- **One transport per QEMU host.** Lazy initialisation was unguarded:
|
|
202
|
+
measured with six threads, six transports were built where one was wanted,
|
|
203
|
+
each SSH one opening a connection nobody owned. A path handed out before
|
|
204
|
+
`close()` now refuses instead of silently reconnecting.
|
|
205
|
+
- **A serial login failure raises `ConsoleProtocolError`**, not a bare
|
|
206
|
+
`TimeoutError` that slipped past every documented handler, and a transcript
|
|
207
|
+
attached to an error has `secret=True` login values redacted -- consoles
|
|
208
|
+
echo what they are sent.
|
|
209
|
+
- **A QGA reader is seekable** where the agent advertises `guest-file-seek`,
|
|
210
|
+
so `zipfile` and `tarfile` work against a guest path, and an exclusive
|
|
211
|
+
`open("x")` is refused at `open()` rather than after the whole payload has
|
|
212
|
+
been staged.
|
|
213
|
+
- **A `cmd`-dialect command sent over SSH to Windows is escaped for the
|
|
214
|
+
login shell that receives it.** Two of the eight adversarial values were
|
|
215
|
+
command injection, not corruption.
|
|
216
|
+
- **`SshConfig("ssh://host")` raises** instead of making the URI the
|
|
217
|
+
hostname, which surfaced much later as an unresolvable name. The
|
|
218
|
+
constructor takes a host; `HostConfig(uri)` takes a URI.
|
|
219
|
+
|
|
10
220
|
## [0.2.7] - 2026-08-16
|
|
11
221
|
|
|
12
222
|
### Changed
|
|
@@ -27,6 +237,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
27
237
|
composite `symlink_to` could never work. `netimps` needs nothing past
|
|
28
238
|
`0.2.0` and floors at the series start.
|
|
29
239
|
|
|
240
|
+
## [0.2.6] - 2026-08-16
|
|
241
|
+
|
|
30
242
|
### Added
|
|
31
243
|
|
|
32
244
|
- `wants_text(text, encoding, errors)` is exported from `hostctl.executor`,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hostctl
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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/
|
|
@@ -24,14 +24,20 @@ Classifier: Topic :: System :: Filesystems
|
|
|
24
24
|
Classifier: Topic :: System :: Systems Administration
|
|
25
25
|
Classifier: Typing :: Typed
|
|
26
26
|
Requires-Python: >=3.9
|
|
27
|
-
Requires-Dist: netimps<0.
|
|
28
|
-
Requires-Dist: pathlib-next<0.10,>=0.9.
|
|
27
|
+
Requires-Dist: netimps<0.4,>=0.3.0
|
|
28
|
+
Requires-Dist: pathlib-next<0.10,>=0.9.10
|
|
29
29
|
Provides-Extra: container
|
|
30
30
|
Requires-Dist: docker<8,>=7.1; extra == 'container'
|
|
31
31
|
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: asyncssh<3,>=2.14; extra == 'dev'
|
|
32
33
|
Requires-Dist: black; extra == 'dev'
|
|
33
34
|
Requires-Dist: build; extra == 'dev'
|
|
35
|
+
Requires-Dist: docker<8,>=7.1; extra == 'dev'
|
|
36
|
+
Requires-Dist: pathlib-next[sftp-async]<0.10,>=0.9.10; extra == 'dev'
|
|
37
|
+
Requires-Dist: pypsrp<1,>=0.9; (python_version >= '3.10') and extra == 'dev'
|
|
38
|
+
Requires-Dist: pyserial<4,>=3.5; extra == 'dev'
|
|
34
39
|
Requires-Dist: pytest; extra == 'dev'
|
|
40
|
+
Requires-Dist: pywinrm<0.6,>=0.5.0; extra == 'dev'
|
|
35
41
|
Provides-Extra: docs
|
|
36
42
|
Requires-Dist: mkdocs; extra == 'docs'
|
|
37
43
|
Requires-Dist: mkdocs-material; extra == 'docs'
|
|
@@ -39,12 +45,12 @@ Requires-Dist: mkdocstrings[python]; extra == 'docs'
|
|
|
39
45
|
Provides-Extra: psrp
|
|
40
46
|
Requires-Dist: pypsrp<1,>=0.9; (python_version >= '3.10') and extra == 'psrp'
|
|
41
47
|
Provides-Extra: qemu-libvirt
|
|
42
|
-
Requires-Dist: libvirt-python; extra == 'qemu-libvirt'
|
|
48
|
+
Requires-Dist: libvirt-python<12,>=9.0; extra == 'qemu-libvirt'
|
|
43
49
|
Provides-Extra: serial
|
|
44
50
|
Requires-Dist: pyserial<4,>=3.5; extra == 'serial'
|
|
45
51
|
Provides-Extra: ssh
|
|
46
|
-
Requires-Dist: asyncssh; extra == 'ssh'
|
|
47
|
-
Requires-Dist: pathlib-next[sftp-async]<0.10,>=0.9.
|
|
52
|
+
Requires-Dist: asyncssh<3,>=2.14; extra == 'ssh'
|
|
53
|
+
Requires-Dist: pathlib-next[sftp-async]<0.10,>=0.9.10; extra == 'ssh'
|
|
48
54
|
Provides-Extra: winrm
|
|
49
55
|
Requires-Dist: pywinrm<0.6,>=0.5.0; extra == 'winrm'
|
|
50
56
|
Provides-Extra: winrm-credssp
|
|
@@ -54,7 +60,8 @@ Requires-Dist: pywinrm[kerberos]<0.6,>=0.5.0; extra == 'winrm-kerberos'
|
|
|
54
60
|
Description-Content-Type: text/markdown
|
|
55
61
|
|
|
56
62
|
[](https://pypi.org/project/hostctl/)
|
|
57
|
-
[](https://pypi.org/project/hostctl/)
|
|
64
|
+
[](https://github.com/jose-pr/hostctl/blob/main/LICENSE)
|
|
58
65
|
[](https://jose-pr.github.io/hostctl/)
|
|
59
66
|
[](https://github.com/jose-pr/hostctl/actions/workflows/release.yml)
|
|
60
67
|
|
|
@@ -67,8 +74,9 @@ capabilities those providers actually support.
|
|
|
67
74
|
## Features
|
|
68
75
|
|
|
69
76
|
- **`Host.run(...)`** — subprocess-compatible results from local shells,
|
|
70
|
-
SSH (`asyncssh`), or PowerShell over WinRM (`pywinrm`).
|
|
71
|
-
|
|
77
|
+
SSH (`asyncssh`), or PowerShell over WinRM (`pywinrm`). Two defaults differ
|
|
78
|
+
from `subprocess.run` deliberately: `check` and `capture_output` are both
|
|
79
|
+
`True`. Unsupported transport options raise `NotImplementedError`.
|
|
72
80
|
- **`Host.path(...)`** — a `pathlib_next.Path` filesystem view: local,
|
|
73
81
|
remote SFTP, Windows over WinRM, container archives, or QEMU Guest Agent.
|
|
74
82
|
- **Cross-host copy and sync** — use `Path.copy()`/`PathSyncer` directly;
|
|
@@ -147,10 +155,10 @@ with WinRMConfig("windows.example.com", "admin", "secret", ssl=True) as windows:
|
|
|
147
155
|
|
|
148
156
|
# An existing running container (needs the `container` extra).
|
|
149
157
|
with ContainerConfig("application") as container:
|
|
150
|
-
container.run(["printf", "%s
|
|
158
|
+
container.run(["printf", "%s", "hello"])
|
|
151
159
|
print(container.path("/etc/os-release").read_text())
|
|
152
160
|
with container.shell.session(terminal=True, encoding="utf-8") as session:
|
|
153
|
-
session.send(["printf", "%s
|
|
161
|
+
session.send(["printf", "%s", "hello from the session"])
|
|
154
162
|
print(session.read())
|
|
155
163
|
|
|
156
164
|
# A QEMU guest through its QGA Unix socket tunneled over SSH.
|
|
@@ -224,6 +232,11 @@ Use `PosixHost`, `WindowsHost`, or `IosHost` when system semantics should be
|
|
|
224
232
|
independent of the transport. Providers are tried in declaration order during
|
|
225
233
|
preflight; a provider may be retried only when it raises
|
|
226
234
|
`OperationNotStarted`, which guarantees that no remote operation was sent.
|
|
235
|
+
`IosHost` is deliberately session- and command-only: it configures no shell
|
|
236
|
+
flavour and no path grammar, so commands must be `Exec(...)` (or raw text the
|
|
237
|
+
device understands) and `path()` raises `NotImplementedError` until an IOS
|
|
238
|
+
path grammar exists.
|
|
239
|
+
|
|
227
240
|
Paths retain their selected provider and expose it through `.provider` and
|
|
228
241
|
`.via(name)`:
|
|
229
242
|
|
|
@@ -241,7 +254,7 @@ print(path.provider.name)
|
|
|
241
254
|
```
|
|
242
255
|
|
|
243
256
|
Application-specific adapters can follow the SFTP/RPC/download pattern in
|
|
244
|
-
[`examples/application_provider.py`](examples/application_provider.py). The
|
|
257
|
+
[`examples/application_provider.py`](https://github.com/jose-pr/hostctl/blob/main/examples/application_provider.py). The
|
|
245
258
|
[Systems and providers](https://jose-pr.github.io/hostctl/guide/providers/)
|
|
246
259
|
guide covers selection traces, backend pinning, per-operation capabilities,
|
|
247
260
|
provider authoring, and the no-replay safety rule in full.
|
|
@@ -292,19 +305,37 @@ and exit statuses.
|
|
|
292
305
|
|
|
293
306
|
```bash
|
|
294
307
|
py -3.14 -m venv .venv/3.14-nt-amd64
|
|
295
|
-
.venv/3.14-nt-amd64/Scripts/python -m pip install -e ".[dev,
|
|
308
|
+
.venv/3.14-nt-amd64/Scripts/python -m pip install -e ".[dev,docs]"
|
|
296
309
|
.venv/3.14-nt-amd64/Scripts/python -m pytest -q
|
|
310
|
+
# The docs gate the release runs, locally:
|
|
311
|
+
.venv/3.14-nt-amd64/Scripts/python -m mkdocs build --strict
|
|
297
312
|
```
|
|
298
313
|
|
|
314
|
+
Every extra, so the documented environment is a superset of CI's: without
|
|
315
|
+
`psrp` a capability test skips silently, and without `docs` the gate that can
|
|
316
|
+
block a release cannot be run at all.
|
|
317
|
+
|
|
299
318
|
Python 3.14 is the default development interpreter. Python 3.9 remains the
|
|
300
319
|
supported compatibility floor and should be selected explicitly with
|
|
301
|
-
`py -3.9` when running floor-specific checks
|
|
320
|
+
`py -3.9` when running floor-specific checks; name that environment
|
|
321
|
+
`.venv/3.9-nt-amd64` so the two agree.
|
|
322
|
+
|
|
323
|
+
The venv names carry the platform `sysconfig.get_platform()` reports, so a
|
|
324
|
+
checkout on an ARM64 Windows machine still names them `amd64`: `cryptography`
|
|
325
|
+
publishes no `win_arm64` wheel and building it from source needs a Rust
|
|
326
|
+
toolchain, so `asyncssh` -- and with it the whole `ssh` extra -- cannot be
|
|
327
|
+
installed under a native ARM64 interpreter. If that changes, the native
|
|
328
|
+
interpreter is `py -V:3.14-arm64` and the environment should be named
|
|
329
|
+
`.venv/3.14-nt-arm64`.
|
|
302
330
|
|
|
303
331
|
### Releasing
|
|
304
332
|
|
|
305
333
|
This project follows [Semantic Versioning](https://semver.org/) and keeps a
|
|
306
|
-
[`CHANGELOG.md`](CHANGELOG.md). Pushing a tag matching `v*` triggers the release
|
|
307
|
-
workflow: test gate → build → publish
|
|
334
|
+
[`CHANGELOG.md`](https://github.com/jose-pr/hostctl/blob/main/CHANGELOG.md). Pushing a tag matching `v*` triggers the release
|
|
335
|
+
workflow: test gate → build → publish, with a strict docs build as a gate.
|
|
336
|
+
The docs site itself is deployed by `docs.yml`, which also redeploys on a push
|
|
337
|
+
to `main` touching docs sources and on manual dispatch -- so a docs fix reaches
|
|
338
|
+
the site without cutting a tag.
|
|
308
339
|
|
|
309
340
|
To prepare a release, update `pyproject.toml` and move the complete
|
|
310
341
|
`[Unreleased]` section to `## [X.Y.Z] - YYYY-MM-DD` in the same commit. Keep
|
|
@@ -316,4 +347,4 @@ the next cycle.
|
|
|
316
347
|
|
|
317
348
|
## License
|
|
318
349
|
|
|
319
|
-
MIT — see [LICENSE](LICENSE).
|
|
350
|
+
MIT — see [LICENSE](https://github.com/jose-pr/hostctl/blob/main/LICENSE).
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
[](https://pypi.org/project/hostctl/)
|
|
2
|
-
[](https://pypi.org/project/hostctl/)
|
|
3
|
+
[](https://github.com/jose-pr/hostctl/blob/main/LICENSE)
|
|
3
4
|
[](https://jose-pr.github.io/hostctl/)
|
|
4
5
|
[](https://github.com/jose-pr/hostctl/actions/workflows/release.yml)
|
|
5
6
|
|
|
@@ -12,8 +13,9 @@ capabilities those providers actually support.
|
|
|
12
13
|
## Features
|
|
13
14
|
|
|
14
15
|
- **`Host.run(...)`** — subprocess-compatible results from local shells,
|
|
15
|
-
SSH (`asyncssh`), or PowerShell over WinRM (`pywinrm`).
|
|
16
|
-
|
|
16
|
+
SSH (`asyncssh`), or PowerShell over WinRM (`pywinrm`). Two defaults differ
|
|
17
|
+
from `subprocess.run` deliberately: `check` and `capture_output` are both
|
|
18
|
+
`True`. Unsupported transport options raise `NotImplementedError`.
|
|
17
19
|
- **`Host.path(...)`** — a `pathlib_next.Path` filesystem view: local,
|
|
18
20
|
remote SFTP, Windows over WinRM, container archives, or QEMU Guest Agent.
|
|
19
21
|
- **Cross-host copy and sync** — use `Path.copy()`/`PathSyncer` directly;
|
|
@@ -92,10 +94,10 @@ with WinRMConfig("windows.example.com", "admin", "secret", ssl=True) as windows:
|
|
|
92
94
|
|
|
93
95
|
# An existing running container (needs the `container` extra).
|
|
94
96
|
with ContainerConfig("application") as container:
|
|
95
|
-
container.run(["printf", "%s
|
|
97
|
+
container.run(["printf", "%s", "hello"])
|
|
96
98
|
print(container.path("/etc/os-release").read_text())
|
|
97
99
|
with container.shell.session(terminal=True, encoding="utf-8") as session:
|
|
98
|
-
session.send(["printf", "%s
|
|
100
|
+
session.send(["printf", "%s", "hello from the session"])
|
|
99
101
|
print(session.read())
|
|
100
102
|
|
|
101
103
|
# A QEMU guest through its QGA Unix socket tunneled over SSH.
|
|
@@ -169,6 +171,11 @@ Use `PosixHost`, `WindowsHost`, or `IosHost` when system semantics should be
|
|
|
169
171
|
independent of the transport. Providers are tried in declaration order during
|
|
170
172
|
preflight; a provider may be retried only when it raises
|
|
171
173
|
`OperationNotStarted`, which guarantees that no remote operation was sent.
|
|
174
|
+
`IosHost` is deliberately session- and command-only: it configures no shell
|
|
175
|
+
flavour and no path grammar, so commands must be `Exec(...)` (or raw text the
|
|
176
|
+
device understands) and `path()` raises `NotImplementedError` until an IOS
|
|
177
|
+
path grammar exists.
|
|
178
|
+
|
|
172
179
|
Paths retain their selected provider and expose it through `.provider` and
|
|
173
180
|
`.via(name)`:
|
|
174
181
|
|
|
@@ -186,7 +193,7 @@ print(path.provider.name)
|
|
|
186
193
|
```
|
|
187
194
|
|
|
188
195
|
Application-specific adapters can follow the SFTP/RPC/download pattern in
|
|
189
|
-
[`examples/application_provider.py`](examples/application_provider.py). The
|
|
196
|
+
[`examples/application_provider.py`](https://github.com/jose-pr/hostctl/blob/main/examples/application_provider.py). The
|
|
190
197
|
[Systems and providers](https://jose-pr.github.io/hostctl/guide/providers/)
|
|
191
198
|
guide covers selection traces, backend pinning, per-operation capabilities,
|
|
192
199
|
provider authoring, and the no-replay safety rule in full.
|
|
@@ -237,19 +244,37 @@ and exit statuses.
|
|
|
237
244
|
|
|
238
245
|
```bash
|
|
239
246
|
py -3.14 -m venv .venv/3.14-nt-amd64
|
|
240
|
-
.venv/3.14-nt-amd64/Scripts/python -m pip install -e ".[dev,
|
|
247
|
+
.venv/3.14-nt-amd64/Scripts/python -m pip install -e ".[dev,docs]"
|
|
241
248
|
.venv/3.14-nt-amd64/Scripts/python -m pytest -q
|
|
249
|
+
# The docs gate the release runs, locally:
|
|
250
|
+
.venv/3.14-nt-amd64/Scripts/python -m mkdocs build --strict
|
|
242
251
|
```
|
|
243
252
|
|
|
253
|
+
Every extra, so the documented environment is a superset of CI's: without
|
|
254
|
+
`psrp` a capability test skips silently, and without `docs` the gate that can
|
|
255
|
+
block a release cannot be run at all.
|
|
256
|
+
|
|
244
257
|
Python 3.14 is the default development interpreter. Python 3.9 remains the
|
|
245
258
|
supported compatibility floor and should be selected explicitly with
|
|
246
|
-
`py -3.9` when running floor-specific checks
|
|
259
|
+
`py -3.9` when running floor-specific checks; name that environment
|
|
260
|
+
`.venv/3.9-nt-amd64` so the two agree.
|
|
261
|
+
|
|
262
|
+
The venv names carry the platform `sysconfig.get_platform()` reports, so a
|
|
263
|
+
checkout on an ARM64 Windows machine still names them `amd64`: `cryptography`
|
|
264
|
+
publishes no `win_arm64` wheel and building it from source needs a Rust
|
|
265
|
+
toolchain, so `asyncssh` -- and with it the whole `ssh` extra -- cannot be
|
|
266
|
+
installed under a native ARM64 interpreter. If that changes, the native
|
|
267
|
+
interpreter is `py -V:3.14-arm64` and the environment should be named
|
|
268
|
+
`.venv/3.14-nt-arm64`.
|
|
247
269
|
|
|
248
270
|
### Releasing
|
|
249
271
|
|
|
250
272
|
This project follows [Semantic Versioning](https://semver.org/) and keeps a
|
|
251
|
-
[`CHANGELOG.md`](CHANGELOG.md). Pushing a tag matching `v*` triggers the release
|
|
252
|
-
workflow: test gate → build → publish
|
|
273
|
+
[`CHANGELOG.md`](https://github.com/jose-pr/hostctl/blob/main/CHANGELOG.md). Pushing a tag matching `v*` triggers the release
|
|
274
|
+
workflow: test gate → build → publish, with a strict docs build as a gate.
|
|
275
|
+
The docs site itself is deployed by `docs.yml`, which also redeploys on a push
|
|
276
|
+
to `main` touching docs sources and on manual dispatch -- so a docs fix reaches
|
|
277
|
+
the site without cutting a tag.
|
|
253
278
|
|
|
254
279
|
To prepare a release, update `pyproject.toml` and move the complete
|
|
255
280
|
`[Unreleased]` section to `## [X.Y.Z] - YYYY-MM-DD` in the same commit. Keep
|
|
@@ -261,4 +286,4 @@ the next cycle.
|
|
|
261
286
|
|
|
262
287
|
## License
|
|
263
288
|
|
|
264
|
-
MIT — see [LICENSE](LICENSE).
|
|
289
|
+
MIT — see [LICENSE](https://github.com/jose-pr/hostctl/blob/main/LICENSE).
|