hostctl 0.1.2__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. {hostctl-0.1.2 → hostctl-0.2.1}/CHANGELOG.md +109 -1
  2. {hostctl-0.1.2 → hostctl-0.2.1}/PKG-INFO +18 -1
  3. {hostctl-0.1.2 → hostctl-0.2.1}/README.md +16 -0
  4. {hostctl-0.1.2 → hostctl-0.2.1}/docs/api/reference.md +2 -0
  5. {hostctl-0.1.2 → hostctl-0.2.1}/docs/guide/run.md +105 -2
  6. {hostctl-0.1.2 → hostctl-0.2.1}/pyproject.toml +7 -4
  7. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/AGENTS.md +14 -3
  8. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/__init__.py +4 -0
  9. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/_cli.py +5 -8
  10. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/_common.py +33 -0
  11. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/container.py +3 -4
  12. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/local.py +2 -15
  13. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/psrp.py +5 -2
  14. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/qemu.py +6 -8
  15. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/ssh.py +2 -1
  16. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/winrm.py +2 -1
  17. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/__init__.py +2 -0
  18. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/_common.py +68 -17
  19. hostctl-0.2.1/src/hostctl/host/_connection.py +384 -0
  20. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/container.py +1 -7
  21. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/system.py +1 -1
  22. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/shell/_common.py +49 -3
  23. {hostctl-0.1.2 → hostctl-0.2.1}/tests/conformance/test_live.py +3 -1
  24. {hostctl-0.1.2 → hostctl-0.2.1}/tests/conformance/test_run_contract.py +15 -17
  25. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_cli.py +6 -4
  26. hostctl-0.2.1/tests/test_connection_string.py +267 -0
  27. hostctl-0.2.1/tests/test_exec_command.py +169 -0
  28. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_host_container.py +2 -2
  29. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_system_hosts.py +3 -2
  30. {hostctl-0.1.2 → hostctl-0.2.1}/.gitignore +0 -0
  31. {hostctl-0.1.2 → hostctl-0.2.1}/LICENSE +0 -0
  32. {hostctl-0.1.2 → hostctl-0.2.1}/docs/changelog.md +0 -0
  33. {hostctl-0.1.2 → hostctl-0.2.1}/docs/guide/cli.md +0 -0
  34. {hostctl-0.1.2 → hostctl-0.2.1}/docs/guide/contracts.md +0 -0
  35. {hostctl-0.1.2 → hostctl-0.2.1}/docs/guide/extending.md +0 -0
  36. {hostctl-0.1.2 → hostctl-0.2.1}/docs/guide/path.md +0 -0
  37. {hostctl-0.1.2 → hostctl-0.2.1}/docs/guide/providers.md +0 -0
  38. {hostctl-0.1.2 → hostctl-0.2.1}/docs/guide/transfer.md +0 -0
  39. {hostctl-0.1.2 → hostctl-0.2.1}/docs/index.md +0 -0
  40. {hostctl-0.1.2 → hostctl-0.2.1}/examples/application_provider.py +0 -0
  41. {hostctl-0.1.2 → hostctl-0.2.1}/examples/copy_between_hosts.py +0 -0
  42. {hostctl-0.1.2 → hostctl-0.2.1}/examples/local_run.py +0 -0
  43. {hostctl-0.1.2 → hostctl-0.2.1}/examples/remote_run.py +0 -0
  44. {hostctl-0.1.2 → hostctl-0.2.1}/mkdocs.yml +0 -0
  45. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/__main__.py +0 -0
  46. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/_async.py +0 -0
  47. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/__init__.py +0 -0
  48. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/_qga.py +0 -0
  49. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/executor/serial.py +0 -0
  50. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/_local.py +0 -0
  51. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/_ssh.py +0 -0
  52. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/_winrm.py +0 -0
  53. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/composite_path.py +0 -0
  54. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/container_path.py +0 -0
  55. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/qemu.py +0 -0
  56. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/host/serial.py +0 -0
  57. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/process/__init__.py +0 -0
  58. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/process/_common.py +0 -0
  59. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/process/container.py +0 -0
  60. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/process/psrp.py +0 -0
  61. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/process/qemu_serial.py +0 -0
  62. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/process/serial.py +0 -0
  63. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/process/ssh.py +0 -0
  64. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/provider/__init__.py +0 -0
  65. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/provider/_common.py +0 -0
  66. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/provider/transports.py +0 -0
  67. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/py.typed +0 -0
  68. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/serial/__init__.py +0 -0
  69. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/shell/__init__.py +0 -0
  70. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/shell/cmd.py +0 -0
  71. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/shell/fish.py +0 -0
  72. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/shell/posix.py +0 -0
  73. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/shell/powershell.py +0 -0
  74. {hostctl-0.1.2 → hostctl-0.2.1}/src/hostctl/sync.py +0 -0
  75. {hostctl-0.1.2 → hostctl-0.2.1}/tests/conformance/__init__.py +0 -0
  76. {hostctl-0.1.2 → hostctl-0.2.1}/tests/conformance/path_fakes.py +0 -0
  77. {hostctl-0.1.2 → hostctl-0.2.1}/tests/conformance/providers.py +0 -0
  78. {hostctl-0.1.2 → hostctl-0.2.1}/tests/conformance/test_composite_path_routing.py +0 -0
  79. {hostctl-0.1.2 → hostctl-0.2.1}/tests/conformance/test_path_contract.py +0 -0
  80. {hostctl-0.1.2 → hostctl-0.2.1}/tests/conformance/test_process_contract.py +0 -0
  81. {hostctl-0.1.2 → hostctl-0.2.1}/tests/conformance/test_sync_contract.py +0 -0
  82. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_application_provider.py +0 -0
  83. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_asyncssh_errors.py +0 -0
  84. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_composite_path_propagation.py +0 -0
  85. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_container_path.py +0 -0
  86. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_container_process.py +0 -0
  87. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_host_api.py +0 -0
  88. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_host_local.py +0 -0
  89. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_host_module_layout.py +0 -0
  90. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_host_qemu.py +0 -0
  91. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_host_remote.py +0 -0
  92. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_host_winrm.py +0 -0
  93. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_observability.py +0 -0
  94. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_process.py +0 -0
  95. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_provider_fault_injection.py +0 -0
  96. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_psrp.py +0 -0
  97. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_qemu_executor.py +0 -0
  98. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_qemu_path.py +0 -0
  99. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_qemu_serial.py +0 -0
  100. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_qemu_serial_process.py +0 -0
  101. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_qga_ssh_transport.py +0 -0
  102. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_qga_transport.py +0 -0
  103. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_serial_executor.py +0 -0
  104. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_serial_host.py +0 -0
  105. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_serial_live.py +0 -0
  106. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_shell.py +0 -0
  107. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_ssh_process.py +0 -0
  108. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_sync_helpers.py +0 -0
  109. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_system_host_fidelity.py +0 -0
  110. {hostctl-0.1.2 → hostctl-0.2.1}/tests/test_winrm_path.py +0 -0
