Skip to main content

hostctl

Version Python versions License: MIT Docs CI

A protocol-agnostic way to run commands and access files on a host, local or remote. Host defines the operations and HostConfig owns secret-safe connection identity, URI dispatch and lifecycle; SSH, WinRM, containers, QEMU guests and serial consoles plug in as providers, and a host exposes only the capabilities its providers support. The guide and API reference are at https://jose-pr.github.io/hostctl/.

Features

  • Host.run(...) — subprocess-compatible results from local shells, SSH (asyncssh), or PowerShell over WinRM (pywinrm). Two defaults differ from subprocess.run deliberately: check and capture_output are both True. Unsupported transport options raise NotImplementedError.
  • Host.path(...) — a pathapi.Path filesystem view: local, remote SFTP, Windows over WinRM, container archives, or QEMU Guest Agent.
  • Cross-host copy and sync — use Path.copy()/PathSyncer directly; host_checksum() computes unchanged-file digests beside remote data.
  • with host.shell as session: — a persistent shell over SSH or a container, closed on exit; host.shell.session(...) takes a starting command, TTY, cwd, env, or encoding. send(*cmds) uses the same structured quoting rules.
  • Serial console hosts. SerialConfig accepts opaque native/PySerial URLs; raw profiles provide exclusive sessions, while an explicitly configured prompt profile can add safely framed run() results. Serial consoles never imply a filesystem or PTY and RFC 2217 has no encryption.
  • Explicit or detected SSH shells. Use a concrete dialect for deterministic behavior or shell="auto" for positive POSIX/Windows probing.
  • Extensible shell languages. Select a registered string, a ShellFlavour subclass, or a configured flavour instance.
  • Extension point, not a closed abstraction. Override Host.path() in a subclass to add a project-specific backend (see Extending hostctl).

Installation

pip install hostctl

Optional features/extras:

Extra/flag Adds Needed for
ssh asyncssh, sftppath[asyncssh] run()/path() over SSH
winrm pywinrm PowerShell run() over WinRM
psrp pypsrp on Python 3.10+ persistent typed PowerShell runspaces
container Docker SDK for Python Docker Engine run()/path()/sessions
serial PySerial raw native/RFC 2217/socket serial sessions
qemu-libvirt libvirt Python bindings local libvirt QGA transport
winrm-credssp pywinrm[credssp] WinRM auth="credssp"
winrm-kerberos pywinrm[kerberos] WinRM auth="kerberos"
cli duho the hostctl command
deploy packaging, pathspec (tomli before 3.11) hostctl.deploy: installing a Python program on a host without pip

Quick start

from hostctl import (
    Host,
    HostConfig,
    ContainerConfig,
    QEMUConfig,
    LocalConfig,
    POWERSHELL,
    SSHConfig,
    WinRMConfig,
)
from pathapi import WindowsPathname

# Local
with LocalConfig() as host:
    result = host.run("echo hello")
    print(result.stdout)

# SSH to a POSIX target (needs the `ssh` extra)
with SSHConfig(host="nas.example.com", username="admin", password="secret") as host:
    result = host.run("df -h")
    for line in host.path("/etc").iterdir():
        print(line)

# SSH to Windows is explicit, not inferred from the transport.
windows_ssh = SSHConfig(
    host="windows.example.com",
    username="admin",
    password="secret",
    shell=POWERSHELL,
    pathname=WindowsPathname,
)

# WinRM supports PowerShell execution and Windows filesystem paths.
with WinRMConfig(
    "windows.example.com", username="admin", password="secret", tls=True
) as windows:
    windows.run(["Write-Output", "hello"])
    windows.path(r"C:\Temp\hello.txt").write_text("hello", encoding="utf-8")

# An existing running container (needs the `container` extra).
with ContainerConfig("application") as container:
    container.run(["printf", "%s", "hello"])
    print(container.path("/etc/os-release").read_text())
    with container.shell.session(terminal=True, encoding="utf-8") as session:
        session.send(["printf", "%s", "hello from the session"])
        print(session.read())

# A QEMU guest through its QGA Unix socket tunneled over SSH.
with QEMUConfig(
    "vm-id",
    transport="ssh",
    ssh=SSHConfig("hypervisor.example", username="root"),
) as guest:
    print(guest.info())
    print(guest.run(["echo", "hello"], encoding="utf-8").stdout)
    print(guest.path("/etc/os-release").read_text())

