hostctl 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. hostctl-0.1.0/.gitignore +19 -0
  2. hostctl-0.1.0/CHANGELOG.md +148 -0
  3. hostctl-0.1.0/LICENSE +21 -0
  4. hostctl-0.1.0/PKG-INFO +302 -0
  5. hostctl-0.1.0/README.md +248 -0
  6. hostctl-0.1.0/docs/api/reference.md +119 -0
  7. hostctl-0.1.0/docs/changelog.md +1 -0
  8. hostctl-0.1.0/docs/guide/cli.md +31 -0
  9. hostctl-0.1.0/docs/guide/contracts.md +79 -0
  10. hostctl-0.1.0/docs/guide/extending.md +56 -0
  11. hostctl-0.1.0/docs/guide/path.md +59 -0
  12. hostctl-0.1.0/docs/guide/providers.md +338 -0
  13. hostctl-0.1.0/docs/guide/run.md +171 -0
  14. hostctl-0.1.0/docs/guide/transfer.md +126 -0
  15. hostctl-0.1.0/docs/index.md +20 -0
  16. hostctl-0.1.0/examples/application_provider.py +86 -0
  17. hostctl-0.1.0/examples/copy_between_hosts.py +39 -0
  18. hostctl-0.1.0/examples/local_run.py +7 -0
  19. hostctl-0.1.0/examples/remote_run.py +20 -0
  20. hostctl-0.1.0/mkdocs.yml +58 -0
  21. hostctl-0.1.0/pyproject.toml +81 -0
  22. hostctl-0.1.0/src/hostctl/AGENTS.md +292 -0
  23. hostctl-0.1.0/src/hostctl/__init__.py +194 -0
  24. hostctl-0.1.0/src/hostctl/__main__.py +6 -0
  25. hostctl-0.1.0/src/hostctl/_async.py +169 -0
  26. hostctl-0.1.0/src/hostctl/_cli.py +232 -0
  27. hostctl-0.1.0/src/hostctl/executor/__init__.py +82 -0
  28. hostctl-0.1.0/src/hostctl/executor/_common.py +234 -0
  29. hostctl-0.1.0/src/hostctl/executor/_qga.py +646 -0
  30. hostctl-0.1.0/src/hostctl/executor/container.py +171 -0
  31. hostctl-0.1.0/src/hostctl/executor/local.py +91 -0
  32. hostctl-0.1.0/src/hostctl/executor/psrp.py +145 -0
  33. hostctl-0.1.0/src/hostctl/executor/qemu.py +305 -0
  34. hostctl-0.1.0/src/hostctl/executor/serial.py +312 -0
  35. hostctl-0.1.0/src/hostctl/executor/ssh.py +226 -0
  36. hostctl-0.1.0/src/hostctl/executor/winrm.py +255 -0
  37. hostctl-0.1.0/src/hostctl/host/__init__.py +93 -0
  38. hostctl-0.1.0/src/hostctl/host/_common.py +804 -0
  39. hostctl-0.1.0/src/hostctl/host/_local.py +258 -0
  40. hostctl-0.1.0/src/hostctl/host/_ssh.py +587 -0
  41. hostctl-0.1.0/src/hostctl/host/_winrm.py +1002 -0
  42. hostctl-0.1.0/src/hostctl/host/composite_path.py +567 -0
  43. hostctl-0.1.0/src/hostctl/host/container.py +606 -0
  44. hostctl-0.1.0/src/hostctl/host/container_path.py +664 -0
  45. hostctl-0.1.0/src/hostctl/host/qemu.py +1078 -0
  46. hostctl-0.1.0/src/hostctl/host/serial.py +401 -0
  47. hostctl-0.1.0/src/hostctl/host/system.py +809 -0
  48. hostctl-0.1.0/src/hostctl/process/__init__.py +49 -0
  49. hostctl-0.1.0/src/hostctl/process/_common.py +113 -0
  50. hostctl-0.1.0/src/hostctl/process/container.py +265 -0
  51. hostctl-0.1.0/src/hostctl/process/psrp.py +208 -0
  52. hostctl-0.1.0/src/hostctl/process/qemu_serial.py +247 -0
  53. hostctl-0.1.0/src/hostctl/process/serial.py +257 -0
  54. hostctl-0.1.0/src/hostctl/process/ssh.py +165 -0
  55. hostctl-0.1.0/src/hostctl/provider/__init__.py +33 -0
  56. hostctl-0.1.0/src/hostctl/provider/_common.py +452 -0
  57. hostctl-0.1.0/src/hostctl/provider/transports.py +303 -0
  58. hostctl-0.1.0/src/hostctl/py.typed +0 -0
  59. hostctl-0.1.0/src/hostctl/serial/__init__.py +285 -0
  60. hostctl-0.1.0/src/hostctl/shell/__init__.py +123 -0
  61. hostctl-0.1.0/src/hostctl/shell/_common.py +616 -0
  62. hostctl-0.1.0/src/hostctl/shell/cmd.py +167 -0
  63. hostctl-0.1.0/src/hostctl/shell/fish.py +63 -0
  64. hostctl-0.1.0/src/hostctl/shell/posix.py +83 -0
  65. hostctl-0.1.0/src/hostctl/shell/powershell.py +120 -0
  66. hostctl-0.1.0/src/hostctl/sync.py +245 -0
  67. hostctl-0.1.0/tests/conformance/__init__.py +5 -0
  68. hostctl-0.1.0/tests/conformance/path_fakes.py +536 -0
  69. hostctl-0.1.0/tests/conformance/providers.py +815 -0
  70. hostctl-0.1.0/tests/conformance/test_composite_path_routing.py +75 -0
  71. hostctl-0.1.0/tests/conformance/test_live.py +49 -0
  72. hostctl-0.1.0/tests/conformance/test_path_contract.py +252 -0
  73. hostctl-0.1.0/tests/conformance/test_process_contract.py +171 -0
  74. hostctl-0.1.0/tests/conformance/test_run_contract.py +131 -0
  75. hostctl-0.1.0/tests/conformance/test_sync_contract.py +223 -0
  76. hostctl-0.1.0/tests/test_application_provider.py +100 -0
  77. hostctl-0.1.0/tests/test_asyncssh_errors.py +81 -0
  78. hostctl-0.1.0/tests/test_cli.py +168 -0
  79. hostctl-0.1.0/tests/test_composite_path_propagation.py +136 -0
  80. hostctl-0.1.0/tests/test_container_path.py +248 -0
  81. hostctl-0.1.0/tests/test_container_process.py +168 -0
  82. hostctl-0.1.0/tests/test_host_api.py +545 -0
  83. hostctl-0.1.0/tests/test_host_container.py +189 -0
  84. hostctl-0.1.0/tests/test_host_local.py +124 -0
  85. hostctl-0.1.0/tests/test_host_module_layout.py +26 -0
  86. hostctl-0.1.0/tests/test_host_qemu.py +126 -0
  87. hostctl-0.1.0/tests/test_host_remote.py +336 -0
  88. hostctl-0.1.0/tests/test_host_winrm.py +253 -0
  89. hostctl-0.1.0/tests/test_observability.py +410 -0
  90. hostctl-0.1.0/tests/test_process.py +155 -0
  91. hostctl-0.1.0/tests/test_provider_fault_injection.py +533 -0
  92. hostctl-0.1.0/tests/test_psrp.py +266 -0
  93. hostctl-0.1.0/tests/test_qemu_executor.py +199 -0
  94. hostctl-0.1.0/tests/test_qemu_path.py +314 -0
  95. hostctl-0.1.0/tests/test_qemu_serial.py +68 -0
  96. hostctl-0.1.0/tests/test_qemu_serial_process.py +157 -0
  97. hostctl-0.1.0/tests/test_qga_ssh_transport.py +125 -0
  98. hostctl-0.1.0/tests/test_qga_transport.py +259 -0
  99. hostctl-0.1.0/tests/test_serial_executor.py +276 -0
  100. hostctl-0.1.0/tests/test_serial_host.py +141 -0
  101. hostctl-0.1.0/tests/test_serial_live.py +21 -0
  102. hostctl-0.1.0/tests/test_shell.py +812 -0
  103. hostctl-0.1.0/tests/test_ssh_process.py +112 -0
  104. hostctl-0.1.0/tests/test_sync_helpers.py +271 -0
  105. hostctl-0.1.0/tests/test_system_host_fidelity.py +367 -0
  106. hostctl-0.1.0/tests/test_system_hosts.py +476 -0
  107. hostctl-0.1.0/tests/test_winrm_path.py +233 -0
