hostctl 0.1.0__py3-none-any.whl

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 (51) hide show
  1. hostctl/AGENTS.md +292 -0
  2. hostctl/README.md +248 -0
  3. hostctl/__init__.py +194 -0
  4. hostctl/__main__.py +6 -0
  5. hostctl/_async.py +169 -0
  6. hostctl/_cli.py +232 -0
  7. hostctl/executor/__init__.py +82 -0
  8. hostctl/executor/_common.py +234 -0
  9. hostctl/executor/_qga.py +646 -0
  10. hostctl/executor/container.py +171 -0
  11. hostctl/executor/local.py +91 -0
  12. hostctl/executor/psrp.py +145 -0
  13. hostctl/executor/qemu.py +305 -0
  14. hostctl/executor/serial.py +312 -0
  15. hostctl/executor/ssh.py +226 -0
  16. hostctl/executor/winrm.py +255 -0
  17. hostctl/host/__init__.py +93 -0
  18. hostctl/host/_common.py +804 -0
  19. hostctl/host/_local.py +258 -0
  20. hostctl/host/_ssh.py +587 -0
  21. hostctl/host/_winrm.py +1002 -0
  22. hostctl/host/composite_path.py +567 -0
  23. hostctl/host/container.py +606 -0
  24. hostctl/host/container_path.py +664 -0
  25. hostctl/host/qemu.py +1078 -0
  26. hostctl/host/serial.py +401 -0
  27. hostctl/host/system.py +809 -0
  28. hostctl/process/__init__.py +49 -0
  29. hostctl/process/_common.py +113 -0
  30. hostctl/process/container.py +265 -0
  31. hostctl/process/psrp.py +208 -0
  32. hostctl/process/qemu_serial.py +247 -0
  33. hostctl/process/serial.py +257 -0
  34. hostctl/process/ssh.py +165 -0
  35. hostctl/provider/__init__.py +33 -0
  36. hostctl/provider/_common.py +452 -0
  37. hostctl/provider/transports.py +303 -0
  38. hostctl/py.typed +0 -0
  39. hostctl/serial/__init__.py +285 -0
  40. hostctl/shell/__init__.py +123 -0
  41. hostctl/shell/_common.py +616 -0
  42. hostctl/shell/cmd.py +167 -0
  43. hostctl/shell/fish.py +63 -0
  44. hostctl/shell/posix.py +83 -0
  45. hostctl/shell/powershell.py +120 -0
  46. hostctl/sync.py +245 -0
  47. hostctl-0.1.0.dist-info/METADATA +302 -0
  48. hostctl-0.1.0.dist-info/RECORD +51 -0
  49. hostctl-0.1.0.dist-info/WHEEL +4 -0
  50. hostctl-0.1.0.dist-info/entry_points.txt +2 -0
  51. hostctl-0.1.0.dist-info/licenses/LICENSE +21 -0
