hostctl 0.2.5__tar.gz → 0.2.7__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.5 → hostctl-0.2.7}/.gitignore +4 -0
  2. {hostctl-0.2.5 → hostctl-0.2.7}/CHANGELOG.md +105 -1
  3. {hostctl-0.2.5 → hostctl-0.2.7}/PKG-INFO +5 -5
  4. {hostctl-0.2.5 → hostctl-0.2.7}/docs/guide/cli.md +4 -1
  5. {hostctl-0.2.5 → hostctl-0.2.7}/docs/guide/contracts.md +3 -3
  6. {hostctl-0.2.5 → hostctl-0.2.7}/pyproject.toml +13 -9
  7. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/AGENTS.md +12 -1
  8. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/_cli.py +29 -2
  9. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/executor/__init__.py +2 -0
  10. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/executor/_common.py +20 -0
  11. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/executor/container.py +2 -1
  12. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/executor/local.py +2 -1
  13. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/executor/psrp.py +10 -24
  14. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/executor/qemu.py +2 -1
  15. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/executor/ssh.py +4 -1
  16. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/executor/winrm.py +62 -21
  17. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/host/_common.py +4 -3
  18. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/host/_ssh.py +14 -1
  19. hostctl-0.2.7/src/hostctl/host/_staged_io.py +132 -0
  20. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/host/_winrm.py +3 -74
  21. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/host/composite_path.py +71 -56
  22. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/host/container.py +1 -0
  23. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/host/container_path.py +3 -57
  24. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/host/qemu.py +3 -57
  25. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/host/serial.py +16 -11
  26. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/host/system.py +9 -16
  27. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/process/psrp.py +8 -1
  28. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/process/ssh.py +6 -9
  29. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/provider/transports.py +5 -20
  30. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/shell/cmd.py +0 -1
  31. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/shell/fish.py +0 -1
  32. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/shell/posix.py +0 -1
  33. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/shell/powershell.py +0 -1
  34. {hostctl-0.2.5 → hostctl-0.2.7}/tests/conformance/test_live.py +29 -0
  35. {hostctl-0.2.5 → hostctl-0.2.7}/tests/conformance/test_path_contract.py +34 -0
  36. {hostctl-0.2.5 → hostctl-0.2.7}/tests/conformance/test_run_contract.py +53 -0
  37. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_cli.py +44 -0
  38. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_executor_exports.py +20 -1
  39. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_host_container.py +38 -0
  40. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_host_remote.py +33 -0
  41. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_host_winrm.py +94 -1
  42. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_psrp.py +111 -2
  43. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_serial_host.py +49 -0
  44. hostctl-0.2.7/tests/test_staged_io.py +143 -0
  45. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_system_hosts.py +29 -0
  46. {hostctl-0.2.5 → hostctl-0.2.7}/LICENSE +0 -0
  47. {hostctl-0.2.5 → hostctl-0.2.7}/README.md +0 -0
  48. {hostctl-0.2.5 → hostctl-0.2.7}/docs/api/reference.md +0 -0
  49. {hostctl-0.2.5 → hostctl-0.2.7}/docs/changelog.md +0 -0
  50. {hostctl-0.2.5 → hostctl-0.2.7}/docs/guide/extending.md +0 -0
  51. {hostctl-0.2.5 → hostctl-0.2.7}/docs/guide/path.md +0 -0
  52. {hostctl-0.2.5 → hostctl-0.2.7}/docs/guide/providers.md +0 -0
  53. {hostctl-0.2.5 → hostctl-0.2.7}/docs/guide/run.md +0 -0
  54. {hostctl-0.2.5 → hostctl-0.2.7}/docs/guide/transfer.md +0 -0
  55. {hostctl-0.2.5 → hostctl-0.2.7}/docs/index.md +0 -0
  56. {hostctl-0.2.5 → hostctl-0.2.7}/examples/application_provider.py +0 -0
  57. {hostctl-0.2.5 → hostctl-0.2.7}/examples/copy_between_hosts.py +0 -0
  58. {hostctl-0.2.5 → hostctl-0.2.7}/examples/local_run.py +0 -0
  59. {hostctl-0.2.5 → hostctl-0.2.7}/examples/remote_run.py +0 -0
  60. {hostctl-0.2.5 → hostctl-0.2.7}/mkdocs.yml +0 -0
  61. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/__init__.py +0 -0
  62. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/__main__.py +0 -0
  63. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/_async.py +0 -0
  64. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/executor/_qga.py +0 -0
  65. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/executor/serial.py +0 -0
  66. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/host/__init__.py +0 -0
  67. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/host/_connection.py +0 -0
  68. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/host/_local.py +0 -0
  69. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/process/__init__.py +0 -0
  70. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/process/_common.py +0 -0
  71. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/process/container.py +0 -0
  72. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/process/qemu_serial.py +0 -0
  73. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/process/serial.py +0 -0
  74. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/provider/__init__.py +0 -0
  75. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/provider/_common.py +0 -0
  76. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/py.typed +0 -0
  77. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/serial/__init__.py +0 -0
  78. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/shell/__init__.py +0 -0
  79. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/shell/_common.py +0 -0
  80. {hostctl-0.2.5 → hostctl-0.2.7}/src/hostctl/sync.py +0 -0
  81. {hostctl-0.2.5 → hostctl-0.2.7}/tests/conformance/__init__.py +0 -0
  82. {hostctl-0.2.5 → hostctl-0.2.7}/tests/conformance/path_fakes.py +0 -0
  83. {hostctl-0.2.5 → hostctl-0.2.7}/tests/conformance/providers.py +0 -0
  84. {hostctl-0.2.5 → hostctl-0.2.7}/tests/conformance/test_composite_path_routing.py +0 -0
  85. {hostctl-0.2.5 → hostctl-0.2.7}/tests/conformance/test_process_contract.py +0 -0
  86. {hostctl-0.2.5 → hostctl-0.2.7}/tests/conformance/test_sync_contract.py +0 -0
  87. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_application_provider.py +0 -0
  88. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_asyncssh_errors.py +0 -0
  89. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_composite_path_backend_kwargs.py +0 -0
  90. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_composite_path_inherited_operations.py +0 -0
  91. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_composite_path_propagation.py +0 -0
  92. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_connection_string.py +0 -0
  93. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_container_path.py +0 -0
  94. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_container_process.py +0 -0
  95. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_exec_command.py +0 -0
  96. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_host_api.py +0 -0
  97. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_host_local.py +0 -0
  98. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_host_module_layout.py +0 -0
  99. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_host_qemu.py +0 -0
  100. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_observability.py +0 -0
  101. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_process.py +0 -0
  102. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_provider_fault_injection.py +0 -0
  103. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_qemu_executor.py +0 -0
  104. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_qemu_path.py +0 -0
  105. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_qemu_serial.py +0 -0
  106. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_qemu_serial_process.py +0 -0
  107. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_qga_ssh_transport.py +0 -0
  108. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_qga_transport.py +0 -0
  109. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_serial_executor.py +0 -0
  110. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_serial_live.py +0 -0
  111. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_shell.py +0 -0
  112. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_ssh_process.py +0 -0
  113. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_sync_helpers.py +0 -0
  114. {hostctl-0.2.5 → hostctl-0.2.7}/tests/test_system_host_fidelity.py +0 -0
  115. {hostctl-0.2.5 → hostctl-0.2.7}/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,108 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.7] - 2026-08-16
