Skip to content

v0.4.0

Choose a tag to compare

@github-actions github-actions released this 18 Oct 18:11

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 a StreamingExecutor that frames each command with
    PosixConsoleProfile on the ConsoleStream the factory returns: capture
    or live stdout, input=/stdin=, the text rule, check, and a timeout
    that types ^C and raises TimeoutExpired(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 an ExecutorProvider no longer needs a worker
    thread per command around execute().

  • SSHConfig(default_username=): a login the ssh_config can still
    override.
    The account when neither username (nor the URI's user) nor
    the ssh_config's User names one, in place of the local user's: a
    library whose targets have a known administrative login passes
    default_username="root" instead of writing Include ~/.ssh/config and
    Host * / User root as configuration text. It reaches asyncssh as the
    User of a configuration file read after ssh_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 most batch typed 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=None types 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 raises ValueError.

  • hostctl.deploy: install a Python program on a host that cannot install
    it itself
    , behind a new deploy extra (packaging, pathspec, and
    tomli before Python 3.11; included in dev). 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 through host.run()
    and host.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, SourceTree ships a working
    tree filtered by its ignore files, and Deployment.command is the argv
    that runs the result. POSIX targets only. A bundle that cannot be built
    raises the new BundleError (HostctlError, RuntimeError), exported
    from hostctl. 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
    of hostctl.deploy (--mode, --package, --extra, --skip,
    --force, --python, and --tree with --ignore-file,
    --ignore-pattern, --pythonpath). Arguments after -- run the
    installed program and its status becomes the command's. Served over MCP
    like every subcommand. A BundleError and 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 run and hostctl info on 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. Each run output line starts with [URI] ,
    info prints one {"target": URI, "result": {...}} line per host, a
    failing host gets a hostctl: [URI] exit status N line, 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 (uri stays a string; further hosts are more).

  • hostctl.console: console profiles over any byte stream, in 0.4.0.
    ConsoleProfile, RawConsoleProfile, PromptConsoleProfile and
    LoginStep move there from the serial transport and drive a
    ConsoleStream -- read(size, *, timeout=) and write(data) -- instead
    of a SerialProcess, with unchanged behaviour. console_stream(process)
    adapts a process whose read blocks (a QEMU console from open_serial(), a
    container or SSH spawn(terminal=True)) by reading it on a thread, so a
    profile can drive those consoles too. A serial spawn() session's read
    takes timeout=.

  • 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 accepts input= and a stdin= 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 PosixConsoleProfile speaks POSIX shell, in 0.4.0.
    PosixConsoleProfile(shell="posix") names the console shell's flavour
    ("bash", "zsh" or any PosixShellFlavour; fish, cmd and PowerShell
    raise ValueError) as shell_flavour, and SerialHost reads it:
    host.shell_flavour returns it, host.shell is a Shell, and run()
    quotes structured argv and a sole Exec with that flavour, as on any
    other POSIX host. cwd= and env= are embedded and run in a subshell, so
    they never persist in the console's shell; CompletedProcess.args masks
    the env values. With this profile several commands (and ShellOperators
    between them) are one script run as one exchange, and input= is its
    stdin. RawConsoleProfile and PromptConsoleProfile declare no flavour
    and keep refusing argv, Exec, cwd= and env=.

  • Typed selection records, a host name and an audible fallback, in
    0.4.0. SelectionStep and ProviderDetail (in hostctl and
    hostctl.provider) replace the dicts in traces and provider_details.
    Host.name/HostConfig.name label the target without a credential, and
    Host.logger (settable) prefixes it to each record and sets record.host.
    When run() 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 SystemHost subclass
    declares its own descriptors in provider_resolvers (name ->
    resolver(host)), merged up the MRO ahead of register_system_provider's
    global ones; mapping a name to None hides it. default_providers() names
    the descriptors used when the config names none, and drops a default
    ssh/sftp when the ssh extra is missing (a name the config gives is
    kept, and fails at use with the install hint). resolve_providers() turns
    names into providers, for replace_providers(); an unknown name raises
    ValueError listing the names the host knows. PosixHost(config) built
    directly now resolves the config's descriptors, which it used to ignore.

  • hostctl.transports.ssh.SSHExecutorProvider is public, with
    .connection (the asyncssh connection run() uses, opened on first
    access) and .loop (the hostctl-asyncssh-loop event loop it is bound
    to; run its coroutines with asyncio.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.HostctlError is the base of every exception hostctl raises, in
    the new hostctl.exceptions module with TransportError and
    ProtocolError (both ConnectionErrors) as categories; every class is
    re-exported from hostctl. This ships in 0.4.0.

  • hostctl.has_ssh() reports whether the ssh extra (asyncssh and sftppath)
    is installed, and has_psrp() the same for psrp, 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 with ssh; None or False
    reads none; an os.PathLike names a file; a str, bytes, a stream or an
    object with read_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() or connection_uri. A str is never a file name (a line with a
    keyword and no value raises ValueError naming pathlib.Path). It is a
    keyword only, never a URI key. hostctl <command> --ssh-config PATH (and
    the ssh_config tool parameter) names the file, --ssh-config '' none.
    SSHConfig(port=None), the default, leaves the port to the ssh_config's
    Port, else 22, as username=None leaves the user. SshConfig(connect_options=) passes extra keywords to
    asyncssh.connect on both legs, and SshConfig(path_flavor="auto") asks the
    server's login directory while the host is created.

  • hostctl.Timeout(total, *, connect, read, write) and hostctl.DEFAULT:
    every config (local, SSH, WinRM, container, QEMU, serial and the system
    configs) takes timeout=, a number or None (the total, the default of
    run(timeout=)) or a Timeout, and stores a Timeout.
    config.timeouts shows what each DEFAULT resolves to; a field the
    transport cannot honour raises ValueError naming the config and field.
    The URI keys are timeout, timeout_connect, timeout_read and
    timeout_write on every scheme, empty for None. This ships in 0.4.0.

  • SSH connections are bounded while they establish: SSHConfig's
    timeout.connect defaults to 30 s (URI ?timeout_connect=; None keeps
    asyncssh's default).

  • WinRMConfig's timeout.connect (default 30 s) bounds establishing the
    connection with the same meaning: -OpenTimeout on the native leg,
    pypsrp's connection_timeout on PSRP, and the connect half of each HTTP
    request's timeout on pywinrm (None keeps each library's default). An
    expiry raises ConnectionError. 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 in hostctl) lists the
    names shell_flavour() accepts, plugin flavours included.

  • run(timeout=) on every WinRM leg, with the config's total (30 s; URI
    ?timeout=, empty for None) 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 no Create, Chmod, Remove or Move, and a QGA path has no
    Readlink or Symlink and answers its metadata and namespace protocols
    from the guest helper.

  • HOSTCTL_MCP=stdio serves every hostctl subcommand as an MCP tool over
    stdio. --ask-password is refused there, since a tool call cannot prompt;
    use HOSTCTL_PASSWORD.

  • hostctl --version, and -v/-q/--loglevel on 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 the serial:/// URI.

  • QemuHost.open_serial(encoding=, errors=) (and QemuSerialConsole.open)
    return a console that reads and writes str.

  • ContainerConfig(path_flavor="posix"), "windows" and SshConfig(path_flavor= "windows") take the strings QemuConfig already 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() and system_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_providers entry point of that name.

  • SystemHost.executor_providers and path_providers (read-only tuples, until
    now only on LocalHost) and SystemHost.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, beside ssh_providers() and winrm_providers(), for a host you
    assemble yourself. posix://...?executor=local now builds the same
    providers.

  • ShellFlavour.redirect(value, target, operand) renders a REDIRECT/APPEND
    together with its target, for a flavour whose spelling depends on the
    target; the default is operator(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 HostConfig subclass with its own scheme
    composing a PosixHost subclass 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), so PathSyncer carries the source's mtime across and a
    stat_checksum sync 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 uses touch -c -d @EPOCH
    (nanoseconds on GNU, whole seconds on busybox); a container path execs
    touch as uid 0 or powershell.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. GuestPathHelper gains 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 a ProviderSelector. host.path() calls
    the same constructor, so per-operation fallback, .via() pinning,
    supports(), forwarding and backend= behave alike. This ships in 0.4.0.

  • hostctl.executor.BufferedExecutor, StreamingExecutor and
    ExecutorRequest: base classes for an executor written outside hostctl.
    A transport implements _exchange(request), or _send/_receive (and
    _abandon when it can stop a command), and inherits the subprocess-shaped
    call every built-in executor shares: option and capability refusal,
    input=/stdin= as bytes, the text/encoding/errors rule,
    capture and stream routing (streamed output written as it arrives),
    check=True, the executor's own default for an omitted timeout, and a
    TimeoutExpired from expired() carrying the output captured so far. An
    OperationNotStartedError raised after the command was sent becomes a
    ConnectionError, so it is never replayed through another provider. The
    container and PSRP executors are built on BufferedExecutor. 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.
    WinRMConfig and ContainerConfig take True, False or a CA bundle
    path (a PEM file or an OpenSSL hashed directory, str or os.PathLike)
    trusted instead of the transport's default; SSHConfig (and so a
    qga+ssh tunnel) takes True or False. The URI key is verify=
    (true/false, or a percent-encoded path), and it round-trips. A bundle
    reaches pywinrm as ca_trust_path and pypsrp as cert_validation, and
    neither certifi nor REQUESTS_CA_BUNDLE replaces it; a missing one raises
    FileNotFoundError before connecting. The native WinRM leg trusts only the
    Windows certificate store and refuses a bundle. A container's verify
    applies to an https:// engine_url hostctl builds the client for, and is
    pinned against requests' REQUESTS_CA_BUNDLE override. This ships in
    0.4.0.

  • ContainerConfig(client_cert=(certificate, key)): a client certificate
    for a Docker Engine over TLS.
    A daemon run with --tlsverify requires
    one, which only a client_factory could supply. The pair of PEM paths
    (one file holding both is passed twice) reaches docker-py as
    TLSConfig(client_cert=..., verify=...) with verify resolved as before;
    it needs an https:// engine_url and no client_factory (ValueError),
    is accepted beside a URI as a credential, is never in repr() or
    connection_uri, and a missing file raises FileNotFoundError before
    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 .python when the extra cannot be installed on this
    interpreter), a HostctlError and an ImportError, so an existing
    except ImportError still catches it. The messages now share one form,
    FEATURE needs the 'EXTRA' extra: pip install "hostctl[EXTRA]". hostctl
    exits 125 with hostctl: <that message> where it printed a traceback
    ending in the hint; an ImportError raised 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=) and Host(uri, ...) use one URI
    with other credentials without rewriting it: password= replaces the
    URI's whole password field ("" means no password), and username=
    replaces the URI's user on ssh, winrm/winrms and qga+ssh
    (elsewhere ValueError); connection_uri shows the user in effect and
    never a password.

  • A callable password= on SSHConfig and WinRMConfig, 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, and provider="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/--username and -p/--password,
    which replace the URI's user and password (-p '': no password, no
    prompt). Without -p the password is HOSTCTL_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 -p and
    HOSTCTL_PASSWORD. Over MCP username and password are 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.reason and the run() fell back from ssh to ... WARNING
    show, where every cause read SSH connection failed before dispatch.

  • Breaking, 0.4.0: every config spells its timeouts as one
    timeout=Timeout(...).
    QemuConfig(agent_timeout=) is
    timeout=Timeout(read=) and SerialConfig(read_timeout=, write_timeout=)
    is timeout=Timeout(read=, write=); their URI keys
    (?agent_timeout=, ?read_timeout=, ?write_timeout=) raise naming
    timeout_read/timeout_write. A number given as timeout= 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-out timeout is the
    config's total. A QEMU guest-agent reply may now be unbounded
    (Timeout(read=None)); serial's non-blocking read_timeout=0 is
    Timeout(read=0), the one transport where a zero is accepted.
    See "Migrating from 0.3".

  • Breaking, 0.4.0: WinRMConfig honours two timeouts, total and
    connect.
    The total, 30 s, is the default for run(timeout=) on every
    WinRM leg. The native leg used read_timeout_sec as each command's total
    deadline; it now uses the total, and timeout.connect (default 30 s)
    bounds opening the remote session (New-PSSessionOption -OpenTimeout).
    pywinrm and PSRP had no deadline and refused run(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. Pass timeout=None for 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 to name. Before, the named provider only built the
    path and each operation re-selected from the first capable provider, so a
    write meant for name could land on another one.

  • Logger names follow module paths under hostctl, which ships in
    0.4.0: every module logs through logging.getLogger(__name__)
    (hostctl.transports.ssh._transport, hostctl.provider._contracts, ...).
    Configure the hostctl logger; the child names are not API.

  • Breaking, 0.4.0: OperationNotStartedError is a ConnectionError, not
    a RuntimeError.
    It is raised only before anything was sent, so an
    except ConnectionError retry around a refused SSH port now catches it.
    An except RuntimeError written for it no longer does. The QGA errors
    are renamed (QGA*), GuestAgentProtocolError is folded into
    QGAProtocolError, and the exceptions leave hostctl.provider,
    hostctl.executor and hostctl.serial for hostctl.exceptions; see
    "Migrating from 0.3".

  • Breaking, 0.4.0: one keyword vocabulary. Every config takes shell=
    and pathname= (hostctl.host.ShellSelection/PathnameSelection), its
    target positionally and everything else by keyword, and the URI query keys
    follow the keywords; an old key raises unknown connection parameter. WinRM
    takes auth=, tls=, verify=True|False and float-second timeouts, the
    container username=, the serial config device= and profile=. See
    "Migrating from 0.3".

  • Breaking, 0.4.0: module paths below hostctl moved. Each transport is
    one package under hostctl.transports, the contracts are hostctl.host,
    hostctl.executor, hostctl.process, hostctl.provider, hostctl.system,
    hostctl.paths and hostctl.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's input-data,
    instead of a base64 -EncodedCommand argument. 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, as ssh does. The exec
    leg did, by asyncssh's default, while the SFTP leg did not, so an alias
    reached the server through run() and failed through path(); they share
    one connection and one configuration now. known_hosts=() follows its
    UserKnownHostsFile. Pass ssh_config=None for 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 to None, the local
    account, as ssh does, instead of "root"; pass username="root" to keep
    the old behaviour. ssh://host carries no user, and connection_uri omits
    root@.

  • Breaking, 0.4.0: connect() and with host: raise OperationNotStarted
    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, as run()'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 -Command where 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 -EncodedCommand only as the last resort.
    ShellCommand gains an optional stdin.

  • Breaking, 0.4.0: ShellFlavour.command() takes login_shell=None; a
    custom flavour must accept it to be used over SSH. The POSIX command()
    puts cwd inside the script instead of a leading cd ... &&, and joins
    with a quoting that sh-family shells and fish read identically.

  • The config extension hooks _from_parsed_uri and _create_host are documented
    as the override points for a HostConfig subclass, with a worked example in
    the extending guide. Their names are unchanged and no from_uri is added.

  • Breaking, 0.4.0: an environment variable name outside
    [A-Za-z_][A-Za-z0-9_]* raises ValueError on every transport
    , before a
    provider is selected. Only the embedding transports refused A.B or
    ProgramFiles(x86) before, so the same call was valid or not depending on
    which provider won. validate_environment_names is exported by
    hostctl.executor.

  • Breaking, 0.4.0: Input no longer includes int. input=3 reached
    subprocess' writer thread and hung until timeout on Python 3.14; it now
    raises TypeError at once.

  • Breaking, 0.4.0: Shell is not an Executor. Shell.execute() and
    shell(command, *args) are removed; calling a Shell configures it
    (host.shell(cwd=..., env=...)) and a positional argument raises
    TypeError. Run a program with argv through host.executor(command, *args)
    or host.run(Exec(command, *args)). A shell's flavour, cwd, env,
    encoding and errors are read-only, and the host.shell proxy is gone.
    A Shell over a host is that host's run; one over a bare executor sends
    the flavour's invocation as argv when the executor declares args.

  • Breaking, 0.4.0: ShellFlavour.script(for_session=) is
    script(epilogue=)
    , with the sense inverted (for_session=True is
    epilogue=False). terminate_submission is unchanged here.

  • Breaking, 0.4.0: a structured cmd call raises ValueError. call
    re-expands % and doubles ^ in its arguments, so no quoting survives it;
    run the program directly. call and start are no longer treated as
    builtins.

  • Breaking, 0.4.0: Cmdlet and PSParam replace the PowerShell -Name
    inference.
    A plain string in a structured command is always a value; a
    string shaped like -LiteralPath was rendered bare, which hid a real
    argument from a native program and let a program-shaped value read as a
    parameter. Write Cmdlet("Remove-Item", PSParam("-LiteralPath"), path).
    Both are exported by hostctl, hostctl.host and hostctl.shell, are
    non-iterable like Exec, and raise TypeError for any other shell.
    ShellTarget gains BATCH and UNRESOLVED.

  • The hostctl command needs the cli extra (pip install "hostctl[cli]", which adds duho>=0.7.5,<0.8). Without it the console
    script prints one line naming the extra and exits non-zero. The command
    line moved from hostctl._cli to hostctl.cli; hostctl.cli.main(argv)
    no longer takes stdout=/stderr= and writes to sys.stdout/sys.stderr.
    Subcommands, operands and exit statuses are unchanged, except that the
    -- before run's program is now required.

  • Paths are pathapi paths (pathapi>=0.1.2,<0.2, which replaces
    pathlib_next; pathlib-next was renamed pathapi). HostPath is
    pathapi.Path, ExecutorCommand takes a pathapi.Pathname, and a path
    provider must return a pathapi.Path; one that returns anything else fails
    with TypeError: path provider 'X' returned LocalPath, expected pathapi.Path. local_providers() is the local pair to use instead of
    building one. Change pathlib_next imports as
    pathapi's changelog says: pathlib_next.utils.sync is pathapi.sync,
    pathlib_next.utils.stat.FileStat is pathapi.FileStat.

    • The ssh extra is asyncssh>=2.20,<3 and sftppath[asyncssh]>=0.1.3,<0.2,
      in place of pathlib_next[sftp-async]. An SSH host's path is an
      sftppath.SFTPPath, and its str() is the path on the server where it
      was sftp://host:22/....
    • pathapi's own changes reach hostctl's paths: touch(mode=...) raises
      NotImplementedError on a path that cannot chmod(), and a path class
      without __fspath__ is no longer os.PathLike.
    • A WinRM, Docker archive or QGA write stream whose with block raises
      still uploads nothing, and no longer also emits a ResourceWarning; one
      collected unclosed still warns. The stream is pathapi's DiscardOnError.
    • pathlib.Path(src).copy(host_path) onto an existing directory of a
      WinRM, Docker archive or QGA host raises FileExistsError, 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, wsmans and psrp now have default ports
      (5985, 5986, 5985, 5986, 5985) where ConnectionString.port was None,
      so str(ConnectionString("winrm://admin@host")) renders
      winrm://admin@host:5985. They are the ports WinRMConfig already chose.
    • An all-digit scheme given through scheme=, defaults= or replace() is
      read as a port, and one outside 0-65535 raises ValueError where it gave
      an object with no port.
    • netimps 0.4 refuses to import when NETIMPS_NO_SOCKET_PATCH is set; set
      NETIMPS_SOCKET_PATCH=0 instead. import hostctl does 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's socket patch on Windows.
  • The sdist no longer ships the contributor AGENTS.md or benchmarks/, and
    neither artifact ships a CLAUDE* file.

  • Extras ranges follow the dependency rule: container accepts docker 7.0
    (was 7.1), serial requires pyserial 3.3 (was 3.5), psrp is limited to
    pypsrp 0.9.x (was <1), and qemu-libvirt accepts 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.exe directly 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 new provider="native" (or "auto") for that. An explicit
    "pywinrm" or "psrp" without a password now needs transport="kerberos".
    str(WinRMConfig) omits an unset port, so HostConfig(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 PermissionDenied is 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= and initializer= beyond shell=, executor= and
    path=
    , which ships in 0.4.0. Any other keyword was kept in options
    without complaint, so executr=("local",) configured nothing; it now raises
    TypeError. executor="local" (a bare string) raises TypeError where it
    became five one-letter descriptors. A SystemHost built from a connection
    string or anything but a HostConfig raises TypeError.

  • ProviderSelector.select() loses policy=, ProviderSelection loses
    policy, and ProviderSelector.last_selection is 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
    HostConfig subclass claiming a scheme that another live config holds
    raises ValueError where the class is defined (it used to fail later as
    "ambiguous host URI"); register_system_provider(name, resolver) raises for
    a name already registered. Pass replace=True to 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() and PathSyncer against 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. pickle and copy.deepcopy() of a composite raise
    TypeError naming 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=None raises ValueError naming it, SSHKnownHosts no longer
    admits None. A known_hosts the caller names is checked even under
    verify=False, and the ssh_config is honoured as written.
    Breaking, 0.4.0.

  • WinRMConfig.verify is bool | str | os.PathLike, where it was a
    bool: a string is a CA bundle path, needs tls=True, and one spelling a
    boolean ("false") raises ValueError. Native WinRM's verify=False adds
    -SkipRevocationCheck to -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 the hostctl-asyncssh-loop thread,
    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 before close() runs on the next connection. ssh_config,
    connect_options and timeout=Timeout(connect=...) therefore apply once,
    to that one connection. The ssh extra needs sftppath[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=True trusts the operating system's store on pywinrm, PSRP and an
    https:// Docker engine
    , as native WinRM does, where it trusted certifi.
    REQUESTS_CA_BUNDLE or CURL_CA_BUNDLE, when set, is still the only trust
    (a missing file it names raises FileNotFoundError naming the variable);
    a verify= bundle and verify=False behave 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 through truststore:
    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; ssl is not patched for the process. On
    Python 3.9, or without truststore, the store is what Python's ssl
    loads, which on Windows is the roots already present and on macOS not the
    keychain (see the contracts ledger). The winrm, psrp, container,
    winrm-credssp and winrm-kerberos extras need requests>=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) and WindowsHost.from_winrm(config), which
    ship removed in 0.4.0. Each was config._create_host() plus a check that
    could not fail for a config that picked its own paths. Use config.open()
    or Host(uri); SshConfig("host").open() is a PosixHost, and a config with
    Windows paths opens a WindowsHost.
  • Breaking, 0.4.0: the undocumented CompositePosixPath.from_path() and
    CompositeWindowsPath.from_path(). Use over(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 carried b"".
  • Reading a directory as a file over SSH raises IsADirectoryError against
    OpenSSH too (sftppath 0.1.1, now the floor of the ssh extra); it returned
    b"" from a Windows server and a bare OSError("Failure") from Linux.
  • SSHConfig(timeout=Timeout(connect=None)) round-trips through
    connection_uri (an empty timeout_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_WINDOW unless the caller passes creationflags=, and so does
    the native WinRM leg for its local powershell.exe relay.
  • WinRM, container and QEMU paths keep their backend through with_name(),
    with_stem(), with_suffix(), relative_to() and parents on Python 3.9 to
    3.11, where pathlib built them without one; an overwriting copy onto such a
    path failed with AttributeError: _backend under 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's repr() no longer embeds <... object at 0x...>. This ships in
    0.4.0.
  • Config and host constructors report their real parameters to type
    checkers, help() and inspect.signature(): SshConfig("h") is an
    SshConfig to a checker, which flags a misspelled keyword, and
    HostConfig(uri)/Host(uri) show (connection_string, /, **secrets).
    This ships in 0.4.0.
  • hostctl ls escapes 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/cp on an unreachable host exit 125 with one line
    instead of a traceback and status 1. hostctl info exits 125 (126 on an
    authentication refusal) instead of printing an identity made up from the
    URI with status 0. hostctl --help no longer lists the unregistered
    psrp:// scheme; PSRP is winrm://...?provider=psrp.
  • hostctl cp no longer splits ssh://user:password@host at the password's
    colon, and no operand error echoes a password. HOSTCTL_PASSWORD no
    longer makes every qemu+libvirt:/qga+unix: command exit 125.
  • A URI whose password holds an unencoded /, ? or # no longer has part
    of the password echoed in the ValueError from HostConfig(uri),
    Host(uri) or ConnectionString(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 in CalledProcessError or
    TimeoutExpired text, repr(), CompletedProcess.args or 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 -gx and 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 sftppath no longer surfaces as a bare ModuleNotFoundError: the
    SFTP provider probes unavailable and path() raises the ssh-extra
    ImportError. The libvirt QGA message names pip 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:0 raises the port ValueError instead 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 a cmd login shell, as
    run() does; an argument such as a&b ran as a second command (command
    injection).
  • A refused SSH exec request (the server declined to start the command) is an
    OSError rather than a ConnectionError, 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:\) while run() 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, and TimeoutExpired.orphaned
    is true unless the command was seen to end.
  • Output stays in order around a dispatched command. The binary fallback
    of dispatch_output wrote past text still pending in sys.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 cmd echo prints its operand verbatim: ("echo", "off")
    switched echo off, ("echo", "/?") printed help and ("echo",) printed
    the echo state.
  • A relative cwd means the process's directory on POSIX and fish. cd -- sub consulted CDPATH first, so with CDPATH set the payload ran in
    another tree with status 0. It is rendered cd -- ./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 delimiter EOF into EOF;, and
    script() joined EOF;echo next; after multi-line raw source the separator
    is now a newline (ShellFlavour.multiline_source), and a fish session no
    longer sends echo a |;.
  • fish quotes a word containing %; a bare %self became 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 what host.run() does. A Shell
    over LocalExecutor (or any executor declaring args) runs, where it
    raised FileNotFoundError, and host.shell(env=...) reaches the command.
    SystemHost.executor(command, *args) raised NameError; 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 cmd redirect to a path with a space writes that file. The operand
    after REDIRECT/APPEND was 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 cmd start always gets an empty window title, so
    ("start", "/b", path) starts the program instead of titling a window.
  • A local Exec of a .bat/.cmd in a directory whose path has a space no
    longer fails with FileNotFoundError, and a cmd structured 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 raises NotADirectoryError
    instead of listing nothing: the guest helper reads find's record for the
    starting point, and follows it with -H when it is a link.
  • move() between two WinRM hosts, or two QGA hosts, copies and deletes
    instead of raising ValueError; a direct rename() 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 ..\x could land outside
    the destination.
  • hostctl cp keeps a transport's native copy instead of bypassing the
    source path's own copy().
  • 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
    raise ConnectionError and its close() 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 UnicodeDecodeError or 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), and SystemHost.run carries the
    same annotations as Host.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, as os.rename
    does, and a skipped mv -n no longer reports success without moving;
    a Windows guest raises FileExistsError.
  • iterdir() of a dash-leading relative path (-delete) no longer runs
    find with it as an expression: the starting point is anchored with ./.
  • QGA exists() below a regular file returns False; a guest-file-read reply
    longer than requested and a deeply nested reply raise QgaProtocolError; a
    listing skips names that are not one path component; a helper-less QGA path
    provider declares exists.
  • Container overwrites keep the file's owner, group and mode instead of
    re-owning it to root; exists() and open("x"/"a"/"r+") read one archive
    header instead of downloading the whole subtree, and an engine error is no
    longer reported as FileExistsError; write_bytes PermissionError carries
    errno and filename.
  • 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 raises OSError with ENOTEMPTY, and scandir of 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 -LiteralPath as 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
    ConnectionError instead of returning its status, so check=False could
    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, resolve and a relative absolute() to its backend, which
    PathSyncer needs to carry mtimes across: a second stat_checksum sync into
    an SSH host copies nothing instead of everything. supports() answers
    Utime, Hardlink, Owned, Resolve, NativeChecksum and Chown from
    the backend path class, so it no longer claims a digest LocalPath lacks or
    a chown a WinRM or container path cannot do. PathProvider declares
    utime and hardlink_to by default.
  • Concurrent run() and close() on one host no longer raise RuntimeError
    ("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") and ConnectionString("10.1.1.1:22") with no
    scheme raise "no scheme" instead of reading nas as the scheme, which left
    the host empty and is_local true for a remote machine (Python 3.9 did the
    same for the address form). With scheme= or defaults= they parse as host
    and port. The ConnectionString docstring and the API header said a bare host
    parses without a scheme; they now say it needs one.
  • SshConfig, WinRMConfig, ContainerConfig and 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_uri and repr. A percent-encoded C1 control character
    (%C2%9B) in a system URI's host or userinfo is refused like the C0 ones,
    instead of reaching authority and info().hostname decoded.
  • A provider's probe narrows its declared capabilities in every reader.
    select(capability=), host.capabilities, executor_capabilities,
    provider_details and a composed path's dispatch each read a different
    union of the two, so a probe that narrowed spawn away 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 is OperationNotStarted and is cached, where a
    raw transport error escaped and the probe re-ran on every call.
  • host_checksum on a Windows SSH target hands certutil and Get-FileHash
    the file the transfer wrote, as C:\data\file.txt: certutil read the SFTP
    spelling /C:/data/file.txt as 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.configs plugin 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
    RuntimeError from 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 ::1 is no longer reported as :), and ConnectionString.is_local,
    which now also answers *.localhost, short IPv4 spellings and this machine's
    own name. import hostctl still does not import netimps.
  • Windows PowerShell 5 writes a REDIRECT/APPEND to 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.cmd ran C:\x\a\t.cmd if that existed. A relative path cannot
    be spelled literally on 5; see the divergence ledger.
  • Captured pwsh 7 output is plain text: PWSH scripts set
    $PSStyle.OutputRendering = 'PlainText', where a TERM naming 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' for a).
  • A serial: URI reads its device the way a file: URI reads its path:
    serial:///dev/ttyUSB0 and serial:/dev/ttyUSB0 open /dev/ttyUSB0, where
    they opened dev/ttyUSB0 relative to the working directory (the CLI help's
    own example did). A Windows port and a pyserial URL keep no slash
    (serial:///COM3 is COM3, serial:///loop:// is loop://), the encoded
    serial:///%2Fdev%2FttyUSB0 is still /dev/ttyUSB0, serial:ttyUSB0 is a
    relative device, and serial:////dev/ttyUSB0 raises ValueError naming 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.
  • LocalConfig and 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_options and initializer are 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 as report" 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 raises ValueError (use PWSH or
    Exec) instead of arriving split.
  • A batch file never executes an argument. Exec(shim_cmd, 'x"&echo ...'),
    a structured command to a .bat/.cmd under cmd, powershell or pwsh,
    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
    CommandLineToArgvW consumer 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) raises ValueError when it holds " (cmd) or any of
      " & | < > (PowerShell), since the program may be a batch file. Name the
      program with its suffix, use Cmdlet for a cmdlet, or use Exec.
    • Breaking, 0.4.0: a local Exec of a batch file raises ValueError
      when the program path contains " or %.
  • WinRMConfig refuses transport basic, plaintext or ssl over plain
    HTTP
    unless cleartext_auth=True (URI query cleartext_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 --% raises ValueError. 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=None switched host-key verification off; it now raises
    and names verify=False (the ssh_config still decides for itself). A
    container's CA bundle holds whatever REQUESTS_CA_BUNDLE says: 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