hostctl 0.2.4__tar.gz → 0.2.6__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 (115) hide show
  1. {hostctl-0.2.4 → hostctl-0.2.6}/.gitignore +4 -0
  2. {hostctl-0.2.4 → hostctl-0.2.6}/CHANGELOG.md +117 -1
  3. {hostctl-0.2.4 → hostctl-0.2.6}/PKG-INFO +2 -2
  4. {hostctl-0.2.4 → hostctl-0.2.6}/docs/guide/cli.md +4 -1
  5. {hostctl-0.2.4 → hostctl-0.2.6}/docs/guide/contracts.md +3 -3
  6. {hostctl-0.2.4 → hostctl-0.2.6}/pyproject.toml +1 -1
  7. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/AGENTS.md +12 -1
  8. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/_cli.py +29 -2
  9. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/__init__.py +8 -0
  10. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/_common.py +20 -0
  11. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/container.py +2 -1
  12. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/local.py +2 -1
  13. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/psrp.py +10 -24
  14. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/qemu.py +2 -1
  15. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/ssh.py +4 -1
  16. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/winrm.py +62 -21
  17. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/_common.py +4 -3
  18. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/_ssh.py +14 -1
  19. hostctl-0.2.6/src/hostctl/host/_staged_io.py +132 -0
  20. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/_winrm.py +3 -74
  21. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/composite_path.py +71 -56
  22. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/container.py +1 -0
  23. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/container_path.py +3 -57
  24. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/qemu.py +3 -57
  25. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/serial.py +16 -11
  26. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/system.py +9 -16
  27. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/process/psrp.py +8 -1
  28. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/process/ssh.py +6 -9
  29. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/provider/transports.py +5 -20
  30. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/shell/cmd.py +0 -1
  31. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/shell/fish.py +0 -1
  32. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/shell/posix.py +0 -1
  33. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/shell/powershell.py +0 -1
  34. {hostctl-0.2.4 → hostctl-0.2.6}/tests/conformance/providers.py +58 -0
  35. {hostctl-0.2.4 → hostctl-0.2.6}/tests/conformance/test_live.py +29 -0
  36. {hostctl-0.2.4 → hostctl-0.2.6}/tests/conformance/test_path_contract.py +34 -0
  37. {hostctl-0.2.4 → hostctl-0.2.6}/tests/conformance/test_run_contract.py +53 -0
  38. {hostctl-0.2.4 → hostctl-0.2.6}/tests/conformance/test_sync_contract.py +12 -6
  39. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_cli.py +44 -0
  40. hostctl-0.2.6/tests/test_executor_exports.py +104 -0
  41. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_host_container.py +38 -0
  42. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_host_remote.py +33 -0
  43. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_host_winrm.py +94 -1
  44. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_psrp.py +111 -2
  45. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_serial_host.py +49 -0
  46. hostctl-0.2.6/tests/test_staged_io.py +143 -0
  47. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_system_hosts.py +29 -0
  48. {hostctl-0.2.4 → hostctl-0.2.6}/LICENSE +0 -0
  49. {hostctl-0.2.4 → hostctl-0.2.6}/README.md +0 -0
  50. {hostctl-0.2.4 → hostctl-0.2.6}/docs/api/reference.md +0 -0
  51. {hostctl-0.2.4 → hostctl-0.2.6}/docs/changelog.md +0 -0
  52. {hostctl-0.2.4 → hostctl-0.2.6}/docs/guide/extending.md +0 -0
  53. {hostctl-0.2.4 → hostctl-0.2.6}/docs/guide/path.md +0 -0
  54. {hostctl-0.2.4 → hostctl-0.2.6}/docs/guide/providers.md +0 -0
  55. {hostctl-0.2.4 → hostctl-0.2.6}/docs/guide/run.md +0 -0
  56. {hostctl-0.2.4 → hostctl-0.2.6}/docs/guide/transfer.md +0 -0
  57. {hostctl-0.2.4 → hostctl-0.2.6}/docs/index.md +0 -0
  58. {hostctl-0.2.4 → hostctl-0.2.6}/examples/application_provider.py +0 -0
  59. {hostctl-0.2.4 → hostctl-0.2.6}/examples/copy_between_hosts.py +0 -0
  60. {hostctl-0.2.4 → hostctl-0.2.6}/examples/local_run.py +0 -0
  61. {hostctl-0.2.4 → hostctl-0.2.6}/examples/remote_run.py +0 -0
  62. {hostctl-0.2.4 → hostctl-0.2.6}/mkdocs.yml +0 -0
  63. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/__init__.py +0 -0
  64. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/__main__.py +0 -0
  65. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/_async.py +0 -0
  66. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/_qga.py +0 -0
  67. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/executor/serial.py +0 -0
  68. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/__init__.py +0 -0
  69. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/_connection.py +0 -0
  70. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/host/_local.py +0 -0
  71. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/process/__init__.py +0 -0
  72. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/process/_common.py +0 -0
  73. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/process/container.py +0 -0
  74. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/process/qemu_serial.py +0 -0
  75. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/process/serial.py +0 -0
  76. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/provider/__init__.py +0 -0
  77. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/provider/_common.py +0 -0
  78. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/py.typed +0 -0
  79. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/serial/__init__.py +0 -0
  80. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/shell/__init__.py +0 -0
  81. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/shell/_common.py +0 -0
  82. {hostctl-0.2.4 → hostctl-0.2.6}/src/hostctl/sync.py +0 -0
  83. {hostctl-0.2.4 → hostctl-0.2.6}/tests/conformance/__init__.py +0 -0
  84. {hostctl-0.2.4 → hostctl-0.2.6}/tests/conformance/path_fakes.py +0 -0
  85. {hostctl-0.2.4 → hostctl-0.2.6}/tests/conformance/test_composite_path_routing.py +0 -0
  86. {hostctl-0.2.4 → hostctl-0.2.6}/tests/conformance/test_process_contract.py +0 -0
  87. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_application_provider.py +0 -0
  88. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_asyncssh_errors.py +0 -0
  89. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_composite_path_backend_kwargs.py +0 -0
  90. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_composite_path_inherited_operations.py +0 -0
  91. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_composite_path_propagation.py +0 -0
  92. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_connection_string.py +0 -0
  93. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_container_path.py +0 -0
  94. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_container_process.py +0 -0
  95. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_exec_command.py +0 -0
  96. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_host_api.py +0 -0
  97. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_host_local.py +0 -0
  98. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_host_module_layout.py +0 -0
  99. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_host_qemu.py +0 -0
  100. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_observability.py +0 -0
  101. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_process.py +0 -0
  102. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_provider_fault_injection.py +0 -0
  103. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_qemu_executor.py +0 -0
  104. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_qemu_path.py +0 -0
  105. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_qemu_serial.py +0 -0
  106. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_qemu_serial_process.py +0 -0
  107. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_qga_ssh_transport.py +0 -0
  108. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_qga_transport.py +0 -0
  109. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_serial_executor.py +0 -0
  110. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_serial_live.py +0 -0
  111. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_shell.py +0 -0
  112. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_ssh_process.py +0 -0
  113. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_sync_helpers.py +0 -0
  114. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_system_host_fidelity.py +0 -0
  115. {hostctl-0.2.4 → hostctl-0.2.6}/tests/test_winrm_path.py +0 -0
