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.
Files changed (159) hide show
  1. hostctl-0.3.0/.gitignore +51 -0
  2. hostctl-0.3.0/AGENTS.md +72 -0
  3. {hostctl-0.2.7 → hostctl-0.3.0}/CHANGELOG.md +212 -0
  4. {hostctl-0.2.7 → hostctl-0.3.0}/PKG-INFO +48 -17
  5. {hostctl-0.2.7 → hostctl-0.3.0}/README.md +36 -11
  6. hostctl-0.3.0/benchmarks/README.md +80 -0
  7. hostctl-0.3.0/benchmarks/results/0.2.7-py3.14.json +99 -0
  8. hostctl-0.3.0/benchmarks/results/0.2.7-py3.9.json +99 -0
  9. hostctl-0.3.0/benchmarks/results/0.3.0-py3.14.json +99 -0
  10. hostctl-0.3.0/benchmarks/results/0.3.0-py3.9.json +99 -0
  11. hostctl-0.3.0/benchmarks/run.py +175 -0
  12. hostctl-0.3.0/docs/guide/cli.md +60 -0
  13. hostctl-0.3.0/docs/guide/contracts.md +107 -0
  14. {hostctl-0.2.7 → hostctl-0.3.0}/docs/guide/extending.md +12 -0
  15. {hostctl-0.2.7 → hostctl-0.3.0}/docs/guide/path.md +13 -3
  16. {hostctl-0.2.7 → hostctl-0.3.0}/docs/guide/providers.md +24 -12
  17. {hostctl-0.2.7 → hostctl-0.3.0}/docs/guide/run.md +110 -15
  18. {hostctl-0.2.7 → hostctl-0.3.0}/docs/guide/transfer.md +7 -7
  19. {hostctl-0.2.7 → hostctl-0.3.0}/mkdocs.yml +4 -0
  20. {hostctl-0.2.7 → hostctl-0.3.0}/pyproject.toml +49 -15
  21. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/AGENTS.md +243 -24
  22. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/_async.py +58 -3
  23. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/_cli.py +146 -41
  24. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/executor/__init__.py +4 -2
  25. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/executor/_common.py +111 -7
  26. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/executor/_qga.py +248 -34
  27. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/executor/container.py +9 -6
  28. hostctl-0.3.0/src/hostctl/executor/local.py +276 -0
  29. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/executor/qemu.py +87 -12
  30. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/executor/serial.py +28 -119
  31. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/executor/ssh.py +77 -51
  32. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/executor/winrm.py +33 -10
  33. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/__init__.py +3 -0
  34. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/_common.py +288 -151
  35. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/_connection.py +121 -14
  36. hostctl-0.3.0/src/hostctl/host/_local.py +156 -0
  37. hostctl-0.3.0/src/hostctl/host/_qga_helper.py +345 -0
  38. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/_ssh.py +181 -37
  39. hostctl-0.3.0/src/hostctl/host/_staged_io.py +385 -0
  40. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/_winrm.py +178 -32
  41. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/composite_path.py +329 -70
  42. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/container.py +80 -26
  43. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/container_path.py +286 -83
  44. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/qemu.py +342 -73
  45. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/serial.py +155 -46
  46. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/host/system.py +478 -107
  47. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/process/_common.py +48 -23
  48. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/process/container.py +76 -12
  49. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/process/psrp.py +66 -11
  50. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/process/qemu_serial.py +54 -15
  51. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/process/serial.py +88 -20
  52. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/process/ssh.py +61 -2
  53. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/provider/_common.py +157 -17
  54. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/provider/transports.py +57 -9
  55. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/serial/__init__.py +176 -24
  56. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/shell/__init__.py +2 -0
  57. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/shell/_common.py +215 -24
  58. hostctl-0.3.0/src/hostctl/shell/_grammar.py +97 -0
  59. hostctl-0.3.0/src/hostctl/shell/cmd.py +308 -0
  60. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/shell/fish.py +23 -3
  61. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/shell/posix.py +21 -3
  62. hostctl-0.3.0/src/hostctl/shell/powershell.py +258 -0
  63. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/sync.py +129 -17
  64. {hostctl-0.2.7 → hostctl-0.3.0}/tests/conformance/path_fakes.py +88 -13
  65. {hostctl-0.2.7 → hostctl-0.3.0}/tests/conformance/providers.py +274 -57
  66. hostctl-0.3.0/tests/conformance/test_composite_path_routing.py +244 -0
  67. hostctl-0.3.0/tests/conformance/test_live.py +157 -0
  68. {hostctl-0.2.7 → hostctl-0.3.0}/tests/conformance/test_path_contract.py +11 -2
  69. {hostctl-0.2.7 → hostctl-0.3.0}/tests/conformance/test_process_contract.py +14 -0
  70. hostctl-0.3.0/tests/conformance/test_registry.py +49 -0
  71. {hostctl-0.2.7 → hostctl-0.3.0}/tests/conformance/test_run_contract.py +128 -7
  72. {hostctl-0.2.7 → hostctl-0.3.0}/tests/conformance/test_sync_contract.py +11 -6
  73. hostctl-0.3.0/tests/conftest.py +25 -0
  74. hostctl-0.3.0/tests/test_asyncssh_errors.py +166 -0
  75. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_cli.py +23 -0
  76. hostctl-0.3.0/tests/test_cli_console.py +131 -0
  77. hostctl-0.3.0/tests/test_cmd_live_effects.py +82 -0
  78. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_composite_path_backend_kwargs.py +3 -9
  79. hostctl-0.3.0/tests/test_composite_path_inherited_operations.py +286 -0
  80. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_composite_path_propagation.py +120 -0
  81. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_connection_string.py +92 -0
  82. hostctl-0.3.0/tests/test_container_path.py +564 -0
  83. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_container_process.py +136 -0
  84. hostctl-0.3.0/tests/test_docs_snippets.py +98 -0
  85. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_exec_command.py +30 -1
  86. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_host_api.py +358 -3
  87. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_host_container.py +63 -0
  88. hostctl-0.3.0/tests/test_host_local.py +257 -0
  89. hostctl-0.3.0/tests/test_host_qemu.py +251 -0
  90. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_host_remote.py +246 -9
  91. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_host_winrm.py +192 -22
  92. hostctl-0.3.0/tests/test_login_shell.py +158 -0
  93. hostctl-0.3.0/tests/test_module_hygiene.py +105 -0
  94. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_observability.py +33 -0
  95. hostctl-0.3.0/tests/test_packaging.py +186 -0
  96. hostctl-0.3.0/tests/test_powershell_status_live.py +74 -0
  97. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_process.py +28 -2
  98. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_provider_fault_injection.py +314 -1
  99. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_psrp.py +84 -3
  100. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_qemu_executor.py +55 -7
  101. hostctl-0.3.0/tests/test_qemu_guest_semantics.py +204 -0
  102. hostctl-0.3.0/tests/test_qemu_lifecycle.py +198 -0
  103. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_qemu_path.py +62 -2
  104. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_qemu_serial_process.py +60 -0
  105. hostctl-0.3.0/tests/test_qga_errors.py +257 -0
  106. hostctl-0.3.0/tests/test_qga_path_helper.py +388 -0
  107. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_qga_ssh_transport.py +44 -0
  108. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_qga_transport.py +116 -0
  109. hostctl-0.3.0/tests/test_serial_contract.py +254 -0
  110. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_serial_executor.py +16 -17
  111. hostctl-0.3.0/tests/test_serial_framing.py +234 -0
  112. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_serial_host.py +56 -0
  113. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_serial_live.py +5 -1
  114. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_shell.py +252 -5
  115. hostctl-0.3.0/tests/test_shell_rendering.py +315 -0
  116. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_ssh_process.py +63 -5
  117. hostctl-0.3.0/tests/test_staged_io.py +348 -0
  118. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_sync_helpers.py +238 -2
  119. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_system_host_fidelity.py +4 -1
  120. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_system_hosts.py +452 -2
  121. hostctl-0.3.0/tests/test_uri_hygiene.py +68 -0
  122. hostctl-0.3.0/tests/test_winrm_path.py +420 -0
  123. hostctl-0.3.0/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.0}/LICENSE +0 -0
  142. {hostctl-0.2.7 → hostctl-0.3.0}/docs/api/reference.md +0 -0
  143. {hostctl-0.2.7 → hostctl-0.3.0}/docs/changelog.md +0 -0
  144. {hostctl-0.2.7 → hostctl-0.3.0}/docs/index.md +0 -0
  145. {hostctl-0.2.7 → hostctl-0.3.0}/examples/application_provider.py +0 -0
  146. {hostctl-0.2.7 → hostctl-0.3.0}/examples/copy_between_hosts.py +0 -0
  147. {hostctl-0.2.7 → hostctl-0.3.0}/examples/local_run.py +0 -0
  148. {hostctl-0.2.7 → hostctl-0.3.0}/examples/remote_run.py +0 -0
  149. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/__init__.py +0 -0
  150. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/__main__.py +0 -0
  151. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/executor/psrp.py +0 -0
  152. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/process/__init__.py +0 -0
  153. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/provider/__init__.py +0 -0
  154. {hostctl-0.2.7 → hostctl-0.3.0}/src/hostctl/py.typed +0 -0
  155. {hostctl-0.2.7 → hostctl-0.3.0}/tests/conformance/__init__.py +0 -0
  156. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_application_provider.py +0 -0
  157. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_executor_exports.py +0 -0
  158. {hostctl-0.2.7 → hostctl-0.3.0}/tests/test_host_module_layout.py +0 -0
  159. {hostctl-0.2.7 → hostctl-0.3.0}/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,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.2.7
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.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).