11
+
12
+ ### Changed
13
+
14
+ - Dependency floors now name versions hostctl is actually tested against.
15
+ `pathlib_next` moves to `>=0.9.1,<0.10` and `netimps` to `>=0.2.0,<0.3`
16
+ (the `ssh` extra's `pathlib_next[sftp-async]` moves from `>=0.8.4` to the
17
+ same `>=0.9.1`). Both ceilings are unchanged, and no API hostctl calls has
18
+ moved — this only stops resolvers from choosing an install that does not
19
+ work.
20
+
21
+ The `pathlib_next` floor is above the start of its minor series for one
22
+ measured reason: `Path.symlink_to()` — and with it the `force=` keyword —
23
+ first exists in **0.9.1**. The composite path forwards `symlink_to` to
24
+ whichever backend path it was handed, so against a stock 0.9.0 `Path` the
25
+ whole chain raises `AttributeError` rather than linking anything. The
26
+ previous `>=0.8.6` floor therefore advertised support for installs where
27
+ composite `symlink_to` could never work. `netimps` needs nothing past
28
+ `0.2.0` and floors at the series start.
29
+
30
+ ### Added
31
+
32
+ - `wants_text(text, encoding, errors)` is exported from `hostctl.executor`,
33
+ joining the four stream helpers made public in 0.2.5 for the same reason:
34
+ an executor implemented outside hostctl must reach the same conclusions as
35
+ the built-in ones, because a `SystemHost` chooses the provider.
36
+
37
+ ### Fixed
38
+
39
+ - Direct execution no longer raises `NameError` on two dispatch paths.
40
+ `ContainerHost.spawn(Exec(...))` raised on *every* direct spawn, and
41
+ `SystemHost.run(Exec(program))` raised whenever the selected executor
42
+ provider advertised neither `args` nor `script` — the shape a bare
43
+ `ExecutorProvider("name", callable)` has, which is the documented minimal
44
+ form of that public authoring contract. Both modules used `command_text`
45
+ without importing it.
46
+
47
+ - `hostctl cp` no longer mistakes a URI port colon for the `URI:PATH`
48
+ separator. `ssh://host:2222/tmp/x` split into `ssh://host` plus the relative
49
+ path `2222/tmp/x` — the default port and the wrong file, with no message. A
50
+ split that lands on the authority's port colon is now rejected with the
51
+ grammar error, so the required spelling `ssh://host:2222:/tmp/x` is the only
52
+ one that runs. Userinfo colons and IPv6 literals are excluded from the check.
53
+
54
+ - Native current-context WinRM now honours `server_cert_validation="ignore"`.
55
+ It was spliced into the rendered wrapper with `str.replace`, which matched
56
+ nothing without a port (the setting vanished) and produced an
57
+ `Invoke-Command` parameter-binding error with one, since `SkipCACheck`
58
+ belongs to `New-PSSessionOption`. The option is now built explicitly and
59
+ passed as `-SessionOption`, skipping both the CA and CN checks to match
60
+ pywinrm's `ignore`.
61
+
62
+ - Native current-context WinRM now reports remote exit codes.
63
+ `Invoke-Command` does not copy the remote `$LASTEXITCODE` into the calling
64
+ session, so a command that failed only by status — a native executable
65
+ exiting non-zero without throwing — returned 0 locally and `check=True`
66
+ never fired. The remote script block now emits the code as a
67
+ `__HOSTCTL_LASTEXITCODE__` marker line, which the local wrapper consumes
68
+ and exits with. **Behaviour change**: calls that silently succeeded against
69
+ a failing remote command now raise `CalledProcessError` under the default
70
+ `check=True`.
71
+
72
+ - Every provider now agrees on when output is text. `errors=` alone selected
73
+ text mode on the local, WinRM, container, and QEMU executors and binary
74
+ mode on SSH, PSRP, and the serial host, so the identical call returned
75
+ `str` or `bytes` depending on which provider a `SystemHost` selected — the
76
+ exact divergence the shared stream helpers exist to prevent. All seven
77
+ sites now call `wants_text`. **Behaviour change**: `run(cmd,
78
+ errors="replace")` returns `str` from SSH, PSRP, and serial where it
79
+ previously returned `bytes`.
80
+
81
+ - SFTP paths percent-encode the remote path before embedding it in the
82
+ `sftp://` URI. `pathlib_next` parses that URI and uridecodes its parts, so
83
+ a filename containing `?` or `#` was truncated into a query or fragment and
84
+ a literal `%xx` was decoded into a different name — reading and writing the
85
+ wrong file with no error. Encoding is minimal (RFC 3986 `pchar`), so a
86
+ Windows-flavoured remote path still reads as `sftp://host:22/C:/Temp`.
87
+
88
+ - `RunspaceSession` no longer closes a pool it was given. `_owns_pool` was
89
+ recorded at construction and never read, so a pool injected to be shared
90
+ across sessions was closed by whichever session finished first. An injected
91
+ pool is now left open and retained, which also leaves the session
92
+ reopenable; a pool the session created is still closed.
93
+
94
+ - An abandoned container or QGA write stream no longer uploads from the
95
+ garbage collector. The staged write-back stream existed as three
96
+ byte-identical copies of which only the WinRM one had a `__del__` guard;
97
+ `io.IOBase.__del__` calls `close()`, and `close()` is what commits, so a
98
+ write stream that went out of scope unclosed performed its network
99
+ transfer at an arbitrary GC point with any error printed and swallowed by
100
+ the interpreter. One `hostctl.host._staged_io` now serves all three, and
101
+ the abandonment case warns instead of uploading. The `open()` mode
102
+ validators were deduplicated with it, so `"rt"` is accepted on the
103
+ container and QGA backends as it always was on WinRM.
104
+
105
+ - `SerialHost.run(capture_output=False)` no longer discards the console
106
+ transcript. It reimplemented the output contract and treated a `None`
107
+ stdout target as "discard"; the shared `dispatch_output` — and every other
108
+ transport, and `subprocess` — treats it as `sys.stdout`. Serial now routes
109
+ through the shared helper. `stdout=subprocess.DEVNULL` remains the way to
110
+ discard deliberately.
111
+
10
112
  ## [0.2.5] - 2026-08-05