@@ -0,0 +1,19 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .hypothesis/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ .coverage*
8
+ htmlcov/
9
+ *.egg-info/
10
+ .venv/
11
+ dist/
12
+ build/
13
+ site/
14
+
15
+ # Private agent working area (dotagents link is a symlink; a directory-only
16
+ # `.agents/` pattern would not match it, so this must stay slashless).
17
+ .agents
18
+ CLAUDE*
19
+ .claude
@@ -0,0 +1,148 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-07-27
11
+
12
+ First release. Alpha: the public surface is deliberately small (66 exported
13
+ names) and may still change, but everything documented here is covered by the
14
+ test suite on Python 3.9 through 3.14.
15
+
16
+ ### Added
17
+
18
+ - Protocol-agnostic `Host` contracts with secret-safe `HostConfig` URI
19
+ dispatch, lifecycle management, normalized host information, and explicit
20
+ capability reporting.
21
+ - Local, SSH, WinRM, Docker Engine container, and QEMU Guest Agent hosts with
22
+ buffered command execution and `pathlib_next.Path` filesystem backends where
23
+ the transport supports them. WinRM includes a Windows-semantic PowerShell
24
+ path backend; container and QEMU paths use archive and guest-agent file RPCs.
25
+ - Explicit and auto-detected shell dialects (POSIX, Bash, Zsh, Fish, CMD, and
26
+ PowerShell), shared structured quoting, environment/cwd helpers, operators,
27
+ and persistent SSH/container sessions with optional terminals.
28
+ - Raw serial and QEMU serial-console transports with validated UART settings,
29
+ exclusive process leases, stream lifecycle controls, and explicit
30
+ non-shell semantics until a console profile is supplied.
31
+ - A dependency-free `hostctl` command with run, path inspection/copy,
32
+ host-info, and interactive shell subcommands; passwords are accepted only
33
+ from the environment or a hidden prompt.
34
+ - Optional native integrations: AsyncSSH/SFTP, pywinrm, Docker SDK, PySerial,
35
+ pypsrp, and libvirt QGA, each isolated behind a matching package extra.
36
+ - Cross-host `pathlib_next.Path.copy()`/`PathSyncer` support with streaming
37
+ remote readers, executor-side checksums, fast stat checksums, and an explicit
38
+ progress-reader recipe.
39
+ - Transport-independent POSIX, Windows, and IOS host semantics with ordered
40
+ executor/path provider selection and capability-safe fallback behavior.
41
+ Providers declare per-operation capabilities, so a read-only backend rejects
42
+ a mutation explicitly instead of falling through to another provider.
43
+ Selection traces record the candidates, probe result, chosen provider,
44
+ generation, policy, and pin, with credential-like values redacted. Composite
45
+ paths keep their provider collection and optional `.via()` pin through `/`,
46
+ `joinpath`, `parent`/`parents`, `with_name`/`with_suffix`, `iterdir`,
47
+ `glob`/`rglob`/`walk`, and open streams. See the "Systems and providers"
48
+ guide for the no-replay safety rule and provider-authoring contract.
49
+ - Symbolic-link support on every path backend whose transport provides it.
50
+ `symlink_to(target, target_is_directory=False)` and `readlink()` follow the
51
+ `pathlib.Path` signatures, `readlink()` reports the stored target verbatim,
52
+ and `stat(follow_symlinks=...)` stays consistent with `is_symlink()`. Local
53
+ paths delegate to `os.symlink`, SFTP uses the SSH backend's
54
+ `symlink`/`readlink`, WinRM issues `New-Item -ItemType SymbolicLink`
55
+ (normalizing the Windows elevation/Developer-Mode requirement to
56
+ `PermissionError`), and container paths ship a `SYMTYPE` tar member through
57
+ `put_archive()`. QGA paths raise `NotImplementedError` because the guest
58
+ agent exposes no symlink RPC. Container reads now follow a symlink member to
59
+ its target instead of failing, without giving up streaming laziness.
60
+ - Subprocess-shaped execution options, normalized transport errors, bounded
61
+ buffered file transfers, and Python 3.9+ typing support (Python 3.14 is the
62
+ default development interpreter).
63
+ - `Shell` is a context manager: `with host.shell as session:` opens one
64
+ default session and closes it on exit, the no-argument shorthand for
65
+ `shell.session()`. Re-entering a shell whose session is still open raises
66
+ rather than sharing or leaking a process.
67
+ - `Shell` carries defaults. `host.shell(cwd=..., env=..., encoding=...,
68
+ errors=...)` returns a shell applying them to every later `run`/`session`
69
+ that omits its own value; `configure(...)` derives a further-configured copy
70
+ without mutating the original. `cwd`/`encoding`/`errors` are replaced by a
71
+ per-call value, `env` merges per key so one variable can change without
72
+ restating the rest, and `env=None` declines the shell's environment while
73
+ keeping whatever the host itself provides.
74
+ - Connection URIs may carry credentials. `scheme://user:password@host` is
75
+ accepted: the password is extracted into the credential arguments and
76
+ stripped from the parsed authority, so it never reaches a field that
77
+ `connection_uri` or `repr()` renders. `redact_uri()` removes a password and
78
+ returns a valid, reusable URI rather than masking it, so a rendered form can
79
+ never round-trip a wrong credential.
80
+ - `parse_credentials()` splits a password field on a newline into the password
81
+ and trailing `key:value` extras, so an OTP or other second factor travels
82
+ through a single field. A bare name is a flag equivalent to `name:`. Inside
83
+ a URI the separator may be written raw — tab, CR, and LF are percent-encoded
84
+ before parsing, since `urlsplit` deletes them silently. A control character
85
+ in the *host* is rejected, because deletion there rewrites the target.
86
+ - `ssh_providers()` and `winrm_providers()` return an executor/path provider
87
+ pair sharing one transport, for composing a transport into a host you
88
+ assemble yourself. `strict_uri_credentials`, `strict_uri_query`, and
89
+ `uri_host` are public for configs implementing `_from_parsed_uri`.
90
+ - A `HostConfig` subclass may declare `uri_credentials`; dispatch then rejects
91
+ any other credential before construction, so a typo fails loudly instead of
92
+ silently producing a config with no password.
93
+ - SSH execution now sends EOF for omitted stdin, rejects unsupported zero
94
+ buffering, normalizes missing exit statuses to `-1`, performs best-effort
95
+ timeout cleanup (with `TimeoutExpired.orphaned`), and normalizes persistent
96
+ process I/O failures. SFTP backends are reused per host and invalidated on
97
+ close; auto-dialect probes preserve the discovered shell executable.
98
+ - Container archive paths now accept normal absolute and relative symlink
99
+ targets, resolve links with a bounded hop count, preserve hardlink/file
100
+ semantics, use Docker's header stat metadata where available, and reject
101
+ traversal names without buffering directory archives unnecessarily. Docker
102
+ exec streams return available data promptly, detect truncated frames,
103
+ preserve merged output ordering, and map missing containers to
104
+ `ConnectionError`.
105
+ - QEMU Guest Agent transports now share one framed-session implementation with
106
+ split-read buffering, parse-error correlation, safe timeout/disconnect
107
+ cleanup, and loop-bound SSH writes. QEMU serial consoles and raw serial
108
+ processes use incremental text decoding and the common `read(-1)` contract
109
+ (up to 64 KiB available data); serial ownership and QEMU URI/lifecycle edge
110
+ cases are normalized consistently.
111
+
112
+ ### Fixed
113
+
114
+ - `run(input=<bytes>, encoding=...)` no longer hangs. Handing bytes to a
115
+ text-mode `subprocess` stdin killed its writer thread, and the call then
116
+ blocked forever because the child never saw EOF — `timeout=` did not fire.
117
+ `input` is now normalized to the stream mode each executor uses, by a helper
118
+ the local, SSH, and QGA executors share, so the same call behaves
119
+ identically whichever provider a `SystemHost` selects.
120
+ - Composite host paths kept their provider, selector, and pin through
121
+ `pathlib.PurePath` derivations (`parents`, `with_name`, `with_suffix`,
122
+ `relative_to`). Those results were previously built without any routing
123
+ state, which made `glob()`, `rglob()`, and `walk()` fail outright.
124
+ - A path provider that declined before dispatch is remembered for the
125
+ connection generation instead of being re-attempted by every later
126
+ operation; `invalidate()` clears the record along with cached probes.
127
+ - `SystemHost` serializes its connection bookkeeping under a reentrant lock.
128
+ Concurrent `run()` calls previously raced the check-then-append in
129
+ `_ensure_provider_connected`, so every caller repeated the connect
130
+ round-trip and appended a duplicate entry to `_connected_providers`, which
131
+ grew without bound. Provider membership is now tested by identity.
132
+ - Capability vocabularies agree. `ExecutorCapability` members subclass `str`,
133
+ so the enum published by executors and the strings published by providers
134
+ and hosts compare equal. `Shell` previously tested `ExecutorCapability.CWD`
135
+ against a set of strings — always false — so a host with native `cwd`/`env`
136
+ still had `cd`/`export` rendered into the script and the native values
137
+ dropped.
138
+ - `SystemConfig` no longer fails with `AttributeError`. It is explicitly
139
+ abstract: `_create_host()` raises `TypeError` naming the concrete
140
+ configurations, and it no longer advertises a `system://` URI that
141
+ `HostConfig` rejected as an unsupported scheme.
142
+ - `scheme` is a URI-derived property across the whole `HostConfig` hierarchy.
143
+ The system configurations shadowed it with a plain string and `SystemHost`
144
+ assigned to it; a config-less host now builds its own family configuration
145
+ instead.
146
+
147
+ [Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.1.0...HEAD
148
+ [0.1.0]: https://github.com/jose-pr/hostctl/releases/tag/v0.1.0
hostctl-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jose A.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
hostctl-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,302 @@
1
+ Metadata-Version: 2.4
2
+ Name: hostctl
3
+ Version: 0.1.0
4
+ Summary: Run commands and access files on a host, local or remote, protocol-agnostic
5
+ Project-URL: Homepage, https://github.com/jose-pr/hostctl/
6
+ Project-URL: Documentation, https://jose-pr.github.io/hostctl/
7
+ Project-URL: Issues, https://github.com/jose-pr/hostctl/issues
8
+ Author: Jose A.
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: pathlib,remote,ssh,subprocess,sysadmin
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: System :: Filesystems
24
+ Classifier: Topic :: System :: Systems Administration
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Requires-Dist: pathlib-next<0.9,>=0.8.6
28
+ Provides-Extra: container
29
+ Requires-Dist: docker<8,>=7.1; extra == 'container'
30
+ Provides-Extra: dev
31
+ Requires-Dist: black; extra == 'dev'
32
+ Requires-Dist: build; extra == 'dev'
33
+ Requires-Dist: pytest; extra == 'dev'
34
+ Provides-Extra: docs
35
+ Requires-Dist: mkdocs; extra == 'docs'
36
+ Requires-Dist: mkdocs-material; extra == 'docs'
37
+ Requires-Dist: mkdocstrings[python]; extra == 'docs'
38
+ Provides-Extra: psrp
39
+ Requires-Dist: pypsrp<1,>=0.9; (python_version >= '3.10') and extra == 'psrp'
40
+ Provides-Extra: qemu-libvirt
41
+ Requires-Dist: libvirt-python; extra == 'qemu-libvirt'
42
+ Provides-Extra: serial
43
+ Requires-Dist: pyserial<4,>=3.5; extra == 'serial'
44
+ Provides-Extra: ssh
45
+ Requires-Dist: asyncssh; extra == 'ssh'
46
+ Requires-Dist: pathlib-next[sftp-async]<0.9,>=0.8.4; extra == 'ssh'
47
+ Provides-Extra: winrm
48
+ Requires-Dist: pywinrm<0.6,>=0.5.0; extra == 'winrm'
49
+ Provides-Extra: winrm-credssp
50
+ Requires-Dist: pywinrm[credssp]<0.6,>=0.5.0; extra == 'winrm-credssp'
51
+ Provides-Extra: winrm-kerberos
52
+ Requires-Dist: pywinrm[kerberos]<0.6,>=0.5.0; extra == 'winrm-kerberos'
53
+ Description-Content-Type: text/markdown
54
+
55
+ [![Version](https://img.shields.io/pypi/v/hostctl.svg)](https://pypi.org/project/hostctl/)
56
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
57
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/hostctl/)
58
+ [![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)
59
+
60
+ A **protocol-agnostic way to run commands and access files on a host**.
61
+ `Host` defines operations; `HostConfig` owns secret-safe connection identity,
62
+ extensible URI dispatch, and lifecycle. Local and application hosts can compose
63
+ SSH, WinRM, serial, container, and QEMU providers while exposing only the
64
+ capabilities those providers actually support.
65
+
66
+ ## Features
67
+
68
+ - **`Host.run(...)`** — subprocess-compatible results from local shells,
69
+ SSH (`asyncssh`), or PowerShell over WinRM (`pywinrm`). Unsupported transport
70
+ options raise `NotImplementedError`.
71
+ - **`Host.path(...)`** — a `pathlib_next.Path` filesystem view: local,
72
+ remote SFTP, Windows over WinRM, container archives, or QEMU Guest Agent.
73
+ - **Cross-host copy and sync** — use `Path.copy()`/`PathSyncer` directly;
74
+ `host_checksum()` computes unchanged-file digests beside remote data.
75
+ - **`with host.shell as session:`** — a persistent shell over SSH or a
76
+ container, closed on exit; `host.shell.session(...)` takes a starting
77
+ command, TTY, `cwd`, `env`, or encoding. `send(*cmds)` uses the same
78
+ structured quoting rules.
79
+ - **Serial console hosts.** `SerialConfig` accepts opaque native/PySerial URLs;
80
+ raw profiles provide exclusive sessions, while an explicitly configured
81
+ prompt profile can add safely framed `run()` results. Serial consoles never
82
+ imply a filesystem or PTY and RFC 2217 has no encryption.
83
+ - **Explicit or detected SSH shells.** Use a concrete dialect for deterministic
84
+ behavior or `dialect="auto"` for positive POSIX/Windows probing.
85
+ - **Extensible shell languages.** Select a registered string, a
86
+ `ShellFlavour` subclass, or a configured flavour instance.
87
+ - **Extension point, not a closed abstraction.** Override `Host.path()` in a
88
+ subclass to add a project-specific backend (see `docs/guide/extending.md`).
89
+
90
+ ## Installation
91
+
92
+ ```bash
93
+ pip install hostctl
94
+ ```
95
+
96
+ Optional features/extras:
97
+
98
+ | Extra/flag | Adds | Needed for |
99
+ | --- | --- | --- |
100
+ | `ssh` | `asyncssh`, `pathlib_next[sftp-async]` | `run()`/`path()` over SSH |
101
+ | `winrm` | `pywinrm` | PowerShell `run()` over WinRM |
102
+ | `psrp` | `pypsrp` on Python 3.10+ | persistent typed PowerShell runspaces |
103
+ | `container` | Docker SDK for Python | Docker Engine `run()`/`path()`/sessions |
104
+ | `serial` | PySerial | raw native/RFC 2217/socket serial sessions |
105
+ | `qemu-libvirt` | libvirt Python bindings | local libvirt QGA transport |
106
+
107
+ ## Quick start
108
+
109
+ ```python
110
+ from hostctl import (
111
+ Host,
112
+ HostConfig,
113
+ ContainerConfig,
114
+ QemuConfig,
115
+ LocalConfig,
116
+ POWERSHELL,
117
+ SshConfig,
118
+ WinRMConfig,
119
+ )
120
+ from pathlib_next import WindowsPathname
121
+
122
+ # Local
123
+ with LocalConfig() as host:
124
+ result = host.run("echo hello")
125
+ print(result.stdout)
126
+
127
+ # SSH to a POSIX target (needs the `ssh` extra)
128
+ with SshConfig(host="nas.example.com", username="admin", password="secret") as host:
129
+ result = host.run("df -h")
130
+ for line in host.path("/etc").iterdir():
131
+ print(line)
132
+
133
+ # SSH to Windows is explicit, not inferred from the transport.
134
+ windows_ssh = SshConfig(
135
+ host="windows.example.com",
136
+ username="admin",
137
+ password="secret",
138
+ dialect=POWERSHELL,
139
+ path_flavor=WindowsPathname,
140
+ )
141
+
142
+ # WinRM supports PowerShell execution and Windows filesystem paths.
143
+ with WinRMConfig("windows.example.com", "admin", "secret", ssl=True) as windows:
144
+ windows.run(["Write-Output", "hello"])
145
+ windows.path(r"C:\Temp\hello.txt").write_text("hello", encoding="utf-8")
146
+
147
+ # An existing running container (needs the `container` extra).
148
+ with ContainerConfig("application") as container:
149
+ container.run(["printf", "%s\n", "hello"])
150
+ print(container.path("/etc/os-release").read_text())
151
+ with container.shell.session(terminal=True, encoding="utf-8") as session:
152
+ session.send(["printf", "%s\n", "hello from the session"])
153
+ print(session.read())
154
+
155
+ # A QEMU guest through its QGA Unix socket tunneled over SSH.
156
+ with QemuConfig(
157
+ "vm-id",
158
+ transport="ssh",
159
+ ssh=SshConfig("hypervisor.example", username="root"),
160
+ ) as guest:
161
+ print(guest.info())
162
+ print(guest.run(["echo", "hello"], encoding="utf-8").stdout)
163
+ print(guest.path("/etc/os-release").read_text())
164
+
165
+ # A canonical connection URI carries configuration, never a password.
166
+ with Host(windows_ssh.connection_uri, password="secret") as same_host:
167
+ same_host.run(["Write-Output", "hello"])
168
+
169
+ # A `user:password@host` URI is still valid input: the password is extracted
170
+ # and used, and never rendered back out. Use `redact_uri` to display one.
171
+ with Host("ssh://admin:secret@nas.example.com") as same_host:
172
+ same_host.run("uptime")
173
+ ```
174
+
175
+ A URI may carry `user:password@host` on the way *in* — it is a valid URI, so
176
+ hostctl accepts it, extracts the password into the credentials, and keeps it
177
+ out of every rendered form. Passing the same password both in the URI and as an
178
+ argument is an error rather than a silent precedence rule. `redact_uri(uri)`
179
+ strips the password and returns a valid, reusable URI — it is removed rather
180
+ than masked, so a rendered form can never round-trip a wrong credential.
181
+
182
+ Shell `env` defaults merge per key; `env=None` declines them, leaving whatever
183
+ environment the host itself provides.
184
+
185
+ A password field may also carry extra credentials, one per line after it:
186
+ `parse_credentials("hunter2\notp:123456")` yields `("hunter2", {"otp":
187
+ "123456"})`. A bare name is a flag meaning the same as `name:`. This lets an
188
+ OTP or other second factor reach a transport through a single field — a URI's
189
+ userinfo, an environment variable, a prompt — without each caller inventing an
190
+ encoding. Inside a URI the separator may be written raw — hostctl encodes it
191
+ before parsing, since `urlsplit` would otherwise delete it and silently merge
192
+ the extras into the password. A control character in the *host* is still
193
+ rejected: no encoding makes a hostname containing one meaningful, and allowing
194
+ it would let a URI that reads as one target resolve to another.
195
+
196
+ `str(config)` is the same canonical, secret-free connection string as
197
+ `config.connection_uri`. `HostConfig(str(config), **secrets)` reconstructs the
198
+ concrete configuration without creating or connecting a host.
199
+
200
+ Install `hostctl[winrm-kerberos]` or `hostctl[winrm-credssp]` when selecting
201
+ those WinRM authentication transports. Certificate authentication is not
202
+ exposed until its required certificate/key configuration is part of the API.
203
+
204
+ ### Composable system hosts
205
+
206
+ Use `PosixHost`, `WindowsHost`, or `IosHost` when system semantics should be
207
+ independent of the transport. Providers are tried in declaration order during
208
+ preflight; a provider may be retried only when it raises
209
+ `OperationNotStarted`, which guarantees that no remote operation was sent.
210
+ Paths retain their selected provider and expose it through `.provider` and
211
+ `.via(name)`:
212
+
213
+ ```python
214
+ from hostctl import ExecutorProvider, PathProvider, PosixHost, LocalExecutor, HostPath
215
+
216
+ host = PosixHost(
217
+ executor_providers=(ExecutorProvider("ssh", ssh_executor),
218
+ ExecutorProvider("local", LocalExecutor())),
219
+ path_providers=(PathProvider("sftp", sftp_path),
220
+ PathProvider("rpc", lambda *p: HostPath(*p))),
221
+ )
222
+ path = host.path("etc", "hosts")
223
+ print(path.provider.name)
224
+ ```
225
+
226
+ Application-specific adapters can follow the SFTP/RPC/download pattern in
227
+ [`examples/application_provider.py`](examples/application_provider.py). The
228
+ [Systems and providers](https://jose-pr.github.io/hostctl/guide/providers/)
229
+ guide covers selection traces, backend pinning, per-operation capabilities,
230
+ provider authoring, and the no-replay safety rule in full.
231
+
232
+ Existing transport URIs remain compatible. To opt into system semantics while
233
+ retaining those URI/configuration objects, compose them explicitly:
234
+
235
+ ```python
236
+ from hostctl import PosixHost, SshConfig, WindowsHost, WinRMConfig
237
+
238
+ posix = PosixHost.from_ssh(SshConfig("server.example", username="root"))
239
+ windows = WindowsHost.from_winrm(WinRMConfig("server.example", "admin"))
240
+ ```
241
+
242
+ SSH and WinRM transport implementations are private providers; QEMU's SSH
243
+ tunnel continues to consume `SshConfig` directly.
244
+
245
+ ## Command line
246
+
247
+ The installed `hostctl` command is a thin wrapper over the library:
248
+
249
+ ```console
250
+ hostctl run local: -- python -c "print('hello')"
251
+ hostctl info ssh://server
252
+ hostctl cp ./artifact ssh://server:/tmp/artifact
253
+ ```
254
+
255
+ Secrets never belong in argv. Use `HOSTCTL_PASSWORD` or the subcommand's
256
+ `--ask-password` option. See the
257
+ [CLI guide](https://jose-pr.github.io/hostctl/guide/cli/) for all subcommands
258
+ and exit statuses.
259
+
260
+ ## API overview
261
+
262
+ | Module | Purpose |
263
+ | --- | --- |
264
+ | `hostctl.host` | Shared contracts and built-in host providers |
265
+ | `hostctl.host.container` | `ContainerConfig`, `ContainerHost` |
266
+ | `hostctl.host.qemu` | `QemuConfig`, `QemuHost` |
267
+ | `hostctl.host.serial` | `SerialConfig`, `SerialHost` |
268
+ | `hostctl.provider` | Ordered executor/path provider composition |
269
+ | `hostctl.executor` | Transport-specific command executors |
270
+ | `hostctl.shell` | Shell flavours and persistent sessions |
271
+ | `hostctl.sync` | Remote checksum and byte-progress helpers for `pathlib_next` |
272
+ | `hostctl._cli` | Dependency-free command-line entry point |
273
+
274
+ ## Development
275
+
276
+ ```bash
277
+ py -3.14 -m venv .venv/3.14-nt-amd64
278
+ .venv/3.14-nt-amd64/Scripts/python -m pip install -e ".[dev,ssh,winrm,container,serial]"
279
+ .venv/3.14-nt-amd64/Scripts/python -m pytest -q
280
+ ```
281
+
282
+ Python 3.14 is the default development interpreter. Python 3.9 remains the
283
+ supported compatibility floor and should be selected explicitly with
284
+ `py -3.9` when running floor-specific checks.
285
+
286
+ ### Releasing
287
+
288
+ This project follows [Semantic Versioning](https://semver.org/) and keeps a
289
+ [`CHANGELOG.md`](CHANGELOG.md). Pushing a tag matching `v*` triggers the release
290
+ workflow: test gate → build → publish → docs deploy.
291
+
292
+ To prepare a release, update `pyproject.toml` and move the complete
293
+ `[Unreleased]` section to `## [X.Y.Z] - YYYY-MM-DD` in the same commit. Keep
294
+ the package version PEP 440-compatible and use the corresponding SemVer tag
295
+ (`vX.Y.Z`, or `vX.Y.Z-rc.N` for a prerelease). Then push the commit and tag;
296
+ the workflow extracts that changelog section for the GitHub release and
297
+ publishes the built artifacts. Leave a fresh empty `[Unreleased]` section for
298
+ the next cycle.
299
+
300
+ ## License
301
+
302
+ MIT — see [LICENSE](LICENSE).