@@ -7,6 +7,112 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-07-29
11
+
12
+ ### Added
13
+
14
+ - `ConnectionString` parses a connection target from whatever a user typed.
15
+ `ConnectionString("nas", scheme="wss")` and `ConnectionString("nas:8443",
16
+ ...)` parse, because a bare host is not an invalid URI — it is a URI with
17
+ the scheme left off, which is what people type on a command line.
18
+
19
+ Every field can be supplied directly, and three layers decide each one: an
20
+ explicit argument wins, then whatever the string carried, then `defaults`.
21
+ `scheme=`/`port=`/`host=`/… are therefore *overrides* — `scheme="ssh"`
22
+ beats a `wss://` in the string — while `defaults` (a mapping, or another
23
+ `ConnectionString` used as a profile) fills only what nothing else
24
+ supplied.
25
+
26
+ A port carries its own resolution strategy wherever one is accepted: an
27
+ `int`, a callable `scheme -> port | None`, or anything indexable by scheme
28
+ (a plain mapping), so a caller passes its whole table without pre-selecting
29
+ an entry. When no layer supplies one, `netimps.get_default_port` resolves
30
+ the scheme — it knows schemes no system services database does (`ws`/`wss`,
31
+ `socks5`), and an application can register more with
32
+ `netimps.register_port`.
33
+
34
+ `False` stops the search outright: no port, and no lookup.
35
+
36
+ Credentials are parsed but never rendered. `str()` and `repr()` both emit
37
+ the redacted form, with the password **removed rather than masked**, so the
38
+ output stays a valid, reusable connection string that cannot round-trip a
39
+ wrong credential — and a value reaching a log line or a traceback frame
40
+ cannot leak one. A password may carry `key:value` extras after a newline,
41
+ as `parse_credentials` describes, written raw.
42
+
43
+ The host keeps the spelling it was given. `is_local` resolves nothing — it
44
+ is called while a configuration is built, which is network-free, and
45
+ resolving would both block and raise on a name that does not resolve. An
46
+ address literal is answered by `netimps.is_local_address`, so an address
47
+ actually assigned to this machine counts as local and not just loopback,
48
+ while a name is compared against the loopback spellings. `qsl` and
49
+ `query_val()` read the query; `replace()` returns a changed copy.
50
+
51
+ ### Changed
52
+
53
+ - **`netimps` is now a required dependency**, supplying scheme/port and
54
+ address semantics rather than hostctl reimplementing them. Note this
55
+ arrives in a patch release: an existing install pinned within `0.2.x` gains
56
+ a new requirement, so a locked or offline environment needs `netimps`
57
+ available before upgrading.
58
+
59
+ ### Fixed
60
+
61
+ - A path in a command is rendered through `__fspath__` rather than `str()`.
62
+ Every transport now shares one `command_text` helper — the SSH, WinRM,
63
+ container, QGA, and PSRP executors each stringified with `str()`, so only
64
+ the local executor asked for the filesystem representation. `__fspath__` is
65
+ tried by attribute rather than by `isinstance(value, os.PathLike)`, so a
66
+ duck-typed path that never registered with the ABC is honoured too, and an
67
+ object whose `__fspath__` raises or returns a non-string falls back to
68
+ `str()` rather than failing the command. The shell layer follows the same
69
+ rule. No shipped path type changes behaviour — `str()` and `__fspath__`
70
+ agree for all of them — but the contract now matches what a path promises.
71
+
72
+ ## [0.2.0] - 2026-07-28
73
+
74
+ ### Added
75
+
76
+ - `Exec(program, *args)` marks one command for direct execution: the program
77
+ runs with that argv and no shell layer renders. The program and every
78
+ argument may be a `str`, `bytes`, or a path object — all reach the transport
79
+ as text, so the spelling records how the caller held the value rather than
80
+ changing what runs. A **bare program name is now possible**
81
+ (`Exec("ls", "-l")`), resolved through the target's `PATH`; it previously had
82
+ no spelling at all, because a plain string command is always shell text.
83
+ - The full `run()` command syntax is documented in the "Running commands"
84
+ guide: the varargs rule, the three command forms, operators, validation, and
85
+ the PowerShell rendering.
86
+
87
+ ### Changed
88
+
89
+ - **Breaking.** Direct execution is marked by `Exec`, not by position. A path
90
+ in the first argument used to mean "this is the executable, the rest is
91
+ argv"; a path is now an ordinary value that stringifies wherever it appears.
92
+
93
+ ```python
94
+ host.run(Path("/bin/ls"), "-l") # before: direct execution
95
+ host.run(Exec("/bin/ls", "-l")) # now
96
+ ```
97
+
98
+ This is a **silent** change for the old spelling: `run(Path(...), *args)`
99
+ still succeeds, but the values become several `;`-joined shell commands
100
+ instead of one program and its argv, so arguments are subject to shell
101
+ quoting and word-splitting. Anything passing a leading path must be updated;
102
+ there is no deprecation shim.
103
+
104
+ What it buys, both unspellable before: several path commands in one call
105
+ (`run(p1, p2)` is two commands), and direct execution of a `PATH`-resolved
106
+ name.
107
+
108
+ An `Exec` cannot be combined with other commands in one call — there is no
109
+ shell to join them with, and running only one would silently drop the rest.
110
+ It raises `TypeError` rather than choosing.
111
+ - `hostctl run` passes its operand as a single `Exec`, so the program is used
112
+ verbatim. It no longer converts the operand through the target shell's path
113
+ flavour, which means a bare name now resolves through the target's `PATH`
114
+ instead of being treated as a relative path.
115
+
10
116
  ## [0.1.2] - 2026-07-28
