hostctl 0.2.7__tar.gz → 0.3.1__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.
Files changed (159) hide show
  1. hostctl-0.3.1/.gitignore +51 -0
  2. hostctl-0.3.1/AGENTS.md +72 -0
  3. {hostctl-0.2.7 → hostctl-0.3.1}/CHANGELOG.md +236 -1
  4. {hostctl-0.2.7 → hostctl-0.3.1}/PKG-INFO +48 -17
  5. {hostctl-0.2.7 → hostctl-0.3.1}/README.md +36 -11
  6. hostctl-0.3.1/benchmarks/README.md +80 -0
  7. hostctl-0.3.1/benchmarks/results/0.2.7-py3.14.json +99 -0
  8. hostctl-0.3.1/benchmarks/results/0.2.7-py3.9.json +99 -0
  9. hostctl-0.3.1/benchmarks/results/0.3.0-py3.14.json +99 -0
  10. hostctl-0.3.1/benchmarks/results/0.3.0-py3.9.json +99 -0
  11. hostctl-0.3.1/benchmarks/run.py +175 -0
  12. hostctl-0.3.1/docs/guide/cli.md +60 -0
  13. hostctl-0.3.1/docs/guide/contracts.md +107 -0
  14. {hostctl-0.2.7 → hostctl-0.3.1}/docs/guide/extending.md +12 -0
  15. {hostctl-0.2.7 → hostctl-0.3.1}/docs/guide/path.md +13 -3
  16. {hostctl-0.2.7 → hostctl-0.3.1}/docs/guide/providers.md +33 -13
  17. {hostctl-0.2.7 → hostctl-0.3.1}/docs/guide/run.md +110 -15
  18. {hostctl-0.2.7 → hostctl-0.3.1}/docs/guide/transfer.md +7 -7
  19. {hostctl-0.2.7 → hostctl-0.3.1}/mkdocs.yml +4 -0
  20. {hostctl-0.2.7 → hostctl-0.3.1}/pyproject.toml +49 -15
  21. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/AGENTS.md +248 -24
  22. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/_async.py +58 -3
  23. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/_cli.py +146 -41
  24. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/executor/__init__.py +4 -2
  25. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/executor/_common.py +111 -7
  26. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/executor/_qga.py +248 -34
  27. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/executor/container.py +9 -6
  28. hostctl-0.3.1/src/hostctl/executor/local.py +276 -0
  29. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/executor/qemu.py +87 -12
  30. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/executor/serial.py +28 -119
  31. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/executor/ssh.py +77 -51
  32. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/executor/winrm.py +33 -10
  33. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/host/__init__.py +3 -0
  34. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/host/_common.py +288 -151
  35. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/host/_connection.py +121 -14
  36. hostctl-0.3.1/src/hostctl/host/_local.py +156 -0
  37. hostctl-0.3.1/src/hostctl/host/_qga_helper.py +345 -0
  38. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/host/_ssh.py +195 -37
  39. hostctl-0.3.1/src/hostctl/host/_staged_io.py +385 -0
  40. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/host/_winrm.py +178 -32
  41. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/host/composite_path.py +350 -71
  42. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/host/container.py +80 -26
  43. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/host/container_path.py +286 -83
  44. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/host/qemu.py +342 -73
  45. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/host/serial.py +155 -46
  46. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/host/system.py +478 -107
  47. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/process/_common.py +48 -23
  48. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/process/container.py +76 -12
  49. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/process/psrp.py +66 -11
  50. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/process/qemu_serial.py +54 -15
  51. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/process/serial.py +88 -20
  52. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/process/ssh.py +61 -2
  53. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/provider/_common.py +191 -17
  54. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/provider/transports.py +57 -9
  55. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/serial/__init__.py +176 -24
  56. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/shell/__init__.py +2 -0
  57. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/shell/_common.py +215 -24
  58. hostctl-0.3.1/src/hostctl/shell/_grammar.py +97 -0
  59. hostctl-0.3.1/src/hostctl/shell/cmd.py +308 -0
  60. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/shell/fish.py +23 -3
  61. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/shell/posix.py +21 -3
  62. hostctl-0.3.1/src/hostctl/shell/powershell.py +258 -0
  63. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/sync.py +129 -17
  64. {hostctl-0.2.7 → hostctl-0.3.1}/tests/conformance/path_fakes.py +88 -13
  65. {hostctl-0.2.7 → hostctl-0.3.1}/tests/conformance/providers.py +274 -57
  66. hostctl-0.3.1/tests/conformance/test_composite_path_routing.py +244 -0
  67. hostctl-0.3.1/tests/conformance/test_live.py +157 -0
  68. {hostctl-0.2.7 → hostctl-0.3.1}/tests/conformance/test_path_contract.py +11 -2
  69. {hostctl-0.2.7 → hostctl-0.3.1}/tests/conformance/test_process_contract.py +14 -0
  70. hostctl-0.3.1/tests/conformance/test_registry.py +49 -0
  71. {hostctl-0.2.7 → hostctl-0.3.1}/tests/conformance/test_run_contract.py +128 -7
  72. {hostctl-0.2.7 → hostctl-0.3.1}/tests/conformance/test_sync_contract.py +11 -6
  73. hostctl-0.3.1/tests/conftest.py +25 -0
  74. hostctl-0.3.1/tests/test_asyncssh_errors.py +166 -0
  75. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_cli.py +23 -0
  76. hostctl-0.3.1/tests/test_cli_console.py +131 -0
  77. hostctl-0.3.1/tests/test_cmd_live_effects.py +82 -0
  78. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_composite_path_backend_kwargs.py +3 -9
  79. hostctl-0.3.1/tests/test_composite_path_inherited_operations.py +286 -0
  80. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_composite_path_propagation.py +120 -0
  81. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_connection_string.py +92 -0
  82. hostctl-0.3.1/tests/test_container_path.py +564 -0
  83. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_container_process.py +136 -0
  84. hostctl-0.3.1/tests/test_docs_snippets.py +98 -0
  85. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_exec_command.py +30 -1
  86. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_host_api.py +358 -3
  87. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_host_container.py +63 -0
  88. hostctl-0.3.1/tests/test_host_local.py +257 -0
  89. hostctl-0.3.1/tests/test_host_qemu.py +251 -0
  90. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_host_remote.py +246 -9
  91. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_host_winrm.py +192 -22
  92. hostctl-0.3.1/tests/test_login_shell.py +158 -0
  93. hostctl-0.3.1/tests/test_module_hygiene.py +105 -0
  94. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_observability.py +33 -0
  95. hostctl-0.3.1/tests/test_packaging.py +186 -0
  96. hostctl-0.3.1/tests/test_powershell_status_live.py +74 -0
  97. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_process.py +28 -2
  98. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_provider_fault_injection.py +445 -1
  99. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_psrp.py +84 -3
  100. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_qemu_executor.py +55 -7
  101. hostctl-0.3.1/tests/test_qemu_guest_semantics.py +204 -0
  102. hostctl-0.3.1/tests/test_qemu_lifecycle.py +198 -0
  103. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_qemu_path.py +62 -2
  104. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_qemu_serial_process.py +60 -0
  105. hostctl-0.3.1/tests/test_qga_errors.py +257 -0
  106. hostctl-0.3.1/tests/test_qga_path_helper.py +388 -0
  107. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_qga_ssh_transport.py +44 -0
  108. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_qga_transport.py +116 -0
  109. hostctl-0.3.1/tests/test_serial_contract.py +254 -0
  110. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_serial_executor.py +16 -17
  111. hostctl-0.3.1/tests/test_serial_framing.py +234 -0
  112. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_serial_host.py +56 -0
  113. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_serial_live.py +5 -1
  114. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_shell.py +252 -5
  115. hostctl-0.3.1/tests/test_shell_rendering.py +315 -0
  116. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_ssh_process.py +63 -5
  117. hostctl-0.3.1/tests/test_staged_io.py +348 -0
  118. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_sync_helpers.py +238 -2
  119. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_system_host_fidelity.py +4 -1
  120. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_system_hosts.py +452 -2
  121. hostctl-0.3.1/tests/test_uri_hygiene.py +68 -0
  122. hostctl-0.3.1/tests/test_winrm_path.py +420 -0
  123. hostctl-0.3.1/tests/test_winrm_powershell_live.py +149 -0
  124. hostctl-0.2.7/.gitignore +0 -26
  125. hostctl-0.2.7/docs/guide/cli.md +0 -34
  126. hostctl-0.2.7/docs/guide/contracts.md +0 -79
  127. hostctl-0.2.7/src/hostctl/executor/local.py +0 -79
  128. hostctl-0.2.7/src/hostctl/host/_local.py +0 -258
  129. hostctl-0.2.7/src/hostctl/host/_staged_io.py +0 -132
  130. hostctl-0.2.7/src/hostctl/shell/cmd.py +0 -166
  131. hostctl-0.2.7/src/hostctl/shell/powershell.py +0 -119
  132. hostctl-0.2.7/tests/conformance/test_composite_path_routing.py +0 -75
  133. hostctl-0.2.7/tests/conformance/test_live.py +0 -80
  134. hostctl-0.2.7/tests/test_asyncssh_errors.py +0 -81
  135. hostctl-0.2.7/tests/test_composite_path_inherited_operations.py +0 -124
  136. hostctl-0.2.7/tests/test_container_path.py +0 -248
  137. hostctl-0.2.7/tests/test_host_local.py +0 -124
  138. hostctl-0.2.7/tests/test_host_qemu.py +0 -126
  139. hostctl-0.2.7/tests/test_staged_io.py +0 -143
  140. hostctl-0.2.7/tests/test_winrm_path.py +0 -233
  141. {hostctl-0.2.7 → hostctl-0.3.1}/LICENSE +0 -0
  142. {hostctl-0.2.7 → hostctl-0.3.1}/docs/api/reference.md +0 -0
  143. {hostctl-0.2.7 → hostctl-0.3.1}/docs/changelog.md +0 -0
  144. {hostctl-0.2.7 → hostctl-0.3.1}/docs/index.md +0 -0
  145. {hostctl-0.2.7 → hostctl-0.3.1}/examples/application_provider.py +0 -0
  146. {hostctl-0.2.7 → hostctl-0.3.1}/examples/copy_between_hosts.py +0 -0
  147. {hostctl-0.2.7 → hostctl-0.3.1}/examples/local_run.py +0 -0
  148. {hostctl-0.2.7 → hostctl-0.3.1}/examples/remote_run.py +0 -0
  149. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/__init__.py +0 -0
  150. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/__main__.py +0 -0
  151. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/executor/psrp.py +0 -0
  152. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/process/__init__.py +0 -0
  153. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/provider/__init__.py +0 -0
  154. {hostctl-0.2.7 → hostctl-0.3.1}/src/hostctl/py.typed +0 -0
  155. {hostctl-0.2.7 → hostctl-0.3.1}/tests/conformance/__init__.py +0 -0
  156. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_application_provider.py +0 -0
  157. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_executor_exports.py +0 -0
  158. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_host_module_layout.py +0 -0
  159. {hostctl-0.2.7 → hostctl-0.3.1}/tests/test_qemu_serial.py +0 -0
