Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
0.4.1 - 2026-10-22
Fixed
DUHO_TRACEBACK=1now adds the traceback after thehostctl: ...line of a failure the command reports; it showed no traceback at all before.
Changed
- The
hostctlcommand enters throughduho.run: Ctrl-C ends it with status 130 and no traceback (also on several hosts), and a closed standard output (hostctl cat ... | head -1) ends it with status 1 and no message. Thecliextra now needsduho>=0.7.8,<0.8.
0.4.0 - 2026-10-19
Migrating from 0.3
Every row ships in 0.4.0. No old name is kept as an alias. Entries further down that predate these renames keep the 0.3 spellings; this table maps them.
| 0.3 | 0.4.0 |
|---|---|
OperationNotStarted (a RuntimeError) |
OperationNotStartedError (a ConnectionError) |
QgaCommandError, QgaProtocolError, executor.GuestAgentProtocolError |
QGACommandError, QGAProtocolError |
QgaTimeoutError, QgaDisconnectedError, QgaError (private module) |
QGATimeoutError, QGADisconnectedError; catch HostctlError |
ConsoleProtocolError from hostctl.serial |
same name, hostctl.exceptions, under HostctlError |
SshConfig, QemuConfig, QemuHost, IosConfig, IosHost |
SSHConfig, QEMUConfig, QEMUHost, IOSConfig, IOSHost |
WinRMPath, HostPath in hostctl |
removed; host.path() returns CompositeWindowsPath; annotate with pathapi.Path |
from hostctl.host import PosixHost, uri_host, ... |
from hostctl import ... (homes: hostctl.system, hostctl.uri; hostctl.host holds only Host, HostConfig, HostInfo, Timeout, DEFAULT and the selections) |
hostctl.host.qemu, .container, .serial, .system, .composite_path |
hostctl.transports.qemu, .container, .serial, hostctl.system, hostctl.paths |
hostctl.serial (console profiles) |
hostctl.console (still re-exported from hostctl); hostctl.transports.serial holds the transport only |
concrete executors, processes, path backends, provider.transports, shell.posix etc.; LocalExecutor from hostctl.executor |
internal; flavour classes stay in hostctl.shell; LocalExecutor is in hostctl and hostctl.transports.local |
provider.transports.READ_ONLY_PATH_OPERATIONS |
hostctl.provider.READ_ONLY_PATH_OPERATIONS |
loggers hostctl.host.ssh, hostctl.host.winrm, hostctl.host.system, hostctl.host.qemu, hostctl.provider, hostctl.provider.transports |
one per module, named for it (hostctl.transports.ssh._transport, hostctl.system._host, hostctl.provider._contracts, ...); configure hostctl |
dialect=, path_flavor= (and ?dialect=, ?path_flavor=); ShellFlavour.path_flavor |
shell=, pathname= (?shell=, ?pathname=); ShellFlavour.pathname |
SSHShellSelection, QEMUShellSelection, ContainerShellSelection, *PathSelection, PathnameConstructor |
hostctl.host.ShellSelection, hostctl.host.PathnameSelection |
ContainerConfig(user=) (?user=); SerialConfig(port=, protocol=); SerialConsoleProtocol |
username= (?username=); device=, profile=; ConsoleProfile, whose methods take a ConsoleStream (any object with read(size, *, timeout=) and write(data)) where they took a SerialProcess |
WinRMConfig(transport=, ssl=, server_cert_validation=) (and the same URI keys) |
auth=, tls=, verify= (bool, ?verify=false) |
WinRMConfig(read_timeout_sec=, operation_timeout_sec=) (and the same URI keys; also read_timeout, operation_timeout) |
timeout=Timeout(connect=10) (default 30 s, ?timeout_connect=10) where the aim was to stop a hang while connecting; timeout=600 (the total, ?timeout=600) where it was to let a long command finish. The per-request knobs are internal; the old names raise and say so |
WinRMConfig(transport="basic"/"plaintext"/"ssl") over plain HTTP (the password sent in cleartext on pywinrm) |
ValueError unless cleartext_auth=True (?cleartext_auth=true); or use tls=True |
native WinRM: read_timeout_sec= raised so a long command could finish (it was the command's deadline) |
timeout=600, the total (default 30 s, None for none), the default for run(timeout=) on every leg; timeout=Timeout(connect=...) bounds opening the session |
pywinrm and PSRP commands with no deadline; run(timeout=) refused |
the 30 s total applies; pass timeout=None to keep them unbounded; run(timeout=) is accepted, and run(timeout=None) lifts the total for one call |
QemuConfig(agent_timeout=5) (?agent_timeout=5) |
QEMUConfig(timeout=Timeout(read=5)) (?timeout_read=5); timeout=5 alone is the command total, not the agent reply |
SerialConfig(read_timeout=0.5, write_timeout=2) (?read_timeout=0.5&write_timeout=2) |
SerialConfig(timeout=Timeout(read=0.5, write=2)) (?timeout_read=0.5&timeout_write=2); read_timeout=0 (non-blocking) is Timeout(read=0) (?timeout_read=0), and write_timeout=0 is Timeout(write=0); inter_byte_timeout is unchanged |
a config with no timeout of its own (LocalConfig(), SshConfig, ContainerConfig, the system configs) |
timeout=60 on any of them sets the default of run(timeout=) (a container refuses it: it has no total); Timeout(connect=, read=, write=) where the transport honours them, and ValueError where it does not |
| positional config arguments after the first | keyword-only (TypeError otherwise) |
ExecutorCommand; executor.PathLike, Input, Environment, FileHandle |
CommandLike; StrPath, CommandInput, CommandEnvironment, StreamTarget |
pypsrp_available(), require_pypsrp() |
has_psrp() (and new has_ssh()); the guard is internal |
host.path(p, backend=name) re-selecting a provider per operation |
pinned to name, as .via(name); host.path(p) selects per operation |
register_system_provider(name, resolver) with resolver(config, kind) returning one provider |
resolver(host) returning (executor provider or None, path provider or None); the config is host.config. One resolver reached by two names runs once per host, so its two legs share a transport. A two-argument resolver raises TypeError at registration |
SystemConfig(executor=(), path=()) meaning "none" |
executor=None/path=None (the defaults) take the host class's default_providers(), which is empty for the built-in hosts; () still composes none. config.executors/config.paths are None when not named |
host.last_selection, ProviderSelection.trace, path.selection_trace entries as dicts (item["provider"], item["chosen"], ..., item["policy"]) |
frozen SelectionStep records (step.provider, step.chosen, ...); policy, which was always "ordered", is gone |
host.provider_details entries as dicts (item["name"], item["kind"], ..., item["policy"]) |
frozen ProviderDetail records; the name is detail.provider; policy is gone |
serial:///dev/ttyUSB0 (any serial:///NAME, serial:/NAME) naming the relative device dev/ttyUSB0 / NAME; serial:////dev/x naming dev/x |
the absolute /dev/ttyUSB0 / /NAME; a relative device is serial:NAME; serial:////... raises ValueError; COMn, \\.\..., pyserial URLs and the encoded serial:///%2Fdev%2Fx are unchanged |
CompositePosixPath.from_path(backend_path, provider, factory, ...) (undocumented) |
CompositePosixPath.over(providers, *segments, backend=None); the same for CompositeWindowsPath |
SSHConfig(known_hosts=None), HostConfig(uri, known_hosts=None) (no host-key check) |
SSHConfig(verify=False), ?verify=false; known_hosts=None raises ValueError |
REQUESTS_CA_BUNDLE to make pywinrm or PSRP trust a private CA |
WinRMConfig(verify="ca.pem") (?verify=ca.pem); the variable still applies under verify=True |
a server certificate trusted only through certifi on pywinrm, PSRP or an https:// Docker engine (verify=True) |
fails: verify=True trusts the operating system's store. Install the CA there, pass verify="ca.pem", or set REQUESTS_CA_BUNDLE to certifi's bundle (python -m certifi prints its path). A public root Windows has not fetched yet is fetched on Python 3.10+; on Python 3.9 it fails until Windows has fetched it |
| HostConfig(uri, password=...) with a password in the URI raising ValueError | the keyword replaces the URI's password (extras included); password="" is no password |
| hostctl <command> --ask-password | nothing: the command prompts by itself when a host asks for a password on a terminal; -p PASSWORD or HOSTCTL_PASSWORD where nothing can prompt |
| SshConfig("h").port, HostConfig("ssh://h").port being 22 | None: the ssh_config's Port applies, else 22 (ssh://h:22 or port=22 pins 22) |
| an SSH host's paths reading no ~/.ssh/config (its commands did) | both read it, as ssh does; ssh_config=None reads none |
pytruenas 0.5.4 and earlier pin hostctl<0.4 and import the 0.3 paths
(from hostctl.host import PosixHost, SshConfig, ...), so they do not import
against 0.4.0; test.yml's informative downstream-pytruenas job fails at
collection until pytruenas ports to these names.
Added
ConsoleExecutor:run()over a POSIX shell's console, with no thread.hostctl.console.ConsoleExecutor(stream_factory, *, profile=None)is aStreamingExecutorthat frames each command withPosixConsoleProfileon theConsoleStreamthe factory returns: capture or live stdout,input=/stdin=, the text rule,check, and a timeout that types ^C and raisesTimeoutExpired(orphaned=True). It declines (OperationNotStartedError, so the host tries its next provider) only before the command line is typed, with a reason naming the stream's error (console write failed before the command was typed: TimeoutError: ...), holds the terminal for one command at a time, and lets a subclass override_abandon(request, *, stream=None)to drop that command's stream; the ^C typed before it is best effort, so a stream that already failed does not stop the override. A web shell or a QEMU console behind anExecutorProviderno longer needs a worker thread per command aroundexecute().SSHConfig(default_username=): a login the ssh_config can still override. The account when neitherusername(nor the URI's user) nor the ssh_config'sUsernames one, in place of the local user's: a library whose targets have a known administrative login passesdefault_username="root"instead of writingInclude ~/.ssh/configandHost */User rootas configuration text. It reaches asyncssh as theUserof a configuration file read afterssh_config, so the ssh_config keeps the last word; it is not part of the URI.PosixConsoleProfile(batch=): size or turn off the acknowledged batch. At mostbatchtyped bytes (default 1024) go out before the shell acknowledges them: a serial line's input queue holds 4 KiB, and a macOS pty driven from one thread stops taking input after about 1.3 KiB.batch=Nonetypes everything with no round trip, for a stream that blocks the writer and whose far end keeps reading the terminal (a web shell, an SSH or container session). Writes stay at most 2 KiB, with the console's echo read between them, so a larger batch works on a Linux pty (8 and 16 KiB measured). A batch under 1024 bytes raisesValueError.-
hostctl.deploy: install a Python program on a host that cannot install it itself, behind a newdeployextra (packaging,pathspec, andtomlibefore Python 3.11; included indev).deploy(host, source, path, mode="pyz"|"dir", ...)probes the target interpreter with a stdlib-only script, bundles only the part of the distribution's installed dependency closure the target lacks (markers evaluated for the target), and installs a reproducible zipapp or tree atomically throughhost.run()andhost.path(); an unchanged digest writes nothing, a path a deploy did not write is never replaced, and an interrupted tree swap is restored.bundle()writes the same layouts locally,SourceTreeships a working tree filtered by its ignore files, andDeployment.commandis the argv that runs the result. POSIX targets only. A bundle that cannot be built raises the newBundleError(HostctlError,RuntimeError), exported fromhostctl. The code and its tests come from pytruenas's generic bundler, which knew nothing of TrueNAS. -
hostctl deploy URI SOURCE PATH [-- ARG...], the command-line form ofhostctl.deploy(--mode,--package,--extra,--skip,--force,--python, and--treewith--ignore-file,--ignore-pattern,--pythonpath). Arguments after--run the installed program and its status becomes the command's. Served over MCP like every subcommand. ABundleErrorand a step that fails on the host (subprocess.CalledProcessError, from any subcommand) exit 125 with one line, the step's last stderr line included. -
hostctl runandhostctl infoon several hosts: more URI operands, comma lists and[1-3]/[a-c]ranges in any operand (outside its user part; a later host without a scheme takes the previous one's scheme and user), and--parallel N. Eachrunoutput line starts with[URI],infoprints one{"target": URI, "result": {...}}line per host, a failing host gets ahostctl: [URI] exit status Nline, and the exit status is the worst: 130, then 125-127, then the largest remote status. A password is read once for all hosts. One host behaves exactly as before, over MCP too (uristays a string; further hosts aremore). -
hostctl.console: console profiles over any byte stream, in 0.4.0.ConsoleProfile,RawConsoleProfile,PromptConsoleProfileandLoginStepmove there from the serial transport and drive aConsoleStream--read(size, *, timeout=)andwrite(data)-- instead of aSerialProcess, with unchanged behaviour.console_stream(process)adapts a process whose read blocks (a QEMU console fromopen_serial(), a container or SSHspawn(terminal=True)) by reading it on a thread, so a profile can drive those consoles too. A serialspawn()session'sreadtakestimeout=. PosixConsoleProfile: framed commands on a POSIX shell's console, in 0.4.0, and the profile to use wherever the far end is a POSIX shell. The command and its input are typed base64-encoded inside fixed syntax, so a ^C, TAB,!or newline in them is data; output, stderr and the exit status come back between markers the echo can never contain, as the program's exact bytes (stty -opost). stdin is a temporary file or/dev/null, never the terminal, so a command reading stdin cannot hang; a timeout types ^C, which discards the rest of the command line.execute(stream, command, *, input=, merge_stderr=, timeout=, on_stdout=, on_stderr=)returns(stdout, stderr, status).SerialHost.run()with it acceptsinput=and astdin=stream and returns stderr separately. Typed lines stay under 1 KiB and at most 1 KiB is typed before the shell acknowledges it, so busybox's line editor and a serial line's input queue lose nothing.-
A serial host with
PosixConsoleProfilespeaks POSIX shell, in 0.4.0.PosixConsoleProfile(shell="posix")names the console shell's flavour ("bash","zsh"or anyPosixShellFlavour; fish, cmd and PowerShell raiseValueError) asshell_flavour, andSerialHostreads it:host.shell_flavourreturns it,host.shellis aShell, andrun()quotes structured argv and a soleExecwith that flavour, as on any other POSIX host.cwd=andenv=are embedded and run in a subshell, so they never persist in the console's shell;CompletedProcess.argsmasks theenvvalues. With this profile several commands (andShellOperators between them) are one script run as one exchange, andinput=is its stdin.RawConsoleProfileandPromptConsoleProfiledeclare no flavour and keep refusing argv,Exec,cwd=andenv=. -
Typed selection records, a host name and an audible fallback, in 0.4.0.
SelectionStepandProviderDetail(inhostctlandhostctl.provider) replace the dicts in traces andprovider_details.Host.name/HostConfig.namelabel the target without a credential, andHost.logger(settable) prefixes it to each record and setsrecord.host. Whenrun()or a path operation is served by a provider ranked below an unavailable one, the host logs one WARNING per skipped provider onhost.logger; it used to be a DEBUG record from the selector only. -
Host-bound provider descriptors, in 0.4.0. A
SystemHostsubclass declares its own descriptors inprovider_resolvers(name ->resolver(host)), merged up the MRO ahead ofregister_system_provider's global ones; mapping a name toNonehides it.default_providers()names the descriptors used when the config names none, and drops a defaultssh/sftpwhen thesshextra is missing (a name the config gives is kept, and fails at use with the install hint).resolve_providers()turns names into providers, forreplace_providers(); an unknown name raisesValueErrorlisting the names the host knows.PosixHost(config)built directly now resolves the config's descriptors, which it used to ignore. -
hostctl.transports.ssh.SSHExecutorProvideris public, with.connection(the asyncssh connectionrun()uses, opened on first access) and.loop(thehostctl-asyncssh-loopevent loop it is bound to; run its coroutines withasyncio.run_coroutine_threadsafe(coro, provider.loop)), so a caller needing the raw connection no longer reads the private transport. This ships in 0.4.0. hostctl.HostctlErroris the base of every exception hostctl raises, in the newhostctl.exceptionsmodule withTransportErrorandProtocolError(bothConnectionErrors) as categories; every class is re-exported fromhostctl. This ships in 0.4.0.hostctl.has_ssh()reports whether thesshextra (asyncssh and sftppath) is installed, andhas_psrp()the same forpsrp, without importing either; one table of extras drives both, the install hint, and the SFTP provider's availability. This ships in 0.4.0.SSHConfig(ssh_config=)is the OpenSSH client configuration of the host's one connection, so commands and paths read the same one:hostctl.USER_SSH_CONFIG(the default, also exported fromhostctl.transports.ssh) is~/.ssh/config, found when the connection opens, a missing file reading nothing, as withssh;NoneorFalsereads none; anos.PathLikenames a file; astr, bytes, a stream or an object withread_text()/open()(a path on another host) is the configuration's TEXT, which reaches asyncssh through a private temporary file that exists only while the connection opens and never appears inrepr()orconnection_uri. Astris never a file name (a line with a keyword and no value raisesValueErrornamingpathlib.Path). It is a keyword only, never a URI key.hostctl <command> --ssh-config PATH(and thessh_configtool parameter) names the file,--ssh-config ''none.SSHConfig(port=None), the default, leaves the port to the ssh_config'sPort, else 22, asusername=Noneleaves the user.SshConfig(connect_options=)passes extra keywords toasyncssh.connecton both legs, andSshConfig(path_flavor="auto")asks the server's login directory while the host is created.hostctl.Timeout(total, *, connect, read, write)andhostctl.DEFAULT: every config (local, SSH, WinRM, container, QEMU, serial and the system configs) takestimeout=, a number orNone(the total, the default ofrun(timeout=)) or aTimeout, and stores aTimeout.config.timeoutsshows what eachDEFAULTresolves to; a field the transport cannot honour raisesValueErrornaming the config and field. The URI keys aretimeout,timeout_connect,timeout_readandtimeout_writeon every scheme, empty forNone. This ships in 0.4.0.- SSH connections are bounded while they establish:
SSHConfig'stimeout.connectdefaults to 30 s (URI?timeout_connect=;Nonekeeps asyncssh's default). WinRMConfig'stimeout.connect(default 30 s) bounds establishing the connection with the same meaning:-OpenTimeouton the native leg, pypsrp'sconnection_timeouton PSRP, and the connect half of each HTTP request's timeout on pywinrm (Nonekeeps each library's default). An expiry raisesConnectionError. This ships in 0.4.0.hostctl.shell.wants_epilogue(capabilities)answers whether a script for a provider needs the flavour's status epilogue (not when it declaresmanages_status).shell_flavour_names()(also inhostctl) lists the namesshell_flavour()accepts, plugin flavours included.run(timeout=)on every WinRM leg, with the config's total (30 s; URI?timeout=, empty forNone) as its default, andRunspaceSession.invoke(timeout=). This ships in 0.4.0.pathapi.protocols.supports(path, Protocol)answers for a host path. A composed host's path answers from the operations its providers declare (only the pinned provider after.via()), without probing; a container path has noCreate,Chmod,RemoveorMove, and a QGA path has noReadlinkorSymlinkand answers its metadata and namespace protocols from the guest helper.HOSTCTL_MCP=stdioserves everyhostctlsubcommand as an MCP tool over stdio.--ask-passwordis refused there, since a tool call cannot prompt; useHOSTCTL_PASSWORD.hostctl --version, and-v/-q/--loglevelon every subcommand.PromptConsoleProfile(login_timeout=10.0)sets how long each login step and the first prompt may take; the default is the previous fixed 10 seconds. It is a profile argument only, not part of theserial:///URI.QemuHost.open_serial(encoding=, errors=)(andQemuSerialConsole.open) return a console that reads and writesstr.ContainerConfig(path_flavor="posix"),"windows"andSshConfig(path_flavor= "windows")take the stringsQemuConfigalready accepted.run(..., stderr=subprocess.STDOUT)on a QGA guest asks qemu-ga to interleave the streams (capture-output: "merged") when the agent is 8.0 or later and the guest is not Windows.host_schemes()andsystem_provider_names()list what the URI-scheme and system-provider registries hold, sorted, with entry-point names included and not imported. Unknown system-provider descriptors load thehostctl.system_providersentry point of that name.SystemHost.executor_providersandpath_providers(read-only tuples, until now only onLocalHost) andSystemHost.replace_providers(*, executor=None, path=None), which swaps either set, closes each replaced transport once unless a kept provider shares it, and starts a new connection generation. The provider teardown rule is documented in the providers guide.local_providers()returns the local(executor_provider, path_provider)pair, besidessh_providers()andwinrm_providers(), for a host you assemble yourself.posix://...?executor=localnow builds the same providers.ShellFlavour.redirect(value, target, operand)renders aREDIRECT/APPENDtogether with its target, for a flavour whose spelling depends on the target; the default isoperator(value) + operand.- A Connecting guide (one section per transport: URI form, query keys,
credentials, host-key or TLS policy, extra) and a worked application host
in "Extending hostctl": a
HostConfigsubclass with its own scheme composing aPosixHostsubclass from SSH providers and an application path provider. This ships in 0.4.0. - WinRM, container and QEMU guest paths set modification times (pathapi's
Utime), soPathSyncercarries the source's mtime across and astat_checksumsync into them converges instead of re-copying every file. WinRM paths and Windows guests use PowerShell (100 ns, a read-only file included); a POSIX guest with a path helper usestouch -c -d @EPOCH(nanoseconds on GNU, whole seconds on busybox); a container path execstouchas uid 0 orpowershell.exe, since the archive API has no time call.supports(path, Utime)is False for a guest without a helper and a container without the program.GuestPathHelpergains an optionalutime(path, *, atime=None, mtime=None). CompositePosixPath.over(providers, *segments, backend=None)andCompositeWindowsPath.over(...)build a composite path with no host, from an iterable of path providers or aProviderSelector.host.path()calls the same constructor, so per-operation fallback,.via()pinning,supports(), forwarding andbackend=behave alike. This ships in 0.4.0.hostctl.executor.BufferedExecutor,StreamingExecutorandExecutorRequest: base classes for an executor written outside hostctl. A transport implements_exchange(request), or_send/_receive(and_abandonwhen it can stop a command), and inherits the subprocess-shaped call every built-in executor shares: option and capability refusal,input=/stdin=as bytes, thetext/encoding/errorsrule, capture and stream routing (streamed output written as it arrives),check=True, the executor's own default for an omittedtimeout, and aTimeoutExpiredfromexpired()carrying the output captured so far. AnOperationNotStartedErrorraised after the command was sent becomes aConnectionError, so it is never replayed through another provider. The container and PSRP executors are built onBufferedExecutor. The guide's "Writing an executor" section has a worked example. This ships in 0.4.0.- One verification switch,
verify=, on every network config.WinRMConfigandContainerConfigtakeTrue,Falseor a CA bundle path (a PEM file or an OpenSSL hashed directory,stroros.PathLike) trusted instead of the transport's default;SSHConfig(and so aqga+sshtunnel) takesTrueorFalse. The URI key isverify=(true/false, or a percent-encoded path), and it round-trips. A bundle reaches pywinrm asca_trust_pathand pypsrp ascert_validation, and neither certifi norREQUESTS_CA_BUNDLEreplaces it; a missing one raisesFileNotFoundErrorbefore connecting. The native WinRM leg trusts only the Windows certificate store and refuses a bundle. A container'sverifyapplies to anhttps://engine_urlhostctl builds the client for, and is pinned againstrequests'REQUESTS_CA_BUNDLEoverride. This ships in 0.4.0. ContainerConfig(client_cert=(certificate, key)): a client certificate for a Docker Engine over TLS. A daemon run with--tlsverifyrequires one, which only aclient_factorycould supply. The pair of PEM paths (one file holding both is passed twice) reaches docker-py asTLSConfig(client_cert=..., verify=...)withverifyresolved as before; it needs anhttps://engine_urland noclient_factory(ValueError), is accepted beside a URI as a credential, is never inrepr()orconnection_uri, and a missing file raisesFileNotFoundErrorbefore connecting.MissingExtraError: a missing extra is its own exception, and the command line reports it in one line. Every place hostctl names an optional extra that is not installed raiseshostctl.exceptions.MissingExtraError(extra, feature)(.extra,.feature, and.pythonwhen the extra cannot be installed on this interpreter), aHostctlErrorand anImportError, so an existingexcept ImportErrorstill catches it. The messages now share one form,FEATURE needs the 'EXTRA' extra: pip install "hostctl[EXTRA]".hostctlexits 125 withhostctl: <that message>where it printed a traceback ending in the hint; anImportErrorraised inside an installed dependency is not mapped and still shows its traceback.MissingExtraError(..., distribution="pytruenas")names another package's extra (pip install "pytruenas[ssh]",.distribution), so a library built on hostctl raises the same type for its own extras.- Keyword credentials override the URI's, and
username=is one.HostConfig(uri, username=, password=)andHost(uri, ...)use one URI with other credentials without rewriting it:password=replaces the URI's whole password field (""means no password), andusername=replaces the URI's user onssh,winrm/winrmsandqga+ssh(elsewhereValueError);connection_urishows the user in effect and never a password. - A callable
password=onSSHConfigandWinRMConfig, asked only when a password is needed. asyncssh calls it when the server asks for a password after key authentication failed; a WinRM leg calls it as it builds its session, andprovider="auto"takes the native leg without calling it when that can serve the config. A program can so prompt its user only when it has to. hostctl -u USERNAME -p PASSWORD, and a prompt only when a host asks. Every connecting subcommand takes-u/--usernameand-p/--password, which replace the URI's user and password (-p '': no password, no prompt). Without-pthe password isHOSTCTL_PASSWORD, else the URI's own; without any, the command prompts once on a terminal when a host asks for a password (never for an accepted SSH key) and, with no terminal or in a tool call, fails at once with exit 126 naming-pandHOSTCTL_PASSWORD. Over MCPusernameandpasswordare tool parameters. A login every provider refused now exits 126 (it was 125).
Changed
- The fallback WARNING says why the provider declined. The SSH, SFTP
and WinRM providers decline with a reason naming the target and what
refused it --
SSH host key of nas:22 is not trusted (...)with the remedy (known_hosts=,verify=False),SSH login to nas:22 was refused,SSH connection to nas:22 was refused,SSH host name nas did not resolve,SSH connection to nas:22 timed out,SSH host nas:22 is unreachable,WinRM TLS certificate of win:5986 is not trusted-- with the error's own message, credentials redacted. It is whatSelectionStep.reasonand therun() fell back from ssh to ...WARNING show, where every cause readSSH connection failed before dispatch. -
Breaking, 0.4.0: every config spells its timeouts as one
timeout=Timeout(...).QemuConfig(agent_timeout=)istimeout=Timeout(read=)andSerialConfig(read_timeout=, write_timeout=)istimeout=Timeout(read=, write=); their URI keys (?agent_timeout=,?read_timeout=,?write_timeout=) raise namingtimeout_read/timeout_write. A number given astimeout=is the total only.run(timeout=None)is no limit for that call on every host, andhost.shell.run(timeout=None)passes it on; a left-outtimeoutis the config's total. A QEMU guest-agent reply may now be unbounded (Timeout(read=None)); serial's non-blockingread_timeout=0isTimeout(read=0), the one transport where a zero is accepted. See "Migrating from 0.3". -
Breaking, 0.4.0:
WinRMConfighonours two timeouts,totalandconnect. The total, 30 s, is the default forrun(timeout=)on every WinRM leg. The native leg usedread_timeout_secas each command's total deadline; it now uses the total, andtimeout.connect(default 30 s) bounds opening the remote session (New-PSSessionOption -OpenTimeout). pywinrm and PSRP had no deadline and refusedrun(timeout=); both now apply the total too, so a command running past 30 s raisesTimeoutExpired(orphaned=True, after asking the server to stop it) where it used to finish. Passtimeout=Nonefor no default, orrun(timeout=)per call. The per-request WS-Man timing (read_timeout_sec,operation_timeout_sec) is internal: a 20 s server hold and a 30 s HTTP read limit. See "Migrating from 0.3". -
Breaking, 0.4.0:
host.path(p, backend=name)pins the path to that provider, as.via(name)does: every operation on it and on every path derived from it goes toname. Before, the named provider only built the path and each operation re-selected from the first capable provider, so a write meant fornamecould land on another one. -
Logger names follow module paths under
hostctl, which ships in 0.4.0: every module logs throughlogging.getLogger(__name__)(hostctl.transports.ssh._transport,hostctl.provider._contracts, ...). Configure thehostctllogger; the child names are not API. -
Breaking, 0.4.0:
OperationNotStartedErroris aConnectionError, not aRuntimeError. It is raised only before anything was sent, so anexcept ConnectionErrorretry around a refused SSH port now catches it. Anexcept RuntimeErrorwritten for it no longer does. The QGA errors are renamed (QGA*),GuestAgentProtocolErroris folded intoQGAProtocolError, and the exceptions leavehostctl.provider,hostctl.executorandhostctl.serialforhostctl.exceptions; see "Migrating from 0.3". - Breaking, 0.4.0: one keyword vocabulary. Every config takes
shell=andpathname=(hostctl.host.ShellSelection/PathnameSelection), its target positionally and everything else by keyword, and the URI query keys follow the keywords; an old key raisesunknown connection parameter. WinRM takesauth=,tls=,verify=True|Falseand float-second timeouts, the containerusername=, the serial configdevice=andprofile=. See "Migrating from 0.3". - Breaking, 0.4.0: module paths below
hostctlmoved. Each transport is one package underhostctl.transports, the contracts arehostctl.host,hostctl.executor,hostctl.process,hostctl.provider,hostctl.system,hostctl.pathsandhostctl.uri, and every public module declares__all__; every other module starts with an underscore. The root__all__is unchanged apart from the rows in "Migrating from 0.3". - A Windows QGA guest's path helper starts
powershell.exe -NoProfile -NonInteractive -Command -and sends the script as the call'sinput-data, instead of a base64-EncodedCommandargument. Endpoint-protection heuristics scan the command line, and a base64 PowerShell launch is what they flag; the script now never appears there. Results are unchanged. - Both legs of an SSH host read
~/.ssh/config, assshdoes. The exec leg did, by asyncssh's default, while the SFTP leg did not, so an alias reached the server throughrun()and failed throughpath(); they share one connection and one configuration now.known_hosts=()follows itsUserKnownHostsFile. Passssh_config=Nonefor a connection that depends on no personal file. Breaking, 0.4.0 for a caller relying on the paths ignoring it. - Breaking, 0.4.0:
SshConfig(username=)defaults toNone, the local account, assshdoes, instead of"root"; passusername="root"to keep the old behaviour.ssh://hostcarries no user, andconnection_uriomitsroot@. - Breaking, 0.4.0:
connect()andwith host:raiseOperationNotStartedwhen providers were offered and none connected (a refused port, a DNS failure, an unknown host key or a bad key), instead of returning with nothing connected. - Breaking, 0.4.0: a relative
host.path()on an SSH host names the login directory, asrun()'s working directory does, instead of the filesystem root;host.path()with no segments is the login directory. The directory is read once per connection, so building a relative path connects; when that connection cannot be opened the SFTP provider declines (OperationNotStartedError) and the host builds the path from its next path provider. - A host used only for paths no longer also opens and authenticates the SSH exec connection; each connection authenticates once.
- The PowerShell dialect over SSH sends
-Commandwhere the login shell allows it, a script with a#comment on the channel's stdin when the call has no input of its own, and-EncodedCommandonly as the last resort.ShellCommandgains an optionalstdin. - Breaking, 0.4.0:
ShellFlavour.command()takeslogin_shell=None; a custom flavour must accept it to be used over SSH. The POSIXcommand()putscwdinside the script instead of a leadingcd ... &&, and joins with a quoting that sh-family shells and fish read identically. - The config extension hooks
_from_parsed_uriand_create_hostare documented as the override points for aHostConfigsubclass, with a worked example in the extending guide. Their names are unchanged and nofrom_uriis added. - Breaking, 0.4.0: an environment variable name outside
[A-Za-z_][A-Za-z0-9_]*raisesValueErroron every transport, before a provider is selected. Only the embedding transports refusedA.BorProgramFiles(x86)before, so the same call was valid or not depending on which provider won.validate_environment_namesis exported byhostctl.executor. - Breaking, 0.4.0:
Inputno longer includesint.input=3reachedsubprocess' writer thread and hung untiltimeouton Python 3.14; it now raisesTypeErrorat once. - Breaking, 0.4.0:
Shellis not anExecutor.Shell.execute()andshell(command, *args)are removed; calling aShellconfigures it (host.shell(cwd=..., env=...)) and a positional argument raisesTypeError. Run a program with argv throughhost.executor(command, *args)orhost.run(Exec(command, *args)). A shell'sflavour,cwd,env,encodinganderrorsare read-only, and thehost.shellproxy is gone. AShellover a host is that host'srun; one over a bare executor sends the flavour's invocation as argv when the executor declaresargs. - Breaking, 0.4.0:
ShellFlavour.script(for_session=)isscript(epilogue=), with the sense inverted (for_session=Trueisepilogue=False).terminate_submissionis unchanged here. - Breaking, 0.4.0: a structured
cmdcallraisesValueError.callre-expands%and doubles^in its arguments, so no quoting survives it; run the program directly.callandstartare no longer treated as builtins. - Breaking, 0.4.0:
CmdletandPSParamreplace the PowerShell-Nameinference. A plain string in a structured command is always a value; a string shaped like-LiteralPathwas rendered bare, which hid a real argument from a native program and let a program-shaped value read as a parameter. WriteCmdlet("Remove-Item", PSParam("-LiteralPath"), path). Both are exported byhostctl,hostctl.hostandhostctl.shell, are non-iterable likeExec, and raiseTypeErrorfor any other shell.ShellTargetgainsBATCHandUNRESOLVED. - The
hostctlcommand needs thecliextra (pip install "hostctl[cli]", which addsduho>=0.7.5,<0.8). Without it the console script prints one line naming the extra and exits non-zero. The command line moved fromhostctl._clitohostctl.cli;hostctl.cli.main(argv)no longer takesstdout=/stderr=and writes tosys.stdout/sys.stderr. Subcommands, operands and exit statuses are unchanged, except that the--beforerun's program is now required. - Paths are
pathapipaths (pathapi>=0.1.2,<0.2, which replacespathlib_next;pathlib-nextwas renamedpathapi).HostPathispathapi.Path,ExecutorCommandtakes apathapi.Pathname, and a path provider must return apathapi.Path; one that returns anything else fails withTypeError: path provider 'X' returned LocalPath, expected pathapi.Path.local_providers()is the local pair to use instead of building one. Changepathlib_nextimports as pathapi's changelog says:pathlib_next.utils.syncispathapi.sync,pathlib_next.utils.stat.FileStatispathapi.FileStat. - The
sshextra isasyncssh>=2.20,<3andsftppath[asyncssh]>=0.1.3,<0.2, in place ofpathlib_next[sftp-async]. An SSH host's path is ansftppath.SFTPPath, and itsstr()is the path on the server where it wassftp://host:22/.... - pathapi's own changes reach hostctl's paths:
touch(mode=...)raisesNotImplementedErroron a path that cannotchmod(), and a path class without__fspath__is no longeros.PathLike. - A WinRM, Docker archive or QGA write stream whose
withblock raises still uploads nothing, and no longer also emits aResourceWarning; one collected unclosed still warns. The stream is pathapi'sDiscardOnError. pathlib.Path(src).copy(host_path)onto an existing directory of a WinRM, Docker archive or QGA host raisesFileExistsError, as onto a local directory; it merged before.- A WinRM, Docker archive or QGA write stream whose upload fails on
close()is closed; write again to retry. - Requires netimps 0.4 (
netimps>=0.4.0,<0.5, was>=0.3.0,<0.4). No hostctl code changed, but three things change for a caller: winrm,winrms,wsman,wsmansandpsrpnow have default ports (5985, 5986, 5985, 5986, 5985) whereConnectionString.portwasNone, sostr(ConnectionString("winrm://admin@host"))renderswinrm://admin@host:5985. They are the portsWinRMConfigalready chose.- An all-digit scheme given through
scheme=,defaults=orreplace()is read as a port, and one outside 0-65535 raisesValueErrorwhere it gave an object with no port. - netimps 0.4 refuses to import when
NETIMPS_NO_SOCKET_PATCHis set; setNETIMPS_SOCKET_PATCH=0instead.import hostctldoes not import netimps and changes no stdlib module: only the first call that needs it (a default port, a bracketed IPv6 authority,ConnectionString) imports it, and with it netimps'ssocketpatch on Windows. - The sdist no longer ships the contributor
AGENTS.mdorbenchmarks/, and neither artifact ships aCLAUDE*file. - Extras ranges follow the dependency rule:
containeraccepts docker 7.0 (was 7.1),serialrequires pyserial 3.3 (was 3.5),psrpis limited to pypsrp 0.9.x (was<1), andqemu-libvirtaccepts libvirt-python 12 and later (was<12). - The pywinrm leg no longer loads the remote user's PowerShell profile and
runs non-interactive, as the native leg already did. It starts
powershell.exedirectly and sends the script on stdin, instead of through cmd.exe. This ships in 0.4.0. provider="pywinrm"always means pywinrm, which ships in 0.4.0. It used to mean the native current-context leg for a password-free config on Windows; use the newprovider="native"(or"auto") for that. An explicit"pywinrm"or"psrp"without a password now needstransport="kerberos".str(WinRMConfig)omits an unset port, soHostConfig(str(config)) == config.- One status rule on every WinRM leg, which ships in 0.4.0: 0 when the last
statement succeeded, else the native exit code, else 1. PSRP reports a
non-terminating error followed by a successful statement as 0, where it was
1, and a remote ACL
PermissionDeniedis a status rather thanPermissionError. Only transport and authentication failures raise. - PSRP
run()text matches the other legs (tables as a console shows them, CRLF line ends), which ships in 0.4.0. It was each object'sToString()joined with LF. SystemConfig(PosixConfig,WindowsConfig,IosConfig) takes onlyprovider_options=andinitializer=beyondshell=,executor=andpath=, which ships in 0.4.0. Any other keyword was kept inoptionswithout complaint, soexecutr=("local",)configured nothing; it now raisesTypeError.executor="local"(a bare string) raisesTypeErrorwhere it became five one-letter descriptors. ASystemHostbuilt from a connection string or anything but aHostConfigraisesTypeError.ProviderSelector.select()losespolicy=,ProviderSelectionlosespolicy, andProviderSelector.last_selectionis gone, which ships in 0.4.0. Nothing varied the policy and nothing but one test readlast_selection;host.last_selection(the trace) stays, and trace entries keep a constant"policy": "ordered".- Registries refuse a duplicate name, which ships in 0.4.0. A second
HostConfigsubclass claiming a scheme that another live config holds raisesValueErrorwhere the class is defined (it used to fail later as "ambiguous host URI");register_system_provider(name, resolver)raises for a name already registered. Passreplace=Trueto take the name over on purpose. Redefining the same class (a module reload) is not a duplicate. - Composite paths are on one filesystem (
_same_filesystem(), which guardscopy(),move()andPathSynceragainst a file copied onto itself) when they can route to the same provider object, not only when they came from one host's provider set.pickleandcopy.deepcopy()of a composite raiseTypeErrornaming the reason, rather than whichever error the first unpicklable provider attribute produced. This ships in 0.4.0. SSHConfig(verify=False)is the one way to skip the host-key check.known_hosts=NoneraisesValueErrornaming it,SSHKnownHostsno longer admitsNone. Aknown_hoststhe caller names is checked even underverify=False, and the ssh_config is honoured as written. Breaking, 0.4.0.WinRMConfig.verifyisbool | str | os.PathLike, where it was a bool: a string is a CA bundle path, needstls=True, and one spelling a boolean ("false") raisesValueError. Native WinRM'sverify=Falseadds-SkipRevocationCheckto-SkipCACheck -SkipCNCheck, so it checks nothing, as the other legs do. This ships in 0.4.0.- An SSH host opens one connection and authenticates once. Its SFTP
paths open their channels on the connection
run()uses (SSHExecutorProvider.connection), on thehostctl-asyncssh-loopthread, where they had a second connection and login of their own on a loop thread sftppath started. A host used only for paths opens that connection on its first path operation; building an absolute path still connects nothing, and a path taken beforeclose()runs on the next connection.ssh_config,connect_optionsandtimeout=Timeout(connect=...)therefore apply once, to that one connection. Thesshextra needssftppath[asyncssh]>=0.1.3(AsyncSSHBackend.over_connection()). An SSH path's sftppath backend can no longer be pickled (TypeError): its connection belongs to the process. This ships in 0.4.0. verify=Truetrusts the operating system's store on pywinrm, PSRP and anhttps://Docker engine, as native WinRM does, where it trusted certifi.REQUESTS_CA_BUNDLEorCURL_CA_BUNDLE, when set, is still the only trust (a missing file it names raisesFileNotFoundErrornaming the variable); averify=bundle andverify=Falsebehave as before. pywinrm no longer warns about reading the variable, since hostctl reads it. On Python 3.10+ the platform's own verifier checks the certificate throughtruststore: Windows CryptoAPI, which fetches a public root the machine has not stored yet, the macOS keychain, OpenSSL with the system CA paths elsewhere. Only hostctl's own sessions use it;sslis not patched for the process. On Python 3.9, or withouttruststore, the store is what Python'ssslloads, which on Windows is the roots already present and on macOS not the keychain (see the contracts ledger). Thewinrm,psrp,container,winrm-credsspandwinrm-kerberosextras needrequests>=2.32.3,urllib3>=2.0.2(with 1.26 a verifying connection to a host given by IP address fails) and, on Python 3.10+,truststore>=0.10.5. Breaking, 0.4.0.
Removed
- Breaking, 0.4.0:
ShellFlavour.command_path(), which nothing called. - Breaking, 0.4.0:
ShellFlavour.terminate_submission()andsubmission_terminators.ShellSession.send()writes the script and the line terminator and nothing else. PosixHost.from_ssh(config)andWindowsHost.from_winrm(config), which ship removed in 0.4.0. Each wasconfig._create_host()plus a check that could not fail for a config that picked its own paths. Useconfig.open()orHost(uri);SshConfig("host").open()is aPosixHost, and a config with Windows paths opens aWindowsHost.- Breaking, 0.4.0: the undocumented
CompositePosixPath.from_path()andCompositeWindowsPath.from_path(). Useover(providers, *segments, backend=name).
Fixed
- A PSRP
run(..., text=True)that times out carries""inTimeoutExpired.output/.stderr, the call's output type, as the local executor does; it carriedb"". - Reading a directory as a file over SSH raises
IsADirectoryErroragainst OpenSSH too (sftppath 0.1.1, now the floor of thesshextra); it returnedb""from a Windows server and a bareOSError("Failure")from Linux. SSHConfig(timeout=Timeout(connect=None))round-trips throughconnection_uri(an emptytimeout_connect=); the URI it rendered could not be parsed.- On Windows, a process without a console no longer opens a console window
for every local command: the local executor starts children with
CREATE_NO_WINDOWunless the caller passescreationflags=, and so does the native WinRM leg for its localpowershell.exerelay. - WinRM, container and QEMU paths keep their backend through
with_name(),with_stem(),with_suffix(),relative_to()andparentson Python 3.9 to 3.11, where pathlib built them without one; an overwriting copy onto such a path failed withAttributeError: _backendunder pathapi 0.1.1. - Closing a container process releases the exec socket's descriptor; it stayed open behind the response and socket file docker-py keeps on it.
- A shell flavour's
repr()names it (<PosixShellFlavour 'posix'>), so a config'srepr()no longer embeds<... object at 0x...>. This ships in 0.4.0. - Config and host constructors report their real parameters to type
checkers,
help()andinspect.signature():SshConfig("h")is anSshConfigto a checker, which flags a misspelled keyword, andHostConfig(uri)/Host(uri)show(connection_string, /, **secrets). This ships in 0.4.0. hostctl lsescapes control characters, backslashes and undecodable bytes in names, so a name can no longer forge listing lines or send terminal escape sequences, and an undecodable name no longer raises.hostctl run/ls/cpon an unreachable host exit 125 with one line instead of a traceback and status 1.hostctl infoexits 125 (126 on an authentication refusal) instead of printing an identity made up from the URI with status 0.hostctl --helpno longer lists the unregisteredpsrp://scheme; PSRP iswinrm://...?provider=psrp.hostctl cpno longer splitsssh://user:password@hostat the password's colon, and no operand error echoes a password.HOSTCTL_PASSWORDno longer makes everyqemu+libvirt:/qga+unix:command exit 125.- A URI whose password holds an unencoded
/,?or#no longer has part of the password echoed in theValueErrorfromHostConfig(uri),Host(uri)orConnectionString(uri); the message now says to percent-encode it. Only an authority that does not parse is refused: a URI with a valid port and an@later (ssh://nas:22/mail/user@example.com,ssh://root:123/rest@nas) is taken at face value, as in 0.3. - Values passed in
env=no longer appear inCalledProcessErrororTimeoutExpiredtext,repr(),CompletedProcess.argsor the DEBUG dispatch log on transports that embed them in the command. Each shows as<redacted>; the value still reaches the remote command line. fishset -gxand quoted values containing'are also redacted in raw command text. - PowerShell-dialect stderr over SSH is text, not CLIXML, and a long
PowerShell script is no longer refused by endpoint protection that flags
-EncodedCommand. - A missing
sftppathno longer surfaces as a bareModuleNotFoundError: the SFTP provider probesunavailableandpath()raises thessh-extraImportError. The libvirt QGA message namespip install hostctl[qemu-libvirt]. dialect="auto"over SSH accepts ash (BusyBox), ksh, mksh and the other POSIX shells it used to refuse; csh and tcsh still raise, now saying they are unsupported.ssh://h:0raises the portValueErrorinstead of silently using 22.SshProcess.wait(timeout=)no longer loses output that arrived before the timeout;read()returns it afterwards.- An SSH host or username that OpenSSH would refuse now raises
ValueErrorat construction; such a host could inject options into a ProxyCommand. spawn()over SSH escapes the command line for acmdlogin shell, asrun()does; an argument such asa&bran as a second command (command injection).- A refused SSH exec request (the server declined to start the command) is an
OSErrorrather than aConnectionError, so it no longer reads as a broken connection. close()of an SSH host in a forked child no longer closes the parent's connection.- A relative path on an SSH host is no longer silently rooted at
/(orC:\) whilerun()starts in the login directory. - A fish login shell over SSH reads structured arguments verbatim; a value ending in a backslash could end the quoting early and run what followed (command injection).
run(timeout=)over SSH returns within about three seconds of the deadline even when the server or command ignores TERM, andTimeoutExpired.orphanedis true unless the command was seen to end.- Output stays in order around a dispatched command. The binary fallback
of
dispatch_outputwrote past text still pending insys.stdout, so a command's output could print before the line written before it. - A local
timeout=no longer detaches the terminal. A child started with a timeout got a new session and could not open/dev/tty; the new session is now used only when this process has no controlling terminal. A timed-out command started from a terminal may therefore leave grandchildren running (see the divergence ledger). - A structured
cmdechoprints its operand verbatim:("echo", "off")switched echo off,("echo", "/?")printed help and("echo",)printed the echo state. - A relative
cwdmeans the process's directory on POSIX and fish.cd -- subconsultedCDPATHfirst, so withCDPATHset the payload ran in another tree with status 0. It is renderedcd -- ./sub, which fails when the directory is absent. - A here-document or open pipeline in a session or script no longer
swallows the rest.
send()turned the delimiterEOFintoEOF;, andscript()joinedEOF;echo next; after multi-line raw source the separator is now a newline (ShellFlavour.multiline_source), and a fish session no longer sendsecho a |;. - fish quotes a word containing
%; a bare%selfbecame the shell's PID. host.shell.run()reports the native-WinRM status. It rendered the PowerShell exit epilogue itself, which ended the remote runspace before the status marker; it now hands the provider whathost.run()does. AShelloverLocalExecutor(or any executor declaringargs) runs, where it raisedFileNotFoundError, andhost.shell(env=...)reaches the command.SystemHost.executor(command, *args)raisedNameError; it runs.- PowerShell keeps deferred object output. The status epilogue ended with
exit, which discards objects still waiting to be formatted:host.run("[pscustomobject]@{a=1}")returned an empty line with status 0 on Windows PowerShell and pwsh. It now ends with$host.SetShouldExit(...); the exit status is unchanged. The WinRS (pywinrm) path shares the epilogue and was not exercised. - A
cmdredirect to a path with a space writes that file. The operand afterREDIRECT/APPENDwas quoted for a child's C runtime and wrote a file named after its first word; it is now quoted for the shell that reads it. - A path used as a whole command runs under PowerShell. It rendered as a
string literal that PowerShell printed, with exit status 0; a standalone path
is now a one-element command (
& 'path'), and cmd quotes a spaced one. - A structured
cmdstartalways gets an empty window title, so("start", "/b", path)starts the program instead of titling a window. - A local
Execof a.bat/.cmdin a directory whose path has a space no longer fails withFileNotFoundError, and acmdstructured command whose program path has a space starts instead of reporting'"C:\Program' is not recognized. The first element of a structured command is the program and takes the quoting of a command word, not of argv data. iterdir()of a regular file on a POSIX QGA guest raisesNotADirectoryErrorinstead of listing nothing: the guest helper readsfind's record for the starting point, and follows it with-Hwhen it is a link.move()between two WinRM hosts, or two QGA hosts, copies and deletes instead of raisingValueError; a directrename()across hosts raisesOSError(EXDEV).chmod("755")works on WinRM and QGA paths.Path.copy()into a WinRM, container or QGA path refuses a source name that is not one path component (ValueError), where..\xcould land outside the destination.hostctl cpkeeps a transport's native copy instead of bypassing the source path's owncopy().- A serial or QEMU console process can no longer free a lease it does not
hold. Closing a stale process after the host was closed and reopened
released the new process's lease, so a third
open()succeeded and read the second process's bytes. A release is now bound to its own acquisition, and closing the host (or the executor) ends a live process: its reads and writes raiseConnectionErrorand itsclose()is a no-op. - Text-mode serial reads no longer corrupt a character split across reads.
An idle line reads empty, which is not EOF; the decoder was finalized on it,
so a split UTF-8 character raised
UnicodeDecodeErroror became two U+FFFD. The text read now returns""and keeps the partial character for the next read. typing.get_type_hints()works on every public function and method on Python 3.9 (18 raised there, 7 on 3.14), andSystemHost.runcarries the same annotations asHost.run.- QGA writes work on Windows guests again. A write went through a
temporary file and a replacing rename that the Windows helper refuses, so
every write failed since the helper probe landed in 0.3.0. A QGA write is now
in place (not atomic), which also keeps a POSIX file's inode, mode, owner and
symlink; only
open("x")writes a temporary file and renames it. - A POSIX QGA
rename()onto an existing target replaces it, asos.renamedoes, and a skippedmv -nno longer reports success without moving; a Windows guest raisesFileExistsError. iterdir()of a dash-leading relative path (-delete) no longer runsfindwith it as an expression: the starting point is anchored with./.- QGA
exists()below a regular file returnsFalse; a guest-file-read reply longer than requested and a deeply nested reply raiseQgaProtocolError; a listing skips names that are not one path component; a helper-less QGA path provider declaresexists. - Container overwrites keep the file's owner, group and mode instead of
re-owning it to root;
exists()andopen("x"/"a"/"r+")read one archive header instead of downloading the whole subtree, and an engine error is no longer reported asFileExistsError;write_bytesPermissionErrorcarrieserrnoandfilename. - A container path taken before
ContainerHost.close()raises instead of reopening docker-py's client, and a Windows container follows relative links with Windows path syntax. - Two threads on a fresh PSRP host opened two pools, and one was never closed.
- A whole-file WinRM read is one round trip (it was one per 8 KiB on Python
3.9-3.13), and
open("x")on an existing file no longer downloads it. - A WinRM rename onto an existing entry raises
FileExistsError, removing a non-empty directory raisesOSErrorwithENOTEMPTY, andscandirof a file and a failed reparse point carry an errno, as the local filesystem's do. transport="ssl"over PSRP raised at the first send; it is now Basic over HTTPS, as on pywinrm.RunspaceSession.invoke(script, *args)rendered-LiteralPathas a quoted string, which PowerShell's binder rejects; it uses the flavour'sstructured_command.- Writes to a WinRM path over pywinrm work again; every write raised
OSError.run()commands over about 3 KB no longer fail with "The command line is too long". - A native (password-free) WinRM command that failed remotely raised
ConnectionErrorinstead of returning its status, socheck=Falsecould not inspect it; a command printing about 20,000 lines hit the deadline because the relay was quadratic. host.executor(program, *args)on a composed or local host raisedNameError; both executor wrappers are now one class.- A composed host's path now forwards
_utime,hardlink_to,owner,group,resolveand a relativeabsolute()to its backend, whichPathSyncerneeds to carry mtimes across: a secondstat_checksumsync into an SSH host copies nothing instead of everything.supports()answersUtime,Hardlink,Owned,Resolve,NativeChecksumandChownfrom the backend path class, so it no longer claims a digestLocalPathlacks or achowna WinRM or container path cannot do.PathProviderdeclaresutimeandhardlink_toby default. - Concurrent
run()andclose()on one host no longer raiseRuntimeError("dictionary changed size during iteration") from provider selection, which read and wrote its decline and probe maps without its lock. Two hosts opened concurrently from one config no longer share a transport, or split one host's executor and path legs across two. ConnectionString("nas:8443")andConnectionString("10.1.1.1:22")with no scheme raise "no scheme" instead of readingnasas the scheme, which left the host empty andis_localtrue for a remote machine (Python 3.9 did the same for the address form). Withscheme=ordefaults=they parse as host and port. TheConnectionStringdocstring and the API header said a bare host parses without a scheme; they now say it needs one.SshConfig,WinRMConfig,ContainerConfigand the system configs refuse a URI given as their host with a message that redacts its password;WinRMConfig("winrm://u:pw@h", "u")used to render the password inconnection_uriandrepr. A percent-encoded C1 control character (%C2%9B) in a system URI's host or userinfo is refused like the C0 ones, instead of reachingauthorityandinfo().hostnamedecoded.- A provider's probe narrows its declared capabilities in every reader.
select(capability=),host.capabilities,executor_capabilities,provider_detailsand a composed path's dispatch each read a different union of the two, so a probe that narrowedspawnaway was still chosen for it and reported.ProviderSelector.effective_capabilities(provider)is the one answer: the declared set intersected with what the probe states, or the declared set when it states none; a capability a probe adds is ignored. host.path(backend=name)probes through the selector, as.via(name)does, so a probe that raises isOperationNotStartedand is cached, where a raw transport error escaped and the probe re-ran on every call.host_checksumon a Windows SSH target handscertutilandGet-FileHashthe file the transfer wrote, asC:\data\file.txt:certutilread the SFTP spelling/C:/data/file.txtas an option, and PowerShell hashed the logical text. A file on this machine's filesystem is hashed in process whichever host owns it; a composed local host (posix://?executor=local&path=local) spawned one process per file.- One broken
hostctl.configsplugin no longer breaks every other unclaimed scheme: the entry point named for the scheme loads first, a plugin that fails to import is warned about and skipped, and only its own scheme raisesRuntimeErrorfrom the import error. - Default ports, IPv6 bracketing and the local-host check come from netimps
where hostctl wrote its own: an SSH, QGA-over-SSH, WinRM or PSRP port, an
endpoint or URI authority with a port,
HostInfo.hostname(a bare IPv6 address::1is no longer reported as:), andConnectionString.is_local, which now also answers*.localhost, short IPv4 spellings and this machine's own name.import hostctlstill does not import netimps. - Windows PowerShell 5 writes a
REDIRECT/APPENDto a path or one-element tuple with| Out-File -LiteralPath NAME -Encoding Unicode, so[ab]in the name is no longer a wildcard: the write failed, or went to a file the pattern matched. The bytes are those>wrote (UTF-16LE with a byte-order mark). Raw text after the operator, and pwsh, keep>. - Windows PowerShell 5 runs the program an absolute path names when the path
contains
[,],*or?: it is backtick-escaped after&, whereC:\x\[ab]\t.cmdranC:\x\a\t.cmdif that existed. A relative path cannot be spelled literally on 5; see the divergence ledger. - Captured pwsh 7 output is plain text:
PWSHscripts set$PSStyle.OutputRendering = 'PlainText', where aTERMnaming a colour terminal put ANSI colour codes into formatted tables and error records even through a pipe (run(..., capture_output=True)returned'\x1b[32;1ma\x1b[0m'fora). - A
serial:URI reads its device the way afile:URI reads its path:serial:///dev/ttyUSB0andserial:/dev/ttyUSB0open/dev/ttyUSB0, where they openeddev/ttyUSB0relative to the working directory (the CLI help's own example did). A Windows port and a pyserial URL keep no slash (serial:///COM3isCOM3,serial:///loop://isloop://), the encodedserial:///%2Fdev%2FttyUSB0is still/dev/ttyUSB0,serial:ttyUSB0is a relative device, andserial:////dev/ttyUSB0raisesValueErrornaming the three-slash spelling.str(SerialConfig("/dev/ttyUSB0"))rendersserial:///dev/ttyUSB0, percent-encoding only what a URI path cannot hold. This ships in 0.4.0. LocalConfigand the system configs (PosixConfig,WindowsConfig,IOSConfig,SystemConfig) print as values, in the dataclass configs' style:LocalConfig(),PosixConfig(authority='nas.example', shell=None, executor=('ssh',), path=('sftp',)), where they printed<... object at 0x...>.provider_optionsandinitializerare left out, so a transport password passed there never reaches a log. This ships in 0.4.0.
Security
- Windows PowerShell 5 no longer splits a native argument at a space that
follows a quote.
host.run(("tool.exe", 'report" /MIR'))reached the child asreport"and/MIR, an argument turned into a flag. PS 5 wraps an argument in quotes only when whitespace sits at an even count of"; a value that cannot be passed that way now raisesValueError(usePWSHorExec) instead of arriving split. - A batch file never executes an argument.
Exec(shim_cmd, 'x"&echo ...'), a structured command to a.bat/.cmdundercmd,powershellorpwsh, and a bare name that PATHEXT resolves to a batch file all let a value close cmd's quoting and run the rest (and expanded%VAR%). Every argument to a batch target is now one inert token ("+ body +","as"",%as%%cd:~,%). A Microsoft C runtime reads""as one quote; aCommandLineToArgvWconsumer may split a value that has whitespace after a quote. - Breaking, 0.4.0: an argument to a program of unknown type (a bare
name,
.ps1) raisesValueErrorwhen it holds"(cmd) or any of" & | < >(PowerShell), since the program may be a batch file. Name the program with its suffix, useCmdletfor a cmdlet, or useExec. - Breaking, 0.4.0: a local
Execof a batch file raisesValueErrorwhen the program path contains"or%. WinRMConfigrefusestransportbasic,plaintextorsslover plain HTTP unlesscleartext_auth=True(URI querycleartext_auth=true), on both the pywinrm and PSRP legs. They sent the password in cleartext on pywinrm. This ships in 0.4.0.- A PowerShell native argument
--%raisesValueError. PowerShell reads it as the stop-parsing token however it is quoted, so the rest of the line reached the program unparsed and re-split. Breaking, 0.4.0. - No check is off unless the config says so. On SSH,
known_hosts=Noneswitched host-key verification off; it now raises and namesverify=False(the ssh_config still decides for itself). A container's CA bundle holds whateverREQUESTS_CA_BUNDLEsays: stock docker-py lets the variable replace a bundle on every request, widening what it trusts. This ships in 0.4.0.
0.3.2 - 2026-09-23
Fixed
redact_urino longer over-redacts a well-formed URI. The textual fallback -- which reads the last@in the whole string as the userinfo delimiter -- ran wheneverurlsplitreported no password, including for URIs that simply do not have one.ssh://nas:22/mail/user@example.comcarries no credential at all and came back asssh://nas@example.com: a diagnostic quietly naming a different host. Guessing is now confined to input that does not parse -- when the URI is well formed,urlsplit's authority is the authority, so an@in a path is left alone. A password hidden by an unencoded/,?or#is still redacted: that input is malformed (the separator ends the authority, leavinguser:secretread as host:port), and detecting exactly that is what now gates the fallback.
Changed
ConnectionStringnow documents the parsing contract it already enforced: the input must be a valid URI, and reserved characters in a password must be percent-encoded. There is no lenient mode and none is planned -- a password may itself contain@and:, so nothing can recover where an unescaped one ended, and a guess in the parser would be a guess about what to connect to. Documentation and tests only; behaviour is unchanged.
0.3.1 - 2026-09-22
Fixed
- A composite path falls back when SFTP cannot connect. Nothing on the
path route connected the provider first: pathlib_next dialled SFTP inside
the operation, so an untrusted host key (
HostKeyNotVerifiable) or a refused port escaped raw and the next path provider was never tried -- whilerun()on the same host fell back. A composite path now calls the provider'sconnect()before dispatch, and SFTP maps asyncssh's connect errors the way the SSH leg already did. Most likely to fire since 0.3.0 started verifying the SFTP host key: any host not yet inknown_hosts. run()no longer re-dials a declined provider on every call. 0.3.0 cleared every decline per call, so a dead primary cost a full connect attempt (~9 s against a refused port) on eachrun()while the fallback served. A decline now lasts for the generation again, and is re-admitted only when no other provider can serve -- which keeps a single-provider host from bricking on one transient refusal, for paths as well asrun().- A path refusal with nothing left to try names why ("no path provider completed read (...: Host key is not trusted ...)") and keeps the cause.
0.3.0 - 2026-09-22
Added
- QGA paths have metadata and namespace operations.
QemuHost.path()now supportsstat,iterdir,mkdir,unlink,renameandchmodthrough a guest-side helper built during discovery -- hostctl previously shipped none, so a caller had to write one and every QGA path reporteddegraded. The helper is POSITIVELY PROBED: GNUstat -c/find -printfon a POSIX guest, PowerShell on a Windows one. A guest that fails the probe keeps content access and loses only what genuinely needs a helper.QemuConfig(path_helper=...)still overrides. SshConfig(login_shell="auto"|"cmd"|"powershell"|"posix")says what parses the command string on arrival. Windows OpenSSH's stock server hands it tocmd.exe /c, so acmd-dialect command line is parsed by cmd twice; unescaped, 4 of 8 adversarial values survived and two of the failures were command injection.autoreads the dialect.ShellFlavour.argument(value, *, target=ShellTarget...)separates quoting for the shell's parser from quoting for whatever reads the token next -- a cmd builtin, a child's argv parser, PowerShell's binder, or the program slot.quote()remains the syntactic layer.
Removed
SerialTransportand itshostctl.executorexport. It duplicatedSerialProcess's locked, deadline-clamping byte path and had no caller outside its own tests, so the two could drift with nothing to notice.SerialProcesskeeps the job -- it is what every console profile reads through -- and gained thewrite(timeout=)it lacked.SerialConfig.usernameandSerialConfig.password. They were stored, whitelisted for URI dispatch, and read by nothing: console credentials live in the profile'slogin=steps. An explicit one is now refused rather than silently ignored, andHOSTCTL_PASSWORDis no longer offered to aserial:URI.
Changed
- The
pathlib_nextfloor moves to>=0.9.10in bothdependenciesand thesshextra. 0.9.4 through 0.9.9 cannot be used: those releases decide "same file" and "overlapping trees" by falling back to path equality, which compares(type, segments)and knows nothing about hosts, so copying/etc/app.conffrom one host to the same path on another was refused withOSError [Errno 22] Source and target are the same file(andPathSyncerwith "source and target overlap"). 0.9.10 adds the_same_filesystem()hook this release answers. LocalExecutormergesenvover the inherited environment instead of replacing it, so the documented cross-transport rule -- "envis additive to the provider's environment" -- now holds locally too. A child no longer starts withoutPATH,HOMEorSystemRootbecause one variable was set.- A serial
run()frames each command as its own profile exchange and refuses a structured (argv) command: a serial host names no shell flavour, so it has no quoting rule to apply, and joining commands with;put two on one line where most device consoles read a single malformed one.stdin=andbufsize=raise instead of being ignored, asinput=already did. - A serial
spawn(encoding=...)reads text, matching the SSH and QEMU console adapters; without either text keyword it still reads bytes. - QEMU
dialect="auto"/path_flavor="auto"need positive evidence of the guest's family --guest-get-osinfo, or a family-exclusive command inguest-info-- and raise naming the explicit settings when neither is available. An agent withguest-get-osinfoblocklisted used to be read as POSIX, silently, including on Windows. QemuHost.info().os_familyis a family (linux,windows,freebsd), taken fromkernel-name. It carriedguest-get-osinfo'sid, so the same machine reportedlinuxover SSH andubuntuover QGA.- Each guest-agent request is bounded by
agent_timeout, separately from a command's owntimeout=: an agent that stops answering is aConnectionErrorsaying so, not a command timeout ten minutes later. - A truncated QGA
run()result warns.stdout_truncated/stderr_truncatedwere set and documented nowhere, so a shortened transcript looked complete. hostctl shellopens a console that cannot allocate a terminal, so aserial:URI works;hostctl cp local:/tmp/xis accepted, the spelling every other subcommand documents; andhostctl --helpcarries the URI forms, theURI:PATHgrammar, theHOSTCTL_PASSWORDrule and the exit codes.asyncsshandlibvirt-pythonare bounded (>=2.14,<3,>=9.0,<12); thedevextra now pulls every transport, sopip install -e ".[dev]"is the whole development environment.LocalHostis aSystemHost. It duplicated the provider selection, failover loop, capability computation and dispatch ladder, and the two copies diverged. Two consequences for callers:LocalHost().path()with no segments no longer returns the absolute working directory -- it returns a path with no segments added, like every other transport, which still resolves against the process's working directory. A local path is still a plainpathlib_nextpath, not a composite.
Fixed
- A transfer between two hosts is no longer mistaken for a file copied
onto itself.
CompositePosixPath/CompositeWindowsPathnow answerpathlib_next's_same_filesystem()with the host's provider set, so two hosts are two namespaces even when a path is spelled identically on both, while two paths on one host still are one — which keeps the guard that refusescopy()of a file onto itself. A.via()pin selects a route to the host, not a different host, and answers accordingly. - The SFTP leg verifies the server's host key.
SshConfig.connect_opts()omittedknown_hostswhenever it held its()default. asyncssh reads a missing key as "resolve known_hosts the usual way", sorun()verified the server — butpathlib_next's SFTP connect seedsknown_hosts=None(verification off) for options it is not handed, sopath()accepted any host key, including an actively substituted one, and sent it the configured password. Opting out is still possible and now explicit:known_hosts=None. ConnectionStringparsesuser:password@host. A scheme-shaped username madeurlsplitreadroot:hunter2@nasas the schemeroot, so the password was never recognised: it stayed in the path, wherestr()andrepr()rendered it verbatim, the host came out empty, andis_localreported a remote machine as local. Without a scheme to assume, a target carrying credentials is now refused rather than guessed at, and both error messages redact (root:<redacted>@nas).- An args-capable executor gets one shell layer, not two.
SystemHostfed a finished shell command line back intoflavour.invocation(), which takes a script. On Windows the outer PowerShell re-parsed the inner-Commandstring, sowindows://node?executor=localreported 0 forcmd /c exit 3—check=Truepassed for a failing command — and quoted text and variables came back mangled or empty. - cmd.exe builtin operands are quoted, and
setno longer escapes inside quotes. cmd splits a builtin's operands on,,;and=as well as whitespace, sodel /q a b.txtdeletedaandb.txtand left the named file. Separately,set "KEY=VALUE"caret-escaped inside a quoted span, where cmd does not process carets:100%reached the child as100^%,%OS%still expanded, and a"in the value ended the assignment early. An empty value remains inexpressible in cmd, and is now documented as such. - PowerShell 5 native arguments survive the C runtime. PS 5.1 rebuilds the
command line for a native program and leaves embedded quotes alone, so
run(("robocopy", src, dst, name))withname = 'my file" /MIR "z'handed robocopy/MIR— mirror mode, which deletes files in the destination. Empty arguments were dropped and a trailing backslash swallowed the next argument. PowerShell 7 is unaffected and keeps the plain literal. - A transfer to another backend no longer renames inside the source host.
ssh_host.path('/srv/export.csv').move(LocalPath('/home/op/export.csv'))issued an SFTP rename on the server: the file left/srv, never arrived, and no error was raised. Astrdestination — documented bypathlib_next— also raisedTypeErroron every transport, and a cross-providermove()aborted instead of falling back to copy + remove. All three now work. - Docker file modes are translated. The archive stat header carries Go's
os.FileMode, used verbatim as a POSIXst_mode:is_file()was False for every ordinary file andis_dir()raisedOverflowError: mode out of rangefor a directory, which also killed any recursive copy. - An unreachable QGA socket is reported as a transport failure. It escaped
as a bare
FileNotFoundError, indistinguishable from the guest file being absent — so an append across a guest reboot staged an empty buffer and truncated the guest file when the agent came back. - Native WinRM reports the remote exit status. The provider did not
declare
manages_status, so PowerShell's; exit $LASTEXITCODEepilogue was appended and itsexitended the remote pipeline before the__HOSTCTL_LASTEXITCODE__marker could be emitted. A command that failed by exit status reported 0 andcheck=Truepassed. - One unsupported operation no longer takes a path provider out of
service. A
NotImplementedErrorfrom a retry-safe call was recorded as a decline on the host's shared provider selector, which every later operation then consulted: after a backend refusedsamefile()— it needsst_dev/st_ino, which many remote stats lack — the next read on any path of that host failed withno path provider supports open_read. The refusal is now scoped to the call that raised it.OperationNotStartedstill declines the provider for the generation, as before. - A
cmd.exeat a path containing a space is launchable. The program slot of a Windows command line is read byCreateProcess, not by cmd, and rendering it with cmd's caret quoting made Windows look for a program named^-- measured asFileNotFoundError: [WinError 2]. - A structured PowerShell command can bind a named parameter.
["Remove-Item", "-LiteralPath", path]rendered every token as a quoted string, which PowerShell's binder reads positionally: measured against a realpowershell.exe, it returned 1 and left the file in place. A token that is exactly a parameter name is now unquoted. ShellSession.send()does not append a separator the text already has, sosend("echo hi;")is notecho hi;;andsend("sleep 5 &")is notsleep 5 &;.zshquoting covers a leading=, which zsh expands to a program path where POSIXshleaves it alone.PowerShellFlavour(7, executable=...)is namedpwsh. The name was derived only when no executable was given, so an explicit path left the flavour identifying aspowershell.Shell(..., env={})forwards no environment, rather than an empty one that starts a child with nothing.normalize_inputcoversbytearrayandmemoryview-- the deadlock it exists to prevent, for the two buffer types it did not check.- Container archive reads no longer lie about links, sizes or errors: a
single-member hardlink archive raises
OSError(ENOENT)instead oftarfile.StreamError, a hardlinked file in a listing reports its real size, the archive root (".") is listable, and every Docker status code maps through one helper that populateserrno. - A container exec stream no longer returns
''mid-character, and a closed container process reads EOF instead of raising the operating system's raw socket error. - QGA failures speak one error vocabulary: a guest error is classified on
the
strerrortail (a file namednotfound.logwas reported missing whatever actually failed),host.run()of a missing guest program raisesFileNotFoundError, libvirt-relayed guest errors stay guest errors, and a guest error no longer tears down and re-handshakes the connection. - One transport per QEMU host. Lazy initialisation was unguarded:
measured with six threads, six transports were built where one was wanted,
each SSH one opening a connection nobody owned. A path handed out before
close()now refuses instead of silently reconnecting. - A serial login failure raises
ConsoleProtocolError, not a bareTimeoutErrorthat slipped past every documented handler, and a transcript attached to an error hassecret=Truelogin values redacted -- consoles echo what they are sent. - A QGA reader is seekable where the agent advertises
guest-file-seek, sozipfileandtarfilework against a guest path, and an exclusiveopen("x")is refused atopen()rather than after the whole payload has been staged. - A
cmd-dialect command sent over SSH to Windows is escaped for the login shell that receives it. Two of the eight adversarial values were command injection, not corruption. SshConfig("ssh://host")raises instead of making the URI the hostname, which surfaced much later as an unresolvable name. The constructor takes a host;HostConfig(uri)takes a URI.
0.2.7 - 2026-08-16
Changed
- Dependency floors now name versions hostctl is actually tested against.
pathlib_nextmoves to>=0.9.1,<0.10andnetimpsto>=0.2.0,<0.3(thesshextra'spathlib_next[sftp-async]moves from>=0.8.4to the same>=0.9.1). Both ceilings are unchanged, and no API hostctl calls has moved — this only stops resolvers from choosing an install that does not work.
The pathlib_next floor is above the start of its minor series for one
measured reason: Path.symlink_to() — and with it the force= keyword —
first exists in 0.9.1. The composite path forwards symlink_to to
whichever backend path it was handed, so against a stock 0.9.0 Path the
whole chain raises AttributeError rather than linking anything. The
previous >=0.8.6 floor therefore advertised support for installs where
composite symlink_to could never work. netimps needs nothing past
0.2.0 and floors at the series start.
0.2.6 - 2026-08-16
Added
wants_text(text, encoding, errors)is exported fromhostctl.executor, joining the four stream helpers made public in 0.2.5 for the same reason: an executor implemented outside hostctl must reach the same conclusions as the built-in ones, because aSystemHostchooses the provider.
Fixed
-
Direct execution no longer raises
NameErroron two dispatch paths.ContainerHost.spawn(Exec(...))raised on every direct spawn, andSystemHost.run(Exec(program))raised whenever the selected executor provider advertised neitherargsnorscript— the shape a bareExecutorProvider("name", callable)has, which is the documented minimal form of that public authoring contract. Both modules usedcommand_textwithout importing it. -
hostctl cpno longer mistakes a URI port colon for theURI:PATHseparator.ssh://host:2222/tmp/xsplit intossh://hostplus the relative path2222/tmp/x— the default port and the wrong file, with no message. A split that lands on the authority's port colon is now rejected with the grammar error, so the required spellingssh://host:2222:/tmp/xis the only one that runs. Userinfo colons and IPv6 literals are excluded from the check. -
Native current-context WinRM now honours
server_cert_validation="ignore". It was spliced into the rendered wrapper withstr.replace, which matched nothing without a port (the setting vanished) and produced anInvoke-Commandparameter-binding error with one, sinceSkipCACheckbelongs toNew-PSSessionOption. The option is now built explicitly and passed as-SessionOption, skipping both the CA and CN checks to match pywinrm'signore. -
Native current-context WinRM now reports remote exit codes.
Invoke-Commanddoes not copy the remote$LASTEXITCODEinto the calling session, so a command that failed only by status — a native executable exiting non-zero without throwing — returned 0 locally andcheck=Truenever fired. The remote script block now emits the code as a__HOSTCTL_LASTEXITCODE__marker line, which the local wrapper consumes and exits with. Behaviour change: calls that silently succeeded against a failing remote command now raiseCalledProcessErrorunder the defaultcheck=True. -
Every provider now agrees on when output is text.
errors=alone selected text mode on the local, WinRM, container, and QEMU executors and binary mode on SSH, PSRP, and the serial host, so the identical call returnedstrorbytesdepending on which provider aSystemHostselected — the exact divergence the shared stream helpers exist to prevent. All seven sites now callwants_text. Behaviour change:run(cmd, errors="replace")returnsstrfrom SSH, PSRP, and serial where it previously returnedbytes. -
SFTP paths percent-encode the remote path before embedding it in the
sftp://URI.pathlib_nextparses that URI and uridecodes its parts, so a filename containing?or#was truncated into a query or fragment and a literal%xxwas decoded into a different name — reading and writing the wrong file with no error. Encoding is minimal (RFC 3986pchar), so a Windows-flavoured remote path still reads assftp://host:22/C:/Temp. -
RunspaceSessionno longer closes a pool it was given._owns_poolwas recorded at construction and never read, so a pool injected to be shared across sessions was closed by whichever session finished first. An injected pool is now left open and retained, which also leaves the session reopenable; a pool the session created is still closed. -
An abandoned container or QGA write stream no longer uploads from the garbage collector. The staged write-back stream existed as three byte-identical copies of which only the WinRM one had a
__del__guard;io.IOBase.__del__callsclose(), andclose()is what commits, so a write stream that went out of scope unclosed performed its network transfer at an arbitrary GC point with any error printed and swallowed by the interpreter. Onehostctl.host._staged_ionow serves all three, and the abandonment case warns instead of uploading. Theopen()mode validators were deduplicated with it, so"rt"is accepted on the container and QGA backends as it always was on WinRM. -
SerialHost.run(capture_output=False)no longer discards the console transcript. It reimplemented the output contract and treated aNonestdout target as "discard"; the shareddispatch_output— and every other transport, andsubprocess— treats it assys.stdout. Serial now routes through the shared helper.stdout=subprocess.DEVNULLremains the way to discard deliberately.
0.2.5 - 2026-08-05
Added
write_output,normalize_input, anddispatch_outputare exported fromhostctl.executor, joiningcapture_streams. An executor implemented outside hostctl previously had to importhostctl.executor._commonto reproduce hostctl's own stdout/stderr and stdin semantics.
Sharing these is a correctness requirement rather than a convenience: a
SystemHost can dispatch the same call through different providers on
different attempts, so providers that disagree about output handling return
results that differ by which transport won. normalize_input is the one
worth not reimplementing — a mismatch there does not raise, it deadlocks,
because bytes handed to a text-mode stdin kill subprocess's writer thread
without closing the pipe, so the child never sees EOF and timeout= never
fires.
No behaviour change; these are the same objects _common defines.
Fixed
- Four conformance tests covering timestamp handling were skipping for a
reason that was not true, so the contract they check went unverified. The
checks called
os.utime()on paths belonging to fake remote providers, which map into a private sandbox root and have no local existence; the resultingFileNotFoundErrorwas reported as "this provider cannot set timestamps". Timestamps are now set through the sandbox that actually stores the file. Test-only change.
0.2.4 - 2026-08-05
Fixed
-
Directory listings no longer discard the backend's own
_scandir().walk()andglob()went through hostctl's_scandir(), which was a verbatim copy ofpathlib_next's generic fallback and callediterdir()— so a scheme whose listing already carries metadata never got to use it.SftpPath._scandir()reads every child's attributes in a singlelistdir_attrround trip; before this fix a remotewalk()paid a listing plus onestat()per entry._scandir()is now the routed primitive anditerdir()derives from it, matching the direction upstream intends. -
copy()andmove()on a composite path now use the backend's own implementation when the destination resolves to a path that backend understands. They previously calledPath.copy(self, ...)unconditionally, which bypassed every backend override —SftpPath.copy()fans out over asyncssh workers andSftpPath.rm()/checksum()run server-side, so the results stayed correct while the transport-native path was silently discarded. A genuine cross-backend transfer (a destination on another provider) still uses the generic implementation, which is what it is for.
Changed
- Composite paths forward the method that was called to the selected backend
instead of re-declaring a copy of
pathlib_next.Path's surface. Operations hostctl never declares —touch(),rm(),lstat(),is_symlink(),chown(),checksum()— now work throughhost.path(), and an operation added upstream is reachable without a new method here.chown(), added inpathlib_next0.9.1, was unreachable before this.
_CompositePathMixin drops from 47 to 36 methods. Operations with real
composite behavior stay hand-written, each for a reason: iterdir/_scandir
(rebuild children as composite paths), rename (cross-provider guard),
readlink (rebuilds its result), copy/move (backend when the destination
resolves on this provider, generic for a true cross-backend transfer), and
open (the capability gate depends on the mode).
The pure-path derivations (parent, parents, joinpath, /,
with_name/with_stem/with_suffix, relative_to, with_segments) also
stay: pathlib.PurePath builds those through object.__new__, bypassing the
composite constructor, so they re-attach routing state that inheritance drops
rather than duplicating anything.
- Provider fallback now also triggers on
NotImplementedError, but only for operations that cannot mutate before raising (reads,stat,chown,chmod). Writes and composed wrappers still propagate it: a wrapper built from several primitives may have already changed something when a later primitive raises —symlink_to(force=True)unlinks before calling_symlink_to(), so a backend lacking that primitive deletes the entry and only then fails. Retrying that against another provider would repeat the work with the original already gone.
0.2.3 - 2026-08-04
Fixed
- Composite paths no longer drop backend-specific keyword arguments.
CompositePosixPath.symlink_to()accepted only the stdlib signature and forwarded nothing else, so a backend's documented extension was unreachable through the very abstraction meant to expose it — a path obtained fromhost.path()raisedTypeError: unexpected keyword argument 'force'even when the selected backend implementedforce=.
Forwarding is signature-aware rather than blind: the selected backend's
method is inspected, and only keywords it declares are passed through.
Anything else still raises TypeError at the composite boundary, naming
the backend class and the rejected keyword. Blind passthrough would have
turned a clear error at the abstraction boundary into a confusing one from
inside a transport, and the existing contract — a backend lacking a
capability raises NotImplementedError, never a silent no-op — is
unchanged. A method whose signature cannot be introspected (a C function,
a functools.partial) receives the keywords, since an error from it is no
worse than calling it directly.
Applied to mkdir(), chmod(), unlink(), and rmdir() alongside
symlink_to(), since the same normalization affected each of them.
This pairs with pathlib_next's symlink_to(force=), which that project
exposes as a generic Path extension over a _symlink_to() backend
primitive. No version floor change: hostctl's pathlib_next>=0.8.6 floor
stays where it is, so force= is forwarded when the installed version
provides it and rejected at the boundary when it does not.
0.2.2 - 2026-07-29
Changed
- Dependency ranges widened to admit
pathlib_next0.9 andnetimps0.2:pathlib_next>=0.8.6,<0.10andnetimps>=0.1,<0.3, with thesshextra'spathlib_next[sftp-async]bound moved to<0.10alongside it. The previousnetimps<0.2also conflicted withpathlib_next0.9's ownuriextra, which requiresnetimps>=0.2.0.
The floors stay where they were rather than rising to the new minors:
hostctl uses no API added in either release — its three netimps functions
(get_default_port, is_local_address, try_parse) all exist in 0.1, and
ProgressReader is hostctl's own wrapper, unrelated to pathlib_next 0.9's
native copy(progress=). A floor demanding versions the code does not need
would exclude working installs for nothing. The ceilings span two minors
because both projects are pre-1.0, where a minor may break.
Verified against both ends of the range: the suite passes with
pathlib_next 0.8.6 and 0.9.0, each alongside netimps 0.2.0.
0.2.1 - 2026-07-29
Added
ConnectionStringparses a connection target from whatever a user typed.ConnectionString("nas", scheme="wss")andConnectionString("nas:8443", ...)parse, because a bare host is not an invalid URI — it is a URI with the scheme left off, which is what people type on a command line.
Every field can be supplied directly, and three layers decide each one: an
explicit argument wins, then whatever the string carried, then defaults.
scheme=/port=/host=/… are therefore overrides — scheme="ssh"
beats a wss:// in the string — while defaults (a mapping, or another
ConnectionString used as a profile) fills only what nothing else
supplied.
A port carries its own resolution strategy wherever one is accepted: an
int, a callable scheme -> port | None, or anything indexable by scheme
(a plain mapping), so a caller passes its whole table without pre-selecting
an entry. When no layer supplies one, netimps.get_default_port resolves
the scheme — it knows schemes no system services database does (ws/wss,
socks5), and an application can register more with
netimps.register_port.
False stops the search outright: no port, and no lookup.
Credentials are parsed but never rendered. str() and repr() both emit
the redacted form, with the password removed rather than masked, so the
output stays a valid, reusable connection string that cannot round-trip a
wrong credential — and a value reaching a log line or a traceback frame
cannot leak one. A password may carry key:value extras after a newline,
as parse_credentials describes, written raw.
The host keeps the spelling it was given. is_local resolves nothing — it
is called while a configuration is built, which is network-free, and
resolving would both block and raise on a name that does not resolve. An
address literal is answered by netimps.is_local_address, so an address
actually assigned to this machine counts as local and not just loopback,
while a name is compared against the loopback spellings. qsl and
query_val() read the query; replace() returns a changed copy.
Changed
netimpsis now a required dependency, supplying scheme/port and address semantics rather than hostctl reimplementing them. Note this arrives in a patch release: an existing install pinned within0.2.xgains a new requirement, so a locked or offline environment needsnetimpsavailable before upgrading.
Fixed
- A path in a command is rendered through
__fspath__rather thanstr(). Every transport now shares onecommand_texthelper — the SSH, WinRM, container, QGA, and PSRP executors each stringified withstr(), so only the local executor asked for the filesystem representation.__fspath__is tried by attribute rather than byisinstance(value, os.PathLike), so a duck-typed path that never registered with the ABC is honoured too, and an object whose__fspath__raises or returns a non-string falls back tostr()rather than failing the command. The shell layer follows the same rule. No shipped path type changes behaviour —str()and__fspath__agree for all of them — but the contract now matches what a path promises.
0.2.0 - 2026-07-28
Added
Exec(program, *args)marks one command for direct execution: the program runs with that argv and no shell layer renders. The program and every argument may be astr,bytes, or a path object — all reach the transport as text, so the spelling records how the caller held the value rather than changing what runs. A bare program name is now possible (Exec("ls", "-l")), resolved through the target'sPATH; it previously had no spelling at all, because a plain string command is always shell text.- The full
run()command syntax is documented in the "Running commands" guide: the varargs rule, the three command forms, operators, validation, and the PowerShell rendering.
Changed
- Breaking. Direct execution is marked by
Exec, not by position. A path in the first argument used to mean "this is the executable, the rest is argv"; a path is now an ordinary value that stringifies wherever it appears.
host.run(Path("/bin/ls"), "-l") # before: direct execution
host.run(Exec("/bin/ls", "-l")) # now
This is a silent change for the old spelling: run(Path(...), *args)
still succeeds, but the values become several ;-joined shell commands
instead of one program and its argv, so arguments are subject to shell
quoting and word-splitting. Anything passing a leading path must be updated;
there is no deprecation shim.
What it buys, both unspellable before: several path commands in one call
(run(p1, p2) is two commands), and direct execution of a PATH-resolved
name.
An Exec cannot be combined with other commands in one call — there is no
shell to join them with, and running only one would silently drop the rest.
It raises TypeError rather than choosing.
- hostctl run passes its operand as a single Exec, so the program is used
verbatim. It no longer converts the operand through the target shell's path
flavour, which means a bare name now resolves through the target's PATH
instead of being treated as a relative path.
0.1.2 - 2026-07-28
Supersedes 0.1.1, which was tagged but never published: a release-gate test timed out on the Windows/Python 3.14 runner, so nothing reached PyPI. There is no 0.1.1 release; its fix is included here.
Added
uri_hostname(parsed)returns the host as it was written in a URI, rather than the case-folded spellingurlsplit().hostnameproduces. Use it in_from_parsed_uriwherever a host is stored; presence checks can keep using.hostname, since emptiness does not depend on case.
Changed
- A config built from a URI now stores the hostname as typed, so
HostConfig("ssh://nasA")keepsnasAin.host, inconnection_uri, and in theHostInfo.hostnamea system host reports without connecting. It previously storedurlsplit's lowercased form, which meant the library echoed a spelling the operator never wrote and left downstream code to recover the original from the URI itself.
Consequently .host is the spelling that was given, not a canonical form:
HostConfig("ssh://nasA") and HostConfig("ssh://nasa") no longer compare
equal, so code using a config or its host as a dict key or for
deduplication should casefold explicitly. Resolution is unaffected — DNS,
SSH, and WinRM all treat the two spellings as one name.
Fixed
redact_uri()no longer case-folds the hostname. Only the branch that rebuilt the authority — the one taken when a password was present — adoptedurlsplit's lowercasedhostname, sonasArendered asnasawith a credential and asnasAwithout one. The same host now renders one way whichever branch runs, and log records stay greppable by the name the operator typed. Redaction removes a credential; normalizing a host is a separate concern and is left to the transport that resolves it.- The spawn conformance test no longer fails on a slow runner. It waited 10s
for a real
powershell.exe -NoProfileto start, exit, and be reaped, which a cold Windows CI runner can exceed. The wait is a hang guard rather than a performance assertion, so it is now 60s. Test-only; no library change.
0.1.0 - 2026-07-27
First release. Alpha: the public surface is deliberately small (66 exported names) and may still change, but everything documented here is covered by the test suite on Python 3.9 through 3.14.
Added
- Protocol-agnostic
Hostcontracts with secret-safeHostConfigURI dispatch, lifecycle management, normalized host information, and explicit capability reporting. - Local, SSH, WinRM, Docker Engine container, and QEMU Guest Agent hosts with
buffered command execution and
pathlib_next.Pathfilesystem backends where the transport supports them. WinRM includes a Windows-semantic PowerShell path backend; container and QEMU paths use archive and guest-agent file RPCs. - Explicit and auto-detected shell dialects (POSIX, Bash, Zsh, Fish, CMD, and PowerShell), shared structured quoting, environment/cwd helpers, operators, and persistent SSH/container sessions with optional terminals.
- Raw serial and QEMU serial-console transports with validated UART settings, exclusive process leases, stream lifecycle controls, and explicit non-shell semantics until a console profile is supplied.
- A dependency-free
hostctlcommand with run, path inspection/copy, host-info, and interactive shell subcommands; passwords are accepted only from the environment or a hidden prompt. - Optional native integrations: AsyncSSH/SFTP, pywinrm, Docker SDK, PySerial, pypsrp, and libvirt QGA, each isolated behind a matching package extra.
- Cross-host
pathlib_next.Path.copy()/PathSyncersupport with streaming remote readers, executor-side checksums, fast stat checksums, and an explicit progress-reader recipe. - Transport-independent POSIX, Windows, and IOS host semantics with ordered
executor/path provider selection and capability-safe fallback behavior.
Providers declare per-operation capabilities, so a read-only backend rejects
a mutation explicitly instead of falling through to another provider.
Selection traces record the candidates, probe result, chosen provider,
generation, policy, and pin, with credential-like values redacted. Composite
paths keep their provider collection and optional
.via()pin through/,joinpath,parent/parents,with_name/with_suffix,iterdir,glob/rglob/walk, and open streams. See the "Systems and providers" guide for the no-replay safety rule and provider-authoring contract. - Symbolic-link support on every path backend whose transport provides it.
symlink_to(target, target_is_directory=False)andreadlink()follow thepathlib.Pathsignatures,readlink()reports the stored target verbatim, andstat(follow_symlinks=...)stays consistent withis_symlink(). Local paths delegate toos.symlink, SFTP uses the SSH backend'ssymlink/readlink, WinRM issuesNew-Item -ItemType SymbolicLink(normalizing the Windows elevation/Developer-Mode requirement toPermissionError), and container paths ship aSYMTYPEtar member throughput_archive(). QGA paths raiseNotImplementedErrorbecause the guest agent exposes no symlink RPC. Container reads now follow a symlink member to its target instead of failing, without giving up streaming laziness. - Subprocess-shaped execution options, normalized transport errors, bounded buffered file transfers, and Python 3.9+ typing support (Python 3.14 is the default development interpreter).
Shellis a context manager:with host.shell as session:opens one default session and closes it on exit, the no-argument shorthand forshell.session(). Re-entering a shell whose session is still open raises rather than sharing or leaking a process.Shellcarries defaults.host.shell(cwd=..., env=..., encoding=..., errors=...)returns a shell applying them to every laterrun/sessionthat omits its own value;configure(...)derives a further-configured copy without mutating the original.cwd/encoding/errorsare replaced by a per-call value,envmerges per key so one variable can change without restating the rest, andenv=Nonedeclines the shell's environment while keeping whatever the host itself provides.- Connection URIs may carry credentials.
scheme://user:password@hostis accepted: the password is extracted into the credential arguments and stripped from the parsed authority, so it never reaches a field thatconnection_uriorrepr()renders.redact_uri()removes a password and returns a valid, reusable URI rather than masking it, so a rendered form can never round-trip a wrong credential. parse_credentials()splits a password field on a newline into the password and trailingkey:valueextras, so an OTP or other second factor travels through a single field. A bare name is a flag equivalent toname:. Inside a URI the separator may be written raw — tab, CR, and LF are percent-encoded before parsing, sinceurlsplitdeletes them silently. A control character in the host is rejected, because deletion there rewrites the target.ssh_providers()andwinrm_providers()return an executor/path provider pair sharing one transport, for composing a transport into a host you assemble yourself.strict_uri_credentials,strict_uri_query, anduri_hostare public for configs implementing_from_parsed_uri.- A
HostConfigsubclass may declareuri_credentials; dispatch then rejects any other credential before construction, so a typo fails loudly instead of silently producing a config with no password. - SSH execution now sends EOF for omitted stdin, rejects unsupported zero
buffering, normalizes missing exit statuses to
-1, performs best-effort timeout cleanup (withTimeoutExpired.orphaned), and normalizes persistent process I/O failures. SFTP backends are reused per host and invalidated on close; auto-dialect probes preserve the discovered shell executable. - Container archive paths now accept normal absolute and relative symlink
targets, resolve links with a bounded hop count, preserve hardlink/file
semantics, use Docker's header stat metadata where available, and reject
traversal names without buffering directory archives unnecessarily. Docker
exec streams return available data promptly, detect truncated frames,
preserve merged output ordering, and map missing containers to
ConnectionError. - QEMU Guest Agent transports now share one framed-session implementation with
split-read buffering, parse-error correlation, safe timeout/disconnect
cleanup, and loop-bound SSH writes. QEMU serial consoles and raw serial
processes use incremental text decoding and the common
read(-1)contract (up to 64 KiB available data); serial ownership and QEMU URI/lifecycle edge cases are normalized consistently.
Fixed
run(input=<bytes>, encoding=...)no longer hangs. Handing bytes to a text-modesubprocessstdin killed its writer thread, and the call then blocked forever because the child never saw EOF —timeout=did not fire.inputis now normalized to the stream mode each executor uses, by a helper the local, SSH, and QGA executors share, so the same call behaves identically whichever provider aSystemHostselects.- Composite host paths kept their provider, selector, and pin through
pathlib.PurePathderivations (parents,with_name,with_suffix,relative_to). Those results were previously built without any routing state, which madeglob(),rglob(), andwalk()fail outright. - A path provider that declined before dispatch is remembered for the
connection generation instead of being re-attempted by every later
operation;
invalidate()clears the record along with cached probes. SystemHostserializes its connection bookkeeping under a reentrant lock. Concurrentrun()calls previously raced the check-then-append in_ensure_provider_connected, so every caller repeated the connect round-trip and appended a duplicate entry to_connected_providers, which grew without bound. Provider membership is now tested by identity.- Capability vocabularies agree.
ExecutorCapabilitymembers subclassstr, so the enum published by executors and the strings published by providers and hosts compare equal.Shellpreviously testedExecutorCapability.CWDagainst a set of strings — always false — so a host with nativecwd/envstill hadcd/exportrendered into the script and the native values dropped. SystemConfigno longer fails withAttributeError. It is explicitly abstract:_create_host()raisesTypeErrornaming the concrete configurations, and it no longer advertises asystem://URI thatHostConfigrejected as an unsupported scheme.schemeis a URI-derived property across the wholeHostConfighierarchy. The system configurations shadowed it with a plain string andSystemHostassigned to it; a config-less host now builds its own family configuration instead.