11
117
 
12
118
  Supersedes 0.1.1, which was tagged but never published: a release-gate test
@@ -186,6 +292,8 @@ test suite on Python 3.9 through 3.14.
186
292
  assigned to it; a config-less host now builds its own family configuration
187
293
  instead.
188
294
 
189
- [Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.1.2...HEAD
295
+ [Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.1...HEAD
296
+ [0.2.1]: https://github.com/jose-pr/hostctl/compare/v0.2.0...v0.2.1
297
+ [0.2.0]: https://github.com/jose-pr/hostctl/compare/v0.1.2...v0.2.0
190
298
  [0.1.2]: https://github.com/jose-pr/hostctl/compare/v0.1.0...v0.1.2
191
299
  [0.1.0]: https://github.com/jose-pr/hostctl/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hostctl
3
- Version: 0.1.2
3
+ Version: 0.2.1
4
4
  Summary: Run commands and access files on a host, local or remote, protocol-agnostic
5
5
  Project-URL: Homepage, https://github.com/jose-pr/hostctl/
6
6
  Project-URL: Documentation, https://jose-pr.github.io/hostctl/
@@ -24,6 +24,7 @@ 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.2,>=0.1
27
28
  Requires-Dist: pathlib-next<0.9,>=0.8.6
28
29
  Provides-Extra: container
29
30
  Requires-Dist: docker<8,>=7.1; extra == 'container'
@@ -172,6 +173,22 @@ with Host("ssh://admin:secret@nas.example.com") as same_host:
172
173
  same_host.run("uptime")
173
174
  ```
174
175
 
176
+ `run()` takes one positional argument per command, so a `list` is a single
177
+ command whose elements are each quoted, not a list of commands:
178
+
179
+ ```python
180
+ host.run(["chmod", "755", target]) # one command
181
+ host.run(["chmod", "755", target], ["chown", "u", target]) # two commands
182
+ host.run("chmod", "755", target) # WRONG: three commands
183
+ ```
184
+
185
+ A raw string is verbatim shell text, and `Exec(program, *args)` is direct
186
+ execution — one program plus argv, with no shell layer, where the program may
187
+ be an absolute path or a bare name resolved through the target's `PATH`. A
188
+ `ShellOperator` between two commands joins them conditionally. See the
189
+ [running commands guide](https://jose-pr.github.io/hostctl/guide/run/) for the
190
+ full rules.
191
+
175
192
  A URI may carry `user:password@host` on the way *in* — it is a valid URI, so
176
193
  hostctl accepts it, extracts the password into the credentials, and keeps it
177
194
  out of every rendered form. Passing the same password both in the URI and as an
@@ -118,6 +118,22 @@ with Host("ssh://admin:secret@nas.example.com") as same_host:
118
118
  same_host.run("uptime")
119
119
  ```
120
120
 
121
+ `run()` takes one positional argument per command, so a `list` is a single
122
+ command whose elements are each quoted, not a list of commands:
123
+
124
+ ```python
125
+ host.run(["chmod", "755", target]) # one command
126
+ host.run(["chmod", "755", target], ["chown", "u", target]) # two commands
127
+ host.run("chmod", "755", target) # WRONG: three commands
128
+ ```
129
+
130
+ A raw string is verbatim shell text, and `Exec(program, *args)` is direct
131
+ execution — one program plus argv, with no shell layer, where the program may
132
+ be an absolute path or a bare name resolved through the target's `PATH`. A
133
+ `ShellOperator` between two commands joins them conditionally. See the
134
+ [running commands guide](https://jose-pr.github.io/hostctl/guide/run/) for the
135
+ full rules.
136
+
121
137
  A URI may carry `user:password@host` on the way *in* — it is a valid URI, so
122
138
  hostctl accepts it, extracts the password into the credentials, and keeps it
123
139
  out of every rendered form. Passing the same password both in the URI and as an
@@ -39,6 +39,7 @@ without a deprecation cycle.
39
39
 
40
40
  ## Executors and processes
41
41
 
42
+ ::: hostctl.Exec
42
43
  ::: hostctl.Executor
43
44
  ::: hostctl.ExecutionOptions
44
45
  ::: hostctl.LocalExecutor
@@ -79,6 +80,7 @@ without a deprecation cycle.
79
80
 
80
81
  ## Connection strings
81
82
 
83
+ ::: hostctl.ConnectionString
82
84
  ::: hostctl.redact_uri
83
85
  ::: hostctl.parse_credentials
84
86
  ::: hostctl.strict_uri_credentials
@@ -12,10 +12,113 @@ result = host.run("echo hello")
12
12
  print(result.stdout)
13
13
  ```
14
14
 
15
+ ## Command syntax
16
+
17
+ `run(*cmds, **options)` takes **one positional argument per command** — it is
18
+ varargs, not a list of commands. This is the distinction to get right:
19
+
20
+ ```python
21
+ host.run(["chmod", "755", target]) # ONE command
22
+ host.run(["chmod", "755", target], ["chown", "u", target]) # TWO commands
23
+ ```
24
+
25
+ A list is *one* command whose elements are quoted individually. Wrapping
26
+ commands in an outer list does not produce several commands — the inner lists
27
+ are stringified into a single nonsensical argument:
28
+
29
+ ```python
30
+ host.run([["chmod", "755", target]]) # WRONG: one command, mangled
31
+ ```
32
+
33
+ The mirror mistake is passing an argv as separate positionals, which makes each
34
+ element its own command:
35
+
36
+ ```python
37
+ host.run("chmod", "755", target) # WRONG: runs chmod;755;/path
38
+ ```
39
+
40
+ ### The three forms a command may take
41
+
42
+ | Form | Example | Result |
43
+ | --- | --- | --- |
44
+ | Structured `list`/`tuple` | `["chmod", "755", p]` | `chmod 755 '/tmp/a b'` — every element quoted as data |
45
+ | Raw `str` | `"echo $HOME"` | verbatim shell text; globs, pipes, and `$VAR` are interpreted |
46
+ | `Exec(...)` | `Exec("/bin/ls", "-l", p)` | direct execution: one program plus argv, no shell |
47
+
48
+ Prefer the structured form. It quotes each element, so a value containing a
49
+ space, quote, `$`, or `;` is passed as data instead of changing the command.
50
+ Use a raw string only when you want the shell to interpret the text.
51
+
52
+ Elements may be `str`, `bytes`, `int`, or path objects; all are normalized and
53
+ quoted. `["echo", 42, b"by", PurePosixPath("/p q")]` renders as
54
+ `echo 42 by '/p q'`.
55
+
56
+ ### Joining and operators
57
+
58
+ Commands are joined with the flavour's separator (`;` on POSIX). Pass a
59
+ `ShellOperator` *between* two commands to join them conditionally instead:
60
+
61
+ ```python
62
+ from hostctl import ShellOperator
63
+
64
+ host.run(["test", "-f", target], ShellOperator.AND, ["rm", target])
65
+ ```
66
+
67
+ `ShellOperator` provides `AND`, `OR`, `PIPE`, `REDIRECT`, `APPEND`, and
68
+ `SEQUENCE`. An operator that is leading, trailing, or adjacent to another
69
+ raises `ValueError`, and a flavour may reject one it cannot represent
70
+ portably — PowerShell 5 rejects `AND`/`OR`, while PowerShell 7 accepts them.
71
+
72
+ ### `Exec` is direct execution
73
+
74
+ `Exec(program, *args)` runs one program with an argv, with no shell layer
75
+ rendered — nothing quotes, splits, or interprets the values:
76
+
77
+ ```python
78
+ from hostctl import Exec
79
+
80
+ host.run(Exec("/bin/ls", "-l", target)) # absolute path
81
+ host.run(Exec("ls", "-l")) # resolved through the target's PATH
82
+ ```
83
+
84
+ The program and each argument may be a `str`, `bytes`, or a path object. All
85
+ of them reach the transport as text, so the spelling records how you happened
86
+ to hold the value rather than changing what runs. A bare name is only possible
87
+ here: a plain string command is always shell text.
88
+
89
+ Arguments are argv values, never nested commands — a list or tuple raises
90
+ `TypeError` rather than blurring the two.
91
+
92
+ Direct execution replaces the whole call, so an `Exec` cannot be combined with
93
+ other commands: there is no shell to join them with, and running just one
94
+ would silently drop the rest. Give each its own call, or use structured
95
+ commands if you want them joined.
96
+
97
+ Everything else is an ordinary value. A path is just a value that
98
+ stringifies, wherever it appears:
99
+
100
+ ```python
101
+ host.run(["chmod", "755", Path("/srv/app")]) # a quoted argument
102
+ host.run(Path("/bin/a"), Path("/bin/b")) # two shell commands
103
+ ```
104
+
105
+ ### Validation
106
+
107
+ An empty structured command and any control character (including a newline
108
+ inside a value, which could otherwise smuggle in a second command) raise
109
+ `ValueError`. An empty raw string is skipped when joining.
110
+
111
+ ### PowerShell targets
112
+
113
+ PowerShell flavours render a structured command through the `&` call operator
114
+ and append `; exit $LASTEXITCODE` so the remote status propagates:
115
+
116
+ ```
117
+ & 'Get-Item' 'C:/a b'; exit $LASTEXITCODE
118
+ ```
119
+
15
120
  `run()` returns a `subprocess.CompletedProcess`. Highlights:
16
121
 
17
- - Multiple positional commands are joined with `;`. A command given as a
18
- `list`/`tuple` is shell-quoted piece by piece.
19
122
  - `capture_output` may be `True` (both streams), `"stdout"`, `"stderr"`, or
20
123
  `False`.
21
124
  - SSH `input=`, `cwd=`, `env=`, `check=`, `timeout=`, and
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hostctl"
7
- version = "0.1.2"
7
+ version = "0.2.1"
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"
@@ -28,9 +28,12 @@ classifiers = [
28
28
  "Topic :: System :: Filesystems",
29
29
  "Typing :: Typed",
30
30
  ]
31
- # pathlib_next supplies the common local/remote path return type. Transport
32
- # implementations remain extras, so importing hostctl never requires them.
33
- dependencies = ["pathlib_next>=0.8.6,<0.9"]
31
+ # pathlib_next supplies the common local/remote path return type. netimps
32
+ # supplies scheme/port and address semantics -- including schemes no system
33
+ # services database knows (ws/wss, socks5) -- so hostctl does not reimplement
34
+ # them. Transport implementations remain extras, so importing hostctl never
35
+ # requires them.
36
+ dependencies = ["pathlib_next>=0.8.6,<0.9", "netimps>=0.1,<0.2"]
34
37
 
35
38
  [project.optional-dependencies]
36
39
  # SSH-backed run()/path(): asyncssh for shell exec, pathlib_next's SftpPath
@@ -57,9 +57,20 @@ shared protocol implementations can be added once; `Shell.__call__` delegates
57
57
  to `Shell.execute()`.
58
58
  `Host` itself supplies the multi-command `run()` provider contract; there is no
59
59
  duplicate host-executor protocol or unused host-options wrapper.
60
- `Shell.execute(path_like)` preserves the path, while string input remains a
61
- string. Executors and hosts distinguish direct commands from shell scripts by
62
- that value type; no duplicate command-kind flag is carried.
60
+ `Exec(program, *args)` is the direct-execution marker for `Host.run()`: one
61
+ program plus argv, no shell layer. The program may be a `str`, `bytes`, or a
62
+ path all reach the transport as text, so a bare name resolves through the
63
+ target's `PATH`. Direct execution replaces the whole call, so an `Exec`
64
+ combined with any other command raises. Everything else is an ordinary value:
65
+ a path is a value that stringifies wherever it appears, so `run(p1, p2)` is two
66
+ shell commands. `Exec` is a deliberately non-iterable frozen dataclass --
67
+ `ShellFlavour.command_text` dispatches structured commands on `Iterable`, and
68
+ an iterable marker would be quoted into an argv instead of taking the direct
69
+ branch; `command_text` raises if an `Exec` reaches it.
70
+ `Shell.execute(command, *args)` keeps its own program-plus-argv signature and
71
+ wraps into an `Exec` only when dispatching to a host `run(*cmds)` and only when
72
+ argv values are present -- `Shell.run` renders every command into one script
73
+ and dispatches it with no args, and that script is shell text, not a program.
63
74
 
64
75
  ## Configuration and lifecycle
65
76
 
@@ -33,7 +33,9 @@ from .executor import (
33
33
  )
34
34
  from .host import (
35
35
  ContainerConfig as ContainerConfig,
36
+ ConnectionString as ConnectionString,
36
37
  ContainerHost as ContainerHost,
38
+ Exec as Exec,
37
39
  Host as Host,
38
40
  HostConfig as HostConfig,
39
41
  HostInfo as HostInfo,
@@ -125,6 +127,7 @@ __all__ = [
125
127
  "ssh_providers",
126
128
  "strict_uri_credentials",
127
129
  "strict_uri_query",
130
+ "ConnectionString",
128
131
  "uri_host",
129
132
  "uri_hostname",
130
133
  "winrm_providers",
@@ -146,6 +149,7 @@ __all__ = [
146
149
  "IosConfig",
147
150
  "IosHost",
148
151
  # Executors and processes
152
+ "Exec",
149
153
  "Executor",
150
154
  "ExecutionOptions",
151
155
  "ExecutorCapability",
@@ -8,7 +8,6 @@ import dataclasses
8
8
  import getpass
9
9
  import json
10
10
  import os
11
- from pathlib import PurePath
12
11
  import re
13
12
  import subprocess
14
13
  import sys
@@ -17,7 +16,7 @@ import typing
17
16
 
18
17
  from pathlib_next import Path as NextPath
19
18
 
20
- from .host import Host, HostPath
19
+ from .host import Exec, Host, HostPath
21
20
 
22
21
  _URI_OPERAND = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
23
22
 
@@ -81,13 +80,11 @@ def _command_run(args: argparse.Namespace, stdout, stderr) -> int:
81
80
  if not command:
82
81
  raise ValueError("run requires a command after --")
83
82
  with Host(args.uri, **_credentials(args)) as host:
84
- try:
85
- command_path = host.shell_flavour.command_path(command[0])
86
- except NotImplementedError:
87
- command_path = PurePath(command[0])
83
+ # `Exec` marks direct execution and takes the program verbatim, so the
84
+ # operand needs no path flavour: a bare name resolves through the
85
+ # target's PATH and an absolute path is used as given.
88
86
  result = host.run(
89
- command_path,
90
- *command[1:],
87
+ Exec(command[0], *command[1:]),
91
88
  check=False,
92
89
  capture_output=True,
93
90
  )
@@ -94,6 +94,39 @@ def normalize_input(
94
94
  return input
95
95
 
96
96
 
97
+ def command_text(
98
+ value: object, *, encoding: str = "utf-8", errors: str = "strict"
99
+ ) -> str:
100
+ """Render one command or argv value as the text a transport sends.
101
+
102
+ Anything offering `__fspath__` is asked for it first: that is the
103
+ filesystem representation a path promises, and it is what a transport
104
+ needs. `str()` happens to match for the path types shipped here, but that
105
+ is a coincidence of their `__str__` rather than a contract -- a path type
106
+ whose `repr`-ish `__str__` differs would otherwise send the wrong text.
107
+
108
+ `__fspath__` is tried directly rather than through
109
+ `isinstance(value, os.PathLike)`, so a duck-typed path that never
110
+ registered with the ABC is still honoured. A object whose `__fspath__`
111
+ raises or returns a non-string falls back to `str()` rather than failing
112
+ the command.
113
+
114
+ Bytes -- returned by `__fspath__` or passed directly -- are decoded;
115
+ everything else is stringified.
116
+ """
117
+ fspath = getattr(value, "__fspath__", None)
118
+ if callable(fspath):
119
+ try:
120
+ resolved = fspath()
121
+ except Exception:
122
+ resolved = None
123
+ if isinstance(resolved, (str, bytes)):
124
+ value = resolved
125
+ if isinstance(value, bytes):
126
+ return value.decode(encoding, errors)
127
+ return str(value)
128
+
129
+
97
130
  def normalize_environment(
98
131
  env: typing.Optional[Environment],
99
132
  ) -> typing.Optional[typing.Dict[str, str]]:
@@ -7,6 +7,7 @@ import typing
7
7
 
8
8
  from ._common import (
9
9
  CaptureOutput,
10
+ command_text,
10
11
  CommandArgument,
11
12
  Environment,
12
13
  Executor,
@@ -79,10 +80,8 @@ class ContainerExecutor(Executor[subprocess.CompletedProcess]):
79
80
  )
80
81
 
81
82
  stdout, stderr = capture_streams(capture_output, stdout, stderr)
82
- argv = [str(command)]
83
- argv.extend(
84
- value.decode() if isinstance(value, bytes) else str(value) for value in args
85
- )
83
+ argv = [command_text(command)]
84
+ argv.extend(command_text(value) for value in args)
86
85
  merge_stderr = stderr is subprocess.STDOUT
87
86
  exec_options: typing.Dict[str, object] = {
88
87
  "stdout": True,
@@ -8,6 +8,7 @@ import typing
8
8
 
9
9
  from ._common import (
10
10
  CaptureOutput,
11
+ command_text,
11
12
  CommandArgument,
12
13
  Environment,
13
14
  Executor,
@@ -52,21 +53,7 @@ class LocalExecutor(Executor[subprocess.CompletedProcess]):
52
53
  raise TypeError(f"unsupported local executor option: {sorted(options)[0]}")
53
54
  reject_stdin_conflict(input, stdin)
54
55
  stdout, stderr = capture_streams(capture_output, stdout, stderr)
55
- argv = [
56
- os.fspath(command),
57
- *[
58
- (
59
- value.decode()
60
- if isinstance(value, bytes)
61
- else (
62
- os.fspath(value)
63
- if isinstance(value, os.PathLike)
64
- else str(value)
65
- )
66
- )
67
- for value in args
68
- ],
69
- ]
56
+ argv = [command_text(command), *(command_text(value) for value in args)]
70
57
  return subprocess.run(
71
58
  argv,
72
59
  bufsize=bufsize,
@@ -9,6 +9,7 @@ import types
9
9
 
10
10
  from ._common import (
11
11
  CaptureOutput,
12
+ command_text,
12
13
  CommandArgument,
13
14
  Executor,
14
15
  ExecutorCapability,
@@ -92,7 +93,9 @@ class PsrpExecutor(Executor[subprocess.CompletedProcess]):
92
93
  "PSRP pipeline timeout is unsupported; configure WinRM timeouts"
93
94
  )
94
95
  stdout, stderr = capture_streams(capture_output, stdout, stderr)
95
- result = self._session().invoke(str(command), raw=False, capture_exit=True)
96
+ result = self._session().invoke(
97
+ command_text(command), raw=False, capture_exit=True
98
+ )
96
99
  codec = encoding or "utf-8"
97
100
  # Preserve PowerShell's object-pipeline line orientation when
98
101
  # projecting objects to subprocess-compatible text.
@@ -135,7 +138,7 @@ class PsrpExecutor(Executor[subprocess.CompletedProcess]):
135
138
  errors=errors,
136
139
  )
137
140
  completed = subprocess.CompletedProcess(
138
- args=str(command),
141
+ args=command_text(command),
139
142
  returncode=returncode,
140
143
  stdout=out,
141
144
  stderr=err,
@@ -11,6 +11,7 @@ import typing
11
11
  from ._qga import GuestAgentTransport, QgaProtocolError
12
12
  from ._common import (
13
13
  CaptureOutput,
14
+ command_text,
14
15
  CommandArgument,
15
16
  Environment,
16
17
  Executor,
@@ -113,7 +114,8 @@ class QemuExecutor(Executor[subprocess.CompletedProcess]):
113
114
  "QGA guest-exec has no native working-directory support"
114
115
  )
115
116
  reject_stdin_conflict(input, stdin)
116
- if not str(command):
117
+ program = command_text(command)
118
+ if not program:
117
119
  raise ValueError("QGA guest-exec path must not be empty")
118
120
  if timeout is not None and timeout < 0:
119
121
  raise ValueError("timeout must not be negative")
@@ -122,15 +124,11 @@ class QemuExecutor(Executor[subprocess.CompletedProcess]):
122
124
 
123
125
  stdout, stderr = capture_streams(capture_output, stdout, stderr)
124
126
  argv = [
125
- (
126
- value.decode(encoding or "utf-8", errors or "strict")
127
- if isinstance(value, bytes)
128
- else str(value)
129
- )
127
+ command_text(value, encoding=encoding or "utf-8", errors=errors or "strict")
130
128
  for value in args
131
129
  ]
132
130
  arguments: typing.Dict[str, object] = {
133
- "path": str(command),
131
+ "path": program,
134
132
  "arg": argv,
135
133
  "capture-output": True,
136
134
  }
@@ -156,7 +154,7 @@ class QemuExecutor(Executor[subprocess.CompletedProcess]):
156
154
  arguments["input-data"] = base64.b64encode(payload).decode("ascii")
157
155
 
158
156
  deadline = None if timeout is None else self._clock() + timeout
159
- command_display = [str(command), *argv]
157
+ command_display = [program, *argv]
160
158
  started = self._execute(
161
159
  {"execute": "guest-exec", "arguments": arguments},
162
160
  deadline,
@@ -10,6 +10,7 @@ import typing
10
10
 
11
11
  from ._common import (
12
12
  CaptureOutput,
13
+ command_text,
13
14
  CommandArgument,
14
15
  Environment,
15
16
  Executor,
@@ -135,7 +136,7 @@ class SshExecutor(Executor[subprocess.CompletedProcess]):
135
136
  raise NotImplementedError("SshExecutor does not support native arguments")
136
137
  if bufsize == 0:
137
138
  raise ValueError("bufsize=0 is unsupported by the buffered SSH executor")
138
- command = str(command)
139
+ command = command_text(command)
139
140
  reject_stdin_conflict(input, stdin)
140
141
  if text and encoding is None:
141
142
  encoding = "utf-8"
@@ -9,6 +9,7 @@ import typing
9
9
 
10
10
  from ._common import (
11
11
  CaptureOutput,
12
+ command_text,
12
13
  CommandArgument,
13
14
  Executor,
14
15
  ExecutorCommand,
@@ -165,7 +166,7 @@ class WinRMExecutor(Executor[subprocess.CompletedProcess]):
165
166
  raise TypeError(f"unsupported WinRM executor option: {sorted(options)[0]}")
166
167
  if args:
167
168
  raise NotImplementedError("WinRMExecutor does not support native arguments")
168
- command = str(command)
169
+ command = command_text(command)
169
170
  if timeout is not None:
170
171
  raise NotImplementedError(
171
172
  "WinRMExecutor timeout is unsupported; configure transport timeouts"
@@ -1,6 +1,7 @@
1
1
  """Host contracts and built-in host implementations."""
2
2
 
3
3
  from ._common import (
4
+ Exec as Exec,
4
5
  Host as Host,
5
6
  HostConfig as HostConfig,
6
7
  HostInfo as HostInfo,
@@ -12,6 +13,7 @@ from ._common import (
12
13
  uri_host as uri_host,
13
14
  uri_hostname as uri_hostname,
14
15
  )
16
+ from ._connection import ConnectionString as ConnectionString
15
17
  from ._ssh import ssh_providers as ssh_providers
16
18
  from ._winrm import winrm_providers as winrm_providers
17
19
  from ._local import LocalConfig as LocalConfig, LocalHost as LocalHost