hostctl/AGENTS.md ADDED
@@ -0,0 +1,292 @@
1
+ # hostctl — API header
2
+
3
+ Protocol-independent host execution and filesystem paths. Core requires
4
+ `pathlib_next`; transport integrations are optional extras.
5
+
6
+ The dependency-free CLI entry point is `hostctl._cli:main`. Commands are
7
+ `run`, `ls`, `cat`, `cp`, `info`, and `shell`; passwords come only from
8
+ `HOSTCTL_PASSWORD` or `--ask-password`, never argv values.
9
+
10
+ `hostctl.__all__` is the stable surface: hosts and configs you construct,
11
+ exceptions you catch, types you annotate with, and the provider/shell contracts
12
+ you implement. Concrete backends, transport adapters, and objects the library
13
+ only hands back (`QgaPathBackend`, `WinRMPathBackend`, `ContainerPathBackend`,
14
+ the `Posix*`/`Windows*` path classes, the concrete `*Executor`s, `*Process`
15
+ classes, and `hostctl.provider.transports`) stay importable from their defining
16
+ module but are **not** exported from `hostctl` and may change without notice.
17
+
18
+ Host implementations are grouped under `hostctl.host`: shared contracts are
19
+ re-exported from the package, with concrete implementations in private
20
+ modules (`hostctl.host._local`, `._ssh`, and `._winrm`) plus the other provider
21
+ modules. WinRM paths live with `_winrm`; QGA paths live with `qemu`.
22
+ The private `hostctl.executor._qga` module owns QGA framing and its Unix,
23
+ libvirt, and SSH transports; it is consumed by the QEMU executor and host.
24
+ Shell construction is transport-independent under `hostctl.shell`:
25
+ `_common.py` owns shared contracts, while `posix.py` and `powershell.py` own
26
+ their concrete flavours. Executor code follows the same layout under
27
+ `hostctl.executor`: `_common.py` owns contracts and option types; `ssh.py` and
28
+ `winrm.py` own `SshExecutor` and `WinRMExecutor`. Each package re-exports its
29
+ own contracts; only the stable subset above reaches top-level `hostctl`.
30
+ `hostctl.sync` adds `stat_checksum(entry)`, `host_checksum(*hosts,
31
+ algorithm="md5", chunk_size=1048576)`, and `ProgressReader`; these plug into
32
+ `pathlib_next.utils.sync.PathSyncer` and the existing path copy machinery.
33
+
34
+ `SystemHost`, `PosixHost`, and `WindowsHost` compose ordered executor/path
35
+ providers. `PosixHost.from_ssh(SshConfig(...))` and
36
+ `WindowsHost.from_winrm(WinRMConfig(...))` retain the original connection URI
37
+ and lifecycle while exposing transport operations through provider adapters;
38
+ transport implementations remain private.
39
+ `register_system_provider(name, resolver)` extends logical system URI
40
+ descriptors. Built-ins include `local`, `ssh`, `sftp`, and `winrm`; transport
41
+ descriptors require matching objects in `SystemConfig(provider_options=...)` and
42
+ never serialize credentials into the canonical URI.
43
+
44
+ `Executor(command, *, stdin=None, stdout=None, stderr=None, cwd=None, env=None,
45
+ capture_output=None, check=None, encoding=None, errors=None, input=None,
46
+ timeout=None, text=None, **options)` defines the shared shell-agnostic option
47
+ surface. `ExecutionOptions` is the corresponding total-false `TypedDict`;
48
+ executor-specific extensions remain keyword options.
49
+ `ExecutorCommand` is `str | pathlib.PurePath | pathlib_next.Pathname`; paths
50
+ remain path objects until the concrete executor converts them for transport.
51
+ `ExecutorCapability.ARGS`, `.CWD`, and `.ENV` declare native executor support.
52
+ `Shell.execute(path, *args)` preserves path/args for an `ARGS` executor;
53
+ otherwise the flavour safely renders them into one script. `cwd` and `env`
54
+ follow the same native-or-embed rule.
55
+ `Shell`, `SshExecutor`, and `WinRMExecutor` inherit the `Executor` protocol so
56
+ shared protocol implementations can be added once; `Shell.__call__` delegates
57
+ to `Shell.execute()`.
58
+ `Host` itself supplies the multi-command `run()` provider contract; there is no
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.
63
+
64
+ ## Configuration and lifecycle
65
+
66
+ - `Host(connection_string, **secrets) -> Host` dispatches a secret-safe URI
67
+ through config implementations and the `hostctl.configs` entry-point group.
68
+ - `HostConfig(connection_string, **secrets) -> HostConfig` performs the same
69
+ dispatch without creating a host. `str(config)` is its canonical,
70
+ secret-free connection string and can be passed back to `HostConfig`.
71
+ - A `scheme://user:password@host` URI is valid *input*: the password is
72
+ extracted into the credential arguments and stripped from the parsed
73
+ authority, so it never reaches a field that `connection_uri` or `repr()`
74
+ renders. Supplying a password both in the URI and as an argument raises.
75
+ `redact_uri(uri)` STRIPS a password and returns a valid, reusable URI (not a
76
+ masked one, so a rendered form can never round-trip a wrong credential), for
77
+ logs, reprs, and error messages.
78
+ - A connection URI may carry a raw tab, CR, or LF in its **userinfo**: those
79
+ are percent-encoded before `urlsplit` sees them, which would otherwise
80
+ delete them silently (`ssh://u:pw<LF>otp:1@host` would authenticate with
81
+ `pwotp:1`). So the credential-extras separator can be written naturally.
82
+ A control character in the **host** is REJECTED -- deletion there rewrites
83
+ the target (`ssh://host<LF>.other.example/` would resolve to
84
+ `host.other.example`), and no encoding makes it meaningful.
85
+ `redact_uri` never raises on either -- it is for diagnostics.
86
+ - `parse_credentials(password) -> (password, extras)` splits a password field
87
+ on a newline: the first line is the password, each later line is a
88
+ credential extra. `name:value` sets a value, a bare `name` is a flag
89
+ equivalent to `name:` (empty string). Names are casefolded and stripped,
90
+ values keep everything after the first `:`, blank lines are ignored, and
91
+ CRLF is handled. URI dispatch runs the password field through it, so an OTP
92
+ or other second factor travels in the same field; an extra a config does not
93
+ declare is rejected by name rather than dropped.
94
+ - `config.connection_uri` never includes passwords or private keys;
95
+ `config.scheme` matches its URI scheme.
96
+ - `with config as host:` and `with config.open() as host:` connect and always
97
+ close. Re-entering the same active config raises `RuntimeError`.
98
+ - External config implementations declare `schemes=(...)`; registry hooks and
99
+ caches are protected implementation details.
100
+
101
+ `LocalConfig`, `SshConfig`, `WinRMConfig`, and `ContainerConfig` produce their
102
+ corresponding hosts.
103
+
104
+ ## Shell contract
105
+
106
+ - `Shell(flavour, executor)` binds a `ShellFlavour` to either a command
107
+ callable or an object exposing `run(command, **options)`. The command is
108
+ always one string. If its inspected signature accepts `cwd` and/or `env`,
109
+ `Shell.run()` forwards those separately; otherwise the flavour embeds that
110
+ context into the script. Shell-agnostic subprocess options (`stdin`,
111
+ `stdout`, `stderr`, `capture_output`, `check`, `encoding`, `errors`, `input`,
112
+ `timeout`, `text`) pass through only to the executor. `Shell.execute(command)`
113
+ passes strings unchanged and converts path-like commands with `str(path)`.
114
+ - `ShellFlavour.script(cmds, *, cwd=None, env=None) -> str` constructs a script
115
+ in one target shell language.
116
+ - `ShellFlavour.environment_script(env) -> str` is a reusable standalone
117
+ environment-mapping renderer. The base normalizes/validates variable names
118
+ and joins each flavour's `environment_assignment(key, value)` output.
119
+ Values remain objects so a flavour can preserve meaningful types; built-ins
120
+ decode bytes and stringify ordinary values.
121
+ - `ShellOperator.PIPE`, `.AND`, `.OR`, `.REDIRECT`, `.APPEND`, and `.SEQUENCE`
122
+ are explicit infix tokens between top-level commands. Flavours own their
123
+ spelling and may reject operators they cannot represent portably.
124
+ - Raw strings stay verbatim; tuple/list commands quote each item as data;
125
+ standalone and structured paths are quoted; top-level commands otherwise
126
+ use the flavour's `command_separator`.
127
+ - `ShellFlavour.command(cmds, *, executable=None, cwd=None, env=None) ->
128
+ ShellCommand` wraps that script for an SSH exec channel.
129
+ - Structured values are normalized through the base class (including bytes and
130
+ iterable argv sequences); empty structured commands and control characters
131
+ are rejected. Raw empty command strings are skipped when joining.
132
+ - Environment assignments embedded by a shell are additive to the inherited
133
+ remote environment. This intentionally differs from local
134
+ `subprocess.run(env=...)`, which replaces the environment; a clear-env mode
135
+ remains a separate future contract.
136
+ - `POSIX_SHELL` and `POWERSHELL` are the built-in strategies;
137
+ common built-ins also include `BASH`, `ZSH`, `FISH`, `CMD`, and PowerShell 7
138
+ `PWSH`. PowerShell 5 rejects `AND`/`OR`; PowerShell 7 supports them.
139
+ - `shell_flavour(selection)` accepts a registered string, configured
140
+ `ShellFlavour` instance, or no-argument `ShellFlavour` subclass.
141
+ `register_shell_flavour()` adds application-defined string selections.
142
+ - `ShellCommand.command` is transport-ready text; `.environment` is the
143
+ environment sent out of band, or `None` when embedded into the script.
144
+ - `Shell(flavour, executor, cwd=None, env=None, encoding=None, errors=None)`
145
+ accepts defaults applied to every `run`/`session` call that omits them.
146
+ `host.shell(cwd=..., env=...)` returns such a shell; bare `host.shell` has
147
+ none. `cwd`/`encoding`/`errors` are replaced by a per-call value, `env`
148
+ merges per key, and `Shell.configure(...)` returns a configured copy without
149
+ mutating the original.
150
+ - A custom `HostConfig` should declare `uri_credentials = ("password", ...)`.
151
+ Dispatch then rejects any other credential BEFORE construction, so a typo
152
+ (`passwrd=`) fails loudly instead of silently building a config with no
153
+ password. `None` (default) skips the check for configs that validate
154
+ themselves. `strict_uri_credentials`, `strict_uri_query`, and `uri_host`
155
+ are public for configs writing `_from_parsed_uri` by hand.
156
+ - `ssh_providers(SshConfig)` and `winrm_providers(WinRMConfig)` return an
157
+ `(executor_provider, path_provider)` pair sharing ONE transport, for
158
+ composing a transport into a host you assemble yourself rather than taking
159
+ the finished host `_create_host()` builds. They return a pair instead of
160
+ exposing the transport because both providers must share it -- building them
161
+ separately silently opens two connections, only one of which is closed.
162
+ - `input=` is normalized to the stream mode every executor is about to use, by
163
+ `executor/_common.normalize_input`. Under a text mode (`encoding`/`errors`/
164
+ `text`) bytes are decoded; under a binary mode str is encoded. This is not
165
+ cosmetic: handing bytes to a text-mode `subprocess` stdin kills its writer
166
+ thread, and the call then blocks forever because the child never sees EOF --
167
+ `timeout=` does not fire. All executors share the helper so the same call
168
+ behaves identically whichever provider a `SystemHost` selects.
169
+ - `env` on `run`/`session`/`configure` accepts `EnvironmentSelection`: a
170
+ mapping merges over the shell's default per key, the default empty mapping
171
+ merges nothing and inherits it, and `None` runs without the shell's
172
+ configured environment -- keeping whatever the host provides on its own,
173
+ since nothing is sent to override it. `None` does NOT mean an empty
174
+ environment. Declining `env` does not affect `cwd`.
175
+ - `Shell.session(*cmds, terminal=False, cwd=None, env=None, ...) -> ShellSession`
176
+ opens a persistent provider process. `Shell` is also a context manager:
177
+ `with host.shell as session:` opens a default session and closes it on exit,
178
+ which is the no-argument shorthand for `with host.shell.session() as ...`.
179
+ Re-entering a shell whose session is still open raises `RuntimeError`.
180
+ `ShellSession.send(*cmds, cwd=None,
181
+ env=None)` uses the same command grammar, writes the flavour terminator and
182
+ a line terminator, and mutates the live shell context. This newline is
183
+ required for interactive shells to submit each command.
184
+ TTY stderr is merged into stdout.
185
+
186
+ ## Host contract
187
+
188
+ - `Host` is abstract. Base `run()` and `path()` raise `NotImplementedError`.
189
+ - Hosts expose delegated `scheme`/`connection_uri`, explicit `capabilities`,
190
+ `info() -> HostInfo`, and `connect()`/`close()` plus context management.
191
+ - `host.shell_flavour` is the explicitly known target-shell strategy;
192
+ `host.shell` builds `Shell(host.shell_flavour, host)`. SSH uses its configured
193
+ or positively detected flavour, WinRM uses PowerShell, and local execution
194
+ selects POSIX sh or Windows PowerShell from the local platform.
195
+ - SSH provider run renders through its shell flavour and delegates the finalized
196
+ command to `SshExecutor`; WinRM provider run delegates its finalized
197
+ PowerShell script to `WinRMExecutor`.
198
+ - `HostInfo` fields are optional; unknown system values remain `None`.
199
+ - A usable `path()` returns `HostPath` (`pathlib_next.Path`).
200
+ - A usable `run()` returns `subprocess.CompletedProcess`; `check=True` raises
201
+ `subprocess.CalledProcessError`, and command timeouts raise
202
+ `subprocess.TimeoutExpired`.
203
+ - `Host.spawn()` is the low-level persistent `Process` contract. Providers
204
+ advertise `spawn` and `tty` separately.
205
+
206
+ ## SSH
207
+
208
+ `SshConfig(host, port=22, username="root", password=None, client_keys=None,
209
+ executable=None, known_hosts=(), dialect=POSIX_SHELL,
210
+ path_flavor=pathlib_next.PosixPathname)`.
211
+
212
+ Authentication fields are explicit and excluded from repr. `dialect` selects
213
+ POSIX or PowerShell command construction independently of the POSIX/Windows
214
+ SFTP path flavor. `dialect` is a `ShellFlavour` strategy. `path_flavor` is a concrete
215
+ `pathlib_next.Pathname` or `pathlib.PurePath` subclass; bare `PurePath` is
216
+ rejected because it would infer the local OS. SSH implies neither an OS nor a
217
+ shell. `dialect="auto"` performs positive cached probing and raises rather than
218
+ guessing. The SSH executor provider closes its AsyncSSH connection and waits for closure.
219
+ AsyncSSH authentication failures are exposed as `PermissionError`; SSH
220
+ host-key, key-exchange, disconnect, connection-loss, protocol, and channel
221
+ failures are exposed as `ConnectionError`. The original AsyncSSH exception is
222
+ retained as `__cause__`.
223
+ The SFTP path provider reuses one `AsyncsshSftpBackend` per host and invalidates its
224
+ cached sources during `close()`; each path call does not create another SFTP
225
+ connection. Provider close performs all AsyncSSH operations through the
226
+ shared bridge. Omitted `run()` stdin is an explicit EOF stream, `bufsize=0` is
227
+ rejected, and a missing remote exit status is reported as return code `-1`.
228
+ Timeouts raise `subprocess.TimeoutExpired` with an `orphaned` flag indicating
229
+ whether a process/channel termination hook was available. `dialect="auto"`
230
+ retains the executable path reported by the successful probe. Persistent SSH
231
+ process reads, writes, EOF, and close operations use the same transport-error
232
+ normalization as `wait()`.
233
+
234
+ ## Containers
235
+
236
+ `ContainerConfig(container, engine_url=None, user=None, workdir=None,
237
+ executable=None, dialect="auto", path_flavor="auto")` uses the optional
238
+ `container` extra and Docker Engine API. Inspection selects Linux/POSIX or
239
+ Windows/PowerShell semantics. `ContainerHost` supports buffered exec,
240
+ persistent sessions/TTYs, and archive-backed POSIX or Windows paths. Archive
241
+ paths support stat/traversal/read/write/append/exclusive-create; archive-only
242
+ mkdir/remove/rename/chmod raise `NotImplementedError`.
243
+
244
+ ## QEMU Guest Agent
245
+
246
+ `QemuConfig(domain, transport="libvirt", connection=None, socket_path=None,
247
+ ssh=None, agent_timeout=10, dialect="auto", path_flavor="auto")` creates a
248
+ `QemuHost`. Transports are local libvirt (`qemu-libvirt` extra), direct Unix
249
+ socket, or an AsyncSSH-tunneled remote Unix socket. Discovery positively probes
250
+ QGA and its enabled command list.
251
+
252
+ `QemuExecutor` uses buffered `guest-exec`/`guest-exec-status`. QGA cannot cancel
253
+ timed-out processes; `TimeoutExpired.orphaned` is true and `.pid` is retained
254
+ when known. `QgaPathBackend` uses bounded file-handle RPCs; metadata/mutations
255
+ without a positively available helper raise `NotImplementedError`.
256
+ An injected `QemuSerialConsole` adds the `serial` capability and
257
+ `QemuHost.open_serial()`. It is raw, exclusive, and makes no shell/status claim.
258
+
259
+ ## WinRM
260
+
261
+ `WinRMConfig(host, username, password=None, transport="ntlm", port=None,
262
+ ssl=False, server_cert_validation="validate", message_encryption="auto",
263
+ operation_timeout_sec=20, read_timeout_sec=30, provider="auto")`. `auto`
264
+ selects PSRP when `hostctl[psrp]` is installed on Python 3.10+, otherwise
265
+ pywinrm; `provider="psrp"` requires the extra.
266
+
267
+ The WinRM executor provider supports PowerShell `run()` and Windows-semantic `WinRMPath`.
268
+ Password-free configs on Windows use current-context native PowerShell
269
+ remoting; explicit credentials use pywinrm. `WinRMPath.open("rb")` fetches
270
+ bounded ranges; writable modes stage content and transfer Base64 chunks on
271
+ close. WinRM stdin and command
272
+ deadlines remain unsupported. Transport timeouts are not a total command
273
+ deadline. pywinrm Session has no guaranteed close API; hostctl calls `close()`
274
+ only when a provided session exposes it.
275
+ PSRP runspaces are exposed separately through the WinRM transport provider and
276
+ `RunspaceSession.invoke()`. They retain typed PowerShell streams and state, and
277
+ are not advertised as a byte-oriented `spawn`/TTY process.
278
+
279
+ ## Local and utilities
280
+
281
+ `LocalHost.path()` and `LocalHost.run()` work on POSIX and Windows.
282
+ `LocalExecutor` provides native argv, cwd, environment, stream, encoding,
283
+ check, and timeout behavior through `subprocess.run`.
284
+
285
+ `SerialConfig`/`SerialHost` provide an opaque `serial:///...` URI and one
286
+ exclusive byte-stream lease. `RawConsoleProfile` supports sessions only;
287
+ `PromptConsoleProfile` adds bounded login/prompt framing and advertises
288
+ `run` only when `reliable_status=True` and a completion marker is configured.
289
+ Streams are merged, PTY/path/status semantics are absent unless the profile
290
+ explicitly supplies them. Optional PySerial support is the `serial` extra and
291
+ injected serial objects remain caller-owned. Break, DTR, and RTS are available
292
+ on `SerialProcess`/`SerialConsoleProcess`; RFC 2217 URLs are not encrypted.
hostctl/README.md ADDED
@@ -0,0 +1,248 @@
1
+ [![Version](https://img.shields.io/pypi/v/hostctl.svg)](https://pypi.org/project/hostctl/)
2
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
3
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/hostctl/)
4
+ [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/hostctl/release.yml?label=release%20gate)](https://github.com/jose-pr/hostctl/actions/workflows/release.yml)
5
+
6
+ A **protocol-agnostic way to run commands and access files on a host**.
7
+ `Host` defines operations; `HostConfig` owns secret-safe connection identity,
8
+ extensible URI dispatch, and lifecycle. Local and application hosts can compose
9
+ SSH, WinRM, serial, container, and QEMU providers while exposing only the
10
+ capabilities those providers actually support.
11
+
12
+ ## Features
13
+
14
+ - **`Host.run(...)`** — subprocess-compatible results from local shells,
15
+ SSH (`asyncssh`), or PowerShell over WinRM (`pywinrm`). Unsupported transport
16
+ options raise `NotImplementedError`.
17
+ - **`Host.path(...)`** — a `pathlib_next.Path` filesystem view: local,
18
+ remote SFTP, Windows over WinRM, container archives, or QEMU Guest Agent.
19
+ - **Cross-host copy and sync** — use `Path.copy()`/`PathSyncer` directly;
20
+ `host_checksum()` computes unchanged-file digests beside remote data.
21
+ - **`with host.shell as session:`** — a persistent shell over SSH or a
22
+ container, closed on exit; `host.shell.session(...)` takes a starting
23
+ command, TTY, `cwd`, `env`, or encoding. `send(*cmds)` uses the same
24
+ structured quoting rules.
25
+ - **Serial console hosts.** `SerialConfig` accepts opaque native/PySerial URLs;
26
+ raw profiles provide exclusive sessions, while an explicitly configured
27
+ prompt profile can add safely framed `run()` results. Serial consoles never
28
+ imply a filesystem or PTY and RFC 2217 has no encryption.
29
+ - **Explicit or detected SSH shells.** Use a concrete dialect for deterministic
30
+ behavior or `dialect="auto"` for positive POSIX/Windows probing.
31
+ - **Extensible shell languages.** Select a registered string, a
32
+ `ShellFlavour` subclass, or a configured flavour instance.
33
+ - **Extension point, not a closed abstraction.** Override `Host.path()` in a
34
+ subclass to add a project-specific backend (see `docs/guide/extending.md`).
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install hostctl
40
+ ```
41
+
42
+ Optional features/extras:
43
+
44
+ | Extra/flag | Adds | Needed for |
45
+ | --- | --- | --- |
46
+ | `ssh` | `asyncssh`, `pathlib_next[sftp-async]` | `run()`/`path()` over SSH |
47
+ | `winrm` | `pywinrm` | PowerShell `run()` over WinRM |
48
+ | `psrp` | `pypsrp` on Python 3.10+ | persistent typed PowerShell runspaces |
49
+ | `container` | Docker SDK for Python | Docker Engine `run()`/`path()`/sessions |
50
+ | `serial` | PySerial | raw native/RFC 2217/socket serial sessions |
51
+ | `qemu-libvirt` | libvirt Python bindings | local libvirt QGA transport |
52
+
53
+ ## Quick start
54
+
55
+ ```python
56
+ from hostctl import (
57
+ Host,
58
+ HostConfig,
59
+ ContainerConfig,
60
+ QemuConfig,
61
+ LocalConfig,
62
+ POWERSHELL,
63
+ SshConfig,
64
+ WinRMConfig,
65
+ )
66
+ from pathlib_next import WindowsPathname
67
+
68
+ # Local
69
+ with LocalConfig() as host:
70
+ result = host.run("echo hello")
71
+ print(result.stdout)
72
+
73
+ # SSH to a POSIX target (needs the `ssh` extra)
74
+ with SshConfig(host="nas.example.com", username="admin", password="secret") as host:
75
+ result = host.run("df -h")
76
+ for line in host.path("/etc").iterdir():
77
+ print(line)
78
+
79
+ # SSH to Windows is explicit, not inferred from the transport.
80
+ windows_ssh = SshConfig(
81
+ host="windows.example.com",
82
+ username="admin",
83
+ password="secret",
84
+ dialect=POWERSHELL,
85
+ path_flavor=WindowsPathname,
86
+ )
87
+
88
+ # WinRM supports PowerShell execution and Windows filesystem paths.
89
+ with WinRMConfig("windows.example.com", "admin", "secret", ssl=True) as windows:
90
+ windows.run(["Write-Output", "hello"])
91
+ windows.path(r"C:\Temp\hello.txt").write_text("hello", encoding="utf-8")
92
+
93
+ # An existing running container (needs the `container` extra).
94
+ with ContainerConfig("application") as container:
95
+ container.run(["printf", "%s\n", "hello"])
96
+ print(container.path("/etc/os-release").read_text())
97
+ with container.shell.session(terminal=True, encoding="utf-8") as session:
98
+ session.send(["printf", "%s\n", "hello from the session"])
99
+ print(session.read())
100
+
101
+ # A QEMU guest through its QGA Unix socket tunneled over SSH.
102
+ with QemuConfig(
103
+ "vm-id",
104
+ transport="ssh",
105
+ ssh=SshConfig("hypervisor.example", username="root"),
106
+ ) as guest:
107
+ print(guest.info())
108
+ print(guest.run(["echo", "hello"], encoding="utf-8").stdout)
109
+ print(guest.path("/etc/os-release").read_text())
110
+
111
+ # A canonical connection URI carries configuration, never a password.
112
+ with Host(windows_ssh.connection_uri, password="secret") as same_host:
113
+ same_host.run(["Write-Output", "hello"])
114
+
115
+ # A `user:password@host` URI is still valid input: the password is extracted
116
+ # and used, and never rendered back out. Use `redact_uri` to display one.
117
+ with Host("ssh://admin:secret@nas.example.com") as same_host:
118
+ same_host.run("uptime")
119
+ ```
120
+
121
+ A URI may carry `user:password@host` on the way *in* — it is a valid URI, so
122
+ hostctl accepts it, extracts the password into the credentials, and keeps it
123
+ out of every rendered form. Passing the same password both in the URI and as an
124
+ argument is an error rather than a silent precedence rule. `redact_uri(uri)`
125
+ strips the password and returns a valid, reusable URI — it is removed rather
126
+ than masked, so a rendered form can never round-trip a wrong credential.
127
+
128
+ Shell `env` defaults merge per key; `env=None` declines them, leaving whatever
129
+ environment the host itself provides.
130
+
131
+ A password field may also carry extra credentials, one per line after it:
132
+ `parse_credentials("hunter2\notp:123456")` yields `("hunter2", {"otp":
133
+ "123456"})`. A bare name is a flag meaning the same as `name:`. This lets an
134
+ OTP or other second factor reach a transport through a single field — a URI's
135
+ userinfo, an environment variable, a prompt — without each caller inventing an
136
+ encoding. Inside a URI the separator may be written raw — hostctl encodes it
137
+ before parsing, since `urlsplit` would otherwise delete it and silently merge
138
+ the extras into the password. A control character in the *host* is still
139
+ rejected: no encoding makes a hostname containing one meaningful, and allowing
140
+ it would let a URI that reads as one target resolve to another.
141
+
142
+ `str(config)` is the same canonical, secret-free connection string as
143
+ `config.connection_uri`. `HostConfig(str(config), **secrets)` reconstructs the
144
+ concrete configuration without creating or connecting a host.
145
+
146
+ Install `hostctl[winrm-kerberos]` or `hostctl[winrm-credssp]` when selecting
147
+ those WinRM authentication transports. Certificate authentication is not
148
+ exposed until its required certificate/key configuration is part of the API.
149
+
150
+ ### Composable system hosts
151
+
152
+ Use `PosixHost`, `WindowsHost`, or `IosHost` when system semantics should be
153
+ independent of the transport. Providers are tried in declaration order during
154
+ preflight; a provider may be retried only when it raises
155
+ `OperationNotStarted`, which guarantees that no remote operation was sent.
156
+ Paths retain their selected provider and expose it through `.provider` and
157
+ `.via(name)`:
158
+
159
+ ```python
160
+ from hostctl import ExecutorProvider, PathProvider, PosixHost, LocalExecutor, HostPath
161
+
162
+ host = PosixHost(
163
+ executor_providers=(ExecutorProvider("ssh", ssh_executor),
164
+ ExecutorProvider("local", LocalExecutor())),
165
+ path_providers=(PathProvider("sftp", sftp_path),
166
+ PathProvider("rpc", lambda *p: HostPath(*p))),
167
+ )
168
+ path = host.path("etc", "hosts")
169
+ print(path.provider.name)
170
+ ```
171
+
172
+ Application-specific adapters can follow the SFTP/RPC/download pattern in
173
+ [`examples/application_provider.py`](examples/application_provider.py). The
174
+ [Systems and providers](https://jose-pr.github.io/hostctl/guide/providers/)
175
+ guide covers selection traces, backend pinning, per-operation capabilities,
176
+ provider authoring, and the no-replay safety rule in full.
177
+
178
+ Existing transport URIs remain compatible. To opt into system semantics while
179
+ retaining those URI/configuration objects, compose them explicitly:
180
+
181
+ ```python
182
+ from hostctl import PosixHost, SshConfig, WindowsHost, WinRMConfig
183
+
184
+ posix = PosixHost.from_ssh(SshConfig("server.example", username="root"))
185
+ windows = WindowsHost.from_winrm(WinRMConfig("server.example", "admin"))
186
+ ```
187
+
188
+ SSH and WinRM transport implementations are private providers; QEMU's SSH
189
+ tunnel continues to consume `SshConfig` directly.
190
+
191
+ ## Command line
192
+
193
+ The installed `hostctl` command is a thin wrapper over the library:
194
+
195
+ ```console
196
+ hostctl run local: -- python -c "print('hello')"
197
+ hostctl info ssh://server
198
+ hostctl cp ./artifact ssh://server:/tmp/artifact
199
+ ```
200
+
201
+ Secrets never belong in argv. Use `HOSTCTL_PASSWORD` or the subcommand's
202
+ `--ask-password` option. See the
203
+ [CLI guide](https://jose-pr.github.io/hostctl/guide/cli/) for all subcommands
204
+ and exit statuses.
205
+
206
+ ## API overview
207
+
208
+ | Module | Purpose |
209
+ | --- | --- |
210
+ | `hostctl.host` | Shared contracts and built-in host providers |
211
+ | `hostctl.host.container` | `ContainerConfig`, `ContainerHost` |
212
+ | `hostctl.host.qemu` | `QemuConfig`, `QemuHost` |
213
+ | `hostctl.host.serial` | `SerialConfig`, `SerialHost` |
214
+ | `hostctl.provider` | Ordered executor/path provider composition |
215
+ | `hostctl.executor` | Transport-specific command executors |
216
+ | `hostctl.shell` | Shell flavours and persistent sessions |
217
+ | `hostctl.sync` | Remote checksum and byte-progress helpers for `pathlib_next` |
218
+ | `hostctl._cli` | Dependency-free command-line entry point |
219
+
220
+ ## Development
221
+
222
+ ```bash
223
+ py -3.14 -m venv .venv/3.14-nt-amd64
224
+ .venv/3.14-nt-amd64/Scripts/python -m pip install -e ".[dev,ssh,winrm,container,serial]"
225
+ .venv/3.14-nt-amd64/Scripts/python -m pytest -q
226
+ ```
227
+
228
+ Python 3.14 is the default development interpreter. Python 3.9 remains the
229
+ supported compatibility floor and should be selected explicitly with
230
+ `py -3.9` when running floor-specific checks.
231
+
232
+ ### Releasing
233
+
234
+ This project follows [Semantic Versioning](https://semver.org/) and keeps a
235
+ [`CHANGELOG.md`](CHANGELOG.md). Pushing a tag matching `v*` triggers the release
236
+ workflow: test gate → build → publish → docs deploy.
237
+
238
+ To prepare a release, update `pyproject.toml` and move the complete
239
+ `[Unreleased]` section to `## [X.Y.Z] - YYYY-MM-DD` in the same commit. Keep
240
+ the package version PEP 440-compatible and use the corresponding SemVer tag
241
+ (`vX.Y.Z`, or `vX.Y.Z-rc.N` for a prerelease). Then push the commit and tag;
242
+ the workflow extracts that changelog section for the GitHub release and
243
+ publishes the built artifacts. Leave a fresh empty `[Unreleased]` section for
244
+ the next cycle.
245
+
246
+ ## License
247
+
248
+ MIT — see [LICENSE](LICENSE).