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.
- hostctl-0.1.0/.gitignore +19 -0
- hostctl-0.1.0/CHANGELOG.md +148 -0
- hostctl-0.1.0/LICENSE +21 -0
- hostctl-0.1.0/PKG-INFO +302 -0
- hostctl-0.1.0/README.md +248 -0
- hostctl-0.1.0/docs/api/reference.md +119 -0
- hostctl-0.1.0/docs/changelog.md +1 -0
- hostctl-0.1.0/docs/guide/cli.md +31 -0
- hostctl-0.1.0/docs/guide/contracts.md +79 -0
- hostctl-0.1.0/docs/guide/extending.md +56 -0
- hostctl-0.1.0/docs/guide/path.md +59 -0
- hostctl-0.1.0/docs/guide/providers.md +338 -0
- hostctl-0.1.0/docs/guide/run.md +171 -0
- hostctl-0.1.0/docs/guide/transfer.md +126 -0
- hostctl-0.1.0/docs/index.md +20 -0
- hostctl-0.1.0/examples/application_provider.py +86 -0
- hostctl-0.1.0/examples/copy_between_hosts.py +39 -0
- hostctl-0.1.0/examples/local_run.py +7 -0
- hostctl-0.1.0/examples/remote_run.py +20 -0
- hostctl-0.1.0/mkdocs.yml +58 -0
- hostctl-0.1.0/pyproject.toml +81 -0
- hostctl-0.1.0/src/hostctl/AGENTS.md +292 -0
- hostctl-0.1.0/src/hostctl/__init__.py +194 -0
- hostctl-0.1.0/src/hostctl/__main__.py +6 -0
- hostctl-0.1.0/src/hostctl/_async.py +169 -0
- hostctl-0.1.0/src/hostctl/_cli.py +232 -0
- hostctl-0.1.0/src/hostctl/executor/__init__.py +82 -0
- hostctl-0.1.0/src/hostctl/executor/_common.py +234 -0
- hostctl-0.1.0/src/hostctl/executor/_qga.py +646 -0
- hostctl-0.1.0/src/hostctl/executor/container.py +171 -0
- hostctl-0.1.0/src/hostctl/executor/local.py +91 -0
- hostctl-0.1.0/src/hostctl/executor/psrp.py +145 -0
- hostctl-0.1.0/src/hostctl/executor/qemu.py +305 -0
- hostctl-0.1.0/src/hostctl/executor/serial.py +312 -0
- hostctl-0.1.0/src/hostctl/executor/ssh.py +226 -0
- hostctl-0.1.0/src/hostctl/executor/winrm.py +255 -0
- hostctl-0.1.0/src/hostctl/host/__init__.py +93 -0
- hostctl-0.1.0/src/hostctl/host/_common.py +804 -0
- hostctl-0.1.0/src/hostctl/host/_local.py +258 -0
- hostctl-0.1.0/src/hostctl/host/_ssh.py +587 -0
- hostctl-0.1.0/src/hostctl/host/_winrm.py +1002 -0
- hostctl-0.1.0/src/hostctl/host/composite_path.py +567 -0
- hostctl-0.1.0/src/hostctl/host/container.py +606 -0
- hostctl-0.1.0/src/hostctl/host/container_path.py +664 -0
- hostctl-0.1.0/src/hostctl/host/qemu.py +1078 -0
- hostctl-0.1.0/src/hostctl/host/serial.py +401 -0
- hostctl-0.1.0/src/hostctl/host/system.py +809 -0
- hostctl-0.1.0/src/hostctl/process/__init__.py +49 -0
- hostctl-0.1.0/src/hostctl/process/_common.py +113 -0
- hostctl-0.1.0/src/hostctl/process/container.py +265 -0
- hostctl-0.1.0/src/hostctl/process/psrp.py +208 -0
- hostctl-0.1.0/src/hostctl/process/qemu_serial.py +247 -0
- hostctl-0.1.0/src/hostctl/process/serial.py +257 -0
- hostctl-0.1.0/src/hostctl/process/ssh.py +165 -0
- hostctl-0.1.0/src/hostctl/provider/__init__.py +33 -0
- hostctl-0.1.0/src/hostctl/provider/_common.py +452 -0
- hostctl-0.1.0/src/hostctl/provider/transports.py +303 -0
- hostctl-0.1.0/src/hostctl/py.typed +0 -0
- hostctl-0.1.0/src/hostctl/serial/__init__.py +285 -0
- hostctl-0.1.0/src/hostctl/shell/__init__.py +123 -0
- hostctl-0.1.0/src/hostctl/shell/_common.py +616 -0
- hostctl-0.1.0/src/hostctl/shell/cmd.py +167 -0
- hostctl-0.1.0/src/hostctl/shell/fish.py +63 -0
- hostctl-0.1.0/src/hostctl/shell/posix.py +83 -0
- hostctl-0.1.0/src/hostctl/shell/powershell.py +120 -0
- hostctl-0.1.0/src/hostctl/sync.py +245 -0
- hostctl-0.1.0/tests/conformance/__init__.py +5 -0
- hostctl-0.1.0/tests/conformance/path_fakes.py +536 -0
- hostctl-0.1.0/tests/conformance/providers.py +815 -0
- hostctl-0.1.0/tests/conformance/test_composite_path_routing.py +75 -0
- hostctl-0.1.0/tests/conformance/test_live.py +49 -0
- hostctl-0.1.0/tests/conformance/test_path_contract.py +252 -0
- hostctl-0.1.0/tests/conformance/test_process_contract.py +171 -0
- hostctl-0.1.0/tests/conformance/test_run_contract.py +131 -0
- hostctl-0.1.0/tests/conformance/test_sync_contract.py +223 -0
- hostctl-0.1.0/tests/test_application_provider.py +100 -0
- hostctl-0.1.0/tests/test_asyncssh_errors.py +81 -0
- hostctl-0.1.0/tests/test_cli.py +168 -0
- hostctl-0.1.0/tests/test_composite_path_propagation.py +136 -0
- hostctl-0.1.0/tests/test_container_path.py +248 -0
- hostctl-0.1.0/tests/test_container_process.py +168 -0
- hostctl-0.1.0/tests/test_host_api.py +545 -0
- hostctl-0.1.0/tests/test_host_container.py +189 -0
- hostctl-0.1.0/tests/test_host_local.py +124 -0
- hostctl-0.1.0/tests/test_host_module_layout.py +26 -0
- hostctl-0.1.0/tests/test_host_qemu.py +126 -0
- hostctl-0.1.0/tests/test_host_remote.py +336 -0
- hostctl-0.1.0/tests/test_host_winrm.py +253 -0
- hostctl-0.1.0/tests/test_observability.py +410 -0
- hostctl-0.1.0/tests/test_process.py +155 -0
- hostctl-0.1.0/tests/test_provider_fault_injection.py +533 -0
- hostctl-0.1.0/tests/test_psrp.py +266 -0
- hostctl-0.1.0/tests/test_qemu_executor.py +199 -0
- hostctl-0.1.0/tests/test_qemu_path.py +314 -0
- hostctl-0.1.0/tests/test_qemu_serial.py +68 -0
- hostctl-0.1.0/tests/test_qemu_serial_process.py +157 -0
- hostctl-0.1.0/tests/test_qga_ssh_transport.py +125 -0
- hostctl-0.1.0/tests/test_qga_transport.py +259 -0
- hostctl-0.1.0/tests/test_serial_executor.py +276 -0
- hostctl-0.1.0/tests/test_serial_host.py +141 -0
- hostctl-0.1.0/tests/test_serial_live.py +21 -0
- hostctl-0.1.0/tests/test_shell.py +812 -0
- hostctl-0.1.0/tests/test_ssh_process.py +112 -0
- hostctl-0.1.0/tests/test_sync_helpers.py +271 -0
- hostctl-0.1.0/tests/test_system_host_fidelity.py +367 -0
- hostctl-0.1.0/tests/test_system_hosts.py +476 -0
- hostctl-0.1.0/tests/test_winrm_path.py +233 -0
hostctl-0.1.0/.gitignore
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/hostctl/)
|
|
56
|
+
[](LICENSE)
|
|
57
|
+
[](https://jose-pr.github.io/hostctl/)
|
|
58
|
+
[](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).
|