# A canonical connection URI carries configuration, never a password.
with Host(windows_ssh.connection_uri, password="secret") as same_host:
    same_host.run(["Write-Output", "hello"])

# A `user:password@host` URI is still valid input: the password is extracted
# and used, and never rendered back out. Use `redact_uri` to display one.
with Host("ssh://admin:secret@nas.example.com") as same_host:
    same_host.run("uptime")

run() takes one positional argument per command, so a list is a single command whose elements are each quoted, not a list of commands:

host.run(["chmod", "755", target])                          # one command
host.run(["chmod", "755", target], ["chown", "u", target])  # two commands
host.run("chmod", "755", target)                            # WRONG: three commands

A raw string is verbatim shell text, and Exec(program, *args) is direct execution — one program plus argv, with no shell layer, where the program may be an absolute path or a bare name resolved through the target's PATH. A ShellOperator between two commands joins them conditionally. See the running commands guide for the full rules.

A URI may carry user:password@host on the way in — it is a valid URI, so hostctl accepts it, extracts the password into the credentials, and keeps it out of every rendered form. Passing the same password both in the URI and as an argument is an error rather than a silent precedence rule. redact_uri(uri) strips the password and returns a valid, reusable URI — it is removed rather than masked, so a rendered form can never round-trip a wrong credential.

Shell env defaults merge per key; env=None declines them, leaving whatever environment the host itself provides.

A password field may also carry extra credentials, one per line after it: parse_credentials("hunter2\notp:123456") yields ("hunter2", {"otp": "123456"}). A bare name is a flag meaning the same as name:. This lets an OTP or other second factor reach a transport through a single field — a URI's userinfo, an environment variable, a prompt — without each caller inventing an encoding. Inside a URI the separator may be written raw — hostctl encodes it before parsing, since urlsplit would otherwise delete it and silently merge the extras into the password. A control character in the host is still rejected: no encoding makes a hostname containing one meaningful, and allowing it would let a URI that reads as one target resolve to another.

str(config) is the same canonical, secret-free connection string as config.connection_uri. HostConfig(str(config), **secrets) reconstructs the concrete configuration without creating or connecting a host.

WinRM certificate authentication is not exposed: its certificate and key configuration is not part of the API.

Composable system hosts

Use PosixHost, WindowsHost, or IOSHost when system semantics should be independent of the transport. Providers are tried in declaration order during preflight; a provider may be retried only when it raises OperationNotStartedError, which guarantees that no remote operation was sent. IOSHost is deliberately session- and command-only: it configures no shell flavour and no path grammar, so commands must be Exec(...) (or raw text the device understands) and path() raises NotImplementedError until an IOS path grammar exists.

Paths retain their selected provider and expose it through .provider and .via(name):

from pathapi import Path

from hostctl import ExecutorProvider, PathProvider, PosixHost, LocalExecutor

host = PosixHost(
    executor_providers=(ExecutorProvider("ssh", ssh_executor),
                        ExecutorProvider("local", LocalExecutor())),
    path_providers=(PathProvider("sftp", sftp_path),
                    PathProvider("rpc", lambda *p: Path(*p))),
)
path = host.path("etc", "hosts")
print(path.provider.name)

Application-specific adapters can follow the SFTP/RPC/download pattern in examples/application_provider.py. The Systems and providers guide covers selection traces, backend pinning, per-operation capabilities, provider authoring, and the no-replay safety rule in full.

Opening a transport config gives the same kind of host: an SSHConfig opens as a PosixHost and a WinRMConfig as a WindowsHost, each composed of that transport's providers:

from hostctl import SSHConfig, WinRMConfig

posix = SSHConfig("server.example", username="root").open()
windows = WinRMConfig("server.example", username="admin").open()

SSH and WinRM transport implementations are private providers; QEMU's SSH tunnel takes an SSHConfig directly.

Command line

The hostctl command is a thin wrapper over the library, and needs the cli extra:

pip install "hostctl[cli]"
hostctl run local: -- python -c "print('hello')"
hostctl info ssh://server
hostctl cp ./artifact ssh://server:/tmp/artifact