@@ -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
+
@@ -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,237 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.1] - 2026-09-22
11
+
12
+ ### Fixed
13
+
14
+ - **A composite path falls back when SFTP cannot connect.** Nothing on the
15
+ path route connected the provider first: pathlib_next dialled SFTP inside
16
+ the operation, so an untrusted host key (`HostKeyNotVerifiable`) or a
17
+ refused port escaped raw and the next path provider was never tried --
18
+ while `run()` on the same host fell back. A composite path now calls the
19
+ provider's `connect()` before dispatch, and SFTP maps asyncssh's connect
20
+ errors the way the SSH leg already did. Most likely to fire since 0.3.0
21
+ started verifying the SFTP host key: any host not yet in `known_hosts`.
22
+ - **`run()` no longer re-dials a declined provider on every call.** 0.3.0
23
+ cleared every decline per call, so a dead primary cost a full connect
24
+ attempt (~9 s against a refused port) on each `run()` while the fallback
25
+ served. A decline now lasts for the generation again, and is re-admitted
26
+ only when no other provider can serve -- which keeps a single-provider
27
+ host from bricking on one transient refusal, for paths as well as `run()`.
28
+ - A path refusal with nothing left to try names why ("no path provider
29
+ completed read (...: Host key is not trusted ...)") and keeps the cause.
30
+
31
+ ## [0.3.0] - 2026-09-22
32
+
33
+ ### Added
34
+
35
+ - **QGA paths have metadata and namespace operations.** `QemuHost.path()`
36
+ now supports `stat`, `iterdir`, `mkdir`, `unlink`, `rename` and `chmod`
37
+ through a guest-side helper built during discovery -- hostctl previously
38
+ shipped none, so a caller had to write one and every QGA path reported
39
+ `degraded`. The helper is POSITIVELY PROBED: GNU `stat -c`/`find -printf`
40
+ on a POSIX guest, PowerShell on a Windows one. A guest that fails the
41
+ probe keeps content access and loses only what genuinely needs a helper.
42
+ `QemuConfig(path_helper=...)` still overrides.
43
+ - **`SshConfig(login_shell="auto"|"cmd"|"powershell"|"posix")`** says what
44
+ parses the command string on arrival. Windows OpenSSH's stock server
45
+ hands it to `cmd.exe /c`, so a `cmd`-dialect command line is parsed by cmd
46
+ twice; unescaped, 4 of 8 adversarial values survived and two of the
47
+ failures were command injection. `auto` reads the dialect.
48
+ - **`ShellFlavour.argument(value, *, target=ShellTarget...)`** separates
49
+ quoting for the shell's parser from quoting for whatever reads the token
50
+ next -- a cmd builtin, a child's argv parser, PowerShell's binder, or the
51
+ program slot. `quote()` remains the syntactic layer.
52
+
53
+ ### Removed
54
+
55
+ - **`SerialTransport`** and its `hostctl.executor` export. It duplicated
56
+ `SerialProcess`'s locked, deadline-clamping byte path and had no caller
57
+ outside its own tests, so the two could drift with nothing to notice.
58
+ `SerialProcess` keeps the job -- it is what every console profile reads
59
+ through -- and gained the `write(timeout=)` it lacked.
60
+ - **`SerialConfig.username` and `SerialConfig.password`.** They were stored,
61
+ whitelisted for URI dispatch, and read by nothing: console credentials live
62
+ in the profile's `login=` steps. An explicit one is now refused rather than
63
+ silently ignored, and `HOSTCTL_PASSWORD` is no longer offered to a `serial:`
64
+ URI.
65
+
66
+ ### Changed
67
+
68
+ - The `pathlib_next` floor moves to `>=0.9.10` in both `dependencies` and
69
+ the `ssh` extra. **0.9.4 through 0.9.9 cannot be used**: those releases
70
+ decide "same file" and "overlapping trees" by falling back to path
71
+ equality, which compares `(type, segments)` and knows nothing about
72
+ hosts, so copying `/etc/app.conf` from one host to the same path on
73
+ another was refused with `OSError [Errno 22] Source and target are the
74
+ same file` (and `PathSyncer` with "source and target overlap"). 0.9.10
75
+ adds the `_same_filesystem()` hook this release answers.
76
+ - **`LocalExecutor` merges `env` over the inherited environment** instead of
77
+ replacing it, so the documented cross-transport rule -- "`env` is additive
78
+ to the provider's environment" -- now holds locally too. A child no longer
79
+ starts without `PATH`, `HOME` or `SystemRoot` because one variable was set.
80
+ - **A serial `run()` frames each command as its own profile exchange** and
81
+ refuses a structured (argv) command: a serial host names no shell flavour,
82
+ so it has no quoting rule to apply, and joining commands with `;` put two
83
+ on one line where most device consoles read a single malformed one.
84
+ `stdin=` and `bufsize=` raise instead of being ignored, as `input=` already
85
+ did.
86
+ - **A serial `spawn(encoding=...)` reads text**, matching the SSH and QEMU
87
+ console adapters; without either text keyword it still reads bytes.
88
+ - **QEMU `dialect="auto"`/`path_flavor="auto"` need positive evidence** of the
89
+ guest's family -- `guest-get-osinfo`, or a family-exclusive command in
90
+ `guest-info` -- and raise naming the explicit settings when neither is
91
+ available. An agent with `guest-get-osinfo` blocklisted used to be read as
92
+ POSIX, silently, including on Windows.
93
+ - **`QemuHost.info().os_family` is a family** (`linux`, `windows`,
94
+ `freebsd`), taken from `kernel-name`. It carried `guest-get-osinfo`'s `id`,
95
+ so the same machine reported `linux` over SSH and `ubuntu` over QGA.
96
+ - **Each guest-agent request is bounded by `agent_timeout`**, separately from
97
+ a command's own `timeout=`: an agent that stops answering is a
98
+ `ConnectionError` saying so, not a command timeout ten minutes later.
99
+ - **A truncated QGA `run()` result warns.** `stdout_truncated`/
100
+ `stderr_truncated` were set and documented nowhere, so a shortened
101
+ transcript looked complete.
102
+ - **`hostctl shell` opens a console that cannot allocate a terminal**, so a
103
+ `serial:` URI works; **`hostctl cp local:/tmp/x`** is accepted, the
104
+ spelling every other subcommand documents; and `hostctl --help` carries the
105
+ URI forms, the `URI:PATH` grammar, the `HOSTCTL_PASSWORD` rule and the exit
106
+ codes.
107
+ - **`asyncssh` and `libvirt-python` are bounded** (`>=2.14,<3`, `>=9.0,<12`);
108
+ the `dev` extra now pulls every transport, so `pip install -e ".[dev]"` is
109
+ the whole development environment.
110
+ - **`LocalHost` is a `SystemHost`.** It duplicated the provider selection,
111
+ failover loop, capability computation and dispatch ladder, and the two
112
+ copies diverged. Two consequences for callers:
113
+ **`LocalHost().path()` with no segments no longer returns the absolute
114
+ working directory** -- it returns a path with no segments added, like
115
+ every other transport, which still resolves against the process's working
116
+ directory. A local path is still a plain `pathlib_next` path, not a
117
+ composite.
118
+
119
+ ### Fixed
120
+
121
+ - **A transfer between two hosts is no longer mistaken for a file copied
122
+ onto itself.** `CompositePosixPath`/`CompositeWindowsPath` now answer
123
+ `pathlib_next`'s `_same_filesystem()` with the host's provider set, so
124
+ two hosts are two namespaces even when a path is spelled identically on
125
+ both, while two paths on one host still are one — which keeps the guard
126
+ that refuses `copy()` of a file onto itself. A `.via()` pin selects a
127
+ route to the host, not a different host, and answers accordingly.
128
+ - **The SFTP leg verifies the server's host key.** `SshConfig.connect_opts()`
129
+ omitted `known_hosts` whenever it held its `()` default. asyncssh reads a
130
+ missing key as "resolve known_hosts the usual way", so `run()` verified the
131
+ server — but `pathlib_next`'s SFTP connect seeds `known_hosts=None`
132
+ (verification off) for options it is not handed, so `path()` accepted any
133
+ host key, including an actively substituted one, and sent it the configured
134
+ password. Opting out is still possible and now explicit: `known_hosts=None`.
135
+ - **`ConnectionString` parses `user:password@host`.** A scheme-shaped username
136
+ made `urlsplit` read `root:hunter2@nas` as the scheme `root`, so the password
137
+ was never recognised: it stayed in the path, where `str()` and `repr()`
138
+ rendered it verbatim, the host came out empty, and `is_local` reported a
139
+ remote machine as local. Without a scheme to assume, a target carrying
140
+ credentials is now refused rather than guessed at, and both error messages
141
+ redact (`root:<redacted>@nas`).
142
+ - **An args-capable executor gets one shell layer, not two.** `SystemHost`
143
+ fed a finished shell command line back into `flavour.invocation()`, which
144
+ takes a script. On Windows the outer PowerShell re-parsed the inner
145
+ `-Command` string, so `windows://node?executor=local` reported 0 for
146
+ `cmd /c exit 3` — `check=True` passed for a failing command — and quoted
147
+ text and variables came back mangled or empty.
148
+ - **cmd.exe builtin operands are quoted, and `set` no longer escapes inside
149
+ quotes.** cmd splits a builtin's operands on `,`, `;` and `=` as well as
150
+ whitespace, so `del /q a b.txt` deleted `a` and `b.txt` and left the named
151
+ file. Separately, `set "KEY=VALUE"` caret-escaped inside a quoted span,
152
+ where cmd does not process carets: `100%` reached the child as `100^%`,
153
+ `%OS%` still expanded, and a `"` in the value ended the assignment early.
154
+ An empty value remains inexpressible in cmd, and is now documented as such.
155
+ - **PowerShell 5 native arguments survive the C runtime.** PS 5.1 rebuilds the
156
+ command line for a native program and leaves embedded quotes alone, so
157
+ `run(("robocopy", src, dst, name))` with `name = 'my file" /MIR "z'` handed
158
+ robocopy `/MIR` — mirror mode, which deletes files in the destination. Empty
159
+ arguments were dropped and a trailing backslash swallowed the next argument.
160
+ PowerShell 7 is unaffected and keeps the plain literal.
161
+ - **A transfer to another backend no longer renames inside the source host.**
162
+ `ssh_host.path('/srv/export.csv').move(LocalPath('/home/op/export.csv'))`
163
+ issued an SFTP rename on the server: the file left `/srv`, never arrived,
164
+ and no error was raised. A `str` destination — documented by `pathlib_next`
165
+ — also raised `TypeError` on every transport, and a cross-provider `move()`
166
+ aborted instead of falling back to copy + remove. All three now work.
167
+ - **Docker file modes are translated.** The archive stat header carries Go's
168
+ `os.FileMode`, used verbatim as a POSIX `st_mode`: `is_file()` was False for
169
+ every ordinary file and `is_dir()` raised `OverflowError: mode out of range`
170
+ for a directory, which also killed any recursive copy.
171
+ - **An unreachable QGA socket is reported as a transport failure.** It escaped
172
+ as a bare `FileNotFoundError`, indistinguishable from the guest file being
173
+ absent — so an append across a guest reboot staged an empty buffer and
174
+ truncated the guest file when the agent came back.
175
+ - **Native WinRM reports the remote exit status.** The provider did not
176
+ declare `manages_status`, so PowerShell's `; exit $LASTEXITCODE` epilogue was
177
+ appended and its `exit` ended the remote pipeline before the
178
+ `__HOSTCTL_LASTEXITCODE__` marker could be emitted. A command that failed by
179
+ exit status reported 0 and `check=True` passed.
180
+ - **One unsupported operation no longer takes a path provider out of
181
+ service.** A `NotImplementedError` from a retry-safe call was recorded as
182
+ a decline on the host's shared provider selector, which every later
183
+ operation then consulted: after a backend refused `samefile()` — it needs
184
+ `st_dev`/`st_ino`, which many remote stats lack — the next read on any
185
+ path of that host failed with `no path provider supports open_read`. The
186
+ refusal is now scoped to the call that raised it. `OperationNotStarted`
187
+ still declines the provider for the generation, as before.
188
+ - **A `cmd.exe` at a path containing a space is launchable.** The program
189
+ slot of a Windows command line is read by `CreateProcess`, not by cmd, and
190
+ rendering it with cmd's caret quoting made Windows look for a program named
191
+ `^` -- measured as `FileNotFoundError: [WinError 2]`.
192
+ - **A structured PowerShell command can bind a named parameter.**
193
+ `["Remove-Item", "-LiteralPath", path]` rendered every token as a quoted
194
+ string, which PowerShell's binder reads positionally: measured against a
195
+ real `powershell.exe`, it returned 1 and left the file in place. A token
196
+ that is exactly a parameter name is now unquoted.
197
+ - **`ShellSession.send()` does not append a separator the text already has**,
198
+ so `send("echo hi;")` is not `echo hi;;` and `send("sleep 5 &")` is not
199
+ `sleep 5 &;`.
200
+ - **`zsh` quoting covers a leading `=`**, which zsh expands to a program path
201
+ where POSIX `sh` leaves it alone.
202
+ - **`PowerShellFlavour(7, executable=...)` is named `pwsh`.** The name was
203
+ derived only when no executable was given, so an explicit path left the
204
+ flavour identifying as `powershell`.
205
+ - **`Shell(..., env={})` forwards no environment**, rather than an empty one
206
+ that starts a child with nothing.
207
+ - **`normalize_input` covers `bytearray` and `memoryview`** -- the deadlock
208
+ it exists to prevent, for the two buffer types it did not check.
209
+ - **Container archive reads no longer lie about links, sizes or errors**: a
210
+ single-member hardlink archive raises `OSError(ENOENT)` instead of
211
+ `tarfile.StreamError`, a hardlinked file in a listing reports its real
212
+ size, the archive root (`"."`) is listable, and every Docker status code
213
+ maps through one helper that populates `errno`.
214
+ - **A container exec stream no longer returns `''` mid-character**, and a
215
+ closed container process reads EOF instead of raising the operating
216
+ system's raw socket error.
217
+ - **QGA failures speak one error vocabulary**: a guest error is classified on
218
+ the `strerror` tail (a file named `notfound.log` was reported missing
219
+ whatever actually failed), `host.run()` of a missing guest program raises
220
+ `FileNotFoundError`, libvirt-relayed guest errors stay guest errors, and a
221
+ guest error no longer tears down and re-handshakes the connection.
222
+ - **One transport per QEMU host.** Lazy initialisation was unguarded:
223
+ measured with six threads, six transports were built where one was wanted,
224
+ each SSH one opening a connection nobody owned. A path handed out before
225
+ `close()` now refuses instead of silently reconnecting.
226
+ - **A serial login failure raises `ConsoleProtocolError`**, not a bare
227
+ `TimeoutError` that slipped past every documented handler, and a transcript
228
+ attached to an error has `secret=True` login values redacted -- consoles
229
+ echo what they are sent.
230
+ - **A QGA reader is seekable** where the agent advertises `guest-file-seek`,
231
+ so `zipfile` and `tarfile` work against a guest path, and an exclusive
232
+ `open("x")` is refused at `open()` rather than after the whole payload has
233
+ been staged.
234
+ - **A `cmd`-dialect command sent over SSH to Windows is escaped for the
235
+ login shell that receives it.** Two of the eight adversarial values were
236
+ command injection, not corruption.
237
+ - **`SshConfig("ssh://host")` raises** instead of making the URI the
238
+ hostname, which surfaced much later as an unresolvable name. The
239
+ constructor takes a host; `HostConfig(uri)` takes a URI.
240
+
10
241
  ## [0.2.7] - 2026-08-16
11
242
 
12
243
  ### Changed
@@ -27,6 +258,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
27
258
  composite `symlink_to` could never work. `netimps` needs nothing past
28
259
  `0.2.0` and floors at the series start.
29
260
 
261
+ ## [0.2.6] - 2026-08-16
262
+
30
263
  ### Added
31
264
 
32
265
  - `wants_text(text, encoding, errors)` is exported from `hostctl.executor`,
@@ -529,7 +762,9 @@ test suite on Python 3.9 through 3.14.
529
762
  assigned to it; a config-less host now builds its own family configuration
530
763
  instead.
531
764
 
532
- [Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.7...HEAD
765
+ [Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.3.1...HEAD
766
+ [0.3.1]: https://github.com/jose-pr/hostctl/compare/v0.3.0...v0.3.1
767
+ [0.3.0]: https://github.com/jose-pr/hostctl/compare/v0.2.7...v0.3.0
533
768
  [0.2.7]: https://github.com/jose-pr/hostctl/compare/v0.2.6...v0.2.7
534
769
  [0.2.6]: https://github.com/jose-pr/hostctl/compare/v0.2.5...v0.2.6
535
770
  [0.2.5]: https://github.com/jose-pr/hostctl/compare/v0.2.4...v0.2.5
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hostctl
3
- Version: 0.2.7
3
+ Version: 0.3.1
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.3,>=0.2.0
28
- Requires-Dist: pathlib-next<0.10,>=0.9.1
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.1; extra == 'ssh'
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
  [![Version](https://img.shields.io/pypi/v/hostctl.svg)](https://pypi.org/project/hostctl/)
57
- [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
63
+ [![Python versions](https://img.shields.io/pypi/pyversions/hostctl.svg)](https://pypi.org/project/hostctl/)
64
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/jose-pr/hostctl/blob/main/LICENSE)
58
65
  [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/hostctl/)
59
66
  [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/hostctl/release.yml?label=release%20gate)](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`). Unsupported transport
71
- options raise `NotImplementedError`.
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\n", "hello"])
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\n", "hello from the session"])
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,ssh,winrm,container,serial]"
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 → docs deploy.
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
  [![Version](https://img.shields.io/pypi/v/hostctl.svg)](https://pypi.org/project/hostctl/)
2
- [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
2
+ [![Python versions](https://img.shields.io/pypi/pyversions/hostctl.svg)](https://pypi.org/project/hostctl/)
3
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/jose-pr/hostctl/blob/main/LICENSE)
3
4
  [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/hostctl/)
4
5
  [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/hostctl/release.yml?label=release%20gate)](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`). Unsupported transport
16
- options raise `NotImplementedError`.
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\n", "hello"])
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\n", "hello from the session"])
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,ssh,winrm,container,serial]"
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 → docs deploy.
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).