Repository navigation
v0.4.0
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 with
PosixConsoleProfileon 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 passes
default_username="root"instead of writingInclude ~/.ssh/configand
Host */User rootas configuration text. It reaches asyncssh as the
Userof 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, and
tomlibefore 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,PromptConsoleProfileand
LoginStepmove there from the serial transport and drive a
ConsoleStream--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'sread
takestimeout=. -
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(inhostctland
hostctl.provider) replace the dicts in traces andprovider_details.
Host.name/HostConfig.namelabel the target without a credential, and
Host.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 on
host.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 default
ssh/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 raises
ValueErrorlisting 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 withTransportErrorand
ProtocolError(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 from
hostctl.transports.ssh) is~/.ssh/config, found when the connection
opens, a missing file reading nothing, as withssh;NoneorFalse
reads 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 in
repr()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's
Port, else 22, asusername=Noneleaves the user.SshConfig(connect_options=)passes extra keywords to
asyncssh.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 of
run(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_readand
timeout_writeon every scheme, empty forNone. This ships in 0.4.0. -
SSH connections are bounded while they establish:
SSHConfig's
timeout.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 declares
manages_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, and
RunspaceSession.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 no
ReadlinkorSymlinkand 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 the
hostctl.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/APPEND
together 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": aHostConfigsubclass 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 a
stat_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 execs
touchas 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 optional
utime(path, *, atime=None, mtime=None). -
CompositePosixPath.over(providers, *segments, backend=None)and
CompositeWindowsPath.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,StreamingExecutorand
ExecutorRequest: 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 a
TimeoutExpiredfromexpired()carrying the output captured so far. An
OperationNotStartedErrorraised after the command was sent becomes a
ConnectionError, 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 a
qga+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 raises
FileNotFoundErrorbefore connecting. The native WinRM leg trusts only the
Windows certificate store and refuses a bundle. A container'sverify
applies 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 as
TLSConfig(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()or
connection_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 raises
hostctl.exceptions.MissingExtraError(extra, feature)(.extra,
.feature, and.pythonwhen the extra cannot be installed on this
interpreter), aHostctlErrorand anImportError, so an existing
except ImportErrorstill catches it. The messages now share one form,
FEATURE needs the 'EXTRA' extra: pip install "hostctl[EXTRA]".hostctl
exits 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-pand
HOSTCTL_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 what
SelectionStep.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=)is
timeout=Timeout(read=)andSerialConfig(read_timeout=, write_timeout=)
istimeout=Timeout(read=, write=); their URI keys
(?agent_timeout=,?read_timeout=,?write_timeout=) raise naming
timeout_read/timeout_write. A number given astimeout=is the total
only.run(timeout=None)is no limit for that call on every host, and
host.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=0is
Timeout(read=0), the one transport where a zero is accepted.
See "Migrating from 0.3". -
Breaking, 0.4.0:
WinRMConfighonours two timeouts,totaland
connect. 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 raises
TimeoutExpired(orphaned=True, after asking the server to stop it)
where it used to finish. Passtimeout=Nonefor no default, or
run(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 an
except ConnectionErrorretry around a refused SSH port now catches it.
Anexcept RuntimeErrorwritten for it no longer does. The QGA errors
are renamed (QGA*),GuestAgentProtocolErroris folded into
QGAProtocolError, 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 its
UserKnownHostsFile. 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_uriomits
root@. -
Breaking, 0.4.0:
connect()andwith host:raiseOperationNotStarted
when 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.Bor
ProgramFiles(x86)before, so the same call was valid or not depending on
which provider won.validate_environment_namesis exported by
hostctl.executor. -
Breaking, 0.4.0:
Inputno longer includesint.input=3reached
subprocess' writer thread and hung untiltimeouton Python 3.14; it now
raisesTypeErrorat once. -
Breaking, 0.4.0:
Shellis not anExecutor.Shell.execute()and
shell(command, *args)are removed; calling aShellconfigures it
(host.shell(cwd=..., env=...)) and a positional argument raises
TypeError. 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=)is
script(epilogue=), with the sense inverted (for_session=Trueis
epilogue=False).terminate_submissionis unchanged here. -
Breaking, 0.4.0: a structured
cmdcallraisesValueError.call
re-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-Name
inference. 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 replaces
pathlib_next;pathlib-nextwas renamedpathapi).HostPathis
pathapi.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 an
sftppath.SFTPPath, and itsstr()is the path on the server where it
wassftp://host:22/.... - pathapi's own changes reach hostctl's paths:
touch(mode=...)raises
NotImplementedErroron 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.
- The
-
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"))renders
winrm://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; set
NETIMPS_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 ACLPermissionDeniedis a status rather than
PermissionError. 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's
ToString()joined with LF. -
SystemConfig(PosixConfig,WindowsConfig,IosConfig) takes only
provider_options=andinitializer=beyondshell=,executor=and
path=, which ships in 0.4.0. Any other keyword was kept inoptions
without complaint, soexecutr=("local",)configured nothing; it now raises
TypeError.executor="local"(a bare string) raisesTypeErrorwhere it
became five one-letter descriptors. ASystemHostbuilt from a connection
string or anything but aHostConfigraisesTypeError. -
ProviderSelector.select()losespolicy=,ProviderSelectionloses
policy, andProviderSelector.last_selectionis gone, which ships in
0.4.0. Nothing varied the policy and nothing but one test read
last_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 guards
copy(),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 raise
TypeErrornaming 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 under
verify=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 connectionrun()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 an
https://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'sssl
loads, 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()and
submission_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()and
CompositeWindowsPath.from_path(). Useover(providers, *segments, backend=name).
Fixed
- A PSRP
run(..., text=True)that times out carries""in
TimeoutExpired.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 returned
b""from a Windows server and a bareOSError("Failure")from Linux. SSHConfig(timeout=Timeout(connect=None))round-trips through
connection_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 an
SshConfigto a checker, which flags a misspelled keyword, and
HostConfig(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 unregistered
psrp://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 inCalledProcessErroror
TimeoutExpiredtext,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. fish
set -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-extra
ImportError. 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
ValueError
at construction; such a host could inject options into a ProxyCommand. spawn()over SSH escapes the command line for acmdlogin shell, as
run()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
/(or
C:\) 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.orphaned
is true unless the command was seen to end.- Output stays in order around a dispatched command. The binary fallback
ofdispatch_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;, and
script()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. AShell
overLocalExecutor(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 raisesNotADirectoryError
instead 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 raises
OSError(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 thirdopen()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 raisedUnicodeDecodeErroror 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; onlyopen("x")writes a temporary file and renames it. - A POSIX QGA
rename()onto an existing target replaces it, asos.rename
does, and a skippedmv -nno longer reports success without moving;
a Windows guest raisesFileExistsError. iterdir()of a dash-leading relative path (-delete) no longer runs
findwith 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_bytesPermissionErrorcarries
errnoandfilename. - 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), andopen("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's
structured_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 raised
NameError; 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, which
PathSyncerneeds to carry mtimes across: a secondstat_checksumsync into
an SSH host copies nothing instead of everything.supports()answers
Utime,Hardlink,Owned,Resolve,NativeChecksumandChownfrom
the backend path class, so it no longer claims a digestLocalPathlacks or
achowna WinRM or container path cannot do.PathProviderdeclares
utimeandhardlink_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 in
connection_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-FileHash
the 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 raises
RuntimeErrorfrom 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&, where
C:\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 encoded
serial:///%2Fdev%2FttyUSB0is still/dev/ttyUSB0,serial:ttyUSB0is a
relative device, andserial:////dev/ttyUSB0raisesValueErrornaming the
three-slash spelling.str(SerialConfig("/dev/ttyUSB0"))renders
serial:///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(usePWSHor
Exec) 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; a
CommandLineToArgvWconsumer 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 raisesValueError
when the program path contains"or%.
- Breaking, 0.4.0: an argument to a program of unknown type (a bare
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.
Full Changelog: v0.3.2...v0.4.0