11
113
 
12
114
  ### Added
@@ -427,7 +529,9 @@ test suite on Python 3.9 through 3.14.
427
529
  assigned to it; a config-less host now builds its own family configuration
428
530
  instead.
429
531
 
430
- [Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.5...HEAD
532
+ [Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.7...HEAD
533
+ [0.2.7]: https://github.com/jose-pr/hostctl/compare/v0.2.6...v0.2.7
534
+ [0.2.6]: https://github.com/jose-pr/hostctl/compare/v0.2.5...v0.2.6
431
535
  [0.2.5]: https://github.com/jose-pr/hostctl/compare/v0.2.4...v0.2.5
432
536
  [0.2.4]: https://github.com/jose-pr/hostctl/compare/v0.2.3...v0.2.4
433
537
  [0.2.3]: https://github.com/jose-pr/hostctl/compare/v0.2.2...v0.2.3
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: hostctl
3
- Version: 0.2.5
3
+ Version: 0.2.7
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,8 +24,8 @@ 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.1
28
- Requires-Dist: pathlib-next<0.10,>=0.8.6
27
+ Requires-Dist: netimps<0.3,>=0.2.0
28
+ Requires-Dist: pathlib-next<0.10,>=0.9.1
29
29
  Provides-Extra: container
30
30
  Requires-Dist: docker<8,>=7.1; extra == 'container'
31
31
  Provides-Extra: dev
@@ -44,7 +44,7 @@ Provides-Extra: serial
44
44
  Requires-Dist: pyserial<4,>=3.5; extra == 'serial'
45
45
  Provides-Extra: ssh
46
46
  Requires-Dist: asyncssh; extra == 'ssh'
47
- Requires-Dist: pathlib-next[sftp-async]<0.10,>=0.8.4; extra == 'ssh'
47
+ Requires-Dist: pathlib-next[sftp-async]<0.10,>=0.9.1; extra == 'ssh'
48
48
  Provides-Extra: winrm
49
49
  Requires-Dist: pywinrm<0.6,>=0.5.0; extra == 'winrm'
50
50
  Provides-Extra: winrm-credssp
@@ -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.5"
7
+ version = "0.2.7"
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"
@@ -34,19 +34,23 @@ classifiers = [
34
34
  # them. Transport implementations remain extras, so importing hostctl never
35
35
  # requires them.
36
36
  #
37
- # Both ranges span two minors deliberately: hostctl uses no API added in
38
- # pathlib_next 0.9 or netimps 0.2, so raising the floor would exclude working
39
- # installs for nothing. The ceilings stay at the next major-ish boundary
37
+ # Each floor is the `.0` of the dependency's current minor series, raised
38
+ # above it only where hostctl needs a later patch. pathlib_next is that
39
+ # exception: `Path.symlink_to()` -- and with it the `force=` keyword -- first
40
+ # exists in 0.9.1, and the composite path forwards `symlink_to` to whichever
41
+ # backend path it was handed, so against a stock 0.9.0 `Path` the call raises
42
+ # `AttributeError` (measured on 3.9 and 3.14). netimps has no such case and
43
+ # floors at the series start. The ceilings stay at the next major-ish boundary
40
44
  # because both projects are pre-1.0, where a minor may break. Composite kwargs
41
- # passthrough does not change this: forwarding is signature-aware, so `force=`
42
- # is forwarded when the installed pathlib_next provides it and rejected at the
43
- # boundary when it does not (verified against 0.9.0 and 0.9.1).
44
- dependencies = ["pathlib_next>=0.8.6,<0.10", "netimps>=0.1,<0.3"]
45
+ # passthrough does not change this: forwarding is signature-aware, so a
46
+ # `force=` the installed pathlib_next does not declare is rejected at the
47
+ # composite boundary rather than inside a transport.
48
+ dependencies = ["pathlib_next>=0.9.1,<0.10", "netimps>=0.2.0,<0.3"]
45
49
 
46
50
  [project.optional-dependencies]
47
51
  # SSH-backed run()/path(): asyncssh for shell exec, pathlib_next's SftpPath
48
52
  # (asyncssh-only since 0.8.2) for the remote path backend.
49
- ssh = ["asyncssh", "pathlib_next[sftp-async]>=0.8.4,<0.10"]
53
+ ssh = ["asyncssh", "pathlib_next[sftp-async]>=0.9.1,<0.10"]
50
54
  # Windows Remote Management command execution.
51
55
  winrm = ["pywinrm>=0.5.0,<0.6"]
52
56
  # PowerShell Remoting Protocol runspaces (Python 3.10+ only). The marker
@@ -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)
@@ -16,6 +16,7 @@ from ._common import (
16
16
  dispatch_output as dispatch_output,
17
17
  normalize_input as normalize_input,
18
18
  reject_stdin_conflict as reject_stdin_conflict,
19
+ wants_text as wants_text,
19
20
  write_output as write_output,
20
21
  )
21
22
  from .container import (
@@ -71,6 +72,7 @@ __all__ = [
71
72
  "dispatch_output",
72
73
  "normalize_input",
73
74
  "reject_stdin_conflict",
75
+ "wants_text",
74
76
  "write_output",
75
77
  "normalize_serial_error",
76
78
  "SerialExecutor",
@@ -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)