A password comes from -p, HOSTCTL_PASSWORD, the URI, or a prompt when a host asks for one on a terminal; -u sets the user. See the CLI guide for all subcommands and exit statuses. With HOSTCTL_MCP=stdio in its environment the same program serves every subcommand as an MCP tool over stdio.

API overview

Module Purpose
hostctl Everything a common task needs, re-exported from the modules below
hostctl.host Host, HostConfig, HostInfo: the contracts every transport implements
hostctl.exceptions HostctlError and the exception tree
hostctl.uri ConnectionString and the URI/credential helpers
hostctl.system Hosts composed from ordered providers: PosixHost, WindowsHost, ...
hostctl.provider Executor/path provider contracts and selection
hostctl.executor The Executor protocol, option types and stream helpers
hostctl.process Persistent process and terminal contracts
hostctl.paths CompositePosixPath, CompositeWindowsPath
hostctl.shell Shell flavours, command markers and persistent sessions
hostctl.sync Remote checksum and byte-progress helpers for pathapi
hostctl.deploy Bundle a Python program and install it on a POSIX host; needs the deploy extra
hostctl.transports.local, .ssh, .winrm, .container, .qemu, .serial One package per transport: its config, host and providers
hostctl.cli The hostctl command line (main); needs the cli extra

Development

py -3.14 -m venv .venv/3.14-nt-amd64
.venv/3.14-nt-amd64/Scripts/python -m pip install -e ".[dev,docs]"
.venv/3.14-nt-amd64/Scripts/python -m pytest -q
.venv/3.14-nt-amd64/Scripts/python -m black --check src tests
# The docs gate the release runs, locally:
.venv/3.14-nt-amd64/Scripts/python -m mkdocs build --strict

Every extra, so the documented environment is a superset of CI's: without psrp a capability test skips silently, and without docs the gate that can block a release cannot be run at all.

Python 3.14 is the default development interpreter. Python 3.9 is the supported floor and a change passes there too; create it the same way with py -3.9 as .venv/3.9-nt-amd64. A venv is named for its interpreter version and platform (.venv/3.14-nt-arm64 under a native ARM64 interpreter, where cryptography has no wheel and the ssh extra builds it from source, which needs a Rust toolchain and OpenSSL).

Releasing

This project follows Semantic Versioning and keeps a CHANGELOG.md. Pushing a tag matching v* triggers the release workflow: tests and dependency floors → build (tag/version and twine checks) → installed-wheel smoke → publish, with a strict docs build as a gate. The docs site itself is deployed by docs.yml, which also redeploys on a push to main touching docs sources and on manual dispatch -- so a docs fix reaches the site without cutting a tag.

To prepare a release, update pyproject.toml and move the complete [Unreleased] section to ## [X.Y.Z] - YYYY-MM-DD in the same commit. Keep the package version PEP 440-compatible and use the corresponding SemVer tag (vX.Y.Z, or vX.Y.Z-rc.N for a prerelease). Then push the commit and tag; the workflow extracts that changelog section for the GitHub release and publishes the built artifacts. Leave a fresh empty [Unreleased] section for the next cycle.

License

MIT — see LICENSE.

Metadata

Release files for hostctl 0.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for hostctl 0.4.0
File Size Uploaded Provenance
hostctl-0.4.0.tar.gz 765.1 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for hostctl 0.4.0
File Interpreter ABI Platform
hostctl-0.4.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.2 MB

Release files / hostctl-0.4.0.tar.gz

Download URL hostctl-0.4.0.tar.gz
Size 765.1 kB
Tags Source
SHA-256 checksum
How to use checksums
a11fdeaed7e04cfc680275794a0f3e630c6ae5a98df4f534b24e2866ed4ad28b
BLAKE2b-256 checksum
How to use checksums
5519b6ad4413f8fc2b556d395b0ef76817eb29a1bcec7eeba9e0231b3a8f3d7d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

Release files / hostctl-0.4.0-py3-none-any.whl

Download URL hostctl-0.4.0-py3-none-any.whl
Size 401.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0e51398775e32a2dc75b73221f08a6eb35a1b4a688d557bf063f63432815ef04
BLAKE2b-256 checksum
How to use checksums
2074c56d9b0b9554f97da761b359bda7382175bd285e04a346b23d2c3e953634
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

Security for hostctl 0.4.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for hostctl-0.4.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page