@@ -19,4 +19,8 @@ CLAUDE*
19
19
  .claude
20
20
  .*
21
21
  !.gitignore
22
+ !.gitattributes
23
+ # Directory form: git cannot re-include a file below an excluded directory,
24
+ # so `.github/` itself has to be un-ignored, not just the files under it.
25
+ !.github/
22
26
 
@@ -7,6 +7,120 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.6] - 2026-08-16
11
+
12
+ ### Added
13
+
14
+ - `wants_text(text, encoding, errors)` is exported from `hostctl.executor`,
15
+ joining the four stream helpers made public in 0.2.5 for the same reason:
16
+ an executor implemented outside hostctl must reach the same conclusions as
17
+ the built-in ones, because a `SystemHost` chooses the provider.
18
+
19
+ ### Fixed
20
+
21
+ - Direct execution no longer raises `NameError` on two dispatch paths.
22
+ `ContainerHost.spawn(Exec(...))` raised on *every* direct spawn, and
23
+ `SystemHost.run(Exec(program))` raised whenever the selected executor
24
+ provider advertised neither `args` nor `script` — the shape a bare
25
+ `ExecutorProvider("name", callable)` has, which is the documented minimal
26
+ form of that public authoring contract. Both modules used `command_text`
27
+ without importing it.
28
+
29
+ - `hostctl cp` no longer mistakes a URI port colon for the `URI:PATH`
30
+ separator. `ssh://host:2222/tmp/x` split into `ssh://host` plus the relative
31
+ path `2222/tmp/x` — the default port and the wrong file, with no message. A
32
+ split that lands on the authority's port colon is now rejected with the
33
+ grammar error, so the required spelling `ssh://host:2222:/tmp/x` is the only
34
+ one that runs. Userinfo colons and IPv6 literals are excluded from the check.
35
+
36
+ - Native current-context WinRM now honours `server_cert_validation="ignore"`.
37
+ It was spliced into the rendered wrapper with `str.replace`, which matched
38
+ nothing without a port (the setting vanished) and produced an
39
+ `Invoke-Command` parameter-binding error with one, since `SkipCACheck`
40
+ belongs to `New-PSSessionOption`. The option is now built explicitly and
41
+ passed as `-SessionOption`, skipping both the CA and CN checks to match
42
+ pywinrm's `ignore`.
43
+
44
+ - Native current-context WinRM now reports remote exit codes.
45
+ `Invoke-Command` does not copy the remote `$LASTEXITCODE` into the calling
46
+ session, so a command that failed only by status — a native executable
47
+ exiting non-zero without throwing — returned 0 locally and `check=True`
48
+ never fired. The remote script block now emits the code as a
49
+ `__HOSTCTL_LASTEXITCODE__` marker line, which the local wrapper consumes
50
+ and exits with. **Behaviour change**: calls that silently succeeded against
51
+ a failing remote command now raise `CalledProcessError` under the default
52
+ `check=True`.
53
+
54
+ - Every provider now agrees on when output is text. `errors=` alone selected
55
+ text mode on the local, WinRM, container, and QEMU executors and binary
56
+ mode on SSH, PSRP, and the serial host, so the identical call returned
57
+ `str` or `bytes` depending on which provider a `SystemHost` selected — the
58
+ exact divergence the shared stream helpers exist to prevent. All seven
59
+ sites now call `wants_text`. **Behaviour change**: `run(cmd,
60
+ errors="replace")` returns `str` from SSH, PSRP, and serial where it
61
+ previously returned `bytes`.
62
+
63
+ - SFTP paths percent-encode the remote path before embedding it in the
64
+ `sftp://` URI. `pathlib_next` parses that URI and uridecodes its parts, so
65
+ a filename containing `?` or `#` was truncated into a query or fragment and
66
+ a literal `%xx` was decoded into a different name — reading and writing the
67
+ wrong file with no error. Encoding is minimal (RFC 3986 `pchar`), so a
68
+ Windows-flavoured remote path still reads as `sftp://host:22/C:/Temp`.
69
+
70
+ - `RunspaceSession` no longer closes a pool it was given. `_owns_pool` was
71
+ recorded at construction and never read, so a pool injected to be shared
72
+ across sessions was closed by whichever session finished first. An injected
73
+ pool is now left open and retained, which also leaves the session
74
+ reopenable; a pool the session created is still closed.
75
+
76
+ - An abandoned container or QGA write stream no longer uploads from the
77
+ garbage collector. The staged write-back stream existed as three
78
+ byte-identical copies of which only the WinRM one had a `__del__` guard;
79
+ `io.IOBase.__del__` calls `close()`, and `close()` is what commits, so a
80
+ write stream that went out of scope unclosed performed its network
81
+ transfer at an arbitrary GC point with any error printed and swallowed by
82
+ the interpreter. One `hostctl.host._staged_io` now serves all three, and
83
+ the abandonment case warns instead of uploading. The `open()` mode
84
+ validators were deduplicated with it, so `"rt"` is accepted on the
85
+ container and QGA backends as it always was on WinRM.
86
+
87
+ - `SerialHost.run(capture_output=False)` no longer discards the console
88
+ transcript. It reimplemented the output contract and treated a `None`
89
+ stdout target as "discard"; the shared `dispatch_output` — and every other
90
+ transport, and `subprocess` — treats it as `sys.stdout`. Serial now routes
91
+ through the shared helper. `stdout=subprocess.DEVNULL` remains the way to
92
+ discard deliberately.
93
+
94
+ ## [0.2.5] - 2026-08-05
95
+
96
+ ### Added
97
+
98
+ - `write_output`, `normalize_input`, and `dispatch_output` are exported from
99
+ `hostctl.executor`, joining `capture_streams`. An executor implemented
100
+ outside hostctl previously had to import `hostctl.executor._common` to
101
+ reproduce hostctl's own stdout/stderr and stdin semantics.
102
+
103
+ Sharing these is a correctness requirement rather than a convenience: a
104
+ `SystemHost` can dispatch the same call through different providers on
105
+ different attempts, so providers that disagree about output handling return
106
+ results that differ by which transport won. `normalize_input` is the one
107
+ worth not reimplementing — a mismatch there does not raise, it deadlocks,
108
+ because bytes handed to a text-mode stdin kill `subprocess`'s writer thread
109
+ without closing the pipe, so the child never sees EOF and `timeout=` never
110
+ fires.
111
+
112
+ No behaviour change; these are the same objects `_common` defines.
113
+
114
+ ### Fixed
115
+
116
+ - Four conformance tests covering timestamp handling were skipping for a
117
+ reason that was not true, so the contract they check went unverified. The
118
+ checks called `os.utime()` on paths belonging to fake *remote* providers,
119
+ which map into a private sandbox root and have no local existence; the
120
+ resulting `FileNotFoundError` was reported as "this provider cannot set
121
+ timestamps". Timestamps are now set through the sandbox that actually stores
122
+ the file. Test-only change.
123
+
10
124
  ## [0.2.4] - 2026-08-05
11
125
 
12
126
  ### Fixed
@@ -397,7 +511,9 @@ test suite on Python 3.9 through 3.14.
397
511
  assigned to it; a config-less host now builds its own family configuration
398
512
  instead.
399
513
 
400
- [Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.4...HEAD
514
+ [Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.6...HEAD
515
+ [0.2.6]: https://github.com/jose-pr/hostctl/compare/v0.2.5...v0.2.6
516
+ [0.2.5]: https://github.com/jose-pr/hostctl/compare/v0.2.4...v0.2.5
401
517
  [0.2.4]: https://github.com/jose-pr/hostctl/compare/v0.2.3...v0.2.4
402
518
  [0.2.3]: https://github.com/jose-pr/hostctl/compare/v0.2.2...v0.2.3
403
519
  [0.2.2]: https://github.com/jose-pr/hostctl/compare/v0.2.1...v0.2.2
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: hostctl
3
- Version: 0.2.4
3
+ Version: 0.2.6
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/
@@ -22,7 +22,10 @@ interactive hidden prompt. Transport extras remain optional and produce their
22
22
  normal actionable import error when absent.
23
23
 
24
24
  `cp` accepts local paths or `URI:PATH` operands and delegates to
25
- `pathlib_next.Path.copy()`. Use `--overwrite` and `--recursive` explicitly.
25
+ `pathlib_next.Path.copy()`. The colon before the path is required even when the
26
+ URI carries a port — `ssh://host:2222:/tmp/x`, never `ssh://host:2222/tmp/x`,
27
+ which is rejected rather than read as port-less host plus a relative path.
28
+ Use `--overwrite` and `--recursive` explicitly.
26
29
  The copy is therefore only as streaming and atomic as the two selected path
27
30
  backends; see the filesystem and transfer guides for provider-specific limits.
28
31
 
@@ -14,9 +14,9 @@ stderr by default. Captured streams are bytes unless `text=True` or an
14
14
  - A string is shell source and remains verbatim (operators such as `&&`, pipes,
15
15
  and redirects are not quoted).
16
16
  - A tuple/list is argv data and each item is quoted by the selected shell.
17
- - A leading `pathlib.PurePath` or `pathlib_next.Path` selects direct argv
18
- execution; following values are arguments and are never interpreted by a
19
- shell.
17
+ - `Exec(program, *args)` is the only direct-execution spelling: one program
18
+ with an argv, never interpreted by a shell, and never combined with other
19
+ commands. A path anywhere else is an ordinary value that stringifies.
20
20
  - Multiple top-level commands are joined with the shell's sequence separator.
21
21
  - `env` is additive to the provider's environment. `cwd` is the process working
22
22
  directory. `check=True` raises `CalledProcessError` for every non-zero status;
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hostctl"
7
- version = "0.2.4"
7
+ version = "0.2.6"
8
8
  authors = [{ name = "Jose A." }]
9
9
  description = "Run commands and access files on a host, local or remote, protocol-agnostic"
10
10
  readme = "README.md"
@@ -177,6 +177,13 @@ corresponding hosts.
177
177
  thread, and the call then blocks forever because the child never sees EOF --
178
178
  `timeout=` does not fire. All executors share the helper so the same call
179
179
  behaves identically whichever provider a `SystemHost` selects.
180
+ - Output is `str` when ANY of `text`, `encoding`, or `errors` is given, and
181
+ `bytes` otherwise -- `subprocess.run`'s rule, decided by the exported
182
+ `wants_text(text, encoding, errors)`. `text=False` does not veto an
183
+ `encoding`. An executor implemented outside hostctl must use the same
184
+ helper, for the same reason as `normalize_input`: a `SystemHost` picks the
185
+ provider, so a caller cannot write one correct invocation if providers
186
+ disagree about the result type.
180
187
  - `env` on `run`/`session`/`configure` accepts `EnvironmentSelection`: a
181
188
  mapping merges over the shell's default per key, the default empty mapping
182
189
  merges nothing and inherits it, and `None` runs without the shell's
@@ -277,7 +284,11 @@ pywinrm; `provider="psrp"` requires the extra.
277
284
 
278
285
  The WinRM executor provider supports PowerShell `run()` and Windows-semantic `WinRMPath`.
279
286
  Password-free configs on Windows use current-context native PowerShell
280
- remoting; explicit credentials use pywinrm. `WinRMPath.open("rb")` fetches
287
+ remoting; explicit credentials use pywinrm. On that native path
288
+ `server_cert_validation="ignore"` becomes `New-PSSessionOption -SkipCACheck
289
+ -SkipCNCheck` (both checks, matching pywinrm), and the remote exit code is
290
+ carried back explicitly, so a native command failing only by status is
291
+ reported as a failure rather than as success. `WinRMPath.open("rb")` fetches
281
292
  bounded ranges; writable modes stage content and transfer Base64 chunks on
282
293
  close. WinRM stdin and command
283
294
  deadlines remain unsupported. Transport timeouts are not a total command
@@ -49,6 +49,22 @@ def _open_host(stack: contextlib.ExitStack, uri: str, credentials: dict[str, obj
49
49
  return stack.enter_context(Host(uri, **credentials))
50
50
 
51
51
 
52
+ def _port_colon(value: str, scheme_end: int, authority_end: int) -> int:
53
+ """Index of the authority's port colon, or ``-1`` when it has none.
54
+
55
+ The port colon is the first colon of the host part: userinfo colons sit
56
+ before the ``@``, and an IPv6 literal keeps its colons inside brackets.
57
+ """
58
+ userinfo_end = value.rfind("@", scheme_end, authority_end)
59
+ host_start = scheme_end if userinfo_end < 0 else userinfo_end + 1
60
+ if value[host_start : host_start + 1] == "[":
61
+ bracket_end = value.find("]", host_start, authority_end)
62
+ if bracket_end < 0:
63
+ return -1
64
+ host_start = bracket_end + 1
65
+ return value.find(":", host_start, authority_end)
66
+
67
+
52
68
  def _path_operand(
53
69
  stack: contextlib.ExitStack,
54
70
  value: str,
@@ -58,10 +74,21 @@ def _path_operand(
58
74
  if not _URI_OPERAND.match(value) or re.match(r"^[A-Za-z]:[\\/]", value):
59
75
  return HostPath(value)
60
76
  if "://" in value:
61
- authority_end = value.find("/", value.find("://") + 3)
77
+ scheme_end = value.find("://") + 3
78
+ authority_end = value.find("/", scheme_end)
62
79
  search_end = len(value) if authority_end < 0 else authority_end
63
80
  separator = value.rfind(":", 0, search_end)
64
- if separator <= value.find("://") + 2:
81
+ if separator < scheme_end:
82
+ raise ValueError("remote path operand must be URI:PATH")
83
+ if (
84
+ separator == _port_colon(value, scheme_end, search_end)
85
+ and value[separator + 1 : search_end].isdigit()
86
+ ):
87
+ # `ssh://host:2222/x` has no path colon, so the last colon before
88
+ # the authority's slash is the port's. Splitting there yields
89
+ # `ssh://host` plus the relative path `2222/x` -- the wrong host
90
+ # and the wrong file, with nothing said. The grammar wants
91
+ # `ssh://host:2222:/x`.
65
92
  raise ValueError("remote path operand must be URI:PATH")
66
93
  else:
67
94
  separator = value.find(":", value.find(":") + 1)
@@ -13,7 +13,11 @@ from ._common import (
13
13
  PathLike as PathLike,
14
14
  normalize_environment as normalize_environment,
15
15
  capture_streams as capture_streams,
16
+ dispatch_output as dispatch_output,
17
+ normalize_input as normalize_input,
16
18
  reject_stdin_conflict as reject_stdin_conflict,
19
+ wants_text as wants_text,
20
+ write_output as write_output,
17
21
  )
18
22
  from .container import (
19
23
  ContainerExecutor as ContainerExecutor,
@@ -65,7 +69,11 @@ __all__ = [
65
69
  "normalize_container_error",
66
70
  "normalize_environment",
67
71
  "capture_streams",
72
+ "dispatch_output",
73
+ "normalize_input",
68
74
  "reject_stdin_conflict",
75
+ "wants_text",
76
+ "write_output",
69
77
  "normalize_serial_error",
70
78
  "SerialExecutor",
71
79
  "SerialFactory",
@@ -94,6 +94,26 @@ def normalize_input(
94
94
  return input
95
95
 
96
96
 
97
+ def wants_text(
98
+ text: typing.Optional[bool],
99
+ encoding: typing.Optional[str],
100
+ errors: typing.Optional[str],
101
+ ) -> bool:
102
+ """Decide whether one call's output is `str` rather than `bytes`.
103
+
104
+ `subprocess.run`'s rule, and the only one hostctl has: any of `text`,
105
+ `encoding`, or `errors` selects text mode. `text=False` does not veto an
106
+ `encoding` -- `subprocess` does not treat it as one either.
107
+
108
+ This exists as a shared function because the answer must not depend on
109
+ which provider a `SystemHost` happened to select. Four executors inferred
110
+ it as `bool(encoding or errors or text)` while SSH, PSRP, and the serial
111
+ host each ignored `errors`, so `run(cmd, errors="replace")` returned
112
+ `str` or `bytes` depending on which transport won the fallback.
113
+ """
114
+ return bool(text or encoding or errors)
115
+
116
+
97
117
  def command_text(
98
118
  value: object, *, encoding: str = "utf-8", errors: str = "strict"
99
119
  ) -> str:
@@ -19,6 +19,7 @@ from ._common import (
19
19
  dispatch_output,
20
20
  normalize_environment,
21
21
  capture_streams,
22
+ wants_text,
22
23
  )
23
24
 
24
25
 
@@ -119,7 +120,7 @@ class ContainerExecutor(Executor[subprocess.CompletedProcess]):
119
120
  out = b""
120
121
  if err is None and not merge_stderr:
121
122
  err = b""
122
- if text or encoding is not None or errors is not None:
123
+ if wants_text(text, encoding, errors):
123
124
  codec = encoding or "utf-8"
124
125
  out = (
125
126
  out.decode(codec, errors or "strict") if isinstance(out, bytes) else out
@@ -20,6 +20,7 @@ from ._common import (
20
20
  normalize_input,
21
21
  capture_streams,
22
22
  reject_stdin_conflict,
23
+ wants_text,
23
24
  )
24
25
 
25
26
 
@@ -69,7 +70,7 @@ class LocalExecutor(Executor[subprocess.CompletedProcess]):
69
70
  # where bytes would kill the writer thread and hang the call.
70
71
  input=normalize_input(
71
72
  input,
72
- text_mode=bool(encoding or errors or text),
73
+ text_mode=wants_text(text, encoding, errors),
73
74
  encoding=encoding,
74
75
  errors=errors,
75
76
  ),
@@ -18,6 +18,7 @@ from ._common import (
18
18
  Input,
19
19
  capture_streams,
20
20
  dispatch_output,
21
+ wants_text,
21
22
  )
22
23
 
23
24
  if typing.TYPE_CHECKING:
@@ -97,35 +98,20 @@ class PsrpExecutor(Executor[subprocess.CompletedProcess]):
97
98
  command_text(command), raw=False, capture_exit=True
98
99
  )
99
100
  codec = encoding or "utf-8"
101
+ # `invoke(capture_exit=True)` already consumed the
102
+ # `__HOSTCTL_LASTEXITCODE__` line and folded it into `returncode`;
103
+ # this used to re-parse the same marker out of output that no longer
104
+ # contained it, which is two implementations of one convention.
105
+ returncode = result.returncode
100
106
  # Preserve PowerShell's object-pipeline line orientation when
101
107
  # projecting objects to subprocess-compatible text.
102
- projected_output = []
103
- returncode = result.returncode
104
- marker = "__HOSTCTL_LASTEXITCODE__:"
105
- for item in result.output:
106
- value = str(item)
107
- if value.startswith(marker):
108
- try:
109
- parsed_returncode = int(value[len(marker) :])
110
- returncode = (
111
- 1
112
- if result.had_errors and parsed_returncode == 0
113
- else parsed_returncode
114
- )
115
- except ValueError:
116
- returncode = 1
117
- continue
118
- projected_output.append(item)
119
- out_text = "\n".join(str(item) for item in projected_output)
108
+ out_text = "\n".join(str(item) for item in result.output)
120
109
  err_text = "\n".join(str(item) for item in result.streams.error)
121
110
  if out_text:
122
111
  out_text += "\n"
123
- out: typing.Union[str, bytes] = (
124
- out_text if text or encoding else out_text.encode(codec)
125
- )
126
- err: typing.Union[str, bytes] = (
127
- err_text if text or encoding else err_text.encode(codec)
128
- )
112
+ as_text = wants_text(text, encoding, errors)
113
+ out: typing.Union[str, bytes] = out_text if as_text else out_text.encode(codec)
114
+ err: typing.Union[str, bytes] = err_text if as_text else err_text.encode(codec)
129
115
  if stderr is subprocess.STDOUT:
130
116
  out = out + err
131
117
  err = None
@@ -25,6 +25,7 @@ from ._common import (
25
25
  normalize_input,
26
26
  capture_streams,
27
27
  reject_stdin_conflict,
28
+ wants_text,
28
29
  )
29
30
 
30
31
 
@@ -180,7 +181,7 @@ class QemuExecutor(Executor[subprocess.CompletedProcess]):
180
181
  "completed guest-exec status has no integer exit code or signal"
181
182
  )
182
183
 
183
- if text or encoding is not None or errors is not None:
184
+ if wants_text(text, encoding, errors):
184
185
  codec = encoding or "utf-8"
185
186
  out = out.decode(codec, errors or "strict")
186
187
  err = err.decode(codec, errors or "strict")
@@ -23,6 +23,7 @@ from ._common import (
23
23
  normalize_input,
24
24
  capture_streams,
25
25
  reject_stdin_conflict,
26
+ wants_text,
26
27
  )
27
28
 
28
29
 
@@ -138,7 +139,9 @@ class SshExecutor(Executor[subprocess.CompletedProcess]):
138
139
  raise ValueError("bufsize=0 is unsupported by the buffered SSH executor")
139
140
  command = command_text(command)
140
141
  reject_stdin_conflict(input, stdin)
141
- if text and encoding is None:
142
+ if wants_text(text, encoding, errors) and encoding is None:
143
+ # AsyncSSH returns `bytes` unless it is given an encoding, so
144
+ # text mode has to be expressed as one.
142
145
  encoding = "utf-8"
143
146
  env = normalize_environment(env)
144
147
 
@@ -18,6 +18,7 @@ from ._common import (
18
18
  Input,
19
19
  dispatch_output,
20
20
  capture_streams,
21
+ wants_text,
21
22
  )
22
23
 
23
24
 
@@ -38,6 +39,24 @@ class _NativeResponse:
38
39
  std_err: bytes
39
40
 
40
41
 
42
+ #: Emitted by the remote script block and consumed by the local wrapper.
43
+ #: `Invoke-Command` does not copy the remote `$LASTEXITCODE` into the calling
44
+ #: session, so a native command that fails only by exit code would otherwise
45
+ #: report success locally. Same convention as the PSRP runspace session.
46
+ NATIVE_EXIT_MARKER = "__HOSTCTL_LASTEXITCODE__"
47
+
48
+ #: Runs *inside* the remote script block, appended on its own line so that the
49
+ #: payload's last statement -- comment, `}` or bare expression alike -- cannot
50
+ #: swallow it.
51
+ _NATIVE_EXIT_EPILOGUE = (
52
+ f"Write-Output ('{NATIVE_EXIT_MARKER}:' + [string]([int]$LASTEXITCODE))"
53
+ )
54
+
55
+
56
+ def _b64(value: str) -> str:
57
+ return base64.b64encode(value.encode("utf-8")).decode("ascii")
58
+
59
+
41
60
  class NativeWinRMSession:
42
61
  """Current-context Windows PowerShell remoting session adapter."""
43
62
 
@@ -76,38 +95,60 @@ class NativeWinRMSession:
76
95
  else:
77
96
  self._skip_ca_check = False
78
97
 
79
- def run_ps(self, script: str) -> _NativeResponse:
80
- host = base64.b64encode(self.host.encode("utf-8")).decode("ascii")
81
- payload = base64.b64encode(script.encode("utf-8")).decode("ascii")
82
- wrapper = (
98
+ def _wrapper(self, script: str) -> str:
99
+ """Build the local PowerShell program that performs one remote call.
100
+
101
+ Everything variable is base64 -- the host, the payload, and the exit
102
+ epilogue -- so no caller-supplied text is ever spliced into PowerShell
103
+ source. The connection options are assembled as a list rather than
104
+ patched into rendered text afterwards: the previous `str.replace`
105
+ approach silently did nothing without a port and produced a hashtable
106
+ `Invoke-Command` rejects with one.
107
+ """
108
+ options = [
109
+ "ComputerName=$h",
110
+ "Authentication='Negotiate'",
111
+ f"UseSSL=${str(self.ssl).lower()}",
112
+ ]
113
+ if self.port is not None:
114
+ options.append(f"Port={self.port}")
115
+ prelude = ""
116
+ if self._skip_ca_check:
117
+ # `SkipCACheck` is a `New-PSSessionOption` parameter, not an
118
+ # `Invoke-Command` one. Both checks are skipped together to match
119
+ # pywinrm's `server_cert_validation="ignore"`.
120
+ prelude = "$so=New-PSSessionOption -SkipCACheck -SkipCNCheck;"
121
+ options.append("SessionOption=$so")
122
+ return (
83
123
  "$OutputEncoding=[Console]::OutputEncoding="
84
124
  "[Text.UTF8Encoding]::new($false);"
85
125
  "$ErrorActionPreference='Stop';"
86
126
  "$h=[Text.Encoding]::UTF8.GetString("
87
- f"[Convert]::FromBase64String('{host}'));"
127
+ f"[Convert]::FromBase64String('{_b64(self.host)}'));"
88
128
  "$s=[Text.Encoding]::UTF8.GetString("
89
- f"[Convert]::FromBase64String('{payload}'));"
90
- "$o=@{ComputerName=$h;Authentication='Negotiate';"
91
- f"UseSSL=${str(self.ssl).lower()}"
92
- + (f";Port={self.port}" if self.port is not None else "")
129
+ f"[Convert]::FromBase64String('{_b64(script)}'));"
130
+ "$e=[Text.Encoding]::UTF8.GetString("
131
+ f"[Convert]::FromBase64String('{_b64(_NATIVE_EXIT_EPILOGUE)}'));"
132
+ + prelude
133
+ + "$o=@{"
134
+ + ";".join(options)
93
135
  + "};"
94
136
  "try{$global:LASTEXITCODE=0;"
95
- "$r=Invoke-Command @o -ScriptBlock ([ScriptBlock]::Create($s));"
96
- "$r;exit ([int]$global:LASTEXITCODE)}"
137
+ "$b=[ScriptBlock]::Create($s+[Environment]::NewLine+$e);"
138
+ "$r=Invoke-Command @o -ScriptBlock $b;"
139
+ "$c=[int]$global:LASTEXITCODE;$out=@();"
140
+ "foreach($v in $r){$t=[string]$v;"
141
+ f"if($t.StartsWith('{NATIVE_EXIT_MARKER}:'))"
142
+ "{$c=[int]($t.Split(':')[1])}else{$out+=$v}};"
143
+ "$out;exit $c}"
97
144
  "catch{$c=[string]$_.CategoryInfo.Category;"
98
145
  "$m=[Convert]::ToBase64String("
99
146
  "[Text.Encoding]::UTF8.GetBytes($_.Exception.Message));"
100
147
  "[Console]::Error.Write('HOSTCTL_NATIVE_ERROR:'+$c+':'+$m);exit 1}"
101
148
  )
102
- if self._skip_ca_check:
103
- wrapper = wrapper.replace(
104
- ";Port=" + str(self.port) if self.port is not None else ";};",
105
- (
106
- ";SkipCACheck=$true;Port=" + str(self.port)
107
- if self.port is not None
108
- else ";SkipCACheck=$true;};"
109
- ),
110
- )
149
+
150
+ def run_ps(self, script: str) -> _NativeResponse:
151
+ wrapper = self._wrapper(script)
111
152
  try:
112
153
  result = subprocess.run(
113
154
  (
@@ -197,7 +238,7 @@ class WinRMExecutor(Executor[subprocess.CompletedProcess]):
197
238
  # Remote errors are represented as a normal non-zero completion;
198
239
  # check=False must be able to inspect them.
199
240
  err = detail.encode(encoding or "utf-8", errors or "replace")
200
- if text or encoding is not None or errors is not None:
241
+ if wants_text(text, encoding, errors):
201
242
  codec = encoding or "utf-8"
202
243
  out = out.decode(codec, errors or "strict")
203
244
  err = err.decode(codec, errors or "strict")
@@ -834,9 +834,10 @@ class Host(_abc.ABC, metaclass=_HostMeta):
834
834
  """Run commands and return a subprocess-compatible result.
835
835
 
836
836
  A string is verbatim shell text. A tuple/list is one quoted argv
837
- command. A leading :class:`pathlib.PurePath`/``pathlib_next`` path is
838
- a direct executable and all trailing values are its argv arguments.
839
- Otherwise multiple top-level commands are joined by the selected
837
+ command. :class:`Exec` is the only direct-execution spelling: it runs
838
+ one program with an argv and no shell layer, and it cannot be combined
839
+ with other commands. A path anywhere else is an ordinary value that
840
+ stringifies. Multiple top-level commands are joined by the selected
840
841
  shell's command separator.
841
842
  """
842
843
  raise NotImplementedError(
@@ -24,6 +24,13 @@ from ..provider import (
24
24
  )
25
25
 
26
26
  log = logging.getLogger("hostctl.host.ssh")
27
+
28
+ #: RFC 3986 `pchar`, plus `/`. Everything legal in a URI path segment is
29
+ #: left as written -- including `:` so a Windows-flavoured remote path still
30
+ #: reads as `sftp://host:22/C:/Temp` -- while `%`, `?`, `#`, space, and
31
+ #: non-ASCII are percent-encoded, because those are what a URI parser would
32
+ #: otherwise take for syntax.
33
+ _URI_PATH_SAFE = "/:@-._~!$&'()*+,;="
27
34
  from ..process import Process, SshProcess, TerminalRequest
28
35
  from ._common import (
29
36
  CaptureOutput,
@@ -392,8 +399,14 @@ class _SshTransport:
392
399
  self._sftp_backend = AsyncsshSftpBackend(
393
400
  connect_opts=self.config.connect_opts()
394
401
  )
402
+ # The remote path becomes part of a URI, so it has to be encoded
403
+ # as one. `pathlib_next` parses with `uritools.urisplit` and then
404
+ # uridecodes the components: a raw `?` or `#` in a filename was
405
+ # taken as the start of the query or fragment and truncated the
406
+ # path, and a genuine `%xx` was decoded into a different name.
395
407
  path = SftpPath(
396
- f"sftp://{uri_host(self.config.host)}:{self.config.port or 22}{remote_path}",
408
+ f"sftp://{uri_host(self.config.host)}:{self.config.port or 22}"
409
+ f"{quote(remote_path, safe=_URI_PATH_SAFE)}",
397
410
  backend=self._sftp_backend,
398
411
  )
399
412
  self._sftp_sources.add(path.source)