Skip to content

API Reference

The names below are the public exports from hostctl.__all__ — everything a caller has to be able to write: a host or config to construct, an exception to catch, a type to annotate with, an argument to pass, or a contract to implement.

Each name also has a public home module: hostctl.host, hostctl.exceptions, hostctl.uri, hostctl.system, hostctl.provider, hostctl.executor, hostctl.process, hostctl.paths, hostctl.shell, hostctl.sync, hostctl.console, hostctl.cli and the transport packages hostctl.transports.local, .ssh, .winrm, .container, .qemu and .serial. Each declares __all__; a name outside it, and every module whose name starts with an underscore, is internal and may change without a deprecation cycle. Concrete executors, process adapters and path backends are internal: a host hands them back typed as pathapi.Path, Process or Executor.

Hosts and configuration

hostctl.Host(*args, **secrets)

Host()
Host(connection_string: str, /, **secrets: str)

Bases: ABC

Protocol-independent operational interface to a machine.

Source code in src/hostctl/host/_host.py
123
124
def __init__(self, *args: object, **secrets: object) -> None:
    super().__init__(*args, **secrets)

capabilities property

Operations supported by this host.

config instance-attribute

connection_uri property

executor property

The command executor used when binding this host's shell.

executor_capabilities property

Native context/argument features of the underlying executor.

Derived from the host's own executor so a host that does not compose providers still reports truthfully. Host.executor's default wrapper reads this property, so it is skipped here to avoid recursing; a host that overrides executor with a real executor reports that executor's capabilities.

last_selection property

The redacted provider trace for the most recent :meth:run.

The run-side counterpart of CompositePosixPath.selection_trace: :class:~hostctl.provider.SelectionStep records in provider precedence order.

The trace accumulates across the failover attempts of a single run(), so a call that fell through from one provider to another reports every provider tried and every refusal reason -- including on the very call that suffered them.

Empty for a host whose run() does not select between providers (QEMUHost, SerialHost), and empty before the first run(). Values are redacted on the way in; see :meth:~hostctl.provider.ProviderSelector.redact for the limits of that guarantee.

logger property writable

Where records about this host go: by default an adapter over a hostctl logger that prefixes [name] and sets record.host.

Assign a Logger or LoggerAdapter to send them elsewhere; a provider fallback's warning is logged here.

name property writable

A short label for this target, for log lines: the config's name.

Never carries a credential. Assigning it replaces the label.

scheme property

shell property

A shell bound to this host: host.shell.run(...) is host.run(...).

host.shell.session(...) and with host.shell as session: open a persistent session. Called with keywords -- host.shell(cwd="/srv/app", env={"TZ": "UTC"}) -- it returns a shell carrying those defaults, applied to every later run and session that does not pass its own value. env merges per key; cwd, encoding, and errors override. A shell's attributes are read-only.

shell_flavour property

The explicitly known shell language used by this host.

__enter__()

Source code in src/hostctl/host/_host.py
256
257
258
259
260
261
262
263
264
265
def __enter__(self) -> Host:
    try:
        self.connect()
    except BaseException:
        try:
            self.close()
        except BaseException:
            pass
        raise
    return self

__exit__(exc_type, exc_value, traceback)

Source code in src/hostctl/host/_host.py
267
268
269
270
271
272
273
274
def __exit__(
    self,
    exc_type: _ty.Optional[_ty.Type[BaseException]],
    exc_value: _ty.Optional[BaseException],
    traceback: _ty.Optional[_types.TracebackType],
) -> bool:
    self.close()
    return False

_run_selector()

The :class:~hostctl.provider.ProviderSelector backing :meth:run.

None for a host that does not select a command provider at all. The default finds the attribute used by the provider-composed system hosts; assemblies that name theirs differently override this.

Source code in src/hostctl/host/_host.py
206
207
208
209
210
211
212
213
def _run_selector(self) -> _ty.Optional[object]:
    """The :class:`~hostctl.provider.ProviderSelector` backing :meth:`run`.

    ``None`` for a host that does not select a command provider at all.
    The default finds the attribute used by the provider-composed system
    hosts; assemblies that name theirs differently override this.
    """
    return getattr(self, "_executor_selector", None)

close()

Close transport resources; must be safe to call repeatedly.

Source code in src/hostctl/host/_host.py
253
254
def close(self) -> None:
    """Close transport resources; must be safe to call repeatedly."""

connect()

Open transport resources; local/default implementations are no-op.

Source code in src/hostctl/host/_host.py
250
251
def connect(self) -> None:
    """Open transport resources; local/default implementations are no-op."""

info()

Return normalized system information without inferred values.

Source code in src/hostctl/host/_host.py
245
246
247
248
@_abstract
def info(self) -> HostInfo:
    """Return normalized system information without inferred values."""
    raise NotImplementedError

path(*segments, backend=None)

Return a pathlib-compatible path for this host.

Source code in src/hostctl/host/_host.py
276
277
278
279
280
def path(self, *segments: StrPath, backend: _ty.Optional[str] = None) -> Path:
    """Return a pathlib-compatible path for this host."""
    raise NotImplementedError(
        f"{type(self).__name__} does not provide the 'path' capability"
    )

run(*cmds, bufsize=-1, executable=None, stdin=None, stdout=None, stderr=None, cwd=None, env=None, capture_output=True, check=True, encoding=None, errors=None, input=None, timeout=_OMITTED, text=None)

Run commands and return a subprocess-compatible result.

A string is verbatim shell text. A tuple/list is one quoted argv command. :class:Exec is the only direct-execution spelling: it runs one program with an argv and no shell layer, and it cannot be combined with other commands. A path anywhere else is an ordinary value that stringifies. Multiple top-level commands are joined by the selected shell's command separator.

Two defaults differ from :func:subprocess.run deliberately, and both are the opposite of it: check is True, so a non-zero status raises :class:subprocess.CalledProcessError unless the caller passes check=False; and capture_output is True, so output is captured rather than inherited by a process that may have no console. The result type and every other keyword do follow subprocess.

timeout is seconds for this call, None for no limit; left out, the config's timeout.total applies (see :attr:HostConfig.timeouts).

Source code in src/hostctl/host/_host.py
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
def run(
    self,
    *cmds: Command,
    bufsize: int = -1,
    executable: _ty.Optional[str] = None,
    stdin: _ty.Optional[StreamTarget] = None,
    stdout: _ty.Optional[StreamTarget] = None,
    stderr: _ty.Optional[StreamTarget] = None,
    cwd: _ty.Optional[StrPath] = None,
    env: _ty.Optional[CommandEnvironment] = None,
    capture_output: CaptureOutput = True,
    check: bool = True,
    encoding: _ty.Optional[str] = None,
    errors: _ty.Optional[str] = None,
    input: CommandInput = None,
    timeout: _ty.Optional[float] = _OMITTED,  # type: ignore[assignment]
    text: _ty.Optional[bool] = None,
) -> _subprocess.CompletedProcess:
    """Run commands and return a subprocess-compatible result.

    A string is verbatim shell text.  A tuple/list is one quoted argv
    command.  :class:`Exec` is the only direct-execution spelling: it runs
    one program with an argv and no shell layer, and it cannot be combined
    with other commands.  A path anywhere else is an ordinary value that
    stringifies.  Multiple top-level commands are joined by the selected
    shell's command separator.

    Two defaults differ from :func:`subprocess.run` deliberately, and
    both are the opposite of it: ``check`` is ``True``, so a non-zero
    status raises :class:`subprocess.CalledProcessError` unless the
    caller passes ``check=False``; and ``capture_output`` is ``True``, so
    output is captured rather than inherited by a process that may have
    no console.  The result type and every other keyword do follow
    ``subprocess``.

    ``timeout`` is seconds for this call, ``None`` for no limit; left out,
    the config's ``timeout.total`` applies (see
    :attr:`HostConfig.timeouts`).
    """
    raise NotImplementedError(
        f"{type(self).__name__} does not provide the 'run' capability"
    )

spawn(*cmds, executable=None, cwd=None, env=None, terminal=None, encoding=None, errors=None)

Start a persistent process controlled through a synchronous facade.

Source code in src/hostctl/host/_host.py
282
283
284
285
286
287
288
289
290
291
292
293
294
295
def spawn(
    self,
    *cmds: Command,
    executable: _ty.Optional[str] = None,
    cwd: _ty.Optional[StrPath] = None,
    env: _ty.Optional[CommandEnvironment] = None,
    terminal: TerminalRequest = None,
    encoding: _ty.Optional[str] = None,
    errors: _ty.Optional[str] = None,
) -> Process:
    """Start a persistent process controlled through a synchronous facade."""
    raise NotImplementedError(
        f"{type(self).__name__} does not provide the 'spawn' capability"
    )

hostctl.HostConfig(*args, **secrets)

HostConfig()
HostConfig(connection_string: str, /, **secrets: str)

Bases: ABC

Secret-safe connection configuration and extensible URI dispatch.

Source code in src/hostctl/host/_config.py
87
88
89
90
91
def __init__(self, *args: object, **secrets: object) -> None:
    if args or secrets:
        raise TypeError("HostConfig.__init__() takes no arguments")
    self._opened_host: _ty.Optional[Host] = None
    self._lifecycle_lock = _threading.Lock()

_lifecycle_lock = _threading.Lock() class-attribute instance-attribute

_opened_host = None class-attribute instance-attribute

_timeout_defaults = {} class-attribute

_uri_entry_points = None class-attribute

_uri_plugin_failures = {} class-attribute

_uri_plugins_loaded = set() class-attribute

_uri_registry_cache = None class-attribute

_uri_registry_generation = 0 class-attribute

_uri_registry_lock = _threading.RLock() class-attribute

_uri_schemes = () class-attribute

connection_uri property

Credential-safe canonical connection URI.

name property writable

A short label for the target, for a log line: never a credential.

By default the authority of connection_uri without its userinfo (nas.example, node:2222), else its path (vm1), else localhost. Assigning it replaces the label.

scheme property

timeouts property

timeout with each DEFAULT replaced by what the transport applies.

A field the transport does not support stays DEFAULT; so does a system config's total, which the selected provider decides. A config without a timeout attribute reports Timeout().

uri_credentials = None class-attribute

__enter__()

Source code in src/hostctl/host/_config.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
def __enter__(self) -> Host:
    with self._lifecycle():
        if self._opened_host is not None:
            raise RuntimeError("host configuration is already open")
        host = self._create_host()
        self._opened_host = host
    try:
        return host.__enter__()
    except BaseException:
        try:
            host.close()
        except BaseException:
            pass
        finally:
            with self._lifecycle():
                if self._opened_host is host:
                    self._opened_host = None
        raise

__exit__(exc_type, exc_value, traceback)

Source code in src/hostctl/host/_config.py
241
242
243
244
245
246
247
248
249
250
251
def __exit__(
    self,
    exc_type: _ty.Optional[_ty.Type[BaseException]],
    exc_value: _ty.Optional[BaseException],
    traceback: _ty.Optional[_types.TracebackType],
) -> _ty.Optional[bool]:
    with self._lifecycle():
        host, self._opened_host = self._opened_host, None
    if host is not None:
        return host.__exit__(exc_type, exc_value, traceback)
    return False

__init_subclass__(*, schemes=(), replace=False, **kwargs)

Source code in src/hostctl/host/_config.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
def __init_subclass__(
    cls,
    *,
    schemes: _ty.Iterable[str] = (),
    replace: bool = False,
    **kwargs: object,
) -> None:
    super().__init_subclass__(**kwargs)
    # Claimed only once nothing else holds them: a class that raises here
    # stays a live subclass until it is collected, and must claim nothing.
    cls._uri_schemes = ()
    wanted = tuple(scheme.casefold() for scheme in schemes)
    with HostConfig._uri_registry_lock:
        cls._claim_schemes(wanted, replace)
        cls._uri_schemes = wanted
        HostConfig._uri_registry_generation += 1
        HostConfig._uri_registry_cache = None

__str__()

Source code in src/hostctl/host/_config.py
200
201
def __str__(self) -> str:
    return self.connection_uri

_claim_schemes(wanted, replace) classmethod

Refuse a scheme another live config already claims.

A redefinition (same module and name: a reload, or a test that builds the class per call) replaces its predecessor. replace=True takes the scheme over from the previous owner.

Source code in src/hostctl/host/_config.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
@classmethod
def _claim_schemes(cls, wanted: _ty.Tuple[str, ...], replace: bool) -> None:
    """Refuse a scheme another live config already claims.

    A redefinition (same module and name: a reload, or a test that builds
    the class per call) replaces its predecessor. `replace=True` takes the
    scheme over from the previous owner.
    """
    for other in _recursive_subclasses(HostConfig):
        if other is cls or not other._uri_schemes:
            continue
        taken = set(wanted) & set(other._uri_schemes)
        if not taken:
            continue
        same = (other.__module__, other.__qualname__) == (
            cls.__module__,
            cls.__qualname__,
        )
        if same:
            other._uri_schemes = ()
        elif replace:
            other._uri_schemes = tuple(
                scheme for scheme in other._uri_schemes if scheme not in taken
            )
        else:
            name = sorted(taken)[0]
            raise ValueError(
                f"host scheme {name!r} is already registered by "
                f"{other.__name__}; pass replace=True to take it over"
            )

_create_host()

Create the operational host represented by this configuration.

Documented override point: implement it in a subclass, and reach it through :meth:open or with config as host.

Source code in src/hostctl/host/_config.py
209
210
211
212
213
214
215
216
@_abstract
def _create_host(self) -> Host:
    """Create the operational host represented by this configuration.

    Documented override point: implement it in a subclass, and reach it
    through :meth:`open` or ``with config as host``.
    """
    raise NotImplementedError

_default_name()

Source code in src/hostctl/host/_config.py
189
190
191
192
193
194
195
196
197
198
def _default_name(self) -> str:
    try:
        parsed = _urlsplit(self.connection_uri)
    except NotImplementedError:
        return ""
    authority = _unquote(parsed.netloc).rpartition("@")[2]
    if authority:
        return authority
    path = _unquote(parsed.path)
    return (path[1:] if path.startswith("/") else path) or "localhost"

_from_parsed_uri(parsed, **credentials) classmethod

Construct from a URI selected by :meth:_matches_uri.

Documented override point for a config subclass; callers use HostConfig(uri).

Source code in src/hostctl/host/_config.py
363
364
365
366
367
368
369
370
371
372
@classmethod
def _from_parsed_uri(
    cls, parsed: _SplitResult, **credentials: object
) -> HostConfig:
    """Construct from a URI selected by :meth:`_matches_uri`.

    Documented override point for a config subclass; callers use
    ``HostConfig(uri)``.
    """
    raise NotImplementedError(f"{cls.__name__} does not implement URI construction")

_from_uri(uri, **credentials) classmethod

Dispatch a connection URI to a registered configuration.

A scheme://user:secret@host URI is accepted: the password is extracted into the credential arguments and stripped from the parsed authority, so it is never stored where connection_uri or repr() would render it. Use :func:redact_uri before showing a URI that may still carry one.

Explicit credentials override the URI's: password= replaces its whole password field ("" meaning no password), and username= replaces its user where the selected config accepts one.

The password field is parsed by :func:parse_credentials, so a newline separates trailing key:value extras -- an OTP or other second factor travels through the same field. The newline may be written raw: it is percent-encoded before parsing (urlsplit would otherwise delete it), and decoded again when the password is read. A control character in the host is still refused, because no encoding makes that meaningful.

Source code in src/hostctl/host/_config.py
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
@classmethod
def _from_uri(
    cls,
    uri: str,
    **credentials: object,
) -> HostConfig:
    """Dispatch a connection URI to a registered configuration.

    A `scheme://user:secret@host` URI is accepted: the password is
    extracted into the credential arguments and stripped from the parsed
    authority, so it is never stored where `connection_uri` or `repr()`
    would render it. Use :func:`redact_uri` before showing a URI that may
    still carry one.

    Explicit credentials override the URI's: `password=` replaces its
    whole password field (`""` meaning no password), and `username=`
    replaces its user where the selected config accepts one.

    The password field is parsed by :func:`parse_credentials`, so a newline
    separates trailing `key:value` extras -- an OTP or other second factor
    travels through the same field. The newline may be written raw: it is
    percent-encoded before parsing (`urlsplit` would otherwise delete it),
    and decoded again when the password is read. A control character in the
    *host* is still refused, because no encoding makes that meaningful.
    """
    parsed = _urlsplit(_encode_stripped_characters(uri))
    _reject_authority_control_characters(parsed)
    _refuse_cut_short_userinfo(parsed)
    if parsed.fragment:
        raise ValueError("connection URI fragments are not supported")
    explicit_password = credentials.get("password") is not None
    if credentials.get("password") == "":
        credentials["password"] = None
    if parsed.password is not None:
        # `scheme://user:secret@host` is a valid URI, so accept it: extract
        # the password into the credential arguments and strip it from the
        # parsed authority. The password therefore never reaches a config
        # field that `connection_uri`/`repr` render, which is what keeps
        # the canonical form credential-free. An explicit `password=`
        # replaces the whole field, extras included.
        if not explicit_password:
            password, extras = parse_credentials(_unquote(parsed.password))
            credentials["password"] = password
            for key, value in extras.items():
                if credentials.get(key) is None:
                    credentials[key] = value
        parsed = _without_password(parsed)
    matches = [
        implementation
        for implementation in cls._uri_implementations(parsed.scheme)
        if implementation._matches_uri(parsed)
    ]
    if not matches:
        raise ValueError(
            f"unsupported host scheme: {parsed.scheme.casefold() or '<missing>'}"
        )
    if len(matches) > 1:
        names = ", ".join(item.__name__ for item in matches)
        raise ValueError(f"ambiguous host URI matched: {names}")
    implementation = matches[0]
    # Fail closed on an unknown credential *here*, so a config gets the
    # safe behaviour by declaring `uri_credentials` rather than by
    # remembering to call a helper. A typo like `passwrd=` would otherwise
    # build a config with no password and no complaint, surfacing much
    # later as an unexplained authentication failure.
    accepted = implementation._uri_credentials_for(parsed)
    if accepted is not None:
        strict_uri_credentials(credentials, accepted)
    username = credentials.pop("username", None)
    if username is not None:
        parsed = _with_username(parsed, _ty.cast(str, username))
    return implementation._from_parsed_uri(parsed, **credentials)

_lifecycle()

The instance's lifecycle lock, created on first use if needed.

Source code in src/hostctl/host/_config.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
def _lifecycle(self) -> "_threading.Lock":
    """The instance's lifecycle lock, created on first use if needed."""
    lock = self.__dict__.get("_lifecycle_lock")
    if lock is None:
        with HostConfig._uri_registry_lock:
            lock = self.__dict__.get("_lifecycle_lock")
            if lock is None:
                lock = _threading.Lock()
                object.__setattr__(self, "_lifecycle_lock", lock)
    return lock

_matches_uri(parsed) classmethod

Whether this implementation accepts a parsed URI.

Source code in src/hostctl/host/_config.py
358
359
360
361
@classmethod
def _matches_uri(cls, parsed: _SplitResult) -> bool:
    """Whether this implementation accepts a parsed URI."""
    return parsed.scheme.casefold() in cls._uri_schemes

_refresh_uri_registry() classmethod

Clear URI implementation discovery for tests and newly loaded plugins.

Source code in src/hostctl/host/_config.py
374
375
376
377
378
379
380
381
382
@classmethod
def _refresh_uri_registry(cls) -> None:
    """Clear URI implementation discovery for tests and newly loaded plugins."""
    with HostConfig._uri_registry_lock:
        HostConfig._uri_registry_generation += 1
        HostConfig._uri_registry_cache = None
        HostConfig._uri_entry_points = None
        HostConfig._uri_plugin_failures = {}
        HostConfig._uri_plugins_loaded = set()

_uri_credentials_for(parsed) classmethod

The credential names this implementation accepts for parsed.

uri_credentials unless a scheme of the class narrows it.

Source code in src/hostctl/host/_config.py
326
327
328
329
330
331
332
333
334
@classmethod
def _uri_credentials_for(
    cls, parsed: _SplitResult
) -> _ty.Optional[_ty.Tuple[str, ...]]:
    """The credential names this implementation accepts for `parsed`.

    `uri_credentials` unless a scheme of the class narrows it.
    """
    return cls.uri_credentials

_uri_implementations(requested_scheme=None) classmethod

Source code in src/hostctl/host/_config.py
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
@classmethod
def _uri_implementations(
    cls,
    requested_scheme: _ty.Optional[str] = None,
) -> _ty.Tuple[_ty.Type[HostConfig], ...]:
    scheme = requested_scheme.casefold() if requested_scheme else None

    # Every import below happens OUTSIDE `_uri_registry_lock`. Holding it
    # across an import inverted the lock order against
    # `__init_subclass__`, which takes the registry lock from inside a
    # module body: one thread held the registry lock and waited for a
    # module lock while another held that module lock and waited for the
    # registry lock. Python's import deadlock detection only covers module
    # locks, so both hung forever.
    with HostConfig._uri_registry_lock:
        needs_builtins = HostConfig._uri_registry_cache is None
    if needs_builtins:
        from ..transports import container as _container
        from ..transports.local import _host as _local
        from ..transports.qemu import _host as _qemu
        from ..transports.serial import _host as _serial
        from ..transports.ssh import _transport as _ssh
        from ..transports.winrm import _transport as _winrm

        del _container, _local, _qemu, _serial, _ssh, _winrm

    with HostConfig._uri_registry_lock:
        while HostConfig._uri_registry_cache is None:
            generation = HostConfig._uri_registry_generation
            discovered = list(_recursive_subclasses(HostConfig))
            if generation != HostConfig._uri_registry_generation:
                continue
            HostConfig._uri_registry_cache = tuple(
                item for item in discovered if item._uri_schemes
            )
        if HostConfig._uri_entry_points is None:
            # Reading metadata imports nothing, so it stays under the lock.
            HostConfig._uri_entry_points = entry_points_in("hostctl.configs")
        candidates = list(HostConfig._uri_registry_cache)
        entry_points = HostConfig._uri_entry_points
        failure = (
            HostConfig._uri_plugin_failures.get(scheme)
            if scheme is not None
            else None
        )

    if failure is not None:
        # A FRESH exception. Re-raising the cached instance appended the
        # current frames to its traceback every time, so a service that
        # retried a broken plugin's scheme per request grew the traceback
        # -- and every frame's locals, including credentials passed as
        # dispatch kwargs -- without bound, for the life of the process.
        raise RuntimeError(
            f"hostctl.configs entry point for scheme {scheme!r} failed to load"
        ) from failure
    if scheme is not None:
        known = {
            value
            for item in candidates
            for value in getattr(item, "_uri_schemes", ())
        }
        # The entry point named for the scheme goes first: a broken
        # unrelated plugin earlier in the list must not stand between a
        # scheme and its own plugin.
        ordered = sorted(
            entry_points,
            key=lambda point: str(getattr(point, "name", "")).casefold() != scheme,
        )
        for entry_point in ordered:
            name = str(getattr(entry_point, "name", "")).casefold()
            with HostConfig._uri_registry_lock:
                if (
                    name in HostConfig._uri_plugins_loaded
                    or name in HostConfig._uri_plugin_failures
                ):
                    continue
            # The entry point's NAME matching the scheme is the fast
            # path, not the rule: a plugin may declare several schemes
            # under one entry point (`schemes=("plug", "plug+tls")`). An
            # unknown scheme loads the remaining entry points too; each
            # load happens once, and a plugin that matches nothing leaves
            # the scheme unsupported.
            if name != scheme and scheme in known:
                continue
            try:
                # The plugin import that must not hold the registry lock.
                implementation = entry_point.load()
                if not isinstance(implementation, type) or not issubclass(
                    implementation, HostConfig
                ):
                    raise TypeError(
                        f"hostctl.configs entry point {name!r} "
                        "must load a HostConfig subclass"
                    )
            except Exception as exc:
                with HostConfig._uri_registry_lock:
                    HostConfig._uri_plugin_failures[name] = exc
                import warnings

                warnings.warn(
                    f"unable to load hostctl.configs entry point {name!r}: {exc}",
                    RuntimeWarning,
                    stacklevel=2,
                )
                continue
            with HostConfig._uri_registry_lock:
                HostConfig._uri_plugins_loaded.add(name)
            candidates.append(implementation)
            if scheme in getattr(implementation, "_uri_schemes", ()):
                break
        with HostConfig._uri_registry_lock:
            failure = HostConfig._uri_plugin_failures.get(scheme)
        claimed = any(
            scheme in getattr(item, "_uri_schemes", ()) for item in candidates
        )
        if failure is not None and not claimed:
            raise RuntimeError(
                f"hostctl.configs entry point for scheme {scheme!r} failed to load"
            ) from failure
    return tuple(
        item
        for item in dict.fromkeys(candidates)
        if issubclass(item, cls) and item._uri_schemes
    )

open()

Return a host context manager for this configuration.

Source code in src/hostctl/host/_config.py
218
219
220
def open(self) -> Host:
    """Return a host context manager for this configuration."""
    return self._create_host()

supported_credentials(uri) classmethod

Credential names the implementation selected by uri accepts.

None means the implementation declares no whitelist and takes whatever it is given. Use this for an ambient credential -- one supplied for a session rather than for a single call, such as the CLI's HOSTCTL_PASSWORD -- which should be offered only where it can be used. An explicitly passed credential still fails closed, because there a name the config does not know is a typo worth reporting.

Source code in src/hostctl/host/_config.py
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
@classmethod
def supported_credentials(cls, uri: str) -> _ty.Optional[_ty.Tuple[str, ...]]:
    """Credential names the implementation selected by `uri` accepts.

    `None` means the implementation declares no whitelist and takes
    whatever it is given. Use this for an *ambient* credential -- one
    supplied for a session rather than for a single call, such as the
    CLI's `HOSTCTL_PASSWORD` -- which should be offered only where it can
    be used. An explicitly passed credential still fails closed, because
    there a name the config does not know is a typo worth reporting.
    """
    parsed = _urlsplit(_encode_stripped_characters(uri))
    _refuse_cut_short_userinfo(parsed)
    matches = [
        implementation
        for implementation in cls._uri_implementations(parsed.scheme)
        if implementation._matches_uri(parsed)
    ]
    if len(matches) != 1:
        return None
    return matches[0]._uri_credentials_for(parsed)

hostctl.HostInfo(hostname=None, os_family=None, os_name=None, os_version=None, architecture=None) dataclass

Normalized system information; unavailable values remain None.

architecture = None class-attribute instance-attribute

hostname = None class-attribute instance-attribute

os_family = None class-attribute instance-attribute

os_name = None class-attribute instance-attribute

os_version = None class-attribute instance-attribute

hostctl.Timeout(total=DEFAULT, *, connect=DEFAULT, read=DEFAULT, write=DEFAULT) dataclass

How long a host's operations may take, field by field, in seconds.

total bounds a whole command and is the default of run(timeout=); connect bounds establishing a connection, session or port; read one reply or one read; write one write. Each field is DEFAULT (the transport's own), None (no limit) or a positive number of seconds; read and write may also be 0, "do not wait". Timeout(30) sets total only. A transport that cannot honour a field, or its 0, refuses it when the config is built.

Raises TypeError for a value that is not a number, None or DEFAULT, and ValueError for a negative number, infinity, or zero as total or connect.

Source code in src/hostctl/host/_timeout.py
80
81
82
83
84
85
86
87
88
89
def __init__(
    self,
    total: _Seconds = DEFAULT,
    *,
    connect: _Seconds = DEFAULT,
    read: _Seconds = DEFAULT,
    write: _Seconds = DEFAULT,
) -> None:
    for name, value in zip(_FIELDS, (total, connect, read, write)):
        object.__setattr__(self, name, _seconds(name, value))

connect = DEFAULT class-attribute instance-attribute

read = DEFAULT class-attribute instance-attribute

total = DEFAULT class-attribute instance-attribute

write = DEFAULT class-attribute instance-attribute

__repr__()

Source code in src/hostctl/host/_timeout.py
112
113
114
115
116
117
118
def __repr__(self) -> str:
    shown = ", ".join(
        f"{name}={getattr(self, name)!r}"
        for name in _FIELDS
        if getattr(self, name) is not DEFAULT
    )
    return f"Timeout({shown})"

coerce(value) classmethod

A Timeout for what a config's timeout= accepts.

A Timeout is returned as it is; a number or None sets total only (coerce(60) == Timeout(total=60)); DEFAULT is Timeout(). Anything else raises TypeError.

Source code in src/hostctl/host/_timeout.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
@classmethod
def coerce(cls, value: typing.Union[float, None, Timeout]) -> Timeout:
    """A `Timeout` for what a config's ``timeout=`` accepts.

    A `Timeout` is returned as it is; a number or `None` sets `total`
    only (``coerce(60) == Timeout(total=60)``); `DEFAULT` is
    ``Timeout()``. Anything else raises `TypeError`.
    """
    if isinstance(value, Timeout):
        return value
    if value is DEFAULT:
        return cls()
    if value is None or (
        isinstance(value, (int, float)) and not isinstance(value, bool)
    ):
        return cls(value)
    raise TypeError(
        "timeout must be a number of seconds, None or a Timeout, "
        f"not {type(value).__name__}"
    )

hostctl.DEFAULT = _Default.DEFAULT module-attribute

hostctl.LocalConfig(*, timeout=Timeout())

Bases: HostConfig

This machine, addressed as local:.

It takes no host, no port and no credentials -- there is nothing to connect to -- so its URI is a bare scheme, with ?timeout= when the default of run(timeout=) is set.

Source code in src/hostctl/transports/local/_host.py
56
57
58
def __init__(self, *, timeout: _ty.Union[float, None, Timeout] = Timeout()) -> None:
    super().__init__()
    self.timeout = timeout  # type: ignore[assignment]

_timeout_defaults = {'total': None} class-attribute instance-attribute

connection_uri property

timeout = timeout class-attribute instance-attribute

uri_credentials = () class-attribute instance-attribute

__repr__()

Source code in src/hostctl/transports/local/_host.py
60
61
62
63
def __repr__(self) -> str:
    if self.timeout == Timeout():
        return f"{type(self).__name__}()"
    return f"{type(self).__name__}(timeout={self.timeout!r})"

_create_host()

Source code in src/hostctl/transports/local/_host.py
79
80
def _create_host(self) -> LocalHost:
    return LocalHost(self)

_from_parsed_uri(parsed, **credentials) classmethod

Source code in src/hostctl/transports/local/_host.py
70
71
72
73
74
75
76
77
@classmethod
def _from_parsed_uri(cls, parsed, **credentials: object) -> LocalConfig:
    if parsed.netloc or parsed.path:
        # `local://` is the same URI with an empty authority, so the
        # message names both spellings.
        raise ValueError("local URI must be 'local:' or 'local://'")
    query = strict_uri_query(parsed, _URI_TIMEOUT_KEYS)
    return cls(timeout=_timeout_from_query(query))

hostctl.LocalHost(config=None, *, executor_providers=(), path_providers=(), **options)

Bases: SystemHost

This machine, assembled from the local providers.

A SystemHost like every other, not a second implementation of one: provider selection, failover, capabilities and the direct/shell dispatch are SystemHost's, so local and remote hosts cannot diverge.

What is genuinely local stays here: the system family and shell come from os.name rather than a config, info() reports this process's own platform, and the default providers are the local ones.

Source code in src/hostctl/transports/local/_host.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def __init__(
    self,
    config: _ty.Optional[LocalConfig] = None,
    *,
    executor_providers: _ty.Iterable[object] = (),
    path_providers: _ty.Iterable[object] = (),
    **options: object,
) -> None:
    # Instance attributes, set before `super().__init__` reads them: the
    # family is a property of the machine this process is on, not of a
    # subclass someone declared.
    # Resolved here, but NOT enforced: an exotic `os.name` could always
    # build a host and use `info()` and `path()`; only naming its shell
    # raised. `shell_flavour` below keeps that boundary.
    if _os.name == "nt":
        self.system_family = "windows"
        self.default_shell = POWERSHELL
    elif _os.name == "posix":
        self.system_family = "posix"
        self.default_shell = POSIX_SHELL
    else:
        self.system_family = "generic"
        self.default_shell = None
    super().__init__(
        config if config is not None else LocalConfig(),
        executor_providers=tuple(executor_providers) or (LocalExecutorProvider(),),
        path_providers=tuple(path_providers) or (LocalPathProvider(),),
        **options,
    )

config_type = LocalConfig class-attribute instance-attribute

default_shell = POWERSHELL instance-attribute

shell_flavour property

The local shell, or a refusal naming the OS it could not place.

system_family = 'windows' instance-attribute

info()

This process's own platform, with no transport to ask.

Source code in src/hostctl/transports/local/_host.py
164
165
166
167
168
169
170
171
172
def info(self) -> HostInfo:
    """This process's own platform, with no transport to ask."""
    return HostInfo(
        hostname=_platform.node() or None,
        os_family=normalize_os_family(_platform.system()),
        os_name=_platform.system() or None,
        os_version=_platform.version() or None,
        architecture=_platform.machine() or None,
    )

path(*segments, backend=None)

A plain pathapi path, not a composite.

The composite exists to select between providers per operation and to pin a route; a local host reaches one filesystem through one provider, so it would add a layer with nothing to decide. Callers also rely on a local path being an ordinary pathlib object.

With no segments this returns the empty relative path, as every other transport does, not os.getcwd(); it still resolves against this process's working directory.

Source code in src/hostctl/transports/local/_host.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
def path(self, *segments: StrPath, backend: _ty.Optional[str] = None) -> Path:
    """A plain `pathapi` path, not a composite.

    The composite exists to select between providers per operation and
    to pin a route; a local host reaches one filesystem through one
    provider, so it would add a layer with nothing to decide. Callers
    also rely on a local path being an ordinary pathlib object.

    With no segments this returns the empty relative path, as every
    other transport does, not `os.getcwd()`; it still resolves against
    this process's working directory.
    """
    names = tuple(provider.name for provider in self._path_selector.providers)
    if backend is not None and backend not in names:
        raise ValueError(
            "local path backend must be "
            + " or ".join(repr(name) for name in names or ("local",))
        )
    if backend is None:
        provider = self._path_selector.select().provider
    else:
        provider = next(
            item for item in self._path_selector.providers if item.name == backend
        )
        if not provider.probe().usable:
            raise OperationNotStartedError(
                f"path provider {backend!r} is unavailable"
            )
    return provider.path(*segments)

hostctl.SSHConfig(host, port=None, username=None, default_username=None, password=None, client_keys=None, executable=None, verify=True, known_hosts=(), shell=POSIX_SHELL, pathname=PosixPathname, login_shell='auto', timeout=_TimeoutField(), ssh_config=USER_SSH_CONFIG, connect_options=dict()) dataclass

Bases: HostConfig

Explicit SSH transport, authentication, and target-shell settings.

_timeout_defaults = {'total': None, 'connect': 30.0} class-attribute instance-attribute

client_keys = dataclasses.field(default=None, repr=False) class-attribute instance-attribute

connect_options = dataclasses.field(default_factory=dict, repr=False) class-attribute instance-attribute

connection_uri property

default_username = None class-attribute instance-attribute

executable = None class-attribute instance-attribute

host instance-attribute

known_hosts = () class-attribute instance-attribute

login_shell = 'auto' class-attribute instance-attribute

password = dataclasses.field(default=None, repr=False) class-attribute instance-attribute

pathname = PosixPathname class-attribute instance-attribute

port = None class-attribute instance-attribute

resolved_login_shell property

login_shell with "auto" answered from the shell.

With pathname="auto" the answer needs the server: use the transport's login_shell.

shell = POSIX_SHELL class-attribute instance-attribute

ssh_config = USER_SSH_CONFIG class-attribute instance-attribute

timeout = _TimeoutField() class-attribute instance-attribute

uri_credentials = ('username', 'password', 'client_keys', 'known_hosts') class-attribute instance-attribute

username = None class-attribute instance-attribute

verify = True class-attribute instance-attribute

__post_init__()

Source code in src/hostctl/transports/ssh/_config.py
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
def __post_init__(self) -> None:
    HostConfig.__init__(self)
    if not self.host:
        raise ValueError("host must not be empty")
    # The constructor takes a HOST; the dispatcher takes a URI.
    _refuse_uri("SSHConfig", self.host)
    _refuse_unsafe_target(self.host, self.username)
    if self.default_username:
        _default_user_config(self.default_username)
    self.verify = _host_key_verify("SSHConfig", self.verify)
    if self.known_hosts is None:
        raise ValueError(
            "SSHConfig: known_hosts names the trusted host keys; "
            "pass verify=False to skip the host-key check"
        )
    if self.port is not None and not 1 <= self.port <= 65535:
        raise ValueError("port must be between 1 and 65535")
    if self.shell != "auto":
        self.shell = shell_flavour(self.shell)
    elif self.executable is not None:
        raise ValueError("executable cannot be combined with shell='auto'")
    self.pathname = pathname_selection(self.pathname, allow_auto=True)
    self.connect_options = dict(self.connect_options)
    for key in self.connect_options:
        if key in _OWNED_CONNECT_OPTIONS:
            raise ValueError(
                f"connect_options cannot set {key!r}; "
                f"use SSHConfig({_OWNED_CONNECT_OPTIONS[key]})"
            )
    if self.login_shell not in _LOGIN_SHELLS:
        values = ", ".join(sorted(_LOGIN_SHELLS))
        raise ValueError(f"login_shell must be one of: {values}")
    self.ssh_config = _ssh_config_source(self.ssh_config)

_create_host()

Source code in src/hostctl/transports/ssh/_config.py
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
def _create_host(self):
    from ._providers import SFTPPathProvider
    from ._providers import SSHExecutorProvider

    from ...system._kinds import PosixHost, WindowsHost

    transport = self._create_transport()
    try:
        # "auto" connects while the host is created, because the host
        # class depends on what the server answers.
        flavor = transport.pathname
    except BaseException:
        transport.close()
        raise
    host_type = WindowsHost if issubclass(flavor, PureWindowsPath) else PosixHost
    return host_type(
        self,
        executor_providers=(SSHExecutorProvider(transport),),
        path_providers=(SFTPPathProvider(transport),),
        shell=(
            self.shell
            if self.shell != "auto"
            else (lambda: transport.shell_flavour)
        ),
    )

_create_transport()

Create the private SSH service shared by executor/path providers.

Source code in src/hostctl/transports/ssh/_config.py
410
411
412
413
414
def _create_transport(self) -> _SSHTransport:
    """Create the private SSH service shared by executor/path providers."""
    from ._transport import _SSHTransport

    return _SSHTransport(self)

_default_name()

Source code in src/hostctl/transports/ssh/_config.py
324
325
326
def _default_name(self) -> str:
    host = uri_host(self.host)
    return host if self.port in (None, 22) else f"{host}:{self.port}"

_from_parsed_uri(parsed, **credentials) classmethod

Source code in src/hostctl/transports/ssh/_config.py
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
@classmethod
def _from_parsed_uri(cls, parsed, **credentials: object) -> SSHConfig:
    query = strict_uri_query(
        parsed,
        {
            "shell",
            "pathname",
            "executable",
            "login_shell",
            "verify",
            "connect_timeout",
            *_URI_TIMEOUT_KEYS,
        },
    )
    _refuse_renamed_keys(query, {"connect_timeout": "timeout_connect"})
    if not parsed.hostname or parsed.path not in ("", "/"):
        raise ValueError("SSH URI requires a host and no path")
    return cls(
        host=uri_hostname(parsed),
        port=parsed.port,
        username=unquote(parsed.username or "") or None,
        password=typing.cast(typing.Optional[str], credentials.get("password")),
        client_keys=typing.cast(SSHClientKeys, credentials.get("client_keys")),
        executable=query.get("executable") or None,
        verify=typing.cast(bool, _verify_from_query(query, bundle=False)),
        known_hosts=typing.cast(SSHKnownHosts, credentials.get("known_hosts", ())),
        shell=(
            "auto"
            if query.get("shell") == "auto"
            else shell_flavour(query.get("shell", POSIX_SHELL.name))
        ),
        pathname=pathname_selection(
            query.get("pathname", "posix"), allow_auto=True
        ),
        login_shell=query.get("login_shell", "auto"),
        timeout=_timeout_from_query(query),
    )

connect_opts()

Source code in src/hostctl/transports/ssh/_config.py
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
def connect_opts(self) -> typing.Dict[str, object]:
    opts: typing.Dict[str, object] = {}
    if self.username:
        # Left out otherwise, so asyncssh uses the local account name,
        # as `ssh` does.
        opts["username"] = self.username
    if self.password is not None:
        opts["password"] = self.password
    if self.client_keys is not None:
        keys = self.client_keys
        if isinstance(keys, (str, bytes, os.PathLike)):
            keys = [keys]
        opts["client_keys"] = keys
    # Resolved here, when the connection opens, and always passed: both
    # legs share these options, so they resolve and verify the host alike.
    config = _asyncssh_config(self.ssh_config)
    if self.default_username and not self.username:
        if config is None or isinstance(config, _SSHConfigText):
            config = [] if config is None else [config]
        config = [*config, _default_user_config(self.default_username)]
    opts["config"] = config
    # Always passed, never omitted on the default: `()` is asyncssh's own
    # "resolve known_hosts the usual way" value, and both legs receive
    # these options, so the path leg verifies the host key exactly as the
    # exec leg does. `None` (no check) comes from `verify=False`, or from
    # the ssh_config resolving `()` that way.
    if self.known_hosts == () and not self.verify:
        opts["known_hosts"] = None
    else:
        opts["known_hosts"] = self.known_hosts
    connect = self.timeouts.connect
    if connect is not None:
        opts["connect_timeout"] = connect
    opts.update(self.connect_options)
    return opts

hostctl.WinRMConfig(host, username, password=None, auth='ntlm', port=None, tls=False, verify=True, message_encryption='auto', timeout=_TimeoutField(), provider='auto', cleartext_auth=False) dataclass

Bases: HostConfig

Connection and transport settings for the Windows provider.

_timeout_defaults = {'total': 30.0, 'connect': 30.0} class-attribute instance-attribute

auth = 'ntlm' class-attribute instance-attribute

cleartext_auth = False class-attribute instance-attribute

connection_uri property

endpoint property

host instance-attribute

message_encryption = 'auto' class-attribute instance-attribute

password = dataclasses.field(default=None, repr=False) class-attribute instance-attribute

port = None class-attribute instance-attribute

provider = 'auto' class-attribute instance-attribute

scheme property

timeout = _TimeoutField() class-attribute instance-attribute

tls = False class-attribute instance-attribute

uri_credentials = ('username', 'password') class-attribute instance-attribute

username instance-attribute

verify = True class-attribute instance-attribute

__post_init__()

Source code in src/hostctl/transports/winrm/_config.py
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
def __post_init__(self) -> None:
    HostConfig.__init__(self)
    if not self.host:
        raise ValueError("host must not be empty")
    _refuse_uri("WinRMConfig", self.host)
    if not self.username:
        raise ValueError("username must not be empty")
    if self.port is not None and not 1 <= self.port <= 65535:
        raise ValueError("port must be between 1 and 65535")
    if self.auth not in typing.get_args(WinRMAuth):
        raise ValueError(f"unsupported WinRM auth: {self.auth}")
    self.verify = _tls_verify("WinRMConfig", self.verify)
    if isinstance(self.verify, str):
        if not self.tls:
            raise ValueError(
                "WinRMConfig: a CA bundle (verify=path) needs tls=True "
                "(winrms://); plain HTTP has no certificate to check"
            )
        if self.provider == "native":
            raise ValueError(_NATIVE_BUNDLE)
    if self.message_encryption not in typing.get_args(MessageEncryption):
        raise ValueError("message_encryption must be 'auto', 'always', or 'never'")
    if self.provider not in typing.get_args(WinRMProviderName):
        raise ValueError("provider must be 'auto', 'native', 'pywinrm', or 'psrp'")
    if self.provider == "native" and isinstance(self.password, str):
        raise ValueError(
            "native WinRM uses the current Windows logon; drop password= "
            "or choose 'pywinrm'/'psrp'"
        )
    if (
        self.provider in ("pywinrm", "psrp")
        and self.password is None
        and self.auth != "kerberos"
    ):
        raise ValueError(
            f"provider={self.provider!r} needs password= (or "
            "auth='kerberos'); use provider='native' for the "
            "current Windows logon"
        )
    if (
        self.auth in ("basic", "plaintext", "ssl")
        and not self.tls
        and not self.cleartext_auth
    ):
        raise ValueError(
            f"auth={self.auth!r} over http sends the password in "
            "cleartext; use tls=True (winrms://) or cleartext_auth=True"
        )

_create_host()

Source code in src/hostctl/transports/winrm/_config.py
256
257
258
259
260
261
262
263
264
265
266
267
268
def _create_host(self):
    from ._providers import WinRMExecutorProvider
    from ._providers import WinRMPathProvider
    from ._transport import _WinRMTransport

    from ...system._kinds import WindowsHost

    transport = _WinRMTransport(self)
    return WindowsHost(
        self,
        executor_providers=(WinRMExecutorProvider(transport),),
        path_providers=(WinRMPathProvider(transport),),
    )

_from_parsed_uri(parsed, **credentials) classmethod

Source code in src/hostctl/transports/winrm/_config.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
@classmethod
def _from_parsed_uri(cls, parsed, **credentials: object) -> WinRMConfig:
    query = strict_uri_query(
        parsed,
        {
            "auth",
            "verify",
            "message_encryption",
            "connect_timeout",
            "provider",
            "cleartext_auth",
            *_URI_TIMEOUT_KEYS,
            *_REMOVED_QUERY_KEYS,
        },
    )
    _refuse_renamed_keys(query, {"connect_timeout": "timeout_connect"})
    for key in _REMOVED_QUERY_KEYS:
        if key in query:
            raise ValueError(
                f"unknown connection parameter: {key}; WinRM takes "
                "timeout_connect (establishing the connection) and "
                "timeout (the whole command)"
            )
    if not parsed.hostname or parsed.path not in ("", "/"):
        raise ValueError("WinRM URI requires a host and no path")
    if not parsed.username:
        raise ValueError("WinRM URI requires a username")
    return cls(
        host=uri_hostname(parsed),
        username=unquote(parsed.username),
        password=typing.cast(typing.Optional[str], credentials.get("password")),
        port=parsed.port,
        tls=parsed.scheme.casefold() == "winrms",
        auth=typing.cast(WinRMAuth, query.get("auth", "ntlm")),
        verify=_verify_from_query(query, bundle=True),
        message_encryption=typing.cast(
            MessageEncryption,
            query.get("message_encryption", "auto"),
        ),
        timeout=_timeout_from_query(query),
        provider=typing.cast(WinRMProviderName, query.get("provider", "auto")),
        cleartext_auth=_query_bool(query, "cleartext_auth", False),
    )

hostctl.ContainerConfig(container, engine_url=None, verify=True, username=None, workdir=None, executable=None, shell='auto', pathname='auto', timeout=_TimeoutField(), client_factory=None, client_cert=None) dataclass

Bases: HostConfig

Docker Engine target and in-container execution defaults.

_timeout_defaults = {'connect': 60.0} class-attribute instance-attribute

client_cert = dataclasses.field(default=None, repr=False) class-attribute instance-attribute

client_factory = dataclasses.field(default=None, repr=False, compare=False) class-attribute instance-attribute

connection_uri property

container instance-attribute

engine_url = None class-attribute instance-attribute

executable = None class-attribute instance-attribute

pathname = 'auto' class-attribute instance-attribute

shell = 'auto' class-attribute instance-attribute

timeout = _TimeoutField() class-attribute instance-attribute

uri_credentials = ('client_factory', 'client_cert') class-attribute instance-attribute

username = None class-attribute instance-attribute

verify = True class-attribute instance-attribute

workdir = None class-attribute instance-attribute

__post_init__()

Source code in src/hostctl/transports/container/_config.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def __post_init__(self) -> None:
    HostConfig.__init__(self)
    if not self.container:
        raise ValueError("container must not be empty")
    _refuse_uri("ContainerConfig", self.container)
    self.verify = _tls_verify("ContainerConfig", self.verify)
    if self.verify is not True:
        if self.client_factory is not None:
            raise ValueError(
                "ContainerConfig: client_factory builds the Docker client and "
                "owns its TLS settings; leave verify=True"
            )
        if not (self.engine_url or "").casefold().startswith("https://"):
            raise ValueError(
                "ContainerConfig: verify applies to an https:// engine_url; "
                "other engine URLs carry no TLS, and without one docker-py "
                "reads DOCKER_TLS_VERIFY and DOCKER_CERT_PATH"
            )
    self.client_cert = _client_cert(self.client_cert)
    if self.client_cert is not None:
        if self.client_factory is not None:
            raise ValueError(
                "ContainerConfig: client_factory builds the Docker client and "
                "owns its TLS settings; pass no client_cert"
            )
        if not (self.engine_url or "").casefold().startswith("https://"):
            raise ValueError(
                "ContainerConfig: client_cert applies to an https:// engine_url; "
                "other engine URLs carry no TLS, and without one docker-py "
                "reads DOCKER_CERT_PATH"
            )
    if self.shell != "auto":
        self.shell = shell_flavour(self.shell)
    elif self.executable is not None:
        raise ValueError("executable cannot be combined with shell='auto'")
    self.pathname = pathname_selection(self.pathname, allow_auto=True)

_create_host()

Source code in src/hostctl/transports/container/_config.py
198
199
200
201
def _create_host(self) -> ContainerHost:
    from ._host import ContainerHost

    return ContainerHost(self)

_from_parsed_uri(parsed, **credentials) classmethod

Source code in src/hostctl/transports/container/_config.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
@classmethod
def _from_parsed_uri(cls, parsed, **credentials: object) -> ContainerConfig:
    query = strict_uri_query(
        parsed,
        {
            "engine_url",
            "verify",
            "username",
            "workdir",
            "executable",
            "shell",
            "pathname",
            *_URI_TIMEOUT_KEYS,
        },
    )
    if not parsed.netloc or parsed.path not in ("", "/"):
        raise ValueError("Docker URI requires a container and no path")
    if parsed.username or parsed.password or parsed.port:
        raise ValueError("Docker URI authority must contain only a container name")
    return cls(
        container=unquote(parsed.netloc),
        engine_url=query.get("engine_url") or None,
        verify=_verify_from_query(query, bundle=True),
        username=query.get("username") or None,
        workdir=query.get("workdir") or None,
        executable=query.get("executable") or None,
        shell=(
            "auto"
            if query.get("shell", "auto") == "auto"
            else shell_flavour(query["shell"])
        ),
        pathname=pathname_selection(query.get("pathname", "auto"), allow_auto=True),
        timeout=_timeout_from_query(query),
        client_factory=typing.cast(
            typing.Optional[typing.Callable[..., object]],
            credentials.get("client_factory"),
        ),
        client_cert=typing.cast(
            typing.Optional[typing.Tuple[str, str]], credentials.get("client_cert")
        ),
    )

hostctl.ContainerHost(config, *, executor_providers=(), path_providers=())

Bases: Host

A running container reached through the Docker Engine API.

Commands and paths are assembled from ordered providers (:class:ContainerExecutorProvider and :class:ContainerArchivePathProvider) without changing the public API. The archive provider declares only the operations the Docker archive API can perform, so an unsupported mutation is rejected outright instead of falling through to another provider.

Source code in src/hostctl/transports/container/_host.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def __init__(
    self,
    config: ContainerConfig,
    *,
    executor_providers: typing.Iterable[object] = (),
    path_providers: typing.Iterable[object] = (),
) -> None:
    self.config = config
    self._client: typing.Optional[object] = None
    self._container: typing.Optional[ContainerLike] = None
    #: The one archive backend handed to every path, so `close()` can
    #: refuse the paths already out.
    self._path_backend: typing.Optional[ContainerPathBackend] = None
    self._attrs: typing.Optional[typing.Mapping[str, object]] = None
    self._executor = ContainerExecutor(
        lambda: self.container,
        user=config.username,
        workdir=config.workdir,
    )
    self._executor_provider_selector = ProviderSelector(
        tuple(executor_providers)
        or (
            ContainerExecutorProvider(
                self._executor,
                connect=lambda: self.container,
                close=self._close_client,
            ),
        )
    )
    self._path_provider_selector = ProviderSelector(
        tuple(path_providers) or (ContainerArchivePathProvider(self._archive_path),)
    )

_attrs = None instance-attribute

_client = None instance-attribute

_container = None instance-attribute

_executor = ContainerExecutor(lambda: self.container, user=config.username, workdir=config.workdir) instance-attribute

_executor_provider_selector = ProviderSelector(tuple(executor_providers) or (ContainerExecutorProvider(self._executor, connect=lambda: self.container, close=self._close_client),)) instance-attribute

_path_backend = None instance-attribute

_path_provider_selector = ProviderSelector(tuple(path_providers) or (ContainerArchivePathProvider(self._archive_path),)) instance-attribute

capabilities property

client property

config = config instance-attribute

container property

executor property

executor_providers property

The ordered command providers backing :meth:run.

inspected_os property

path_providers property

The ordered filesystem providers backing :meth:path.

shell_flavour property

_archive_path(*segments)

Build a Docker-archive-backed path with the configured flavour.

Source code in src/hostctl/transports/container/_host.py
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
def _archive_path(self, *segments: StrPath) -> Path:
    """Build a Docker-archive-backed path with the configured flavour."""
    selection = self.config.pathname
    windows = (
        self.inspected_os == "windows"
        if selection == "auto"
        else issubclass(typing.cast(PathnameType, selection), PureWindowsPath)
    )
    container = self.container
    flavour = "windows" if windows else "posix"
    path_backend = self._path_backend
    if (
        path_backend is None
        or path_backend.container is not container
        or path_backend.pathname != flavour
    ):
        path_backend = self._path_backend = ContainerPathBackend(
            container, pathname=flavour
        )
    if windows:
        values = segments or ("C:\\",)
        return WindowsContainerPath(*values, backend=path_backend)
    values = segments or ("/",)
    return PosixContainerPath(*values, backend=path_backend)

_close_client()

Source code in src/hostctl/transports/container/_host.py
283
284
285
286
287
288
289
290
291
292
293
294
295
def _close_client(self) -> None:
    backend, self._path_backend = self._path_backend, None
    if backend is not None:
        # docker-py reopens a closed client on the next request, so a path
        # obtained earlier would otherwise quietly leak a new connection.
        backend.invalidate()
    self._container = None
    self._attrs = None
    if self._client is not None:
        client, self._client = self._client, None
        close = getattr(client, "close", None)
        if close is not None:
            close()

_run_selector()

Source code in src/hostctl/transports/container/_host.py
176
177
def _run_selector(self) -> ProviderSelector:
    return self._executor_provider_selector

_run_with_provider(provider, cmds, *, bufsize, executable, stdin, stdout, stderr, cwd, env, capture_output, check, encoding, errors, input, timeout, text)

Source code in src/hostctl/transports/container/_host.py
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
def _run_with_provider(
    self,
    provider,
    cmds: typing.Sequence[Command],
    *,
    bufsize: int,
    executable: typing.Optional[str],
    stdin,
    stdout,
    stderr,
    cwd,
    env,
    capture_output,
    check,
    encoding,
    errors,
    input,
    timeout,
    text,
) -> subprocess.CompletedProcess:
    direct = starts_direct_command(cmds)
    if direct is not None:
        command, args = direct
        if executable is not None:
            raise NotImplementedError(
                "executable cannot be combined with a direct command"
            )
        return provider.execute(
            command,
            *args,
            bufsize=bufsize,
            stdin=stdin,
            stdout=stdout,
            stderr=stderr,
            cwd=cwd,
            env=env,
            capture_output=capture_output,
            check=check,
            encoding=encoding,
            errors=errors,
            input=input,
            timeout=timeout,
            text=text,
        )
    script = self.shell_flavour.script(cmds, cwd=None, env=None)
    invocation = self.shell_flavour.invocation(
        script, executable=executable or self.config.executable
    )
    return provider.execute(
        invocation[0],
        *invocation[1:],
        bufsize=bufsize,
        stdin=stdin,
        stdout=stdout,
        stderr=stderr,
        cwd=cwd,
        env=env,
        capture_output=capture_output,
        check=check,
        encoding=encoding,
        errors=errors,
        input=input,
        timeout=timeout,
        text=text,
    )

close()

Source code in src/hostctl/transports/container/_host.py
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def close(self) -> None:
    first_error: typing.Optional[BaseException] = None
    closed: typing.List[int] = []
    for provider in (
        *self._executor_provider_selector.providers,
        *self._path_provider_selector.providers,
    ):
        if id(provider) in closed:
            continue
        closed.append(id(provider))
        close = getattr(provider, "close", None)
        if close is None:
            continue
        try:
            close()
        except BaseException as exc:  # pragma: no cover - defensive
            if first_error is None:
                first_error = exc
    self._close_client()
    self._executor_provider_selector.invalidate()
    self._path_provider_selector.invalidate()
    if first_error is not None:  # pragma: no cover - defensive
        raise first_error

connect()

Source code in src/hostctl/transports/container/_host.py
280
281
def connect(self) -> None:
    _ = self.container

info()

Source code in src/hostctl/transports/container/_host.py
321
322
323
324
325
326
327
328
329
330
331
332
def info(self) -> HostInfo:
    result = self.run(self.shell_flavour.info_script, check=False, encoding="utf-8")
    info = parse_host_info(result.stdout)
    attrs = self._attrs or {}
    return HostInfo(
        hostname=info.hostname,
        os_family=info.os_family or self.inspected_os,
        os_name=info.os_name,
        os_version=info.os_version,
        architecture=info.architecture
        or typing.cast(typing.Optional[str], attrs.get("Architecture")),
    )

path(*segments, backend=None)

Source code in src/hostctl/transports/container/_host.py
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
def path(self, *segments: StrPath, backend: typing.Optional[str] = None) -> Path:
    names = {provider.name for provider in self._path_provider_selector.providers}
    if backend is not None and backend not in (names | {"docker"}):
        raise ValueError(f"unsupported container path backend: {backend!r}")
    if backend in (None, "docker"):
        provider = self._path_provider_selector.select().provider
    else:
        provider = next(
            item
            for item in self._path_provider_selector.providers
            if item.name == backend
        )
        if not provider.probe().usable:
            raise OperationNotStartedError(
                f"path provider {backend!r} is unavailable"
            )
    return provider.path(*segments)

run(*cmds, bufsize=-1, executable=None, stdin=None, stdout=None, stderr=None, cwd=None, env=None, capture_output=True, check=True, encoding=None, errors=None, input=None, timeout=_OMITTED, text=None)

Source code in src/hostctl/transports/container/_host.py
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
def run(
    self,
    *cmds: Command,
    bufsize: int = -1,
    executable: typing.Optional[str] = None,
    stdin: typing.Optional[StreamTarget] = None,
    stdout: typing.Optional[StreamTarget] = None,
    stderr: typing.Optional[StreamTarget] = None,
    cwd: typing.Optional[StrPath] = None,
    env: typing.Optional[CommandEnvironment] = None,
    capture_output: CaptureOutput = True,
    check: bool = True,
    encoding: typing.Optional[str] = None,
    errors: typing.Optional[str] = None,
    input: CommandInput = None,
    timeout: typing.Optional[float] = _OMITTED,  # type: ignore[assignment]
    text: typing.Optional[bool] = None,
) -> subprocess.CompletedProcess:
    # The config supports no total, so an omitted timeout is no limit.
    if timeout is _OMITTED or timeout is DEFAULT:
        timeout = None
    excluded: typing.List[str] = []
    while True:
        provider = self._executor_provider_selector.select(
            exclude=excluded
        ).provider
        try:
            return self._run_with_provider(
                provider,
                cmds,
                bufsize=bufsize,
                executable=executable,
                stdin=stdin,
                stdout=stdout,
                stderr=stderr,
                cwd=cwd,
                env=env,
                capture_output=capture_output,
                check=check,
                encoding=encoding,
                errors=errors,
                input=input,
                timeout=timeout,
                text=text,
            )
        except OperationNotStartedError as exc:
            # Only a proven pre-dispatch refusal may reach another
            # provider; a dispatched exec is never replayed.
            self._executor_provider_selector.decline(provider.name, str(exc))
            excluded.append(provider.name)

spawn(*cmds, executable=None, cwd=None, env=None, terminal=None, encoding=None, errors=None)

Start a persistent Docker exec process with an optional TTY.

Source code in src/hostctl/transports/container/_host.py
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
def spawn(
    self,
    *cmds: Command,
    executable: typing.Optional[str] = None,
    cwd: typing.Optional[StrPath] = None,
    env: typing.Optional[CommandEnvironment] = None,
    terminal: TerminalRequest = None,
    encoding: typing.Optional[str] = None,
    errors: typing.Optional[str] = None,
) -> Process:
    """Start a persistent Docker exec process with an optional TTY."""
    # Either `errors=` or `encoding=` selects text mode, the rule every
    # transport follows (`executor.wants_text`), so `errors="replace"`
    # alone applies to undecodable output.
    if errors is not None and encoding is None:
        encoding = "utf-8"
    direct = starts_direct_command(cmds)
    if direct is not None:
        command, args = direct
        if executable is not None:
            # `run()` refuses this too, so the same call means one thing
            # at both entry points.
            raise NotImplementedError(
                "executable cannot be combined with a direct command"
            )
        invocation = [command_text(command), *(command_text(v) for v in args)]
        environment = normalize_environment(env)
    elif cmds:
        script = self.shell_flavour.script(cmds, cwd=None, env=None)
        invocation = self.shell_flavour.invocation(
            script, executable=executable or self.config.executable
        )
        environment = normalize_environment(env)
    else:
        if cwd is not None or env is not None:
            raise ValueError("cwd and env require a command when spawning")
        selected = executable or self.config.executable
        invocation = (
            [selected] if selected else [self.shell_flavour.default_executable]
        )
        environment = None

    selected_terminal = terminal_options(terminal)
    tty = selected_terminal is not None
    # Through the provider, as `run()` goes: it inspects the container
    # before dispatch, so a stopped or vanished container is a proven
    # pre-dispatch refusal (`OperationNotStartedError`/`ConnectionError`)
    # rather than the `FileNotFoundError` the raw API answers with --
    # two different errors for one condition, depending on which entry
    # point the caller used.
    provider = self._executor_provider_selector.select().provider
    connect = getattr(provider, "connect", None)
    if callable(connect):
        connect()
    api = typing.cast(typing.Any, self.client).api
    selected_workdir = str(cwd) if cwd is not None else self.config.workdir
    options: typing.Dict[str, object] = {
        "cmd": list(invocation),
        "stdin": True,
        "stdout": True,
        "stderr": True,
        "tty": tty,
    }
    if selected_terminal is not None and selected_terminal.term_type:
        # `term_type` was accepted and never sent. Docker's exec API has
        # no TERM field, so it travels as the environment variable every
        # program actually reads -- without clobbering a caller's own.
        environment = dict(environment or {})
        environment.setdefault("TERM", selected_terminal.term_type)
    if environment is not None:
        options["environment"] = environment
    if selected_workdir is not None:
        options["workdir"] = selected_workdir
    if self.config.username is not None:
        options["user"] = self.config.username
    try:
        created = api.exec_create(
            getattr(self.container, "id", self.config.container),
            **options,
        )
        exec_id = created["Id"] if isinstance(created, dict) else created
        stream = api.exec_start(exec_id, socket=True, tty=tty)
        if selected_terminal is not None:
            try:
                api.exec_resize(
                    exec_id,
                    height=selected_terminal.rows,
                    width=selected_terminal.columns,
                )
            except Exception:
                _close_hijacked(stream)
                raise
    except Exception as exc:
        raise_normalized(exc, normalize_container_error)
    try:
        return ContainerProcess(
            api,
            str(exec_id),
            stream,
            tty=tty,
            command=list(invocation),
            encoding=encoding,
            errors=errors,
        )
    except Exception:
        # The exec has already started, so the hijacked daemon connection
        # is live, and this construction can raise (a bad `encoding` gives
        # LookupError). `SocketIO.close()` does not close the underlying
        # socket, which would then stay open until a GC pass.
        _close_hijacked(stream)
        raise

hostctl.QEMUConfig(domain, transport='libvirt', connection=None, socket_path=None, ssh=None, timeout=_TimeoutField(), max_reply_size=48 * 1024 * 1024, shell='auto', pathname='auto', transport_factory=None, serial_console=None, path_helper=None) dataclass

Bases: HostConfig

Guest identity, QGA transport, and guest semantic selections.

_COMMON_CREDENTIALS = ('transport_factory', 'serial_console', 'path_helper') class-attribute instance-attribute

_opens_agent_stream property

Whether hostctl connects the agent stream (unix, ssh) itself.

_timeout_defaults = {'total': None, 'connect': DEFAULT, 'read': 10.0} class-attribute instance-attribute

connection = None class-attribute instance-attribute

connection_uri property

domain instance-attribute

max_reply_size = 48 * 1024 * 1024 class-attribute instance-attribute

path_helper = dataclasses.field(default=None, repr=False, compare=False) class-attribute instance-attribute

pathname = 'auto' class-attribute instance-attribute

serial_console = dataclasses.field(default=None, repr=False, compare=False) class-attribute instance-attribute

shell = 'auto' class-attribute instance-attribute

socket_path = None class-attribute instance-attribute

ssh = dataclasses.field(default=None, repr=False) class-attribute instance-attribute

timeout = _TimeoutField() class-attribute instance-attribute

timeouts property

transport = 'libvirt' class-attribute instance-attribute

transport_factory = dataclasses.field(default=None, repr=False, compare=False) class-attribute instance-attribute

uri_credentials = ('username', 'password', 'client_keys', 'known_hosts', 'transport_factory', 'serial_console', 'path_helper') class-attribute instance-attribute

__post_init__()

Source code in src/hostctl/transports/qemu/_config.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def __post_init__(self) -> None:
    HostConfig.__init__(self)
    if not self.domain or "\x00" in self.domain:
        raise ValueError("domain must not be empty or contain NUL")
    if self.transport not in typing.get_args(QEMUTransport):
        raise ValueError(f"unsupported QEMU transport: {self.transport}")
    if self.timeout.connect is not DEFAULT and not self._opens_agent_stream:
        raise ValueError(
            "QEMUConfig: timeout.connect is not supported with "
            + (
                "transport_factory"
                if self.transport_factory is not None
                else "transport='libvirt'"
            )
        )
    if self.max_reply_size <= 0:
        raise ValueError("max_reply_size must be positive")
    if self.transport == "unix" and not self.socket_path:
        raise ValueError("unix QGA transport requires socket_path")
    if self.transport == "ssh" and self.ssh is None:
        raise ValueError("SSH QGA transport requires an SSHConfig")
    if self.transport != "ssh" and self.ssh is not None:
        raise ValueError("ssh config requires transport='ssh'")
    if self.shell != "auto":
        self.shell = shell_flavour(self.shell)
    self.pathname = pathname_selection(self.pathname, allow_auto=True)

_create_host()

Source code in src/hostctl/transports/qemu/_config.py
271
272
273
274
def _create_host(self) -> QEMUHost:
    from ._host import QEMUHost

    return QEMUHost(self)

_default_name()

Source code in src/hostctl/transports/qemu/_config.py
139
140
def _default_name(self) -> str:
    return self.domain

_from_parsed_uri(parsed, **credentials) classmethod

Source code in src/hostctl/transports/qemu/_config.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
@classmethod
def _from_parsed_uri(cls, parsed, **credentials: object) -> QEMUConfig:
    scheme = parsed.scheme.casefold()
    query = strict_uri_query(
        parsed,
        (
            "agent_timeout",
            "connection",
            "shell",
            "domain",
            "max_reply_size",
            "pathname",
            "socket_path",
            "verify",
            *_URI_TIMEOUT_KEYS,
        ),
    )
    if "verify" in query and scheme != "qga+ssh":
        # libvirt and a local socket have no peer for hostctl to verify.
        raise ValueError("verify applies only to a qga+ssh URI")
    _refuse_renamed_keys(query, {"agent_timeout": "timeout_read"})
    try:
        max_reply_size = int(query.get("max_reply_size", cls.max_reply_size))
    except ValueError as exc:
        raise ValueError("max_reply_size must be an integer") from exc
    common = {
        "timeout": _timeout_from_query(query),
        "max_reply_size": max_reply_size,
        "shell": (
            "auto"
            if query.get("shell", "auto") == "auto"
            else shell_flavour(query["shell"])
        ),
        "pathname": pathname_selection(
            query.get("pathname", "auto"), allow_auto=True
        ),
        "transport_factory": credentials.get("transport_factory"),
        "serial_console": credentials.get("serial_console"),
        "path_helper": credentials.get("path_helper"),
    }
    if scheme == "qemu+libvirt":
        if parsed.netloc or not parsed.path.strip("/"):
            raise ValueError("libvirt QEMU URI requires a domain path")
        return cls(
            unquote(parsed.path.lstrip("/")),
            transport="libvirt",
            connection=query.get("connection") or None,
            **common,
        )
    if scheme == "qga+unix":
        path = unquote(parsed.path.lstrip("/"))
        domain = query.get("domain")
        if not path or not domain:
            raise ValueError("Unix QGA URI requires socket path and domain")
        if not path.startswith("/"):
            path = "/" + path
        return cls(
            domain,
            transport="unix",
            socket_path=path,
            **common,
        )
    if not parsed.hostname or not parsed.username or not parsed.path.strip("/"):
        raise ValueError("SSH QGA URI requires user, host, and domain")
    ssh = SSHConfig(
        uri_hostname(parsed),
        port=parsed.port,
        username=unquote(parsed.username),
        password=typing.cast(typing.Optional[str], credentials.get("password")),
        client_keys=credentials.get("client_keys"),
        verify=typing.cast(bool, _verify_from_query(query, bundle=False)),
        known_hosts=credentials.get("known_hosts", ()),
    )
    return cls(
        unquote(parsed.path.lstrip("/")),
        transport="ssh",
        socket_path=query.get("socket_path") or None,
        ssh=ssh,
        **common,
    )

_uri_credentials_for(parsed) classmethod

Source code in src/hostctl/transports/qemu/_config.py
182
183
184
185
186
187
188
@classmethod
def _uri_credentials_for(cls, parsed):
    # A `password=` on a libvirt or Unix socket URI would read as
    # authentication that had been applied.
    if parsed.scheme.casefold() != "qga+ssh":
        return cls._COMMON_CREDENTIALS
    return cls.uri_credentials

hostctl.QEMUHost(config)

Bases: Host

A guest OS reached through QEMU Guest Agent.

Source code in src/hostctl/transports/qemu/_host.py
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def __init__(self, config: QEMUConfig) -> None:
    self.config = config
    # Lazy initialisation is racy without this. Two threads reaching
    # `transport` first each built a transport, each opened its own SSH
    # connection, and the loser's was dropped on the floor still open --
    # a connection nobody owned and `close()` could not reach.
    self._lock = threading.RLock()
    self._transport: typing.Optional[GuestAgentTransport] = None
    self._ssh_transport: typing.Optional[_SSHTransport] = None
    self._commands: typing.Optional[typing.FrozenSet[str]] = None
    self._os_info: typing.Mapping[str, object] = {}
    self._hostname: typing.Optional[str] = None
    #: qemu-ga's `guest-info` version string, once discovered.
    self._agent_version: typing.Optional[str] = None
    self._executor = QEMUExecutor(
        lambda: self.transport,
        agent_timeout=config.timeouts.read,
        merged_capture=self._merges_output,
    )
    self._path_backend: typing.Optional[QGAPathBackend] = None
    #: The probed guest helper, or `None` once probing has failed.
    #: `_UNPROBED` distinguishes "not asked yet" from "asked, no helper".
    self._path_helper: object = _UNPROBED
    self._path_provider: typing.Optional[object] = None

_POSIX_ONLY_COMMANDS = frozenset({'guest-get-cpustats', 'guest-get-diskstats'}) class-attribute instance-attribute

_WINDOWS_ONLY_COMMANDS = frozenset({'guest-get-devices'}) class-attribute instance-attribute

_agent_version = None instance-attribute

_commands = None instance-attribute

_executor = QEMUExecutor(lambda: self.transport, agent_timeout=config.timeouts.read, merged_capture=self._merges_output) instance-attribute

_hostname = None instance-attribute

_lock = threading.RLock() instance-attribute

_os_info = {} instance-attribute

_path_backend = None instance-attribute

_path_helper = _UNPROBED instance-attribute

_path_provider = None instance-attribute

_ssh_transport = None instance-attribute

_transport = None instance-attribute

_windows property

capabilities property

config = config instance-attribute

executor property

path_provider property

The QGA path provider, once :meth:path has assembled it.

shell_flavour property

supported_commands property

transport property

_build_transport()

Source code in src/hostctl/transports/qemu/_host.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
def _build_transport(self) -> GuestAgentTransport:
    if self._transport is None:
        if self.config.transport_factory is not None:
            self._transport = self.config.transport_factory()
        elif self.config.transport == "libvirt":
            self._transport = LibvirtGuestAgentTransport(
                self.config.domain,
                connection_uri=self.config.connection,
                timeout=self.config.timeouts.read,
            )
        elif self.config.transport == "unix":
            self._transport = UnixSocketGuestAgentTransport(
                typing.cast(str, self.config.socket_path),
                timeout=self.config.timeouts.read,
                connect_timeout=self._connect_timeout(),
                max_reply_size=self.config.max_reply_size,
            )
        else:
            ssh_transport = typing.cast(
                SSHConfig, self.config.ssh
            )._create_transport()
            try:
                ssh_transport.connect()
            except Exception:
                try:
                    ssh_transport.close()
                except Exception:
                    pass
                raise
            self._ssh_transport = ssh_transport
            socket_path = self.config.socket_path or (
                f"/run/qemu-server/{self.config.domain}.qga"
            )
            self._transport = SSHUnixGuestAgentTransport(
                socket_path,
                lambda: ssh_transport.ssh,
                timeout=self.config.timeouts.read,
                connect_timeout=self._connect_timeout(),
                max_reply_size=self.config.max_reply_size,
            )
    return self._transport

_close_locked()

Source code in src/hostctl/transports/qemu/_host.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
def _close_locked(self) -> None:
    self._commands = None
    self._agent_version = None
    self._os_info = {}
    self._hostname = None
    self._path_helper = _UNPROBED
    backend, self._path_backend = self._path_backend, None
    self._path_provider = None
    if backend is not None:
        # A path handed out earlier still holds this backend, and the
        # backend still holds the transport. Dropping the host's
        # reference did nothing to that path: its next operation
        # reconnected the transport -- a fresh SSH connection nobody
        # owned and no `close()` could reach.
        backend.invalidate()
    transport, self._transport = self._transport, None
    ssh, self._ssh_transport = self._ssh_transport, None
    try:
        if transport is not None:
            transport.close()
    finally:
        if ssh is not None:
            ssh.close()

_connect_timeout()

The agent-stream connect bound; omitted, it shares the first reply's.

Source code in src/hostctl/transports/qemu/_host.py
133
134
135
136
def _connect_timeout(self) -> typing.Union[float, None, _Omitted]:
    """The agent-stream connect bound; omitted, it shares the first reply's."""
    connect = self.config.timeout.connect
    return _OMITTED if connect is DEFAULT else connect

_discover()

Source code in src/hostctl/transports/qemu/_host.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
def _discover(self) -> None:
    if self._commands is not None:
        return
    transport = self.transport
    transport.execute({"execute": "guest-ping"}, self.config.timeouts.read)
    info = transport.execute({"execute": "guest-info"}, self.config.timeouts.read)
    if not isinstance(info, typing.Mapping):
        raise ConnectionError("guest-info returned a non-object result")
    version = info.get("version")
    entries = info.get("supported_commands", ())
    commands = set()
    if isinstance(entries, typing.Iterable):
        for entry in entries:
            if (
                isinstance(entry, typing.Mapping)
                and entry.get("enabled", True)
                and isinstance(entry.get("name"), str)
            ):
                commands.add(typing.cast(str, entry["name"]))
    # `_commands` is the "already discovered" flag, so it is committed
    # only once the optional probes below have run. Set first, a single
    # transient probe failure froze a Windows guest as POSIX for the
    # object's lifetime, and a retried connect() silently succeeded
    # without re-probing.
    discovered = frozenset(commands)
    if "guest-get-osinfo" in commands:
        value = transport.execute(
            {"execute": "guest-get-osinfo"}, self.config.timeouts.read
        )
        if isinstance(value, typing.Mapping):
            self._os_info = value
    if "guest-get-host-name" in commands:
        value = transport.execute(
            {"execute": "guest-get-host-name"},
            self.config.timeouts.read,
        )
        if isinstance(value, typing.Mapping):
            hostname = value.get("host-name")
            self._hostname = str(hostname) if hostname else None
    self._agent_version = version if isinstance(version, str) else None
    self._commands = discovered

_guest_path_helper()

Probe the guest for a metadata/namespace helper, or answer None.

QGA's file RPCs move bytes and nothing else, so stat, scandir and every mutation need a guest-side helper. One is built here from guest-exec -- positively probed, never assumed: stat -c and find -printf are GNU spellings busybox and the BSDs do not share, and a helper that assumes GNU and silently mis-parses is worse than none. A failed probe returns None, which leaves the backend in exactly the degraded mode it had before: reads and writes keep working, and only the operations that genuinely need a helper are unavailable.

QEMUConfig(path_helper=...) overrides this entirely.

Source code in src/hostctl/transports/qemu/_host.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
def _guest_path_helper(self):
    """Probe the guest for a metadata/namespace helper, or answer None.

    QGA's file RPCs move bytes and nothing else, so `stat`, `scandir`
    and every mutation need a guest-side helper. One is built here from
    `guest-exec` -- positively probed, never assumed: `stat -c` and
    `find -printf` are GNU spellings busybox and the BSDs do not share,
    and a helper that assumes GNU and silently mis-parses is worse than
    none. A failed probe returns `None`, which leaves the backend in
    exactly the degraded mode it had before: reads and writes keep
    working, and only the operations that genuinely need a helper are
    unavailable.

    `QEMUConfig(path_helper=...)` overrides this entirely.
    """
    if self._path_helper is not _UNPROBED:
        return self._path_helper
    self._path_helper = None
    if not {"guest-exec", "guest-exec-status"} <= self.supported_commands:
        return None
    from ._helper import PosixGuestPathHelper, WindowsGuestPathHelper

    family = self._probe_os_family()
    if family is None:
        # The same refusal `shell_flavour` makes: a helper for the wrong
        # family would run commands the guest does not have.
        return None
    helper_type = (
        WindowsGuestPathHelper if family == "windows" else PosixGuestPathHelper
    )
    try:
        self._path_helper = helper_type.probe(self._executor)
    except Exception:
        log.debug("guest path helper probe failed", exc_info=True)
        self._path_helper = None
    return self._path_helper

_merges_output()

Whether the agent can interleave stderr into stdout itself.

capture-output: "merged" exists from qemu-ga 8.0 and is compiled out of the Windows agent, so both facts must be positively known.

Source code in src/hostctl/transports/qemu/_host.py
284
285
286
287
288
289
290
291
292
293
294
295
def _merges_output(self) -> bool:
    """Whether the agent can interleave stderr into stdout itself.

    `capture-output: "merged"` exists from qemu-ga 8.0 and is compiled
    out of the Windows agent, so both facts must be positively known.
    """
    self.connect()
    match = re.match(r"(\d+)\.(\d+)", self._agent_version or "")
    if not match or (int(match[1]), int(match[2])) < (8, 0):
        return False
    family = self._probe_os_family()
    return family is not None and "windows" not in family

_probe_os_family()

The guest's OS family, or None when nothing positively said.

Every branch here is positive evidence. Reading "not Windows" out of an guest-get-osinfo that never ran is a guess, and the project's rule for auto-detection is to raise rather than guess.

Source code in src/hostctl/transports/qemu/_host.py
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
def _probe_os_family(self) -> typing.Optional[str]:
    """The guest's OS family, or `None` when nothing positively said.

    Every branch here is positive evidence. Reading "not Windows" out of
    an `guest-get-osinfo` that never ran is a guess, and the project's
    rule for auto-detection is to raise rather than guess.
    """
    self.connect()
    values = " ".join(
        str(self._os_info.get(key, "")) for key in ("id", "name", "pretty-name")
    ).casefold()
    if "windows" in values:
        return "windows"
    kernel = str(self._os_info.get("kernel-name", "")).strip().casefold()
    if kernel:
        # `Linux`, `FreeBSD`, ... -- the family, where `id` is the
        # distribution (`ubuntu`, `rhel`) every other transport does
        # not report here.
        return kernel
    if self._os_info:
        # osinfo answered and named no kernel: an older agent, but the
        # answer itself is evidence the guest is not Windows.
        return "posix"
    commands = self._commands or frozenset()
    if commands & self._WINDOWS_ONLY_COMMANDS:
        return "windows"
    if commands & self._POSIX_ONLY_COMMANDS:
        return "posix"
    return None

_qga_path(*segments)

Build a QGA-backed guest path with the configured pathname flavour.

Source code in src/hostctl/transports/qemu/_host.py
407
408
409
410
411
412
413
414
415
416
417
418
def _qga_path(self, *segments: StrPath) -> Path:
    """Build a QGA-backed guest path with the configured pathname flavour."""
    path_backend = self._path_backend
    selection = self.config.pathname
    windows = (
        self._windows
        if selection == "auto"
        else issubclass(typing.cast(PathnameType, selection), PureWindowsPath)
    )
    if windows:
        return WindowsQEMUPath(*(segments or ("C:\\",)), backend=path_backend)
    return PosixQEMUPath(*(segments or ("/",)), backend=path_backend)

close()

Source code in src/hostctl/transports/qemu/_host.py
220
221
222
def close(self) -> None:
    with self._lock:
        self._close_locked()

connect()

Source code in src/hostctl/transports/qemu/_host.py
172
173
174
175
176
def connect(self) -> None:
    if self._commands is not None:
        return
    with self._lock:
        self._discover()

info()

Source code in src/hostctl/transports/qemu/_host.py
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
def info(self) -> HostInfo:
    self.connect()
    # `os_family` is a FAMILY (`linux`, as over SSH), not
    # `guest-get-osinfo`'s `id`, which is an os-release id such as
    # `ubuntu`; `id` and `pretty-name` feed `os_name`.
    return HostInfo(
        hostname=self._hostname,
        os_family=self._probe_os_family(),
        os_name=typing.cast(
            typing.Optional[str],
            self._os_info.get("pretty-name")
            or self._os_info.get("name")
            or self._os_info.get("id"),
        ),
        os_version=typing.cast(
            typing.Optional[str],
            self._os_info.get("version") or self._os_info.get("version-id"),
        ),
        architecture=typing.cast(
            typing.Optional[str], self._os_info.get("machine")
        ),
    )

open_serial(*, encoding=None, errors=None)

Open the explicitly configured VM console; str I/O with encoding.

Source code in src/hostctl/transports/qemu/_host.py
161
162
163
164
165
166
167
168
169
170
def open_serial(
    self,
    *,
    encoding: typing.Optional[str] = None,
    errors: typing.Optional[str] = None,
) -> Process:
    """Open the explicitly configured VM console; `str` I/O with `encoding`."""
    if self.config.serial_console is None:
        raise NotImplementedError("QEMU host has no configured serial console")
    return self.config.serial_console.open(encoding=encoding, errors=errors)

path(*segments, backend=None)

Source code in src/hostctl/transports/qemu/_host.py
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
def path(self, *segments: StrPath, backend: typing.Optional[str] = None) -> Path:
    if "path" not in self.capabilities:
        raise NotImplementedError("guest agent does not provide file RPCs")
    if backend not in (None, "qga"):
        raise ValueError(f"unsupported QEMU path backend: {backend!r}")
    if self._path_backend is None:
        self._path_backend = QGAPathBackend(
            self.transport,
            supported_commands=self.supported_commands,
            helper=self.config.path_helper or self._guest_path_helper(),
            timeout=self.config.timeouts.read,
        )
    path_backend = self._path_backend
    if (
        self._path_provider is None
        or self._path_provider.backend is not path_backend
    ):
        from ._providers import QGAPathProvider

        # The provider declares exactly the operations the probed guest
        # agent supports, so an unavailable metadata/mutation RPC is
        # rejected rather than routed to a different backend.
        self._path_provider = QGAPathProvider(self._qga_path, path_backend)
    if not self._path_provider.probe().usable:
        raise NotImplementedError("guest agent does not provide usable file RPCs")
    return self._path_provider.path(*segments)

run(*cmds, bufsize=-1, executable=None, stdin=None, stdout=None, stderr=None, cwd=None, env=None, capture_output=True, check=True, encoding=None, errors=None, input=None, timeout=_OMITTED, text=None)

Source code in src/hostctl/transports/qemu/_host.py
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
def run(
    self,
    *cmds: Command,
    bufsize: int = -1,
    executable: typing.Optional[str] = None,
    stdin: typing.Optional[StreamTarget] = None,
    stdout: typing.Optional[StreamTarget] = None,
    stderr: typing.Optional[StreamTarget] = None,
    cwd: typing.Optional[StrPath] = None,
    env: typing.Optional[CommandEnvironment] = None,
    capture_output: CaptureOutput = True,
    check: bool = True,
    encoding: typing.Optional[str] = None,
    errors: typing.Optional[str] = None,
    input: CommandInput = None,
    timeout: typing.Optional[float] = _OMITTED,  # type: ignore[assignment]
    text: typing.Optional[bool] = None,
) -> subprocess.CompletedProcess:
    timeout = _call_timeout(self.config, timeout)
    if "run" not in self.capabilities:
        raise NotImplementedError("guest agent does not provide guest-exec")
    direct = starts_direct_command(cmds)
    if direct is not None:
        command, args = direct
        if executable is not None:
            raise NotImplementedError(
                "executable cannot be combined with a direct QGA command"
            )
        if cwd is not None:
            raise NotImplementedError("QGA direct argv execution cannot apply cwd")
        if env is not None:
            # guest-exec's `env` list is handed to the guest agent as
            # envp, which REPLACES the child environment -- no PATH, no
            # HOME, no SystemRoot. hostctl's contract is additive, and a
            # direct argv execution has no shell to embed assignments
            # into, so this is refused exactly as cwd is.
            raise NotImplementedError("QGA direct argv execution cannot apply env")
    else:
        script = self.shell_flavour.script(cmds, cwd=cwd, env=env)
        selected_executable = executable
        if selected_executable is None and self._windows:
            selected_executable = {
                "powershell.exe": (
                    r"C:\Windows\System32\WindowsPowerShell" r"\v1.0\powershell.exe"
                ),
                "cmd.exe": r"C:\Windows\System32\cmd.exe",
            }.get(self.shell_flavour.default_executable.casefold())
            if selected_executable is None:
                raise NotImplementedError(
                    "QGA Windows shell execution requires an absolute "
                    "executable path for this shell flavour"
                )
        invocation = self.shell_flavour.invocation(
            script, executable=selected_executable
        )
        command, args = invocation[0], invocation[1:]
    return self.executor(
        command,
        *args,
        bufsize=bufsize,
        stdin=stdin,
        stdout=stdout,
        stderr=stderr,
        # Never natively: the assignments are already in the script, and
        # sending them again would replace the guest environment.
        env=None,
        capture_output=capture_output,
        check=check,
        encoding=encoding,
        errors=errors,
        input=input,
        timeout=timeout,
        text=text,
    )

hostctl.SerialConfig(device, baudrate=115200, bytesize=8, parity='N', stopbits=1, xonxoff=False, rtscts=False, dsrdtr=False, timeout=_TimeoutField(), inter_byte_timeout=None, exclusive=None, profile=RawConsoleProfile(), serial_factory=None, serial_port=None) dataclass

Bases: HostConfig

Serial transport settings; credentials and profiles stay out of URIs.

_timeout_defaults = {'total': None, 'read': 0.1, 'write': 10.0} class-attribute instance-attribute

_timeout_zero = ('read', 'write') class-attribute instance-attribute

baudrate = 115200 class-attribute instance-attribute

bytesize = 8 class-attribute instance-attribute

connection_uri property

device instance-attribute

dsrdtr = False class-attribute instance-attribute

exclusive = None class-attribute instance-attribute

inter_byte_timeout = None class-attribute instance-attribute

parity = 'N' class-attribute instance-attribute

profile = dataclasses.field(default_factory=RawConsoleProfile, repr=False, compare=False) class-attribute instance-attribute

rtscts = False class-attribute instance-attribute

serial_factory = dataclasses.field(default=None, repr=False, compare=False) class-attribute instance-attribute

serial_port = dataclasses.field(default=None, repr=False, compare=False) class-attribute instance-attribute

settings = None class-attribute

stopbits = 1 class-attribute instance-attribute

timeout = _TimeoutField() class-attribute instance-attribute

uri_credentials = ('profile', 'serial_factory', 'serial_port') class-attribute instance-attribute

xonxoff = False class-attribute instance-attribute

__post_init__()

Source code in src/hostctl/transports/serial/_host.py
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
def __post_init__(self) -> None:
    HostConfig.__init__(self)
    # Built ONCE, here, by keyword, and kept: `SerialHost` reads it
    # rather than building a second one from the same fields.
    timeouts = self.timeouts
    settings = SerialSettings(
        self.device,
        baudrate=self.baudrate,
        bytesize=self.bytesize,
        parity=self.parity,
        stopbits=self.stopbits,
        xonxoff=self.xonxoff,
        rtscts=self.rtscts,
        dsrdtr=self.dsrdtr,
        read_timeout=typing.cast(typing.Optional[float], timeouts.read),
        write_timeout=typing.cast(typing.Optional[float], timeouts.write),
        inter_byte_timeout=self.inter_byte_timeout,
        exclusive=self.exclusive,
    )
    self.device = settings.port
    self.baudrate, self.bytesize, self.parity, self.stopbits = (
        settings.baudrate,
        settings.bytesize,
        settings.parity,
        settings.stopbits,
    )
    if not isinstance(self.profile, ConsoleProfile):
        raise TypeError("profile must implement ConsoleProfile")
    object.__setattr__(self, "settings", settings)

_create_host()

Source code in src/hostctl/transports/serial/_host.py
278
279
def _create_host(self) -> "SerialHost":
    return SerialHost(self)

_default_name()

Source code in src/hostctl/transports/serial/_host.py
196
197
def _default_name(self) -> str:
    return self.device

_from_parsed_uri(parsed, **credentials) classmethod

Source code in src/hostctl/transports/serial/_host.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
@classmethod
def _from_parsed_uri(cls, parsed, **credentials: object) -> "SerialConfig":
    device = _device_from_uri_path(parsed.path)
    if parsed.netloc:
        raise ValueError("serial URI must not contain an authority")
    if not device:
        raise ValueError("serial URI requires a device")
    query = strict_uri_query(
        parsed,
        (
            "baudrate",
            "bytesize",
            "parity",
            "stopbits",
            "xonxoff",
            "rtscts",
            "dsrdtr",
            "read_timeout",
            "write_timeout",
            "inter_byte_timeout",
            "exclusive",
            *_URI_TIMEOUT_KEYS,
        ),
    )
    _refuse_renamed_keys(
        query, {"read_timeout": "timeout_read", "write_timeout": "timeout_write"}
    )

    def integer(name: str, default: int) -> int:
        try:
            return int(query.get(name, default))
        except ValueError as exc:
            raise ValueError(f"{name} must be an integer") from exc

    return cls(
        device,
        baudrate=integer("baudrate", 115200),
        bytesize=integer("bytesize", 8),
        parity=query.get("parity", "N"),
        stopbits=float(query.get("stopbits", "1")),
        xonxoff=_bool(query.get("xonxoff", "false"), "xonxoff"),
        rtscts=_bool(query.get("rtscts", "false"), "rtscts"),
        dsrdtr=_bool(query.get("dsrdtr", "false"), "dsrdtr"),
        timeout=_timeout_from_query(query),
        inter_byte_timeout=_optional_float(
            query.get("inter_byte_timeout", ""), "inter_byte_timeout"
        ),
        exclusive=(
            None
            if query.get("exclusive", "") == ""
            else _bool(query["exclusive"], "exclusive")
        ),
        profile=typing.cast(
            ConsoleProfile, credentials.get("profile", RawConsoleProfile())
        ),
        serial_factory=typing.cast(
            typing.Optional[SerialFactory], credentials.get("serial_factory")
        ),
        serial_port=typing.cast(
            typing.Optional[SerialLike], credentials.get("serial_port")
        ),
    )

hostctl.SerialHost(config)

Bases: Host

Host facade over one exclusive serial console byte stream.

Source code in src/hostctl/transports/serial/_host.py
285
286
287
288
289
290
291
292
293
294
295
296
297
298
def __init__(self, config: SerialConfig) -> None:
    self.config = config
    self._executor = SerialExecutor(
        typing.cast(SerialSettings, config.settings),
        serial_factory=config.serial_factory,
        serial_port=config.serial_port,
        owns_serial_port=False if config.serial_port is not None else True,
    )
    #: The transport the negotiation was performed on, not a bare
    #: flag. `executor.close()` -- a public property's public method --
    #: dropped the port without touching a flag on this object, so the
    #: next `run()` opened a fresh port, skipped every `LoginStep`, and
    #: typed the command at the device's `login:` prompt.
    self._negotiated_on: typing.Optional[object] = None

_executor = SerialExecutor(typing.cast(SerialSettings, config.settings), serial_factory=config.serial_factory, serial_port=config.serial_port, owns_serial_port=False if config.serial_port is not None else True) instance-attribute

_negotiated_on = None instance-attribute

capabilities property

config = config instance-attribute

executor property

shell property

shell_flavour property

The flavour the console profile declares (PosixConsoleProfile's shell).

close()

Source code in src/hostctl/transports/serial/_host.py
349
350
351
def close(self) -> None:
    self._negotiated_on = None
    self._executor.close()

connect()

Source code in src/hostctl/transports/serial/_host.py
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
def connect(self) -> None:
    port = self._executor.connect()
    if self._negotiated_on is port:
        return
    process = self._executor.open()
    try:
        self.config.profile.negotiate(process)
    except TimeoutError as exc:
        # A negotiation timeout is a PROTOCOL failure, not a command
        # timeout: `negotiate()` has its own budget, and the raw
        # `TimeoutError` escaped `connect()`, `run()`, `spawn()` and
        # `with config as host:` alike -- past every
        # `except subprocess.TimeoutExpired` a caller had written, and
        # indistinguishable from a port-open failure under `OSError`.
        raise ConsoleProtocolError(
            "serial console did not complete its login exchange"
        ) from exc
    else:
        self._negotiated_on = port
    finally:
        process.close()

info()

Source code in src/hostctl/transports/serial/_host.py
353
354
def info(self) -> HostInfo:
    return HostInfo()

path(*segments, backend=None)

Source code in src/hostctl/transports/serial/_host.py
356
357
def path(self, *segments: StrPath, backend: typing.Optional[str] = None):
    raise NotImplementedError("serial consoles do not provide filesystem paths")

run(*cmds, bufsize=-1, executable=None, stdin=None, stdout=None, stderr=None, cwd=None, env=None, capture_output=True, check=True, encoding=None, errors=None, input=None, timeout=_OMITTED, text=None)

Source code in src/hostctl/transports/serial/_host.py
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
def run(
    self,
    *cmds: Command,
    bufsize: int = -1,
    executable: typing.Optional[str] = None,
    stdin: typing.Optional[StreamTarget] = None,
    stdout: typing.Optional[StreamTarget] = None,
    stderr: typing.Optional[StreamTarget] = None,
    cwd: typing.Optional[StrPath] = None,
    env: typing.Optional[CommandEnvironment] = None,
    capture_output: CaptureOutput = True,
    check: bool = True,
    encoding: typing.Optional[str] = None,
    errors: typing.Optional[str] = None,
    input: CommandInput = None,
    timeout: typing.Optional[float] = _OMITTED,  # type: ignore[assignment]
    text: typing.Optional[bool] = None,
) -> subprocess.CompletedProcess:
    timeout = _call_timeout(self.config, timeout)
    profile = self.config.profile
    if not profile.can_run:
        raise NotImplementedError(
            "serial console profile does not provide reliable run()"
        )
    flavour = getattr(profile, "shell_flavour", None)
    direct = starts_direct_command(cmds)
    if direct is not None:
        if flavour is None:
            raise NotImplementedError(
                "serial consoles do not provide native argv execution"
            )
        # A console has no argv channel: the program and its arguments are
        # quoted into the script, as for any script-only provider.
        cmds = ((direct[0], *direct[1]),)
    if executable is not None:
        raise NotImplementedError("serial consoles do not accept executable")
    if flavour is None and (cwd is not None or env is not None):
        raise NotImplementedError("serial console profile does not support cwd/env")
    # A profile with `execute` (`PosixConsoleProfile`) frames stdin and
    # stderr of its own; any other one has a single merged stream.
    execute = getattr(profile, "execute", None)
    framed = callable(execute)
    if not framed and input is not None:
        raise NotImplementedError("serial framed run does not support stdin input")
    if not framed and stdin is not None:
        # Accepted and dropped, this looked like a second way to feed a
        # command -- while `input=` right above refuses explicitly.
        raise NotImplementedError(
            "serial framed run does not support stdin redirection"
        )
    if bufsize != -1:
        raise NotImplementedError(
            "serial framed run has no pipe to size: bufsize is not supported"
        )
    rendered: typing.List[str] = []
    shown: typing.Optional[str] = None
    if flavour is not None:
        # One script, one framed exchange: operators and the separator
        # are the shell's, exactly as on any other host of this flavour.
        script = _console_script(flavour, cmds, cwd=cwd, env=env)
        rendered = [script] if script else []
        shown = (
            _console_script(flavour, cmds, cwd=cwd, env=masked_environment(env))
            if env
            else script
        )
    else:
        for value in cmds:
            if isinstance(value, (tuple, list)):
                # `shlex.join` is POSIX quoting, and this profile names no
                # shell flavour at all. On a Cisco-style console
                # `["show", "run | include hostname"]` went on the wire as
                # `show 'run | include hostname'`, which the device takes
                # literally and rejects. The profile owns the device's
                # grammar; nothing here knows it.
                raise NotImplementedError(
                    "a serial console profile defines no argv quoting: pass "
                    "the command as text, spelled the way the device expects"
                )
            rendered.append(str(value))
    data = None
    if framed:
        reject_stdin_conflict(input, stdin)
        if stdin is not None:
            # The terminal is never the command's stdin: a stream is read
            # to its end here and travels as input.
            input = _read_stdin(stdin, encoding=encoding, errors=errors)
        data = normalize_input(
            input, text_mode=False, encoding=encoding, errors=errors
        )
        if data is not None and len(rendered) > 1:
            raise NotImplementedError(
                "serial run sends input= to one command; run them separately"
            )
    output_stream, error_stream = capture_streams(capture_output, stdout, stderr)
    merge = not framed or error_stream == subprocess.STDOUT
    text_mode = wants_text(text, encoding, errors)
    self.connect()
    process = self._executor.open()
    deadline = None if timeout is None else time.monotonic() + timeout
    outputs = []
    error_outputs = []
    returncode = 0
    command: typing.Union[str, typing.List[str]] = (
        rendered[0] if len(rendered) == 1 else list(rendered)
    )
    if shown:
        command = shown
    try:
        for single in rendered:
            remaining = (
                None if deadline is None else max(0.0, deadline - time.monotonic())
            )
            try:
                # One framed exchange per command. Joined with `";"` they
                # went out as one line, which most device consoles parse
                # as a single malformed command rather than two -- and a
                # profile that frames status per exchange could only
                # report one status for all of them.
                if framed:
                    output, error_output, returncode = typing.cast(
                        typing.Callable[..., typing.Tuple[bytes, bytes, int]],
                        execute,
                    )(
                        process,
                        single,
                        input=data,
                        merge_stderr=merge,
                        timeout=remaining,
                    )
                    error_outputs.append(error_output)
                else:
                    output, returncode = profile.run(
                        process, single, timeout=remaining
                    )
            except TimeoutError as exc:
                # `orphaned=True`: a console cannot confirm the device
                # stopped the command, so it may still be running there.
                # One payload shape for every transport (see
                # `executor.expired`).
                partial = b"".join(outputs) + (getattr(exc, "output", None) or b"")
                partial_error = (
                    None
                    if merge
                    else b"".join(error_outputs)
                    + (getattr(exc, "stderr", None) or b"")
                )
                raise expired(
                    command,
                    timeout,
                    output=partial,
                    stderr=partial_error,
                    orphaned=True,
                    text=bool(text or encoding or errors),
                ) from exc
            outputs.append(output)
    finally:
        process.close()

    def value(raw: bytes) -> typing.Union[str, bytes]:
        return (
            raw.decode(encoding or "utf-8", errors or "strict")
            if text_mode
            else raw
        )

    # The shared dispatcher owns the "a `None` target means `sys.stdout`"
    # rule. A merged stream pins the stderr side to PIPE, so the
    # dispatcher does not write an empty second stream.
    captured, captured_error = dispatch_output(
        output_stream,
        subprocess.PIPE if merge else error_stream,
        value(b"".join(outputs)),
        None if merge else value(b"".join(error_outputs)),
        encoding=encoding,
        errors=errors,
    )
    result = subprocess.CompletedProcess(
        command, returncode, captured, captured_error
    )
    if check and returncode:
        raise subprocess.CalledProcessError(
            returncode, command, output=captured, stderr=captured_error
        )
    return result

spawn(*cmds, executable=None, cwd=None, env=None, terminal=None, encoding=None, errors=None)

Source code in src/hostctl/transports/serial/_host.py
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
def spawn(
    self,
    *cmds: Command,
    executable: typing.Optional[str] = None,
    cwd: typing.Optional[StrPath] = None,
    env: typing.Optional[CommandEnvironment] = None,
    terminal=None,
    encoding: typing.Optional[str] = None,
    errors: typing.Optional[str] = None,
) -> Process:
    if cmds:
        raise NotImplementedError("serial sessions do not accept startup commands")
    if executable is not None or cwd is not None or env is not None:
        raise NotImplementedError(
            "serial sessions do not support executable/cwd/env"
        )
    selected_terminal = terminal_options(terminal)
    if selected_terminal is not None and not callable(
        getattr(self.config.profile, "terminal_setup", None)
    ):
        raise NotImplementedError("serial connections cannot allocate a PTY")
    self.connect()
    raw = self._executor.open()
    if selected_terminal is not None:
        try:
            self.config.profile.resize(
                raw, selected_terminal.columns, selected_terminal.rows
            )
        except Exception:
            raw.close()
            raise
    return SerialConsoleProcess(
        raw,
        self.config.profile,
        encoding or getattr(self.config.profile, "encoding", "utf-8"),
        errors,
        # Text only when the caller ASKED for it. The profile's encoding
        # is a write-side default; promoting it to a read mode would
        # turn every existing byte session into a text one. With either
        # keyword given, reads decode -- as they already do on the SSH
        # and QEMU console adapters, where the same call returns `str`.
        text=bool(encoding or errors),
    )

hostctl.SystemConfig(authority='localhost', *, shell=None, executor=None, path=None, provider_options=None, initializer=None, timeout=Timeout())

Bases: HostConfig

Base configuration for a logical system with one or more providers.

Abstract: concrete system families are :class:PosixConfig, :class:WindowsConfig, and :class:IOSConfig. Each binds host_type and uri_scheme; this class binds neither and cannot be instantiated into a host on its own.

Source code in src/hostctl/system/_config.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
def __init__(
    self,
    authority: str = "localhost",
    *,
    shell: object = None,
    executor: typing.Optional[typing.Iterable[str]] = None,
    path: typing.Optional[typing.Iterable[str]] = None,
    provider_options: typing.Optional[typing.Mapping[str, object]] = None,
    initializer: typing.Optional[typing.Callable[..., object]] = None,
    timeout: typing.Union[float, None, Timeout] = Timeout(),
):
    super().__init__()
    self.timeout = timeout  # type: ignore[assignment]
    _refuse_uri(type(self).__name__, authority)
    for name, value in (("executor", executor), ("path", path)):
        if isinstance(value, (str, bytes)):
            raise TypeError(
                f"{name}= takes a sequence of descriptor names, "
                "e.g. ('local',), not a string"
            )
    self.authority = authority or "localhost"
    self.shell = shell
    #: Descriptor names in precedence order; `None` takes the host
    #: class's `default_providers()`, `()` composes none.
    self.executors = None if executor is None else tuple(executor)
    self.paths = None if path is None else tuple(path)
    self.options = {
        key: value
        for key, value in (
            ("provider_options", provider_options),
            ("initializer", initializer),
        )
        if value is not None
    }

_timeout_defaults = {'total': DEFAULT} class-attribute instance-attribute

authority = authority or 'localhost' instance-attribute

connection_uri property

executors = None if executor is None else tuple(executor) instance-attribute

host_type = None class-attribute

options = {key: value for key, value in (('provider_options', provider_options), ('initializer', initializer)) if value is not None} instance-attribute

paths = None if path is None else tuple(path) instance-attribute

shell = shell instance-attribute

timeout = timeout class-attribute instance-attribute

uri_scheme = '' class-attribute

__repr__()

Source code in src/hostctl/system/_config.py
88
89
90
91
92
93
94
95
96
97
98
99
def __repr__(self) -> str:
    # `provider_options` and `initializer` stay out, as the dataclass
    # configs keep their credential and injection fields out: provider
    # options carry a transport's password.
    return (
        f"{type(self).__name__}(authority={self.authority!r}, "
        f"shell={self.shell!r}"
        + ("" if self.executors is None else f", executor={self.executors!r}")
        + ("" if self.paths is None else f", path={self.paths!r}")
        + ("" if self.timeout == Timeout() else f", timeout={self.timeout!r}")
        + ")"
    )

_create_host()

Source code in src/hostctl/system/_config.py
165
166
167
168
169
170
171
172
173
174
175
176
177
def _create_host(self):
    if self.host_type is None:
        from ._kinds import IOSConfig, PosixConfig, WindowsConfig

        names = ", ".join(
            item.__name__ for item in (PosixConfig, WindowsConfig, IOSConfig)
        )
        raise TypeError(
            f"{type(self).__name__} is abstract and creates no host; "
            f"use a concrete system configuration ({names})"
        )
    # The host resolves the descriptors itself: resolvers receive it.
    return self.host_type(self)

_default_name()

Source code in src/hostctl/system/_config.py
101
102
def _default_name(self) -> str:
    return self.authority.rpartition("@")[2]

_from_parsed_uri(parsed, **credentials) classmethod

Source code in src/hostctl/system/_config.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
@classmethod
def _from_parsed_uri(cls, parsed, **credentials):
    constructor_only = {}
    for key in ("provider_options", "initializer"):
        if key in credentials:
            constructor_only[key] = credentials.pop(key)
    if credentials:
        names = ", ".join(sorted(str(key) for key in credentials))
        raise ValueError(
            "system URI reconstruction accepts only provider_options= and "
            f"initializer= constructor options; unsupported credentials: {names}"
        )
    # The SHARED parser, with the two keys that legitimately repeat
    # declared.
    query = strict_uri_query(
        parsed,
        ("executor", "path", "shell", *_URI_TIMEOUT_KEYS),
        repeatable=("executor", "path"),
    )
    # An absent key is "not named", so the host's defaults apply.
    executors = typing.cast(
        typing.Optional[typing.Tuple[str, ...]], query.get("executor")
    )
    paths = typing.cast(typing.Optional[typing.Tuple[str, ...]], query.get("path"))
    # The whole netloc, userinfo included, is unquoted below, so a
    # percent-encoded control character there would arrive decoded.
    reject_control_characters(parsed.netloc)
    return cls(
        # Unquoted: `connection_uri` percent-encodes the authority, so
        # storing the still-encoded netloc added a layer on every
        # `HostConfig(str(config))` round trip -- `node:22` became
        # `node%3A22`, then `node%253A22` -- and the API header promises
        # `str(config)` can be handed straight back.
        unquote(parsed.netloc) or unquote(parsed.path) or "localhost",
        shell=query.get("shell"),
        executor=executors,
        path=paths,
        timeout=_timeout_from_query(query),
        **constructor_only,
    )

hostctl.SystemHost(config=None, *, executor_providers=None, path_providers=None, shell=None, info=None, initializer=None)

Bases: Host

Host orchestration shared by POSIX, Windows, and IOS systems.

Source code in src/hostctl/system/_host.py
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def __init__(
    self,
    config: typing.Optional[SystemConfig] = None,
    *,
    executor_providers: typing.Optional[typing.Iterable[ExecutorProvider]] = None,
    path_providers: typing.Optional[typing.Iterable[PathProvider]] = None,
    shell=None,
    info: typing.Optional[HostInfo] = None,
    initializer=None,
):
    # A config-less host builds the configuration matching its own system
    # family, which keeps `scheme` derived from the URI: `HostConfig`
    # defines it as a read-only computed property.
    if isinstance(config, str):
        raise TypeError(
            f"{type(self).__name__} takes a config, not a connection string; "
            "use Host(uri) or HostConfig(uri).open()"
        )
    if config is not None and not isinstance(config, HostConfig):
        raise TypeError(
            f"{type(self).__name__} takes a HostConfig, "
            f"not {type(config).__name__}"
        )
    self.config = config if config is not None else self.config_type()
    self._shell_resolver = (
        shell if callable(shell) and not isinstance(shell, type) else None
    )
    self._shell = (
        None
        if self._shell_resolver is not None
        else (
            shell_flavour(shell)
            if shell is not None
            else (
                shell_flavour(getattr(self.config, "shell", None))
                if getattr(self.config, "shell", None)
                else self.default_shell
            )
        )
    )
    self._info = info
    config_options = getattr(self.config, "options", {})
    self._initializer = (
        initializer
        if initializer is not None
        else (
            config_options.get("initializer")
            if isinstance(config_options, dict)
            else None
        )
    )
    if self._initializer is not None and not callable(self._initializer):
        raise TypeError("initializer must be callable")
    self._initializer_generation = False
    self._initializing = False
    self._connected = False
    self._connected_providers = []
    self._closed_targets = set()
    # Serializes this host's connection *bookkeeping*: `_connected`,
    # `_connected_providers`, `_closed_targets`, and `_initializer_
    # generation`.  Without it, concurrent `run()` calls race the
    # check-then-append in `_ensure_provider_connected` and each append a
    # duplicate entry for the same provider, so the list grows without
    # bound and every racing caller repeats the connect round-trip.
    #
    # RLock, not Lock: these paths nest.  `connect()` runs the session
    # initializer, which is handed this same host and legitimately calls
    # `run()` -> `_ensure_provider_connected`; a plain Lock would deadlock
    # on that re-entry.
    #
    # Scope: the lock deliberately spans the provider `connect()` call.
    # Connecting is the operation being deduplicated, so releasing the
    # lock around it would reintroduce the very race it exists to close --
    # two callers would both observe "not connected" and both dial out.
    # Providers already own the slow part behind their own locks
    # (`_SSHTransport._ssh_lock`), and a `SystemHost` is one logical
    # target whose providers are ordered fallbacks for that same target,
    # not independent endpoints to be dialed in parallel.  Command
    # dispatch itself -- `run()`, `path()`, `spawn()` -- stays outside the
    # lock, so this never serializes actual remote work.
    self._lifecycle_lock = threading.RLock()
    # Last: a resolver is handed this host, so it sees it fully set up.
    if executor_providers is None or path_providers is None:
        resolved_executors, resolved_paths = self.resolve_providers(
            executor=None if executor_providers is None else (),
            path=None if path_providers is None else (),
        )
        if executor_providers is None:
            executor_providers = resolved_executors
        if path_providers is None:
            path_providers = resolved_paths
    #: (kind, provider name) pairs already warned about as fallen back from.
    self._fallbacks_logged: typing.Set[typing.Tuple[str, str]] = set()
    self._fallbacks_lock = threading.Lock()
    self._executor_selector = self._selector(executor_providers, "executor")
    self._path_selector = self._selector(path_providers, "path")

_closed_targets = set() instance-attribute

_connected = False instance-attribute

_connected_providers = [] instance-attribute

_executor_selector = self._selector(executor_providers, 'executor') instance-attribute

_fallbacks_lock = threading.Lock() instance-attribute

_fallbacks_logged = set() instance-attribute

_info = info instance-attribute

_initializer = initializer if initializer is not None else config_options.get('initializer') if isinstance(config_options, dict) else None instance-attribute

_initializer_generation = False instance-attribute

_initializing = False instance-attribute

_lifecycle_lock = threading.RLock() instance-attribute

_path_selector = self._selector(path_providers, 'path') instance-attribute

_shell = None if self._shell_resolver is not None else shell_flavour(shell) if shell is not None else shell_flavour(getattr(self.config, 'shell', None)) if getattr(self.config, 'shell', None) else self.default_shell instance-attribute

_shell_resolver = shell if callable(shell) and not isinstance(shell, type) else None instance-attribute

capabilities property

config = config if config is not None else self.config_type() instance-attribute

config_type class-attribute

default_shell = None class-attribute instance-attribute

executor property

The selected provider, presented as an Executor.

A bare ExecutorProvider has no executor_capabilities, so a Shell built on one inferred CWD/ENV support from the callable's signature instead of reading the provider's declared set -- and inferred it wrongly. The wrapper carries the declared capabilities and dispatches through run(), so the host's own rendering and failover still apply.

executor_capabilities property

executor_providers property

The ordered command providers backing :meth:run.

path_providers property

The ordered filesystem providers backing :meth:path.

provider_details property

Every provider's availability, executors first; dispatches nothing.

provider_resolvers = {} class-attribute

shell_flavour property

system_family = 'generic' class-attribute instance-attribute

_close_locked()

Source code in src/hostctl/system/_host.py
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
def _close_locked(self):
    targets = []
    # Dedupe by identity, matching the `id(target)` check below. Value
    # equality would collapse two distinct transports that compare equal
    # (e.g. a user-supplied dataclass transport registered through
    # `register_system_provider`) into one entry and leak the other.
    seen_targets = set()
    for provider in (
        *self._connected_providers,
        *self._executor_selector.providers,
        *self._path_selector.providers,
    ):
        target = getattr(provider, "transport", provider)
        if id(target) not in seen_targets:
            seen_targets.add(id(target))
            targets.append(target)
    first_error = None
    for target in reversed(targets):
        if id(target) in self._closed_targets:
            continue
        close = getattr(target, "close", None)
        if close:
            try:
                close()
            except BaseException as exc:
                if first_error is None:
                    first_error = exc
        self._closed_targets.add(id(target))
    self._connected = False
    self._connected_providers = []
    self._initializer_generation = False
    self._executor_selector.invalidate()
    self._path_selector.invalidate()
    if self._shell_resolver is not None:
        self._shell = None
    if first_error is not None:
        raise first_error

_collect_info()

info() with what it learned about the providers.

Returns the HostInfo, whether some provider answered, and the transport errors raised by those that did not. info() stays best effort; a caller that must tell "nothing answered" from an answer (the command line's exit status) reads the rest.

Source code in src/hostctl/system/_host.py
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
def _collect_info(
    self,
) -> typing.Tuple[HostInfo, bool, typing.List[BaseException]]:
    """`info()` with what it learned about the providers.

    Returns the `HostInfo`, whether some provider answered, and the
    transport errors raised by those that did not. `info()` stays best
    effort; a caller that must tell "nothing answered" from an answer
    (the command line's exit status) reads the rest.
    """
    answered = False
    failures: typing.List[BaseException] = []
    fields = {name: None for name in HostInfo.__dataclass_fields__}
    if self._info is not None:
        fields.update(
            {
                name: value
                for name, value in dataclasses.asdict(self._info).items()
                if value is not None
            }
        )
    for provider in self._executor_selector.providers:
        if all(value is not None for value in fields.values()):
            # Everything is known. Dialling a backup transport merely to
            # read `HostInfo` could prompt for 2FA or wait out a TCP
            # timeout on a host whose primary already answered.
            break
        if not self._provider_probe(self._executor_selector, provider).usable:
            continue
        callback = getattr(provider, "info", None)
        if callback is None:
            continue
        try:
            self._ensure_provider_connected(provider)
            value = callback()
        # Transport failures only. A bare `except Exception` here also
        # swallowed programming errors, so a broken provider reported
        # "no information available" instead of its own traceback.
        except (
            OperationNotStartedError,
            ConnectionError,
            TimeoutError,
            PermissionError,
            OSError,
        ) as exc:
            log.debug(
                "provider %s could not report info: %s",
                ProviderSelector.redact(provider.name),
                type(exc).__name__,
            )
            failures.append(exc)
            continue
        if not isinstance(value, HostInfo):
            continue
        answered = True
        for name, item in dataclasses.asdict(value).items():
            if fields[name] is None and item is not None:
                fields[name] = item
    authority = getattr(self.config, "authority", None) or getattr(
        self.config, "host", None
    )
    # The host, not the authority: `root@node:2222` is a target, and
    # reporting it as `HostInfo.hostname` put userinfo -- a username, and
    # with it a hint at the credential -- into every diagnostic that
    # printed the hostname.
    hostname = authority
    if isinstance(authority, str) and authority:
        from netimps import split_host

        hostname = authority.rpartition("@")[2]
        try:
            hostname = split_host(hostname)[0]
        except ValueError:
            pass
    if fields["hostname"] is None:
        fields["hostname"] = hostname
    if fields["os_family"] is None:
        fields["os_family"] = self.system_family
    return HostInfo(**fields), answered, failures

_composite_path_type()

The path grammar this system family actually has.

Any other family raises: an IOSHost is session/command-only until an IOS path grammar is designed, and POSIX rules would misread flash:/config.text, where : is a device separator rather than part of a name.

Source code in src/hostctl/system/_host.py
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
def _composite_path_type(self):
    """The path grammar this system family actually has.

    Any other family raises: an `IOSHost` is session/command-only until
    an IOS path grammar is designed, and POSIX rules would misread
    `flash:/config.text`, where `:` is a device separator rather than part
    of a name.
    """
    if self.system_family == "windows":
        return CompositeWindowsPath
    if self.system_family == "posix":
        return CompositePosixPath
    raise NotImplementedError(
        f"{type(self).__name__} has no path grammar; "
        f"the {self.system_family!r} family is command-only"
    )

_connect_locked()

Source code in src/hostctl/system/_host.py
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
def _connect_locked(self):
    if self._connected:
        return
    connected = []
    # A selector that offered a candidate is a connection that was meant
    # to happen; if none of them did, saying so beats a `_connected` host
    # that fails on its first use.
    candidates = 0
    declined: typing.List[str] = []
    first_decline: typing.Optional[OperationNotStartedError] = None
    try:
        for selector in (self._executor_selector, self._path_selector):
            excluded = []
            while True:
                try:
                    provider = selector.select(exclude=excluded).provider
                except OperationNotStartedError:
                    break
                candidates += 1
                try:
                    connect = getattr(provider, "connect", None)
                    if connect:
                        connect()
                except OperationNotStartedError as exc:
                    excluded.append(provider.name)
                    declined.append(
                        f"{ProviderSelector.redact(provider.name)}: "
                        f"{ProviderSelector.redact(exc)}"
                    )
                    first_decline = first_decline or exc
                    continue
                target = getattr(provider, "transport", provider)
                self._closed_targets.discard(id(target))
                connected.append(provider)
                break
    except BaseException:
        for provider in reversed(connected):
            close = getattr(provider, "close", None)
            if close:
                close()
        raise
    if candidates and not connected:
        raise OperationNotStartedError(
            "no provider connected: " + "; ".join(declined),
            cause=first_decline,
        ) from first_decline
    self._connected_providers = connected
    self._run_initializer_locked(connected)
    self._connected = True

_ensure_provider_connected(provider)

Source code in src/hostctl/system/_host.py
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
def _ensure_provider_connected(self, provider):
    # The check and the append must be one atomic step; see the lock's
    # rationale in __init__.  Membership is tested by identity because a
    # provider is a live object, not a value.
    with self._lifecycle_lock:
        if any(item is provider for item in self._connected_providers):
            # A kept provider after `replace_providers()` is already
            # connected but belongs to a new generation.
            self._run_initializer_locked([provider])
            return
        connect = getattr(provider, "connect", None)
        if connect is not None:
            connect()
        target = getattr(provider, "transport", provider)
        self._closed_targets.discard(id(target))
        self._connected_providers.append(provider)
        self._run_initializer_locked([provider])

_execute_command_line(provider, flavour, cmds, *, executable, context, options)

Render one finalized command line as (command, args, options).

The one place a ShellCommand is turned into a dispatch: it carries an out-of-band environment for providers that cannot embed one, and it is the only path that needs the login-shell layer.

Source code in src/hostctl/system/_host.py
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
def _execute_command_line(
    self, provider, flavour, cmds, *, executable, context, options
):
    """Render one finalized command line as `(command, args, options)`.

    The one place a `ShellCommand` is turned into a dispatch: it carries
    an out-of-band environment for providers that cannot embed one, and
    it is the only path that needs the login-shell layer.
    """
    login_shell = getattr(provider, "login_shell", None)
    rendered = render_command(
        flavour,
        cmds,
        executable=executable,
        cwd=context.cwd,
        env=context.env,
        login_shell=login_shell,
        # Only a transport that parses with a login shell reads a script
        # from standard input, and only when the call sends none itself.
        stdin_free=login_shell is not None
        and options.get("input") is None
        and options.get("stdin") is None,
    )
    options = self._out_of_band_env(provider, rendered, options)
    if rendered.stdin is not None:
        options["input"] = rendered.stdin
    return rendered.command, (), options

_log_fallback(kind, provider, skipped)

One WARNING per host and skipped provider: the operation was served by a provider ranked below one that is unavailable.

Source code in src/hostctl/system/_host.py
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
def _log_fallback(
    self, kind: str, provider, skipped: typing.Tuple[SelectionStep, ...]
) -> None:
    """One WARNING per host and skipped provider: the operation was served
    by a provider ranked below one that is unavailable."""
    with self._fallbacks_lock:
        fresh = [
            step
            for step in skipped
            if (kind, step.provider) not in self._fallbacks_logged
        ]
        self._fallbacks_logged.update((kind, step.provider) for step in fresh)
    if not fresh:
        return
    self.logger.warning(
        "%s fell back from %s to %s: %s",
        "run()" if kind == "executor" else "path()",
        ", ".join(step.provider for step in fresh),
        ProviderSelector.redact(provider.name),
        "; ".join(
            f"{step.provider}: {step.reason or 'unavailable'}" for step in fresh
        ),
    )

_note_provider_in_use(provider)

Mark a provider's transport as live again, without connecting it.

close() skips a transport it has already closed; a transport that reconnects lazily behind an operation would otherwise never be closed again.

Source code in src/hostctl/system/_host.py
955
956
957
958
959
960
961
962
963
964
def _note_provider_in_use(self, provider) -> None:
    """Mark a provider's transport as live again, without connecting it.

    `close()` skips a transport it has already closed; a transport that
    reconnects lazily behind an operation would otherwise never be closed
    again.
    """
    with self._lifecycle_lock:
        target = getattr(provider, "transport", provider)
        self._closed_targets.discard(id(target))

_out_of_band_env(provider, rendered, options) staticmethod

Carry a flavour's out-of-band environment, or refuse to lose it.

ShellCommand.environment means "the environment is NOT embedded in this command; send it separately" -- which is what _SSHTransport.run does. SystemHost passed only .command, so a registered flavour for a shell that cannot export variables lost every one of them in silence. Latent for the four built-in flavours, which all return None; that is a reason to fix it cheaply, not to keep it.

Source code in src/hostctl/system/_host.py
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
@staticmethod
def _out_of_band_env(provider, rendered, options):
    """Carry a flavour's out-of-band environment, or refuse to lose it.

    `ShellCommand.environment` means "the environment is NOT embedded in
    this command; send it separately" -- which is what `_SSHTransport.run`
    does. `SystemHost` passed only `.command`, so a registered flavour
    for a shell that cannot export variables lost every one of them in
    silence. Latent for the four built-in flavours, which all return
    `None`; that is a reason to fix it cheaply, not to keep it.
    """
    environment = getattr(rendered, "environment", None)
    if not environment:
        # `None` means embedded, and an empty mapping says the same
        # thing with fewer words -- neither may overwrite an environment
        # the provider is already carrying natively.
        return options
    if "env" not in provider.capabilities:
        raise NotImplementedError(
            f"shell flavour sends its environment out of band, and executor "
            f"provider {provider.name!r} cannot carry one"
        )
    return {**options, "env": environment}

_provider_probe(selector, provider) staticmethod

Source code in src/hostctl/system/_host.py
347
348
349
@staticmethod
def _provider_probe(selector, provider):
    return selector.probe(provider)

_render_dispatch(provider, cmds, kwargs)

The (command, args, options) this call sends to provider.

Pure: it connects nothing and sends nothing, so it can be run again with a masked environment.

Source code in src/hostctl/system/_host.py
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
def _render_dispatch(self, provider, cmds, kwargs):
    """The `(command, args, options)` this call sends to `provider`.

    Pure: it connects nothing and sends nothing, so it can be run again
    with a masked environment.
    """
    cwd = kwargs.get("cwd")
    env = kwargs.get("env")
    options = dict(kwargs)
    options.pop("executable", None)
    options.pop("cwd", None)
    options.pop("env", None)
    if cwd is not None and "cwd" in provider.capabilities:
        options["cwd"] = cwd
    if env is not None and "env" in provider.capabilities:
        options["env"] = env
    direct = starts_direct_command(cmds)
    if direct is not None:
        command, args = direct
        if kwargs.get("executable") is not None:
            raise NotImplementedError(
                "executable cannot be combined with a direct command"
            )
        # `cwd`/`env` were stripped from `options` above and put back only
        # for a provider that carries them natively. Whatever is left must
        # be embedded in a rendered script, or the call silently runs in
        # the wrong directory with the wrong environment.
        native_cwd = cwd is None or "cwd" in provider.capabilities
        native_env = env is None or "env" in provider.capabilities
        if "args" in provider.capabilities and native_cwd and native_env:
            return command, args, options
        if self._shell is None and self._shell_resolver is None:
            if args:
                raise NotImplementedError(
                    f"executor provider {provider.name!r} does not support argv arguments"
                )
            if not (native_cwd and native_env):
                raise NotImplementedError(
                    f"executor provider {provider.name!r} cannot apply cwd or env "
                    "and no shell is configured to embed them"
                )
            return command_text(command), (), options
        flavour = self.shell_flavour
        shell_executable = getattr(provider, "shell_executable", None)
        context = self._shell_context(provider, cwd, env)
        if "script" in provider.capabilities:
            script = flavour.script(
                ((command, *args),),
                cwd=context.cwd,
                env=context.env,
                epilogue=context.epilogue,
            )
            return script, (), options
        if "args" in provider.capabilities:
            leading = _shell_invocation(
                flavour,
                ((command, *args),),
                cwd=context.cwd,
                env=context.env,
                epilogue=context.epilogue,
                executable=shell_executable,
                provider=provider,
            )
            return leading[0], tuple(leading[1:]), options
        if not args and native_cwd and native_env:
            # Nothing to embed. Send the program itself when it needs no
            # quoting -- a provider that is not a shell (a bare callable,
            # a container exec) must not be handed `sh -c ...`. A program
            # whose text would not survive a shell verbatim still goes
            # through the flavour below, because the one provider shape
            # that reaches here -- an SSH exec request -- is read by the
            # remote login shell, which word-split a spaced path.
            text = command_text(command)
            if flavour.quote(text) == text:
                return text, (), options
        return self._execute_command_line(
            provider,
            flavour,
            ((command, *args),),
            executable=shell_executable,
            context=context,
            options=options,
        )

    if self._shell is None and self._shell_resolver is None:
        raise NotImplementedError(
            "buffered run requires a shell or a direct executable"
        )
    flavour = self.shell_flavour
    executable = kwargs.get("executable")
    context = self._shell_context(provider, cwd, env)
    if "script" in provider.capabilities:
        if executable is not None:
            raise NotImplementedError(
                f"script executor provider {provider.name!r} "
                "does not accept an executable override"
            )
        script = flavour.script(
            cmds,
            cwd=context.cwd,
            env=context.env,
            epilogue=context.epilogue,
        )
        return script, (), options
    shell_executable = executable or getattr(provider, "shell_executable", None)
    if "args" not in provider.capabilities:
        return self._execute_command_line(
            provider,
            flavour,
            cmds,
            executable=shell_executable,
            context=context,
            options=options,
        )
    # One shell layer, exactly as LocalHost renders it. `flavour.command()`
    # already contains the shell invocation, so feeding *that* to
    # `invocation()` -- whose argument is a script -- ran the target shell
    # twice: on Windows the outer PowerShell re-parsed the inner
    # `-Command "...; exit $LASTEXITCODE"` as its own double-quoted string,
    # so the status never came back and `check=True` passed for a command
    # that failed.
    leading = _shell_invocation(
        flavour,
        cmds,
        cwd=context.cwd,
        env=context.env,
        epilogue=context.epilogue,
        executable=shell_executable,
        provider=provider,
    )
    return leading[0], tuple(leading[1:]), options

_run_initializer_locked(connected)

Run the session initializer once per connection generation.

Called from the explicit connect() and from the lazy path, because run(), path() and spawn() all open a generation without going through connect() -- and providers.md promises the initializer runs once per generation, before the host is handed to the caller. It used to run only from connect(), so a host used lazily was bootstrapped silently not at all.

The _initializing guard is what makes the lazy path safe: the initializer is handed this host and legitimately calls run(), which re-enters here through _ensure_provider_connected (the lock is an RLock for exactly that), and _initializer_generation is only set afterwards -- so without the guard the nested call would start the initializer again.

Source code in src/hostctl/system/_host.py
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
def _run_initializer_locked(self, connected) -> None:
    """Run the session initializer once per connection generation.

    Called from the explicit `connect()` *and* from the lazy path, because
    `run()`, `path()` and `spawn()` all open a generation without going
    through `connect()` -- and providers.md promises the initializer runs
    once per generation, before the host is handed to the caller. It used
    to run only from `connect()`, so a host used lazily was bootstrapped
    silently not at all.

    The `_initializing` guard is what makes the lazy path safe: the
    initializer is handed this host and legitimately calls `run()`, which
    re-enters here through `_ensure_provider_connected` (the lock is an
    RLock for exactly that), and `_initializer_generation` is only set
    afterwards -- so without the guard the nested call would start the
    initializer again.
    """
    if (
        self._initializer is None
        or self._initializer_generation
        or self._initializing
    ):
        return
    self._initializing = True
    try:
        # `SessionInitializer` is itself callable and applies its own
        # default timeout, so it and a plain callable are invoked
        # identically -- there is no separate branch to take.
        self._initializer(self)
        self._initializer_generation = True
    except BaseException:
        for provider in reversed(connected):
            close = getattr(provider, "close", None)
            if close:
                try:
                    close()
                except BaseException:
                    pass
        self._connected_providers = []
        self._executor_selector.invalidate()
        self._path_selector.invalidate()
        raise
    finally:
        self._initializing = False

_run_with_provider(provider, cmds, **kwargs)

Source code in src/hostctl/system/_host.py
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
def _run_with_provider(self, provider, cmds, **kwargs):
    self._ensure_provider_connected(provider)
    command, args, options = self._render_dispatch(provider, cmds, kwargs)
    env = kwargs.get("env")
    if env and isinstance(env, collections.abc.Mapping):
        # A value embedded in the command text would otherwise reach the
        # log and any exception that carries the command; render again with
        # each value masked and show that instead.
        masked = {**kwargs, "env": masked_environment(env)}
        shown = self._render_dispatch(provider, cmds, masked)
        if shown[:2] != (command, args):
            return run_displayed(
                (shown[0], *shown[1]),
                lambda: provider.execute(command, *args, **options),
            )
    return provider.execute(command, *args, **options)

_select_capable(capability, operation)

Select an executor provider offering capability.

An unsupported operation is NotImplementedError, the documented contract run() and path() also follow; select() alone would report it as OperationNotStartedError.

Source code in src/hostctl/system/_host.py
984
985
986
987
988
989
990
991
992
993
994
995
996
def _select_capable(self, capability: str, operation: str):
    """Select an executor provider offering `capability`.

    An unsupported operation is `NotImplementedError`, the documented
    contract `run()` and `path()` also follow; `select()` alone would
    report it as `OperationNotStartedError`.
    """
    try:
        return self._executor_selector.select(capability=capability).provider
    except OperationNotStartedError as exc:
        raise NotImplementedError(
            f"{type(self).__name__} does not provide the {operation!r} capability"
        ) from exc

_selector(providers, kind)

A selector over providers that reports a fallback to this host.

Source code in src/hostctl/system/_host.py
169
170
171
172
173
174
175
176
177
178
179
180
def _selector(self, providers, kind: str) -> ProviderSelector:
    """A selector over `providers` that reports a fallback to this host."""
    selector = ProviderSelector(providers)
    host = weakref.ref(self)

    def report(provider, skipped: typing.Tuple[SelectionStep, ...]) -> None:
        owner = host()
        if owner is not None:
            owner._log_fallback(kind, provider, skipped)

    selector._fallback_hook = report
    return selector

_shell_context(provider, cwd, env) staticmethod

Which of cwd/env the shell must embed for this provider.

Source code in src/hostctl/system/_host.py
769
770
771
772
773
774
775
776
@staticmethod
def _shell_context(provider, cwd, env) -> _ShellContext:
    """Which of cwd/env the shell must embed for this provider."""
    return _ShellContext(
        None if "cwd" in provider.capabilities else cwd,
        None if "env" in provider.capabilities else env,
        wants_epilogue(provider.capabilities),
    )

close()

Source code in src/hostctl/system/_host.py
517
518
519
def close(self):
    with self._lifecycle_lock:
        self._close_locked()

connect()

Source code in src/hostctl/system/_host.py
418
419
420
def connect(self):
    with self._lifecycle_lock:
        self._connect_locked()

default_providers()

The (executor, path) descriptor names used when the config names none.

Override per host class; it may read self.config. A default ssh/sftp is dropped when the ssh extra is missing.

Source code in src/hostctl/system/_host.py
206
207
208
209
210
211
212
213
214
def default_providers(
    self,
) -> typing.Tuple[typing.Tuple[str, ...], typing.Tuple[str, ...]]:
    """The `(executor, path)` descriptor names used when the config names none.

    Override per host class; it may read `self.config`. A default
    `ssh`/`sftp` is dropped when the `ssh` extra is missing.
    """
    return (), ()

info()

Source code in src/hostctl/system/_host.py
559
560
def info(self) -> HostInfo:
    return self._collect_info()[0]

path(*segments, backend=None)

Source code in src/hostctl/system/_host.py
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
def path(self, *segments: StrPath, backend: typing.Optional[str] = None) -> Path:
    if not self._path_selector.providers:
        raise NotImplementedError(
            f"{type(self).__name__} does not provide the 'path' capability"
        )
    # `on_use` notes each provider as in use WITHOUT connecting: a
    # transport these paths later reopen lazily was left on
    # `_closed_targets`, so every later close() skipped it and the
    # connection lived until process exit.
    return self._composite_path_type()._select(
        self._path_selector,
        segments,
        backend=backend,
        on_use=self._note_provider_in_use,
    )

replace_providers(*, executor=None, path=None)

Swap either provider set; None keeps it.

Starts a new connection generation: the session initializer runs again before the next operation. A replaced provider's transport is closed unless a provider that stays shares it, and a transport shared by several replaced providers is closed once. A path obtained earlier still routes to the old providers, so obtain it again. An invalid set (duplicate names) raises before anything changes.

Source code in src/hostctl/system/_host.py
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
def replace_providers(self, *, executor=None, path=None) -> None:
    """Swap either provider set; ``None`` keeps it.

    Starts a new connection generation: the session initializer runs again
    before the next operation. A replaced provider's transport is closed
    unless a provider that stays shares it, and a transport shared by
    several replaced providers is closed once. A path obtained earlier
    still routes to the old providers, so obtain it again. An invalid
    set (duplicate names) raises before anything changes.
    """
    with self._lifecycle_lock:
        new_executor = (
            self._selector(executor, "executor") if executor is not None else None
        )
        new_path = self._selector(path, "path") if path is not None else None
        old = [
            *(self._executor_selector.providers if new_executor else ()),
            *(self._path_selector.providers if new_path else ()),
        ]
        kept = [
            *(new_executor or self._executor_selector).providers,
            *(new_path or self._path_selector).providers,
        ]
        replaced = [
            provider
            for provider in old
            if not any(provider is item for item in kept)
        ]
        kept_targets = {id(getattr(item, "transport", item)) for item in kept}
        targets = []
        seen = set()
        for provider in replaced:
            target = getattr(provider, "transport", provider)
            if (
                id(target) not in seen
                and id(target) not in kept_targets
                and id(target) not in self._closed_targets
            ):
                seen.add(id(target))
                targets.append(target)
        self._connected_providers = [
            item
            for item in self._connected_providers
            if not any(item is provider for provider in replaced)
        ]
        if new_executor is not None:
            self._executor_selector = new_executor
        else:
            self._executor_selector.invalidate()
        if new_path is not None:
            self._path_selector = new_path
        else:
            self._path_selector.invalidate()
        self._connected = False
        self._initializer_generation = False
        first_error = None
        for target in reversed(targets):
            close = getattr(target, "close", None)
            if close:
                try:
                    close()
                except BaseException as exc:
                    if first_error is None:
                        first_error = exc
            self._closed_targets.add(id(target))
        if first_error is not None:
            raise first_error

resolve_providers(executor=None, path=None)

Build providers from descriptor names, in order.

None takes the config's executors/paths, or default_providers() when the config names none. Each resolver runs once per call, so the two legs of one transport share it: replace both sets with one call's result. An unknown name raises ValueError listing the known ones.

Source code in src/hostctl/system/_host.py
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
def resolve_providers(
    self,
    executor: typing.Optional[typing.Iterable[str]] = None,
    path: typing.Optional[typing.Iterable[str]] = None,
) -> typing.Tuple[
    typing.Tuple[ExecutorProvider, ...], typing.Tuple[PathProvider, ...]
]:
    """Build providers from descriptor names, in order.

    `None` takes the config's `executors`/`paths`, or `default_providers()`
    when the config names none. Each resolver runs once per call, so the
    two legs of one transport share it: replace both sets with one call's
    result. An unknown name raises `ValueError` listing the known ones.
    """
    from ._resolvers import _default_names, _resolve

    defaults = None
    names = []
    for index, (given, attribute, kind) in enumerate(
        ((executor, "executors", "executor"), (path, "paths", "path"))
    ):
        if given is None:
            given = getattr(self.config, attribute, None)
        if given is None:
            if defaults is None:
                defaults = self.default_providers()
            given = _default_names(defaults[index], kind)
        elif isinstance(given, (str, bytes)):
            raise TypeError(
                f"{kind} descriptors are a sequence of names, e.g. ('local',), "
                "not a string"
            )
        names.append(tuple(given))
    return _resolve(self, names[0], names[1])

run(*cmds, bufsize=-1, executable=None, stdin=None, stdout=None, stderr=None, cwd=None, env=None, capture_output=True, check=True, encoding=None, errors=None, input=None, timeout=_OMITTED, text=None)

Source code in src/hostctl/system/_host.py
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
def run(
    self,
    *cmds: Command,
    bufsize: int = -1,
    executable: typing.Optional[str] = None,
    stdin: typing.Optional[StreamTarget] = None,
    stdout: typing.Optional[StreamTarget] = None,
    stderr: typing.Optional[StreamTarget] = None,
    cwd: typing.Optional[StrPath] = None,
    env: typing.Optional[CommandEnvironment] = None,
    capture_output: CaptureOutput = True,
    check: bool = True,
    encoding: typing.Optional[str] = None,
    errors: typing.Optional[str] = None,
    input: CommandInput = None,
    timeout: typing.Optional[float] = _OMITTED,  # type: ignore[assignment]
    text: typing.Optional[bool] = None,
) -> subprocess.CompletedProcess:
    if not self._executor_selector.providers:
        raise NotImplementedError(
            f"{type(self).__name__} does not provide the 'run' capability"
        )
    # Before any provider is selected: whether a name is valid must not
    # depend on which provider happens to carry the call.
    validate_environment_names(env)
    # An omitted timeout with no configured total is left out of the
    # dispatch, so the provider applies its own default.
    deadline = _run_timeout(self.config, timeout)
    timeout_option = {} if deadline is _OMITTED else {"timeout": deadline}
    # No `retry_declined()` here. Clearing every refusal per call re-dialled
    # a dead primary on EVERY run() -- ~9 s each against a refused port --
    # although the fallback was serving. The selector re-admits an earlier
    # refusal only when nothing else is left, which is what keeps a
    # single-provider host from bricking on one transient failure.
    excluded: list[str] = []
    while True:
        provider = self._executor_selector.select(exclude=excluded).provider
        try:
            result = self._run_with_provider(
                provider,
                cmds,
                bufsize=bufsize,
                executable=executable,
                stdin=stdin,
                stdout=stdout,
                stderr=stderr,
                cwd=cwd,
                env=env,
                capture_output=capture_output,
                check=check,
                encoding=encoding,
                errors=errors,
                input=input,
                text=text,
                **timeout_option,
            )
        except OperationNotStartedError as exc:
            # Providers may be retried only when they prove no operation
            # was dispatched; planning is repeated for the next provider.
            self._executor_selector.decline(provider.name, str(exc), cause=exc)
            excluded.append(provider.name)
            continue
        except BaseException:
            # Dispatched (a failing command among them): this provider
            # served the call.
            self._executor_selector._served(provider)
            raise
        self._executor_selector._served(provider)
        return result

runspace()

Return a provider-owned typed runspace when one is available.

Source code in src/hostctl/system/_host.py
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
def runspace(self):
    """Return a provider-owned typed runspace when one is available."""
    provider = self._select_capable("runspace", "runspace")
    self._ensure_provider_connected(provider)
    method = getattr(provider, "runspace", None)
    if method is None:
        raise NotImplementedError(
            f"executor provider {provider.name!r} does not support runspaces"
        )
    return method()

spawn(*cmds, **options)

Source code in src/hostctl/system/_host.py
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
def spawn(self, *cmds, **options):
    # Filtered on the capability, as runspace() already was: selecting
    # blind meant a first provider without sessions ended the search, so a
    # WindowsHost composed as (winrm, ssh) could not open an SSH session
    # at all.
    provider = self._select_capable("spawn", "spawn")
    self._ensure_provider_connected(provider)
    spawn = getattr(provider, "spawn", None)
    if spawn is None:
        raise NotImplementedError(
            f"executor provider {provider.name!r} does not support sessions"
        )
    return spawn(*cmds, **options)

hostctl.PosixConfig(authority='localhost', *, shell=None, executor=None, path=None, provider_options=None, initializer=None, timeout=Timeout())

Bases: SystemConfig

Connection identity for a :class:PosixHost (posix:, posix+ssh:).

Source code in src/hostctl/system/_config.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
def __init__(
    self,
    authority: str = "localhost",
    *,
    shell: object = None,
    executor: typing.Optional[typing.Iterable[str]] = None,
    path: typing.Optional[typing.Iterable[str]] = None,
    provider_options: typing.Optional[typing.Mapping[str, object]] = None,
    initializer: typing.Optional[typing.Callable[..., object]] = None,
    timeout: typing.Union[float, None, Timeout] = Timeout(),
):
    super().__init__()
    self.timeout = timeout  # type: ignore[assignment]
    _refuse_uri(type(self).__name__, authority)
    for name, value in (("executor", executor), ("path", path)):
        if isinstance(value, (str, bytes)):
            raise TypeError(
                f"{name}= takes a sequence of descriptor names, "
                "e.g. ('local',), not a string"
            )
    self.authority = authority or "localhost"
    self.shell = shell
    #: Descriptor names in precedence order; `None` takes the host
    #: class's `default_providers()`, `()` composes none.
    self.executors = None if executor is None else tuple(executor)
    self.paths = None if path is None else tuple(path)
    self.options = {
        key: value
        for key, value in (
            ("provider_options", provider_options),
            ("initializer", initializer),
        )
        if value is not None
    }

host_type = PosixHost class-attribute instance-attribute

uri_scheme = 'posix' class-attribute instance-attribute

hostctl.PosixHost(config=None, *, executor_providers=None, path_providers=None, shell=None, info=None, initializer=None)

Bases: SystemHost

A POSIX system: POSIX-shell command construction and POSIX paths.

Compose it from providers directly, or open an :class:SSHConfig.

Source code in src/hostctl/system/_host.py
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def __init__(
    self,
    config: typing.Optional[SystemConfig] = None,
    *,
    executor_providers: typing.Optional[typing.Iterable[ExecutorProvider]] = None,
    path_providers: typing.Optional[typing.Iterable[PathProvider]] = None,
    shell=None,
    info: typing.Optional[HostInfo] = None,
    initializer=None,
):
    # A config-less host builds the configuration matching its own system
    # family, which keeps `scheme` derived from the URI: `HostConfig`
    # defines it as a read-only computed property.
    if isinstance(config, str):
        raise TypeError(
            f"{type(self).__name__} takes a config, not a connection string; "
            "use Host(uri) or HostConfig(uri).open()"
        )
    if config is not None and not isinstance(config, HostConfig):
        raise TypeError(
            f"{type(self).__name__} takes a HostConfig, "
            f"not {type(config).__name__}"
        )
    self.config = config if config is not None else self.config_type()
    self._shell_resolver = (
        shell if callable(shell) and not isinstance(shell, type) else None
    )
    self._shell = (
        None
        if self._shell_resolver is not None
        else (
            shell_flavour(shell)
            if shell is not None
            else (
                shell_flavour(getattr(self.config, "shell", None))
                if getattr(self.config, "shell", None)
                else self.default_shell
            )
        )
    )
    self._info = info
    config_options = getattr(self.config, "options", {})
    self._initializer = (
        initializer
        if initializer is not None
        else (
            config_options.get("initializer")
            if isinstance(config_options, dict)
            else None
        )
    )
    if self._initializer is not None and not callable(self._initializer):
        raise TypeError("initializer must be callable")
    self._initializer_generation = False
    self._initializing = False
    self._connected = False
    self._connected_providers = []
    self._closed_targets = set()
    # Serializes this host's connection *bookkeeping*: `_connected`,
    # `_connected_providers`, `_closed_targets`, and `_initializer_
    # generation`.  Without it, concurrent `run()` calls race the
    # check-then-append in `_ensure_provider_connected` and each append a
    # duplicate entry for the same provider, so the list grows without
    # bound and every racing caller repeats the connect round-trip.
    #
    # RLock, not Lock: these paths nest.  `connect()` runs the session
    # initializer, which is handed this same host and legitimately calls
    # `run()` -> `_ensure_provider_connected`; a plain Lock would deadlock
    # on that re-entry.
    #
    # Scope: the lock deliberately spans the provider `connect()` call.
    # Connecting is the operation being deduplicated, so releasing the
    # lock around it would reintroduce the very race it exists to close --
    # two callers would both observe "not connected" and both dial out.
    # Providers already own the slow part behind their own locks
    # (`_SSHTransport._ssh_lock`), and a `SystemHost` is one logical
    # target whose providers are ordered fallbacks for that same target,
    # not independent endpoints to be dialed in parallel.  Command
    # dispatch itself -- `run()`, `path()`, `spawn()` -- stays outside the
    # lock, so this never serializes actual remote work.
    self._lifecycle_lock = threading.RLock()
    # Last: a resolver is handed this host, so it sees it fully set up.
    if executor_providers is None or path_providers is None:
        resolved_executors, resolved_paths = self.resolve_providers(
            executor=None if executor_providers is None else (),
            path=None if path_providers is None else (),
        )
        if executor_providers is None:
            executor_providers = resolved_executors
        if path_providers is None:
            path_providers = resolved_paths
    #: (kind, provider name) pairs already warned about as fallen back from.
    self._fallbacks_logged: typing.Set[typing.Tuple[str, str]] = set()
    self._fallbacks_lock = threading.Lock()
    self._executor_selector = self._selector(executor_providers, "executor")
    self._path_selector = self._selector(path_providers, "path")

default_shell = POSIX_SHELL class-attribute instance-attribute

system_family = 'posix' class-attribute instance-attribute

hostctl.WindowsConfig(authority='localhost', *, shell=None, executor=None, path=None, provider_options=None, initializer=None, timeout=Timeout())

Bases: SystemConfig

Connection identity for a :class:WindowsHost (windows:, windows+winrm:).

Source code in src/hostctl/system/_config.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
def __init__(
    self,
    authority: str = "localhost",
    *,
    shell: object = None,
    executor: typing.Optional[typing.Iterable[str]] = None,
    path: typing.Optional[typing.Iterable[str]] = None,
    provider_options: typing.Optional[typing.Mapping[str, object]] = None,
    initializer: typing.Optional[typing.Callable[..., object]] = None,
    timeout: typing.Union[float, None, Timeout] = Timeout(),
):
    super().__init__()
    self.timeout = timeout  # type: ignore[assignment]
    _refuse_uri(type(self).__name__, authority)
    for name, value in (("executor", executor), ("path", path)):
        if isinstance(value, (str, bytes)):
            raise TypeError(
                f"{name}= takes a sequence of descriptor names, "
                "e.g. ('local',), not a string"
            )
    self.authority = authority or "localhost"
    self.shell = shell
    #: Descriptor names in precedence order; `None` takes the host
    #: class's `default_providers()`, `()` composes none.
    self.executors = None if executor is None else tuple(executor)
    self.paths = None if path is None else tuple(path)
    self.options = {
        key: value
        for key, value in (
            ("provider_options", provider_options),
            ("initializer", initializer),
        )
        if value is not None
    }

host_type = WindowsHost class-attribute instance-attribute

uri_scheme = 'windows' class-attribute instance-attribute

hostctl.WindowsHost(config=None, *, executor_providers=None, path_providers=None, shell=None, info=None, initializer=None)

Bases: SystemHost

A Windows system: PowerShell command construction and Windows paths.

Compose it from providers directly, or open a :class:WinRMConfig.

Source code in src/hostctl/system/_host.py
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def __init__(
    self,
    config: typing.Optional[SystemConfig] = None,
    *,
    executor_providers: typing.Optional[typing.Iterable[ExecutorProvider]] = None,
    path_providers: typing.Optional[typing.Iterable[PathProvider]] = None,
    shell=None,
    info: typing.Optional[HostInfo] = None,
    initializer=None,
):
    # A config-less host builds the configuration matching its own system
    # family, which keeps `scheme` derived from the URI: `HostConfig`
    # defines it as a read-only computed property.
    if isinstance(config, str):
        raise TypeError(
            f"{type(self).__name__} takes a config, not a connection string; "
            "use Host(uri) or HostConfig(uri).open()"
        )
    if config is not None and not isinstance(config, HostConfig):
        raise TypeError(
            f"{type(self).__name__} takes a HostConfig, "
            f"not {type(config).__name__}"
        )
    self.config = config if config is not None else self.config_type()
    self._shell_resolver = (
        shell if callable(shell) and not isinstance(shell, type) else None
    )
    self._shell = (
        None
        if self._shell_resolver is not None
        else (
            shell_flavour(shell)
            if shell is not None
            else (
                shell_flavour(getattr(self.config, "shell", None))
                if getattr(self.config, "shell", None)
                else self.default_shell
            )
        )
    )
    self._info = info
    config_options = getattr(self.config, "options", {})
    self._initializer = (
        initializer
        if initializer is not None
        else (
            config_options.get("initializer")
            if isinstance(config_options, dict)
            else None
        )
    )
    if self._initializer is not None and not callable(self._initializer):
        raise TypeError("initializer must be callable")
    self._initializer_generation = False
    self._initializing = False
    self._connected = False
    self._connected_providers = []
    self._closed_targets = set()
    # Serializes this host's connection *bookkeeping*: `_connected`,
    # `_connected_providers`, `_closed_targets`, and `_initializer_
    # generation`.  Without it, concurrent `run()` calls race the
    # check-then-append in `_ensure_provider_connected` and each append a
    # duplicate entry for the same provider, so the list grows without
    # bound and every racing caller repeats the connect round-trip.
    #
    # RLock, not Lock: these paths nest.  `connect()` runs the session
    # initializer, which is handed this same host and legitimately calls
    # `run()` -> `_ensure_provider_connected`; a plain Lock would deadlock
    # on that re-entry.
    #
    # Scope: the lock deliberately spans the provider `connect()` call.
    # Connecting is the operation being deduplicated, so releasing the
    # lock around it would reintroduce the very race it exists to close --
    # two callers would both observe "not connected" and both dial out.
    # Providers already own the slow part behind their own locks
    # (`_SSHTransport._ssh_lock`), and a `SystemHost` is one logical
    # target whose providers are ordered fallbacks for that same target,
    # not independent endpoints to be dialed in parallel.  Command
    # dispatch itself -- `run()`, `path()`, `spawn()` -- stays outside the
    # lock, so this never serializes actual remote work.
    self._lifecycle_lock = threading.RLock()
    # Last: a resolver is handed this host, so it sees it fully set up.
    if executor_providers is None or path_providers is None:
        resolved_executors, resolved_paths = self.resolve_providers(
            executor=None if executor_providers is None else (),
            path=None if path_providers is None else (),
        )
        if executor_providers is None:
            executor_providers = resolved_executors
        if path_providers is None:
            path_providers = resolved_paths
    #: (kind, provider name) pairs already warned about as fallen back from.
    self._fallbacks_logged: typing.Set[typing.Tuple[str, str]] = set()
    self._fallbacks_lock = threading.Lock()
    self._executor_selector = self._selector(executor_providers, "executor")
    self._path_selector = self._selector(path_providers, "path")

default_shell = POWERSHELL class-attribute instance-attribute

system_family = 'windows' class-attribute instance-attribute

hostctl.IOSConfig(authority='localhost', *, shell=None, executor=None, path=None, provider_options=None, initializer=None, timeout=Timeout())

Bases: SystemConfig

Connection identity for an :class:IOSHost (ios:), which is session- and command-only.

Source code in src/hostctl/system/_config.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
def __init__(
    self,
    authority: str = "localhost",
    *,
    shell: object = None,
    executor: typing.Optional[typing.Iterable[str]] = None,
    path: typing.Optional[typing.Iterable[str]] = None,
    provider_options: typing.Optional[typing.Mapping[str, object]] = None,
    initializer: typing.Optional[typing.Callable[..., object]] = None,
    timeout: typing.Union[float, None, Timeout] = Timeout(),
):
    super().__init__()
    self.timeout = timeout  # type: ignore[assignment]
    _refuse_uri(type(self).__name__, authority)
    for name, value in (("executor", executor), ("path", path)):
        if isinstance(value, (str, bytes)):
            raise TypeError(
                f"{name}= takes a sequence of descriptor names, "
                "e.g. ('local',), not a string"
            )
    self.authority = authority or "localhost"
    self.shell = shell
    #: Descriptor names in precedence order; `None` takes the host
    #: class's `default_providers()`, `()` composes none.
    self.executors = None if executor is None else tuple(executor)
    self.paths = None if path is None else tuple(path)
    self.options = {
        key: value
        for key, value in (
            ("provider_options", provider_options),
            ("initializer", initializer),
        )
        if value is not None
    }

host_type = IOSHost class-attribute instance-attribute

uri_scheme = 'ios' class-attribute instance-attribute

hostctl.IOSHost(config=None, *, executor_providers=None, path_providers=None, shell=None, info=None, initializer=None)

Bases: SystemHost

A Cisco IOS-style device: session and command only, by design.

It configures no shell flavour and no path grammar, which is a deliberate gap rather than an unfinished one: IOS command syntax is not a shell, and quoting it as one would corrupt ordinary commands. So commands must be :class:Exec (or raw text the device understands), and :meth:path raises NotImplementedError until an IOS path grammar exists.

Source code in src/hostctl/system/_host.py
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def __init__(
    self,
    config: typing.Optional[SystemConfig] = None,
    *,
    executor_providers: typing.Optional[typing.Iterable[ExecutorProvider]] = None,
    path_providers: typing.Optional[typing.Iterable[PathProvider]] = None,
    shell=None,
    info: typing.Optional[HostInfo] = None,
    initializer=None,
):
    # A config-less host builds the configuration matching its own system
    # family, which keeps `scheme` derived from the URI: `HostConfig`
    # defines it as a read-only computed property.
    if isinstance(config, str):
        raise TypeError(
            f"{type(self).__name__} takes a config, not a connection string; "
            "use Host(uri) or HostConfig(uri).open()"
        )
    if config is not None and not isinstance(config, HostConfig):
        raise TypeError(
            f"{type(self).__name__} takes a HostConfig, "
            f"not {type(config).__name__}"
        )
    self.config = config if config is not None else self.config_type()
    self._shell_resolver = (
        shell if callable(shell) and not isinstance(shell, type) else None
    )
    self._shell = (
        None
        if self._shell_resolver is not None
        else (
            shell_flavour(shell)
            if shell is not None
            else (
                shell_flavour(getattr(self.config, "shell", None))
                if getattr(self.config, "shell", None)
                else self.default_shell
            )
        )
    )
    self._info = info
    config_options = getattr(self.config, "options", {})
    self._initializer = (
        initializer
        if initializer is not None
        else (
            config_options.get("initializer")
            if isinstance(config_options, dict)
            else None
        )
    )
    if self._initializer is not None and not callable(self._initializer):
        raise TypeError("initializer must be callable")
    self._initializer_generation = False
    self._initializing = False
    self._connected = False
    self._connected_providers = []
    self._closed_targets = set()
    # Serializes this host's connection *bookkeeping*: `_connected`,
    # `_connected_providers`, `_closed_targets`, and `_initializer_
    # generation`.  Without it, concurrent `run()` calls race the
    # check-then-append in `_ensure_provider_connected` and each append a
    # duplicate entry for the same provider, so the list grows without
    # bound and every racing caller repeats the connect round-trip.
    #
    # RLock, not Lock: these paths nest.  `connect()` runs the session
    # initializer, which is handed this same host and legitimately calls
    # `run()` -> `_ensure_provider_connected`; a plain Lock would deadlock
    # on that re-entry.
    #
    # Scope: the lock deliberately spans the provider `connect()` call.
    # Connecting is the operation being deduplicated, so releasing the
    # lock around it would reintroduce the very race it exists to close --
    # two callers would both observe "not connected" and both dial out.
    # Providers already own the slow part behind their own locks
    # (`_SSHTransport._ssh_lock`), and a `SystemHost` is one logical
    # target whose providers are ordered fallbacks for that same target,
    # not independent endpoints to be dialed in parallel.  Command
    # dispatch itself -- `run()`, `path()`, `spawn()` -- stays outside the
    # lock, so this never serializes actual remote work.
    self._lifecycle_lock = threading.RLock()
    # Last: a resolver is handed this host, so it sees it fully set up.
    if executor_providers is None or path_providers is None:
        resolved_executors, resolved_paths = self.resolve_providers(
            executor=None if executor_providers is None else (),
            path=None if path_providers is None else (),
        )
        if executor_providers is None:
            executor_providers = resolved_executors
        if path_providers is None:
            path_providers = resolved_paths
    #: (kind, provider name) pairs already warned about as fallen back from.
    self._fallbacks_logged: typing.Set[typing.Tuple[str, str]] = set()
    self._fallbacks_lock = threading.Lock()
    self._executor_selector = self._selector(executor_providers, "executor")
    self._path_selector = self._selector(path_providers, "path")

default_shell = None class-attribute instance-attribute

system_family = 'ios' class-attribute instance-attribute

Executors and processes

hostctl.Exec(program, *args) dataclass

One command executed directly, with no shell layer.

Exec(program, *args) names a program and its argv. The program may be an absolute path or a bare name the target resolves through PATH; a bare name has no other spelling, since a plain string is always shell text.

This is the explicit marker for direct execution. A path used anywhere else is an ordinary value that stringifies, so several commands can be written without one of them silently becoming an executable::

host.run(Exec("/bin/ls", "-l"))       # one direct command
host.run(Exec("ls", "-l"))            # PATH-resolved, no shell
host.run(Exec("/bin/a"), Exec("/bin/b"))   # two direct commands
host.run(PurePosixPath("/bin/a"), PurePosixPath("/bin/b"))
                                      # two ordinary shell commands

Arguments are argv values, never nested commands: a list or tuple raises TypeError rather than blurring argv and shell semantics.

A deliberately non-iterable container. ShellFlavour.command_text dispatches structured commands on Iterable, so an iterable marker would be quoted into an argv string instead of taking the direct branch.

Source code in src/hostctl/shell/_grammar.py
57
58
59
60
61
62
63
64
65
66
67
68
def __init__(self, program: ExecValue, *args: ExecValue) -> None:
    if not isinstance(program, (str, bytes, _PurePath, _Pathname)):
        raise TypeError("Exec program must be a str, bytes, or path value")
    for value in args:
        if isinstance(value, (tuple, list)):
            raise TypeError("direct command arguments must be scalar values")
        if not isinstance(value, (str, bytes, _PurePath, _Pathname)):
            raise TypeError(
                "direct command arguments must be str, bytes, or path values"
            )
    object.__setattr__(self, "program", program)
    object.__setattr__(self, "args", tuple(args))

args instance-attribute

program instance-attribute

hostctl.Executor

Bases: Protocol[_Result]

Callable executor receiving one command string and execution options.

executor_capabilities instance-attribute

__call__(command, *args, stdin=None, stdout=None, stderr=None, cwd=None, env=None, capture_output=None, check=None, encoding=None, errors=None, input=None, timeout=None, text=None, **options)

Source code in src/hostctl/executor.py
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
def __call__(
    self,
    command: CommandLike,
    *args: CommandArgument,
    stdin: typing.Optional[StreamTarget] = None,
    stdout: typing.Optional[StreamTarget] = None,
    stderr: typing.Optional[StreamTarget] = None,
    cwd: typing.Optional[StrPath] = None,
    env: typing.Optional[CommandEnvironment] = None,
    capture_output: typing.Optional[CaptureOutput] = None,
    check: typing.Optional[bool] = None,
    encoding: typing.Optional[str] = None,
    errors: typing.Optional[str] = None,
    input: CommandInput = None,
    timeout: typing.Optional[float] = None,
    text: typing.Optional[bool] = None,
    **options: object,
) -> _Result: ...

hostctl.ExecutionOptions

Bases: TypedDict

Shell-agnostic subprocess-style options understood by executors.

capture_output instance-attribute

check instance-attribute

cwd instance-attribute

encoding instance-attribute

env instance-attribute

errors instance-attribute

input instance-attribute

stderr instance-attribute

stdin instance-attribute

stdout instance-attribute

text instance-attribute

timeout instance-attribute

hostctl.LocalExecutor

Bases: Executor[CompletedProcess]

Execute direct argv or finalized shell invocations locally.

Not a BufferedExecutor: subprocess is the contract that base reproduces, and it hands the child real descriptors rather than buffers.

executor_capabilities = frozenset((ExecutorCapability.ARGS, ExecutorCapability.CWD, ExecutorCapability.ENV)) class-attribute instance-attribute

__call__(command, *args, bufsize=-1, stdin=None, stdout=None, stderr=None, cwd=None, env=None, capture_output=True, check=True, encoding=None, errors=None, input=None, timeout=None, text=None, **options)

Source code in src/hostctl/transports/local/_executor.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
def __call__(
    self,
    command: CommandLike,
    *args: CommandArgument,
    bufsize: int = -1,
    stdin: typing.Optional[StreamTarget] = None,
    stdout: typing.Optional[StreamTarget] = None,
    stderr: typing.Optional[StreamTarget] = None,
    cwd: typing.Optional[typing.Union[str, os.PathLike[str]]] = None,
    env: typing.Optional[CommandEnvironment] = None,
    capture_output: CaptureOutput = True,
    check: bool = True,
    encoding: typing.Optional[str] = None,
    errors: typing.Optional[str] = None,
    input: CommandInput = None,
    timeout: typing.Optional[float] = None,
    text: typing.Optional[bool] = None,
    **options: object,
) -> subprocess.CompletedProcess:
    # Windows only: the caller's own flags (a visible console, a process
    # group); without them a child gets no window unless this process has
    # a console to share.
    creationflags = options.pop("creationflags", None)
    if options:
        raise TypeError(f"unsupported local executor option: {sorted(options)[0]}")
    if creationflags is None:
        creationflags = no_window_flags()
    reject_stdin_conflict(input, stdin)
    stdout, stderr = capture_streams(capture_output, stdout, stderr)
    if isinstance(command, CommandLine):
        # Already quoted for the target's own parser. Windows submits a
        # command LINE to CreateProcess, so this is the one spelling that
        # can carry a cmd script; re-quoting it as argv is what corrupted
        # every value containing a quote or a metacharacter.
        if args:
            raise ValueError("a CommandLine carries no separate arguments")
        if os.name != "nt":
            raise NotImplementedError(
                "a pre-rendered command line is a Windows spelling; "
                "POSIX shells render as argv"
            )
        argv = str(command)
    else:
        argv = [command_text(command), *(command_text(value) for value in args)]
        argv = _batch_safe(argv)
    payload = normalize_input(
        input,
        text_mode=wants_text(text, encoding, errors),
        encoding=encoding,
        errors=errors,
    )
    if timeout is not None:
        return _run_bounded(
            argv,
            bufsize=bufsize,
            stdin=stdin,
            stdout=stdout,
            stderr=stderr,
            cwd=os.fspath(cwd) if cwd is not None else None,
            env=_merged_environment(env),
            check=check,
            encoding=encoding,
            errors=errors,
            input=payload,
            timeout=timeout,
            text=text,
            creationflags=creationflags,
        )
    return subprocess.run(
        argv,
        bufsize=bufsize,
        stdin=stdin,
        stdout=stdout,
        stderr=stderr,
        cwd=os.fspath(cwd) if cwd is not None else None,
        # Additive, like every other transport. `subprocess.run(env=...)`
        # replaces the environment, so the one documented cross-provider
        # rule -- "env is additive to the provider's environment" -- held
        # for SSH, WinRM, container and QGA and was inverted for the
        # local executor. A SystemHost that fell back to local therefore
        # handed the child no PATH where the same call over SSH kept it,
        # which on Windows is enough to stop powershell.exe starting.
        env=_merged_environment(env),
        check=check,
        encoding=encoding,
        errors=errors,
        # `encoding`/`errors`/`text` put subprocess's stdin in text mode,
        # where bytes would kill the writer thread and hang the call.
        input=payload,
        timeout=timeout,
        text=text,
        creationflags=creationflags,
    )

The bases a transport written outside hostctl subclasses; the override points and a worked example are in Writing an executor.

hostctl.executor.BufferedExecutor

Bases: Executor[CompletedProcess]

Base of an executor whose transport returns a command's whole output.

A subclass declares executor_capabilities and implements _exchange. The base implements the subprocess-shaped __call__ every hostctl executor shares: unknown options and undeclared args/cwd/env are refused, input=/stdin= become bytes, the output type follows wants_text, capture and stream targets follow capture_streams and dispatch_output, a non-zero status raises under check=True (the default), and a timeout raises expired()'s TimeoutExpired.

executor_capabilities = frozenset() class-attribute instance-attribute

input_unsupported = None class-attribute instance-attribute

timeout_unsupported = None class-attribute instance-attribute

__call__(command, *args, bufsize=-1, stdin=None, stdout=None, stderr=None, cwd=None, env=None, capture_output=True, check=True, encoding=None, errors=None, input=None, timeout=_OMITTED, text=None, **options)

Source code in src/hostctl/executor.py
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
def __call__(
    self,
    command: CommandLike,
    *args: CommandArgument,
    bufsize: int = -1,
    stdin: typing.Optional[StreamTarget] = None,
    stdout: typing.Optional[StreamTarget] = None,
    stderr: typing.Optional[StreamTarget] = None,
    cwd: typing.Optional[StrPath] = None,
    env: typing.Optional[CommandEnvironment] = None,
    capture_output: CaptureOutput = True,
    check: bool = True,
    encoding: typing.Optional[str] = None,
    errors: typing.Optional[str] = None,
    input: CommandInput = None,
    timeout: typing.Optional[float] = _OMITTED,  # type: ignore[assignment]
    text: typing.Optional[bool] = None,
    **options: object,
) -> subprocess.CompletedProcess:
    del bufsize
    name = type(self).__name__
    if options:
        raise TypeError(f"unsupported {name} option: {sorted(options)[0]}")
    capabilities = self.executor_capabilities
    if args and ExecutorCapability.ARGS not in capabilities:
        raise NotImplementedError(f"{name} takes no native arguments")
    if cwd is not None and ExecutorCapability.CWD not in capabilities:
        raise NotImplementedError(f"{name} has no native working directory")
    if env is not None and ExecutorCapability.ENV not in capabilities:
        raise NotImplementedError(f"{name} has no native environment")
    if self.input_unsupported is not None and (
        stdin is not None or input is not None
    ):
        raise NotImplementedError(self.input_unsupported)
    if timeout is _OMITTED:
        timeout = self._default_timeout()
    if timeout is not None and self.timeout_unsupported is not None:
        raise NotImplementedError(self.timeout_unsupported)
    reject_stdin_conflict(input, stdin)
    stdout, stderr = capture_streams(capture_output, stdout, stderr)
    environment = normalize_environment(env)
    payload: typing.Optional[bytes]
    if input is not None:
        # Whole bytes whatever the call's mode: the transport sends them.
        payload = typing.cast(
            bytes,
            normalize_input(
                input, text_mode=False, encoding=encoding, errors=errors
            ),
        )
    elif stdin is not None:
        payload = _read_stdin(stdin, encoding=encoding, errors=errors)
    else:
        payload = None
    request = ExecutorRequest(
        command=command_text(command),
        args=tuple(command_text(value) for value in args),
        input=payload,
        merge_stderr=stderr == subprocess.STDOUT,
        cwd=None if cwd is None else command_text(cwd),
        env=environment,
        timeout=timeout,
        deadline=None if timeout is None else time.monotonic() + timeout,
    )
    call = _Call(
        (
            [request.command, *request.args]
            if ExecutorCapability.ARGS in capabilities
            else request.command
        ),
        stdout,
        stderr,
        wants_text(text, encoding, errors),
        encoding,
        errors,
    )
    out, err, returncode = self._dispatch_request(request, call)
    # `CompletedProcess` reads a `None` status as success.
    completed = subprocess.CompletedProcess(
        call.display, -1 if returncode is None else returncode, out, err
    )
    if check:
        completed.check_returncode()
    return completed

_default_timeout()

The limit of a call made without timeout=; None (no limit) here.

Source code in src/hostctl/executor.py
826
827
828
def _default_timeout(self) -> typing.Optional[float]:
    """The limit of a call made without `timeout=`; `None` (no limit) here."""
    return None

_dispatch_request(request, call)

Source code in src/hostctl/executor.py
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
def _dispatch_request(
    self, request: ExecutorRequest, call: _Call
) -> typing.Tuple[_Output, _Output, typing.Optional[int]]:
    try:
        out, err, returncode = self._exchange(request)
    except OperationNotStartedError:
        raise
    except subprocess.TimeoutExpired as exc:
        raise call.expired(exc, request) from exc
    except Exception as exc:
        self._raise_normalized(exc, self._normalize_error(exc), request, call)
    out = _in_mode(b"" if out is None else out, *call.mode)
    err = _in_mode(b"" if err is None else err, *call.mode)
    if request.merge_stderr:
        if err:
            out = typing.cast(typing.Any, out) + err
        err = None
    out, err = dispatch_output(
        call.stdout,
        call.stderr,
        out,
        err,
        encoding=call.encoding,
        errors=call.errors,
    )
    return out, err, returncode

_exchange(request)

Run request and return (stdout, stderr, returncode); override it.

Either stream may be bytes, str or None (empty). Raise subprocess.TimeoutExpired when request.timeout passes (build it with expired() to set orphaned, pid and partial output), and OperationNotStartedError only when nothing was sent.

Source code in src/hostctl/executor.py
814
815
816
817
818
819
820
821
822
823
824
def _exchange(
    self, request: ExecutorRequest
) -> typing.Tuple[_Output, _Output, typing.Optional[int]]:
    """Run `request` and return `(stdout, stderr, returncode)`; override it.

    Either stream may be `bytes`, `str` or `None` (empty). Raise
    `subprocess.TimeoutExpired` when `request.timeout` passes (build it
    with `expired()` to set `orphaned`, `pid` and partial output), and
    `OperationNotStartedError` only when nothing was sent.
    """
    raise NotImplementedError(f"{type(self).__name__} does not implement _exchange")

_normalize_error(exc)

Map a transport error to the one to raise; exc itself by default.

A subprocess.TimeoutExpired returned here is raised in expired()'s shape; anything else is raised chained to exc.

Source code in src/hostctl/executor.py
830
831
832
833
834
835
836
def _normalize_error(self, exc: Exception) -> Exception:
    """Map a transport error to the one to raise; `exc` itself by default.

    A `subprocess.TimeoutExpired` returned here is raised in `expired()`'s
    shape; anything else is raised chained to `exc`.
    """
    return exc

_raise_normalized(exc, normalized, request, call) staticmethod

Source code in src/hostctl/executor.py
838
839
840
841
842
843
844
845
846
@staticmethod
def _raise_normalized(
    exc: Exception, normalized: Exception, request: ExecutorRequest, call: _Call
) -> "typing.NoReturn":
    if isinstance(normalized, subprocess.TimeoutExpired):
        raise call.expired(normalized, request) from exc
    if normalized is exc:
        raise exc
    raise normalized from exc

hostctl.executor.StreamingExecutor

Bases: BufferedExecutor

Base of an executor whose transport delivers output as it arrives.

A subclass implements _send and _receive, and _abandon when it can stop a running command. The base sends, then receives until a status arrives: captured output is collected, and output routed elsewhere (a file, None for the console) is written as each piece arrives. When the deadline passes it calls _abandon and raises TimeoutExpired carrying the output captured so far. OperationNotStartedError declines the provider only from _send; out of _receive, after the command was sent, it is raised as ConnectionError so the command is not replayed.

_abandon(request)

Stop the command after a timeout or a failure; True if it may still run.

The default stops nothing, so the command is reported orphaned.

Source code in src/hostctl/executor.py
936
937
938
939
940
941
def _abandon(self, request: ExecutorRequest) -> bool:
    """Stop the command after a timeout or a failure; True if it may still run.

    The default stops nothing, so the command is reported orphaned.
    """
    return True

_dispatch_request(request, call)

Source code in src/hostctl/executor.py
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
def _dispatch_request(
    self, request: ExecutorRequest, call: _Call
) -> typing.Tuple[_Output, _Output, typing.Optional[int]]:
    captured_out = bytearray() if call.stdout == subprocess.PIPE else None
    captured_err = bytearray() if call.stderr == subprocess.PIPE else None
    out_sink = (
        None if captured_out is not None else _Sink(call.stdout, sys.stdout, call)
    )
    err_sink = (
        None
        if captured_err is not None or request.merge_stderr
        else _Sink(call.stderr, sys.stderr, call)
    )

    def collect(data: bytes, captured, sink) -> None:
        if not data:
            return
        if captured is not None:
            captured += data
        else:
            sink.write(data)

    sent = False
    deadline_passed: typing.Optional[subprocess.TimeoutExpired] = None
    try:
        self._send(request)
        sent = True
        while True:
            wait = request.remaining()
            if wait is not None and wait <= 0:
                deadline_passed = expired(call.display, request.timeout)
                raise deadline_passed
            out, err, returncode = self._receive(request, wait)
            if request.merge_stderr:
                out, err = (out or b"") + (err or b""), b""
            collect(out, captured_out, out_sink)
            collect(err, captured_err, err_sink)
            if returncode is not None:
                break
    except subprocess.TimeoutExpired as exc:
        orphaned = self._stop(request)
        raise call.expired(
            exc,
            request,
            output=b"" if captured_out is None else bytes(captured_out),
            stderr=b"" if captured_err is None else bytes(captured_err),
            orphaned=orphaned,
        ) from (None if exc is deadline_passed else exc)
    except Exception as exc:
        normalized = (
            exc
            if isinstance(exc, OperationNotStartedError)
            else self._normalize_error(exc)
        )
        declined = isinstance(normalized, OperationNotStartedError)
        if sent or not declined:
            self._stop(request)
        if sent and declined:
            # The command is out: replaying it elsewhere could run it twice.
            raise ConnectionError(
                f"{typing.cast(OperationNotStartedError, normalized).reason}; "
                "the command was already sent, so it is not retried through "
                "another provider"
            ) from exc
        self._raise_normalized(exc, normalized, request, call)
    except BaseException:
        self._stop(request)
        raise
    for sink in (out_sink, err_sink):
        if sink is not None:
            sink.write(b"", final=True)
    return (
        None if captured_out is None else _in_mode(captured_out, *call.mode),
        None if captured_err is None else _in_mode(captured_err, *call.mode),
        returncode,
    )

_receive(request, wait)

Return the next (stdout, stderr, returncode) output; override it.

Wait at most wait seconds (None: until something arrives) and return (b"", b"", None) when nothing did. A status other than None ends the command; output returned with it is its last.

Source code in src/hostctl/executor.py
925
926
927
928
929
930
931
932
933
934
def _receive(
    self, request: ExecutorRequest, wait: typing.Optional[float]
) -> typing.Tuple[bytes, bytes, typing.Optional[int]]:
    """Return the next `(stdout, stderr, returncode)` output; override it.

    Wait at most `wait` seconds (`None`: until something arrives) and
    return `(b"", b"", None)` when nothing did. A status other than
    `None` ends the command; output returned with it is its last.
    """
    raise NotImplementedError(f"{type(self).__name__} does not implement _receive")

_send(request)

Send request (command and input) to the target; override it.

Raise OperationNotStartedError only when no byte of it was sent.

Source code in src/hostctl/executor.py
918
919
920
921
922
923
def _send(self, request: ExecutorRequest) -> None:
    """Send `request` (command and input) to the target; override it.

    Raise `OperationNotStartedError` only when no byte of it was sent.
    """
    raise NotImplementedError(f"{type(self).__name__} does not implement _send")

_stop(request)

Source code in src/hostctl/executor.py
943
944
945
946
947
def _stop(self, request: ExecutorRequest) -> bool:
    try:
        return bool(self._abandon(request))
    except Exception:
        return True

hostctl.executor.ExecutorRequest(command, args=(), input=None, merge_stderr=False, cwd=None, env=None, timeout=None, deadline=None) dataclass

One command as a transport primitive of BufferedExecutor receives it.

command is the command text and args the native arguments as text, empty unless the executor declares args. input is the whole standard input, or None when the call sent none: the command then gets an empty stdin (EOF), never a terminal. merge_stderr is stderr=STDOUT: merge natively where the transport can, else return stderr apart and the base appends it. cwd and env are None unless declared and passed; env adds to the target's environment. timeout is the call's limit in seconds (None: no limit) and deadline the same limit on time.monotonic().

args = () class-attribute instance-attribute

command instance-attribute

cwd = None class-attribute instance-attribute

deadline = None class-attribute instance-attribute

env = None class-attribute instance-attribute

input = None class-attribute instance-attribute

merge_stderr = False class-attribute instance-attribute

timeout = None class-attribute instance-attribute

remaining()

Seconds left before deadline, zero or less once it passed; None without one.

Source code in src/hostctl/executor.py
628
629
630
631
632
def remaining(self) -> typing.Optional[float]:
    """Seconds left before `deadline`, zero or less once it passed; `None` without one."""
    if self.deadline is None:
        return None
    return self.deadline - time.monotonic()

hostctl.Process

Bases: Protocol

Synchronous control surface for a persistent child process.

Every member raises rather than carrying the conventional ... body. Protocol members are NOT @abstractmethod, so five concrete adapters inherit this class explicitly and an inherited-but-unimplemented member was a real method returning None: instantiation succeeded, isinstance(x, Process) passed, and process.wait(60) == 0 failed as assert None == 0 -- or, in if process.wait():, read as "exited 0". The conformance battery exists to catch exactly "advertised but not implemented", and this base class was converting that mistake into a silent wrong value.

Structural typing is unaffected: a class that satisfies the protocol without inheriting it never reaches these bodies.

returncode property

__enter__()

Source code in src/hostctl/process.py
136
137
def __enter__(self) -> Process:
    raise NotImplementedError(f"{type(self).__name__}.__enter__")

__exit__(exc_type, exc_value, traceback)

Source code in src/hostctl/process.py
139
140
141
142
143
144
145
def __exit__(
    self,
    exc_type: typing.Optional[typing.Type[BaseException]],
    exc_value: typing.Optional[BaseException],
    traceback: typing.Optional[types.TracebackType],
) -> bool:
    raise NotImplementedError(f"{type(self).__name__}.__exit__")

close()

Source code in src/hostctl/process.py
133
134
def close(self) -> None:
    raise NotImplementedError(f"{type(self).__name__}.close")

kill()

Source code in src/hostctl/process.py
130
131
def kill(self) -> None:
    raise NotImplementedError(f"{type(self).__name__}.kill")

read(size=-1)

Source code in src/hostctl/process.py
106
107
def read(self, size: int = -1) -> ProcessData:
    raise NotImplementedError(f"{type(self).__name__}.read")

read_stderr(size=-1)

Source code in src/hostctl/process.py
109
110
def read_stderr(self, size: int = -1) -> ProcessData:
    raise NotImplementedError(f"{type(self).__name__}.read_stderr")

resize(columns, rows, pixel_width=0, pixel_height=0)

Source code in src/hostctl/process.py
115
116
117
118
119
120
121
122
def resize(
    self,
    columns: int,
    rows: int,
    pixel_width: int = 0,
    pixel_height: int = 0,
) -> None:
    raise NotImplementedError(f"{type(self).__name__}.resize")

send_eof()

Source code in src/hostctl/process.py
112
113
def send_eof(self) -> None:
    raise NotImplementedError(f"{type(self).__name__}.send_eof")

terminate()

Source code in src/hostctl/process.py
127
128
def terminate(self) -> None:
    raise NotImplementedError(f"{type(self).__name__}.terminate")

wait(timeout=None)

Source code in src/hostctl/process.py
124
125
def wait(self, timeout: typing.Optional[float] = None) -> int:
    raise NotImplementedError(f"{type(self).__name__}.wait")

write(data)

Source code in src/hostctl/process.py
103
104
def write(self, data: ProcessData) -> None:
    raise NotImplementedError(f"{type(self).__name__}.write")

hostctl.TerminalOptions(term_type='xterm-256color', columns=80, rows=24, pixel_width=0, pixel_height=0) dataclass

Requested pseudo-terminal type and initial dimensions.

columns = 80 class-attribute instance-attribute

pixel_height = 0 class-attribute instance-attribute

pixel_width = 0 class-attribute instance-attribute

rows = 24 class-attribute instance-attribute

size property

term_type = 'xterm-256color' class-attribute instance-attribute

__post_init__()

Source code in src/hostctl/process.py
42
43
44
45
46
47
48
def __post_init__(self) -> None:
    if not self.term_type:
        raise ValueError("term_type must not be empty")
    if self.columns <= 0 or self.rows <= 0:
        raise ValueError("terminal columns and rows must be positive")
    if self.pixel_width < 0 or self.pixel_height < 0:
        raise ValueError("terminal pixel dimensions must not be negative")

Shells and sessions

hostctl.ShellFlavour

Bases: ABC

Construct scripts and commands for one explicitly selected target shell.

accepts_script_stdin = False class-attribute

argv_invocation = True class-attribute

command_separator instance-attribute

context_order = ('env', 'cwd', 'command') class-attribute instance-attribute

cwd_guard = 'operator' class-attribute

default_executable instance-attribute

execution_epilogue = '' class-attribute instance-attribute

info_script instance-attribute

line_terminator = '\n' class-attribute instance-attribute

multiline_source = False class-attribute

name instance-attribute

pathname = PurePath class-attribute instance-attribute

structured_command_prefix = '' class-attribute instance-attribute

__repr__()

Source code in src/hostctl/shell/_flavour.py
59
60
def __repr__(self) -> str:
    return f"<{type(self).__name__} {self.name!r}>"

__str__()

Source code in src/hostctl/shell/_flavour.py
400
401
def __str__(self) -> str:
    return self.name

_raw_source(value) staticmethod

Validate raw shell source, which is not a value.

A quoted value must not carry control characters -- that is what _text enforces. Raw text is the opposite case: it is shell source the caller wrote deliberately, where a newline is ordinary syntax (and where a generated script, such as PowerShell's multi-line exit epilogue, may legitimately be handed back for a second render). Only NUL and the other non-whitespace control characters are refused -- nothing in any supported shell needs them and they defeat terminal and log inspection.

Source code in src/hostctl/shell/_flavour.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
@staticmethod
def _raw_source(value: str) -> str:
    """Validate raw shell *source*, which is not a value.

    A quoted value must not carry control characters -- that is what
    `_text` enforces. Raw text is the opposite case: it is shell source
    the caller wrote deliberately, where a newline is ordinary syntax
    (and where a generated script, such as PowerShell's multi-line exit
    epilogue, may legitimately be handed back for a second render). Only
    NUL and the other non-whitespace control characters are refused --
    nothing in any supported shell needs them and they defeat terminal
    and log inspection.
    """
    if any(
        (ord(char) < 32 and char not in _RAW_WHITESPACE) or ord(char) == 127
        for char in value
    ):
        raise ValueError("shell text cannot contain control characters")
    return value

_separator_after(command)

The separator that may follow command without breaking it.

Source code in src/hostctl/shell/_flavour.py
334
335
336
337
338
def _separator_after(self, command: str) -> str:
    """The separator that may follow `command` without breaking it."""
    if self.multiline_source and "\n" in command:
        return self.line_terminator
    return self.command_separator

_text(value) staticmethod

Normalize values and reject shell control characters.

Source code in src/hostctl/shell/_flavour.py
 94
 95
 96
 97
 98
 99
100
@staticmethod
def _text(value: object) -> str:
    """Normalize values and reject shell control characters."""
    text = command_text(value, errors="surrogateescape")
    if any(ord(char) < 32 or ord(char) == 127 for char in text):
        raise ValueError("shell values cannot contain control characters")
    return text

argument(value, *, target=ShellTarget.NATIVE)

Quote one value for target, the parser that reads it next.

The default is every flavour whose shell hands tokens straight to execve: there is no second parser, so quote() is the whole answer. cmd and PowerShell override it, because on Windows the child re-parses its own command line and the two layers disagree.

Source code in src/hostctl/shell/_flavour.py
131
132
133
134
135
136
137
138
139
140
141
142
def argument(
    self, value: object, *, target: ShellTarget = ShellTarget.NATIVE
) -> str:
    """Quote one value for `target`, the parser that reads it next.

    The default is every flavour whose shell hands tokens straight to
    `execve`: there is no second parser, so `quote()` is the whole
    answer. `cmd` and PowerShell override it, because on Windows the
    child re-parses its own command line and the two layers disagree.
    """
    del target
    return self.quote(value)

change_directory(cwd) abstractmethod

Render a directory change which fails if the directory is absent.

Source code in src/hostctl/shell/_flavour.py
330
331
332
@abc.abstractmethod
def change_directory(self, cwd: StrPath) -> str:
    """Render a directory change which fails if the directory is absent."""

cmdlet_command(value)

Render a Cmdlet; only PowerShell has cmdlets.

Source code in src/hostctl/shell/_flavour.py
176
177
178
def cmdlet_command(self, value: Cmdlet) -> str:
    """Render a `Cmdlet`; only PowerShell has cmdlets."""
    raise TypeError(f"Cmdlet is a PowerShell command and cannot run in {self.name}")

command(cmds, *, executable=None, cwd=None, env=None, login_shell=None) abstractmethod

Build the command submitted to an SSH exec channel.

The string is parsed first by login_shell: None means nothing parses it before the command line's own shell (as with CreateProcess), "posix" an sh-family shell or fish, "cmd" and "powershell" those shells. Each flavour's rule:

  • POSIX and fish join the invocation with login_join, which sh, bash, zsh, dash, ash and fish read back identically; cwd is part of the script. csh and tcsh are not supported.
  • PowerShell renders -Command for a cmd or POSIX login shell, the script on standard input (ShellCommand.stdin) when it cannot cross cmd.exe as an argument and the call leaves stdin free, and -EncodedCommand <base64 utf-16le> as the last resort.
  • cmd escapes exactly one cmd layer and, for login_shell="cmd", one more for the login shell's own parse -- see the divergence ledger in docs/guide/contracts.md.
Source code in src/hostctl/shell/_flavour.py
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
@abc.abstractmethod
def command(
    self,
    cmds: typing.Iterable[ShellToken],
    *,
    executable: typing.Optional[str] = None,
    cwd: typing.Optional[StrPath] = None,
    env: typing.Optional[CommandEnvironment] = None,
    login_shell: typing.Optional[str] = None,
) -> ShellCommand:
    """Build the command submitted to an SSH exec channel.

    The string is parsed first by `login_shell`: `None` means nothing
    parses it before the command line's own shell (as with
    `CreateProcess`), `"posix"` an sh-family shell or fish, `"cmd"` and
    `"powershell"` those shells. Each flavour's rule:

    * POSIX and fish join the invocation with `login_join`, which sh,
      bash, zsh, dash, ash and fish read back identically; `cwd` is part
      of the script. csh and tcsh are not supported.
    * PowerShell renders `-Command` for a `cmd` or POSIX login shell,
      the script on standard input (`ShellCommand.stdin`) when it
      cannot cross cmd.exe as an argument and the call leaves stdin
      free, and `-EncodedCommand <base64 utf-16le>` as the last resort.
    * `cmd` escapes exactly one cmd layer and, for `login_shell="cmd"`,
      one more for the login shell's own parse -- see the divergence
      ledger in `docs/guide/contracts.md`.
    """

command_target(values)

Which parser this command's arguments will meet.

Inferred where it is decidable -- cmd knows its own builtin list -- and NATIVE otherwise. It is deliberately NOT inferred for PowerShell: whether Remove-Item is a cmdlet or an executable on the guest's PATH cannot be known without a runspace, and guessing is the class of mistake this package has been removing. A caller who knows passes target= to :meth:argument directly.

Source code in src/hostctl/shell/_flavour.py
144
145
146
147
148
149
150
151
152
153
154
155
def command_target(self, values: typing.Sequence[object]) -> ShellTarget:
    """Which parser this command's arguments will meet.

    Inferred where it is decidable -- `cmd` knows its own builtin list --
    and `NATIVE` otherwise. It is deliberately NOT inferred for
    PowerShell: whether `Remove-Item` is a cmdlet or an executable on the
    guest's PATH cannot be known without a runspace, and guessing is the
    class of mistake this package has been removing. A caller who knows
    passes `target=` to :meth:`argument` directly.
    """
    del values
    return ShellTarget.NATIVE

command_text(value)

Source code in src/hostctl/shell/_flavour.py
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
def command_text(self, value: Command) -> str:
    if isinstance(value, Exec):
        # Direct execution bypasses the shell entirely, so an `Exec` that
        # reaches here means a transport did not take its direct branch.
        # Rendering it as a quoted argv would silently reintroduce the
        # shell layer the caller asked to skip.
        raise TypeError(
            "Exec is executed directly and cannot be rendered into a shell "
            "script; this host does not support direct execution"
        )
    if isinstance(value, Cmdlet):
        return self.cmdlet_command(value)
    if isinstance(value, PSParam):
        raise TypeError("PSParam is only valid inside a PowerShell command")
    if isinstance(value, (bytes, PurePath, Path, os.PathLike)):
        # A standalone path is a program to run: PowerShell needs the call
        # operator or it prints the string, and cmd reads a command word.
        return self.structured_command((value,))
    if isinstance(value, str):
        return self._raw_source(value)
    if isinstance(value, collections.abc.Iterable):
        values = tuple(value)
        if not values:
            raise ValueError("structured command must not be empty")
        return self.structured_command(values)
    return self._text(value)

environment_assignment(key, value) abstractmethod

Render one validated environment assignment.

Source code in src/hostctl/shell/_flavour.py
180
181
182
@abc.abstractmethod
def environment_assignment(self, key: str, value: object) -> str:
    """Render one validated environment assignment."""

environment_script(env)

Convert an environment mapping into a standalone shell script.

Source code in src/hostctl/shell/_flavour.py
184
185
186
187
188
189
190
191
192
def environment_script(self, env: CommandEnvironment) -> str:
    """Convert an environment mapping into a standalone shell script."""
    assignments = []
    for key, value in env.items():
        if isinstance(key, bytes):
            key = key.decode("utf-8", "surrogateescape")
        validate_environment_names({key: value})
        assignments.append(self.environment_assignment(key, value))
    return self.command_separator.join(assignments)

escape_for_one_parse(text)

Escape text so ONE parse by this shell hands it back verbatim.

This is the OUTER layer, for a transport that delivers a finalized command line to a login shell which parses it before the command line's own shell ever runs. SSH is the case: AsyncSSH sends one command string, and Windows OpenSSH's stock server hands it to cmd.exe /c -- so a line from CmdShellFlavour.command(), which already carries one cmd layer, is parsed by cmd twice.

The default refuses rather than guessing. A POSIX login shell receiving a POSIX-rendered line is the ordinary single-parse case and never reaches here; a flavour that genuinely has an escape for an extra parse implements it.

Source code in src/hostctl/shell/_flavour.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def escape_for_one_parse(self, text: str) -> str:
    """Escape `text` so ONE parse by this shell hands it back verbatim.

    This is the OUTER layer, for a transport that delivers a finalized
    command line to a login shell which parses it before the command
    line's own shell ever runs. SSH is the case: AsyncSSH sends one
    command string, and Windows OpenSSH's stock server hands it to
    `cmd.exe /c` -- so a line from `CmdShellFlavour.command()`, which
    already carries one cmd layer, is parsed by cmd twice.

    The default refuses rather than guessing. A POSIX login shell
    receiving a POSIX-rendered line is the ordinary single-parse case
    and never reaches here; a flavour that genuinely has an escape for
    an extra parse implements it.
    """
    raise NotImplementedError(
        f"{self.name} has no escaping for an additional parse by itself; "
        "the command line is already rendered for exactly one"
    )

group(command)

Wrap command so an operator applies to all of it, not its head.

Source code in src/hostctl/shell/_flavour.py
340
341
342
343
344
345
def group(self, command: str) -> str:
    """Wrap `command` so an operator applies to all of it, not its head."""
    if self.multiline_source and "\n" in command:
        # `; }` after a here-document delimiter would not close it.
        return "{ " + command + self.line_terminator + "}"
    return "{ " + command + "; }"

invocation(script, *, executable=None) abstractmethod

Build local-process argv which invokes this shell for one script.

Raises NotImplementedError when this shell cannot be invoked that way -- see argv_invocation, and use command() instead.

Source code in src/hostctl/shell/_flavour.py
387
388
389
390
391
392
393
394
395
396
397
398
@abc.abstractmethod
def invocation(
    self,
    script: str,
    *,
    executable: typing.Optional[str] = None,
) -> typing.Sequence[str]:
    """Build local-process argv which invokes this shell for one script.

    Raises `NotImplementedError` when this shell cannot be invoked that
    way -- see `argv_invocation`, and use `command()` instead.
    """

join(values)

Join commands, preserving raw strings and explicit operators.

Source code in src/hostctl/shell/_flavour.py
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
def join(self, values: typing.Iterable[ShellToken]) -> str:
    """Join commands, preserving raw strings and explicit operators."""
    result = []
    pending = self.command_separator
    redirecting: typing.Optional[ShellOperator] = None
    has_command = False
    expecting_command = False
    for value in values:
        if isinstance(value, ShellOperator):
            if not has_command or expecting_command:
                raise ValueError("shell operator must appear between commands")
            pending = self.operator(value)
            redirecting = (
                value
                if value in (ShellOperator.REDIRECT, ShellOperator.APPEND)
                else None
            )
            expecting_command = True
            continue
        if redirecting is not None:
            # What follows `>` is a file name, not a command.
            operand = self.redirect_operand(value)
            if not operand:
                continue
            result.append(self.redirect(redirecting, value, operand))
        else:
            command = self.command_text(value)
            if not command:
                continue
            if has_command:
                if pending == self.command_separator:
                    pending = self._separator_after(result[-1])
                result.append(pending)
            result.append(command)
        has_command = True
        expecting_command = False
        redirecting = None
        pending = self.command_separator
    if expecting_command:
        raise ValueError("shell operator must be followed by a command")
    return "".join(result)

join_cwd(changed, command)

Join cwd setup and payload with the shell's AND operator.

Reached only under cwd_guard = "operator". The payload is grouped first: fused on directly, the AND bound to the first command only, so cd /srv && a; b ran b in the login directory and exited 0 -- reachable through QGA (which always embeds cwd), fish or cmd over SSH, and any Shell over a capability-less executor.

Source code in src/hostctl/shell/_flavour.py
347
348
349
350
351
352
353
354
355
356
def join_cwd(self, changed: str, command: str) -> str:
    """Join cwd setup and payload with the shell's AND operator.

    Reached only under `cwd_guard = "operator"`. The payload is grouped
    first: fused on directly, the AND bound to the *first* command only,
    so `cd /srv && a; b` ran `b` in the login directory and exited 0 --
    reachable through QGA (which always embeds cwd), fish or cmd over
    SSH, and any Shell over a capability-less executor.
    """
    return f"{changed}{self.operator(ShellOperator.AND)}{self.group(command)}"

operator(value) abstractmethod

Render one supported command operator.

Source code in src/hostctl/shell/_flavour.py
157
158
159
@abc.abstractmethod
def operator(self, value: ShellOperator) -> str:
    """Render one supported command operator."""

quote(value) abstractmethod

Quote one value for THIS SHELL's parser.

The syntactic layer only: enough that the shell sees one word and expands nothing. What the token meets after that is :meth:argument's question.

Source code in src/hostctl/shell/_flavour.py
122
123
124
125
126
127
128
129
@abc.abstractmethod
def quote(self, value: object) -> str:
    """Quote one value for THIS SHELL's parser.

    The syntactic layer only: enough that the shell sees one word and
    expands nothing. What the token meets after that is
    :meth:`argument`'s question.
    """

redirect(value, target, operand)

Render a REDIRECT or APPEND value together with its target.

operand is redirect_operand(target); target is passed as well so a flavour can tell raw source from a file name.

Source code in src/hostctl/shell/_flavour.py
239
240
241
242
243
244
245
246
def redirect(self, value: ShellOperator, target: Command, operand: str) -> str:
    """Render a REDIRECT or APPEND `value` together with its target.

    `operand` is `redirect_operand(target)`; `target` is passed as well so
    a flavour can tell raw source from a file name.
    """
    del target
    return self.operator(value) + operand

redirect_operand(value)

Render the file name after > or >>.

Raw text stays source. A path, bytes or a one-element structured command is a name, quoted for the shell's own parser: the redirect target is read by the shell, never by a child's C runtime.

Source code in src/hostctl/shell/_flavour.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
def redirect_operand(self, value: Command) -> str:
    """Render the file name after `>` or `>>`.

    Raw text stays source. A path, bytes or a one-element structured
    command is a name, quoted for the shell's own parser: the redirect
    target is read by the shell, never by a child's C runtime.
    """
    if isinstance(value, str):
        return self._raw_source(value)
    if isinstance(value, (bytes, PurePath, Path, os.PathLike)):
        return self.argument(value, target=ShellTarget.SHELL)
    if isinstance(value, collections.abc.Iterable):
        values = tuple(value)
        if len(values) != 1:
            raise ValueError("a redirect takes exactly one file name")
        return self.argument(values[0], target=ShellTarget.SHELL)
    raise TypeError("a redirect operand must be a path or a one-element command")

script(cmds, *, cwd=None, env=None, epilogue=True)

Build a script, applying environment and cwd consistently.

epilogue=False leaves off the flavour's status epilogue, for a script whose transport reports the status itself (wants_epilogue answers that for a set of capabilities) or that is one line of a persistent session.

Source code in src/hostctl/shell/_flavour.py
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
def script(
    self,
    cmds: typing.Iterable[ShellToken],
    *,
    cwd: typing.Optional[StrPath] = None,
    env: typing.Optional[CommandEnvironment] = None,
    epilogue: bool = True,
) -> str:
    """Build a script, applying environment and cwd consistently.

    `epilogue=False` leaves off the flavour's status epilogue, for a
    script whose transport reports the status itself (`wants_epilogue`
    answers that for a set of capabilities) or that is one line of a
    persistent session.
    """
    command = self.join(cmds)
    changed = self.change_directory(cwd) if cwd is not None else ""
    rendered = {
        "env": self.environment_script(env) if env else "",
        "cwd": changed,
        "command": command,
    }
    if cwd is not None and command and self.cwd_guard == "operator":
        if "cwd" not in self.context_order or "command" not in self.context_order:
            raise ValueError(
                f"{self.name}: cwd_guard='operator' needs both 'cwd' and "
                f"'command' in context_order, found {self.context_order!r}"
            )
        rendered["cwd"] = self.join_cwd(changed, command)
        rendered["command"] = ""
    parts = [rendered[name] for name in self.context_order if rendered[name]]
    script = ""
    for part in parts:
        if script:
            script += self._separator_after(script)
        script += part
    if script and epilogue and self.execution_epilogue:
        script += self.execution_epilogue
    return script

structured_command(values)

Source code in src/hostctl/shell/_flavour.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
def structured_command(self, values: typing.Iterable[object]) -> str:
    values = tuple(values)
    if any(isinstance(value, PSParam) for value in values):
        raise TypeError(
            f"PSParam is a PowerShell token; {self.name} has no use for it"
        )
    if not values:
        return self.structured_command_prefix
    target = self.command_target(values)
    # The first element names the program; it is never argv data.
    head = self.argument(values[0], target=ShellTarget.SHELL)
    return self.structured_command_prefix + " ".join(
        [head] + [self.argument(value, target=target) for value in values[1:]]
    )

hostctl.Shell(flavour, executor, *, cwd=None, env=None, encoding=None, errors=None)

Bases: Generic[_Result]

Bind one shell language to a host or to an executor.

Over a host, run() is that host's run(): the host renders exactly one shell layer with its provider's capabilities, so host.shell.run(x) hands the provider what host.run(x) does. Over an executor (a callable such as LocalExecutor()), the shell renders the script itself and sends it as a script string, as argv (args), or as a command line, embedding cwd/env unless the executor carries them natively.

A Shell is not an Executor: calling it configures it, and a positional argument raises TypeError.

Source code in src/hostctl/shell/_shell.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
def __init__(
    self,
    flavour: ShellFlavour,
    executor: typing.Union[typing.Callable[..., _Result], _CommandHost],
    *,
    cwd: typing.Optional[StrPath] = None,
    env: typing.Optional[CommandEnvironment] = None,
    encoding: typing.Optional[str] = None,
    errors: typing.Optional[str] = None,
) -> None:
    self._flavour = flavour
    #: Defaults applied to every `run` and `session` call that does not
    #: pass its own value. `cwd`, `encoding`, and `errors` override
    #: wholesale; `env` merges per key so a call can change one variable
    #: without restating the rest (see `_resolve_env`).
    self._cwd = cwd
    # An empty mapping would reach `subprocess` as `env={}` -- a child
    # started with NO environment -- where the caller meant "no overrides".
    self._env = dict(env) if env else None
    self._encoding = encoding
    self._errors = errors
    run = getattr(executor, "run", None)
    spawn = getattr(executor, "spawn", None)
    self._spawn = spawn if callable(spawn) else None
    self._session: typing.Optional[ShellSession] = None
    self._host = None
    if callable(executor):
        self._execute = executor
    elif callable(run):
        self._host = executor
        self._execute = run
        try:
            named = executor.shell_flavour.name
        except NotImplementedError:
            named = None
        if named != flavour.name:
            raise ValueError(
                f"a {flavour.name} shell cannot run on a host that names "
                f"{named or 'no'} shell flavour"
            )
    else:
        raise TypeError("executor must be callable or provide run(command)")
    try:
        self._executor_parameters = inspect.signature(self._execute).parameters
    except (TypeError, ValueError):
        self._executor_parameters = {}
    self._executor_accepts_options = any(
        item.kind is inspect.Parameter.VAR_KEYWORD
        for item in self._executor_parameters.values()
    )
    published = getattr(executor, "executor_capabilities", None)
    if published is None:
        inferred = set()
        if self._accepts_keyword("cwd"):
            inferred.add(ExecutorCapability.CWD)
        if self._accepts_keyword("env"):
            inferred.add(ExecutorCapability.ENV)
        if any(
            item.kind is inspect.Parameter.VAR_POSITIONAL
            for item in self._executor_parameters.values()
        ):
            inferred.add(ExecutorCapability.ARGS)
        published = frozenset(inferred)
    # One capability vocabulary, strings -- see `ExecutorCapability`.
    self.executor_capabilities = frozenset(published)
    self._executor_accepts_cwd = (
        ExecutorCapability.CWD in self.executor_capabilities
    )
    self._executor_accepts_env = (
        ExecutorCapability.ENV in self.executor_capabilities
    )

_cwd = cwd instance-attribute

_encoding = encoding instance-attribute

_env = dict(env) if env else None instance-attribute

_errors = errors instance-attribute

_execute = executor instance-attribute

_executor_accepts_cwd = ExecutorCapability.CWD in self.executor_capabilities instance-attribute

_executor_accepts_env = ExecutorCapability.ENV in self.executor_capabilities instance-attribute

_executor_accepts_options = any(item.kind is inspect.Parameter.VAR_KEYWORD for item in self._executor_parameters.values()) instance-attribute

_executor_parameters = inspect.signature(self._execute).parameters instance-attribute

_flavour = flavour instance-attribute

_host = None instance-attribute

_session = None instance-attribute

_spawn = spawn if callable(spawn) else None instance-attribute

cwd = property(lambda self: self._cwd) class-attribute instance-attribute

encoding = property(lambda self: self._encoding) class-attribute instance-attribute

env = property(lambda self: self._env) class-attribute instance-attribute

errors = property(lambda self: self._errors) class-attribute instance-attribute

executor_capabilities = frozenset(published) instance-attribute

flavour = property(lambda self: self._flavour) class-attribute instance-attribute

__call__(*args, cwd=None, env=_INHERIT_ENV, encoding=None, errors=None)

Configure: return a copy carrying these defaults (see configure).

Source code in src/hostctl/shell/_shell.py
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
def __call__(
    self,
    *args: object,
    cwd: typing.Optional[StrPath] = None,
    env: EnvironmentSelection = _INHERIT_ENV,
    encoding: typing.Optional[str] = None,
    errors: typing.Optional[str] = None,
) -> "Shell[_Result]":
    """Configure: return a copy carrying these defaults (see `configure`)."""
    if args:
        raise TypeError(
            "a Shell is not an Executor: call host.executor(command, *args) "
            "or host.run(Exec(command, *args)) to run a program with argv, "
            "or shell.run(...) to run shell commands"
        )
    return self.configure(cwd=cwd, env=env, encoding=encoding, errors=errors)

__enter__()

Open a default session, so with host.shell as session: works.

The session is closed on exit. session(...) remains the way to pass a command, cwd, env, terminal, or encoding; this is the no-argument shorthand. A Shell is not reusable as a context manager while a session it opened is still active -- each with opens its own.

Source code in src/hostctl/shell/_shell.py
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
def __enter__(self) -> ShellSession:
    """Open a default session, so ``with host.shell as session:`` works.

    The session is closed on exit. `session(...)` remains the way to pass
    a command, cwd, env, terminal, or encoding; this is the no-argument
    shorthand. A `Shell` is not reusable as a context manager while a
    session it opened is still active -- each `with` opens its own.
    """
    if self._session is not None:
        raise RuntimeError("shell already has an active session")
    session = self.session()
    try:
        # Enter the session so the underlying process sees a balanced
        # __enter__/__exit__ pair; `session.__exit__` delegates to it.
        session.__enter__()
    except BaseException:
        session.close()
        raise
    self._session = session
    return session

__exit__(exc_type, exc_value, traceback)

Source code in src/hostctl/shell/_shell.py
425
426
427
428
429
430
431
432
433
434
def __exit__(
    self,
    exc_type: typing.Optional[typing.Type[BaseException]],
    exc_value: typing.Optional[BaseException],
    traceback: typing.Optional[types.TracebackType],
) -> bool:
    session, self._session = self._session, None
    if session is None:
        return False
    return session.__exit__(exc_type, exc_value, traceback)

_accepts_keyword(name)

Source code in src/hostctl/shell/_shell.py
198
199
200
201
202
203
def _accepts_keyword(self, name: str) -> bool:
    parameter = self._executor_parameters.get(name)
    return parameter is not None and parameter.kind in (
        inspect.Parameter.POSITIONAL_OR_KEYWORD,
        inspect.Parameter.KEYWORD_ONLY,
    )

_resolve_cwd(cwd)

Source code in src/hostctl/shell/_shell.py
230
231
def _resolve_cwd(self, cwd: typing.Optional[StrPath]) -> typing.Optional[StrPath]:
    return self._cwd if cwd is None else cwd

_resolve_env(env)

Merge a per-call environment over this shell's default.

The default {} merges nothing, so a call that says nothing about the environment inherits the shell's. A mapping merges over that default per key: the call wins for keys it names, and default-only keys survive, so one variable can change without restating the rest.

None declines the shell's configured defaults. It does not mean an empty environment: the command still runs with whatever the host provides on its own -- a login profile, rc files, the service environment -- because nothing is sent to override it. Requesting a genuinely empty environment is a separate feature that does not exist yet.

Source code in src/hostctl/shell/_shell.py
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
def _resolve_env(
    self, env: EnvironmentSelection
) -> typing.Optional[CommandEnvironment]:
    """Merge a per-call environment over this shell's default.

    The default `{}` merges nothing, so a call that says nothing about the
    environment inherits the shell's. A mapping merges over that default
    per key: the call wins for keys it names, and default-only keys
    survive, so one variable can change without restating the rest.

    `None` declines the shell's configured defaults. It does **not** mean
    an empty environment: the command still runs with whatever the host
    provides on its own -- a login profile, rc files, the service
    environment -- because nothing is sent to override it. Requesting a
    genuinely empty environment is a separate feature that does not exist
    yet.
    """
    if env is None:
        return None
    if self._env is None:
        return dict(env) if env else None
    merged = dict(self._env)
    merged.update(env)
    return merged

configure(*, cwd=None, env=_INHERIT_ENV, encoding=None, errors=None)

Return a copy of this shell with additional defaults applied.

env merges over this shell's default the same way a per-call env does, so configuring twice layers rather than replaces, and None produces a copy carrying no environment default at all. The original shell is left unchanged.

Source code in src/hostctl/shell/_shell.py
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
def configure(
    self,
    *,
    cwd: typing.Optional[StrPath] = None,
    env: EnvironmentSelection = _INHERIT_ENV,
    encoding: typing.Optional[str] = None,
    errors: typing.Optional[str] = None,
) -> "Shell[_Result]":
    """Return a copy of this shell with additional defaults applied.

    `env` merges over this shell's default the same way a per-call `env`
    does, so configuring twice layers rather than replaces, and `None`
    produces a copy carrying no environment default at all. The original
    shell is left unchanged.
    """
    clone = copy.copy(self)
    clone._session = None
    clone._cwd = self._cwd if cwd is None else cwd
    clone._env = self._resolve_env(env)
    clone._encoding = self._encoding if encoding is None else encoding
    clone._errors = self._errors if errors is None else errors
    return clone

run(*cmds, stdin=None, stdout=None, stderr=None, cwd=None, env=_INHERIT_ENV, capture_output=None, check=None, encoding=None, errors=None, input=None, timeout=_OMITTED, text=None)

Run shell commands, as one script, on the host or executor.

env merges over the shell's default per key; the default merges nothing and so inherits it. Pass None to run without the shell's configured environment, keeping whatever the host provides itself. timeout=None is passed on as "no limit"; left out, the host's configured default applies.

Source code in src/hostctl/shell/_shell.py
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
def run(
    self,
    *cmds: ShellToken,
    stdin: typing.Optional[StreamTarget] = None,
    stdout: typing.Optional[StreamTarget] = None,
    stderr: typing.Optional[StreamTarget] = None,
    cwd: typing.Optional[StrPath] = None,
    env: EnvironmentSelection = _INHERIT_ENV,
    capture_output: typing.Optional[CaptureOutput] = None,
    check: typing.Optional[bool] = None,
    encoding: typing.Optional[str] = None,
    errors: typing.Optional[str] = None,
    input: CommandInput = None,
    timeout: typing.Optional[float] = _OMITTED,  # type: ignore[assignment]
    text: typing.Optional[bool] = None,
) -> _Result:
    """Run shell commands, as one script, on the host or executor.

    `env` merges over the shell's default per key; the default merges
    nothing and so inherits it. Pass `None` to run without the shell's
    configured environment, keeping whatever the host provides itself.
    `timeout=None` is passed on as "no limit"; left out, the host's
    configured default applies.
    """
    cwd = self._resolve_cwd(cwd)
    env = self._resolve_env(env)
    options = {
        name: value
        for name, value in (
            ("stdin", stdin),
            ("stdout", stdout),
            ("stderr", stderr),
            ("capture_output", capture_output),
            ("check", check),
            ("encoding", self._encoding if encoding is None else encoding),
            ("errors", self._errors if errors is None else errors),
            ("input", input),
            ("text", text),
        )
        if value is not None
    }
    if timeout is not _OMITTED:
        options["timeout"] = timeout
    if self._host is not None:
        if cwd is not None:
            options["cwd"] = cwd
        if env is not None:
            options["env"] = env
        return self._execute(*cmds, **options)
    if cwd is not None and self._executor_accepts_cwd:
        options["cwd"] = cwd
    if env is not None and self._executor_accepts_env:
        options["env"] = env
    unsupported = [
        name
        for name in options
        if not self._accepts_keyword(name) and not self._executor_accepts_options
    ]
    if unsupported:
        raise TypeError(f"executor does not accept {sorted(unsupported)[0]}")
    embed_cwd = None if self._executor_accepts_cwd else cwd
    embed_env = None if self._executor_accepts_env else env
    epilogue = wants_epilogue(self.executor_capabilities)

    def render(environment):
        if ExecutorCapability.ARGS in self.executor_capabilities:
            from ._invocation import shell_invocation

            leading = shell_invocation(
                self._flavour,
                cmds,
                cwd=embed_cwd,
                env=environment,
                epilogue=epilogue,
            )
            return (leading[0], *leading[1:])
        return (
            self._flavour.script(
                cmds, cwd=embed_cwd, env=environment, epilogue=epilogue
            ),
        )

    sent = render(embed_env)
    if embed_env:
        # Embedded values would otherwise reach any exception or log line
        # that carries the command; show a rendering with them masked.
        shown = render(masked_environment(embed_env))
        if shown != sent:
            return run_displayed(shown, lambda: self._execute(*sent, **options))
    return self._execute(*sent, **options)

session(*cmds, executable=None, cwd=None, env=_INHERIT_ENV, terminal=None, encoding=None, errors=None)

Open a persistent process using this shell language.

env merges over the shell's default per key; the default merges nothing and so inherits it. Pass None to open the session without the shell's configured environment, keeping whatever the host provides itself.

Source code in src/hostctl/shell/_shell.py
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
def session(
    self,
    *cmds: ShellToken,
    executable: typing.Optional[str] = None,
    cwd: typing.Optional[StrPath] = None,
    env: EnvironmentSelection = _INHERIT_ENV,
    terminal: TerminalRequest = None,
    encoding: typing.Optional[str] = None,
    errors: typing.Optional[str] = None,
) -> ShellSession:
    """Open a persistent process using this shell language.

    `env` merges over the shell's default per key; the default merges
    nothing and so inherits it. Pass `None` to open the session without
    the shell's configured environment, keeping whatever the host
    provides itself.
    """
    if self._spawn is None:
        raise NotImplementedError("executor does not provide persistent sessions")
    cwd = self._resolve_cwd(cwd)
    env = self._resolve_env(env)
    session = ShellSession(
        self._flavour,
        self._spawn(
            executable=executable,
            terminal=terminal,
            encoding=self._encoding if encoding is None else encoding,
            errors=self._errors if errors is None else errors,
        ),
    )
    # A shell default cwd/env applies to the session too: it is submitted
    # once here so it persists for every later `send` in that shell.
    if cmds or cwd is not None or env:
        session.send(
            *cmds,
            cwd=cwd,
            env=env,
        )
    return session

hostctl.ShellSession(flavour, process)

Bases: Process

A persistent process with shell-aware command submission.

Source code in src/hostctl/shell/_shell.py
38
39
40
def __init__(self, flavour: ShellFlavour, process: Process) -> None:
    self.flavour = flavour
    self.process = process

flavour = flavour instance-attribute

process = process instance-attribute

returncode property

__enter__()

Source code in src/hostctl/shell/_shell.py
93
94
95
def __enter__(self) -> ShellSession:
    self.process.__enter__()
    return self

__exit__(exc_type, exc_value, traceback)

Source code in src/hostctl/shell/_shell.py
 97
 98
 99
100
101
102
103
def __exit__(
    self,
    exc_type: typing.Optional[typing.Type[BaseException]],
    exc_value: typing.Optional[BaseException],
    traceback: typing.Optional[types.TracebackType],
) -> bool:
    return self.process.__exit__(exc_type, exc_value, traceback)

close()

Source code in src/hostctl/shell/_shell.py
90
91
def close(self) -> None:
    self.process.close()

kill()

Source code in src/hostctl/shell/_shell.py
87
88
def kill(self) -> None:
    self.process.kill()

read(size=-1)

Source code in src/hostctl/shell/_shell.py
63
64
def read(self, size: int = -1):
    return self.process.read(size)

read_stderr(size=-1)

Source code in src/hostctl/shell/_shell.py
66
67
def read_stderr(self, size: int = -1):
    return self.process.read_stderr(size)

resize(columns, rows, pixel_width=0, pixel_height=0)

Source code in src/hostctl/shell/_shell.py
72
73
74
75
76
77
78
79
def resize(
    self,
    columns: int,
    rows: int,
    pixel_width: int = 0,
    pixel_height: int = 0,
) -> None:
    self.process.resize(columns, rows, pixel_width, pixel_height)

send(*cmds, cwd=None, env=None)

Render and submit commands using this session's shell language.

Source code in src/hostctl/shell/_shell.py
46
47
48
49
50
51
52
53
54
55
56
57
58
def send(
    self,
    *cmds: ShellToken,
    cwd: typing.Optional[StrPath] = None,
    env: typing.Optional[CommandEnvironment] = None,
) -> None:
    """Render and submit commands using this session's shell language."""
    if not cmds and cwd is None and not env:
        raise ValueError("send requires commands, cwd, or env")
    script = self.flavour.script(cmds, cwd=cwd, env=env, epilogue=False)
    # A line terminator ends a command in every supported shell; an
    # appended `;` broke a heredoc delimiter and an open pipeline.
    self.process.write(script + self.flavour.line_terminator)

send_eof()

Source code in src/hostctl/shell/_shell.py
69
70
def send_eof(self) -> None:
    self.process.send_eof()

terminate()

Source code in src/hostctl/shell/_shell.py
84
85
def terminate(self) -> None:
    self.process.terminate()

wait(timeout=None)

Source code in src/hostctl/shell/_shell.py
81
82
def wait(self, timeout: typing.Optional[float] = None) -> int:
    return self.process.wait(timeout)

write(data)

Source code in src/hostctl/shell/_shell.py
60
61
def write(self, data):
    self.process.write(data)

hostctl.ShellOperator

Bases: Enum

A command operator, spelled by each shell flavour in its own syntax.

Place a member between two commands in a run()/script() call and the flavour renders it: PIPE is | everywhere, AND/OR are &&/|| in POSIX and PowerShell 7 but raise on PowerShell 5, which has neither. A flavour that cannot express one refuses rather than approximating it.

AND = 'and' class-attribute instance-attribute

APPEND = 'append' class-attribute instance-attribute

OR = 'or' class-attribute instance-attribute

PIPE = 'pipe' class-attribute instance-attribute

REDIRECT = 'redirect' class-attribute instance-attribute

SEQUENCE = 'sequence' class-attribute instance-attribute

hostctl.register_shell_flavour(value, *, name=None, replace=False)

Register a configured flavour instance for string-based selection.

Source code in src/hostctl/shell/__init__.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def register_shell_flavour(
    value: typing.Union[ShellFlavour, typing.Type[ShellFlavour]],
    *,
    name: typing.Optional[str] = None,
    replace: bool = False,
) -> ShellFlavour:
    """Register a configured flavour instance for string-based selection."""
    flavour = value() if isinstance(value, type) else value
    if not isinstance(flavour, ShellFlavour):
        raise TypeError("shell flavour must be a ShellFlavour instance or subclass")
    selected_name = name or flavour.name
    if not selected_name:
        raise ValueError("shell flavour name must not be empty")
    if selected_name in _SHELLS and not replace:
        raise ValueError(f"shell flavour is already registered: {selected_name}")
    _SHELLS[selected_name] = flavour
    return flavour

hostctl.shell_flavour(value)

Normalize a public shell selection without inferring from the transport.

Source code in src/hostctl/shell/__init__.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def shell_flavour(value: ShellFlavourSelection) -> ShellFlavour:
    """Normalize a public shell selection without inferring from the transport."""
    if isinstance(value, type):
        if not issubclass(value, ShellFlavour):
            raise TypeError("shell flavour class must inherit ShellFlavour")
        value = value()
    if isinstance(value, ShellFlavour):
        return value
    try:
        return _SHELLS[value]
    except (KeyError, TypeError) as exc:
        if isinstance(value, str):
            try:
                entries = _metadata.entry_points()
                selected = (
                    entries.select(group="hostctl.shell_flavours", name=value)
                    if hasattr(entries, "select")
                    else [
                        item
                        for item in entries.get("hostctl.shell_flavours", ())
                        if item.name == value
                    ]
                )
                if selected:
                    loaded = selected[0].load()
                    register_shell_flavour(loaded, name=value)
                    return _SHELLS[value]
            except Exception as plugin_error:
                raise ValueError(
                    f"invalid shell flavour plugin {value!r}: {plugin_error}"
                ) from plugin_error
        supported = ", ".join(sorted(_SHELLS))
        raise ValueError(
            f"unsupported shell flavour; choose one of: {supported}"
        ) from exc

Consoles

hostctl.ConsoleStream

Bases: Protocol

A console's merged byte stream, as a profile drives it.

read returns up to size bytes (-1: whatever is available), or b"" when nothing arrived within timeout seconds: None is the stream's own wait, 0 returns only what is already there. It raises ConnectionError once the stream has ended. write delivers all of data or raises.

A stream may also offer reset_input_buffer(), which a profile calls to discard bytes nobody is waiting for.

read(size=-1, *, timeout=None)

Source code in src/hostctl/console/_stream.py
23
24
def read(self, size: int = -1, *, timeout: typing.Optional[float] = None) -> bytes:
    raise NotImplementedError(f"{type(self).__name__}.read")

write(data)

Source code in src/hostctl/console/_stream.py
26
27
def write(self, data: bytes) -> None:
    raise NotImplementedError(f"{type(self).__name__}.write")

hostctl.console_stream(process)

process as a ConsoleStream a profile can drive with a deadline.

A process whose read already takes timeout= (a SerialProcess, a serial spawn() session) is returned as it is. Any other byte-mode Process -- a QEMU console from open_serial(), a container or SSH session opened with terminal=True -- is read by a background thread from then on, so read it only through the returned stream: its read must block until data arrives and return b"" only at the end.

Source code in src/hostctl/console/_stream.py
41
42
43
44
45
46
47
48
49
50
51
52
53
def console_stream(process: typing.Any) -> ConsoleStream:
    """`process` as a `ConsoleStream` a profile can drive with a deadline.

    A process whose `read` already takes `timeout=` (a `SerialProcess`, a
    serial `spawn()` session) is returned as it is. Any other byte-mode
    `Process` -- a QEMU console from `open_serial()`, a container or SSH
    session opened with `terminal=True` -- is read by a background thread
    from then on, so read it only through the returned stream: its `read`
    must block until data arrives and return `b""` only at the end.
    """
    if _takes_timeout(process.read):
        return typing.cast(ConsoleStream, process)
    return _ReaderThreadStream(process)

hostctl.ConsoleProfile

Bases: Protocol

What a console profile implements: negotiate, send, and optionally run.

run returns the command's output and status, and is called only when can_run is true. Every method takes the ConsoleStream to drive.

can_run instance-attribute

encoding instance-attribute

line_terminator instance-attribute

negotiate(stream)

Source code in src/hostctl/console/_profiles.py
31
def negotiate(self, stream: ConsoleStream) -> None: ...

run(stream, command, *, timeout=None)

Source code in src/hostctl/console/_profiles.py
37
38
39
40
41
42
43
def run(
    self,
    stream: ConsoleStream,
    command: typing.Union[str, bytes],
    *,
    timeout: typing.Optional[float] = None,
) -> tuple[bytes, int]: ...

send(stream, command)

Source code in src/hostctl/console/_profiles.py
33
34
35
def send(
    self, stream: ConsoleStream, command: typing.Union[str, bytes]
) -> None: ...

hostctl.RawConsoleProfile

Raw byte console; it supports interactive sessions but not run.

can_run = False class-attribute instance-attribute

encoding = 'utf-8' class-attribute instance-attribute

line_terminator = b'\r\n' class-attribute instance-attribute

max_buffer = 64 * 1024 class-attribute instance-attribute

shell_flavour = None class-attribute instance-attribute

negotiate(stream)

Source code in src/hostctl/console/_profiles.py
79
80
def negotiate(self, stream: ConsoleStream) -> None:
    return None

run(stream, command, *, timeout=None)

Source code in src/hostctl/console/_profiles.py
90
91
92
93
def run(
    self, stream: ConsoleStream, command: typing.Union[str, bytes], *, timeout=None
):
    raise NotImplementedError("raw serial consoles do not provide framed run()")

send(stream, command)

Source code in src/hostctl/console/_profiles.py
82
83
84
85
86
87
88
def send(self, stream: ConsoleStream, command: typing.Union[str, bytes]) -> None:
    data = (
        command.encode(self.encoding)
        if isinstance(command, str)
        else bytes(command)
    )
    stream.write(data + self.line_terminator)

hostctl.PromptConsoleProfile(prompt, *, login=(), line_terminator=b'\r\n', error_patterns=(), status_marker=None, reliable_status=False, max_buffer=64 * 1024, read_size=4096, wakeup=b'\r', echo=True, paging_prompt=None, paging_continue=b' ', paging_disable=None, max_paging_pages=32, terminal_setup=None, status_parser=None, login_timeout=10.0)

Configurable prompt/login framing for terminal-like devices.

A profile only advertises run when reliable_status is explicitly enabled. Without a real status marker a prompt can delimit output, but it cannot truthfully represent a process exit status.

Source code in src/hostctl/console/_profiles.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def __init__(
    self,
    prompt: typing.Union[bytes, str],
    *,
    login: typing.Iterable[
        typing.Union[
            LoginStep,
            typing.Tuple[typing.Union[bytes, str], typing.Union[bytes, str]],
        ]
    ] = (),
    line_terminator: typing.Union[bytes, str] = b"\r\n",
    error_patterns: typing.Iterable[typing.Union[bytes, str]] = (),
    status_marker: typing.Optional[typing.Union[bytes, str]] = None,
    reliable_status: bool = False,
    max_buffer: int = 64 * 1024,
    read_size: int = 4096,
    wakeup: bytes = b"\r",
    echo: bool = True,
    paging_prompt: typing.Optional[typing.Union[bytes, str]] = None,
    paging_continue: typing.Union[bytes, str] = b" ",
    paging_disable: typing.Optional[typing.Union[bytes, str]] = None,
    max_paging_pages: int = 32,
    terminal_setup: typing.Optional[
        typing.Callable[[ConsoleStream, int, int], None]
    ] = None,
    status_parser: typing.Optional[typing.Callable[[re.Match[bytes]], int]] = None,
    login_timeout: float = 10.0,
) -> None:
    if not login_timeout > 0:
        raise ValueError("login_timeout must be positive")
    #: Seconds each login step and the first prompt may take in `negotiate()`.
    self.login_timeout = float(login_timeout)
    self._prompt = self._compile(prompt)
    self.login = tuple(
        step if isinstance(step, LoginStep) else LoginStep(step[0], step[1])
        for step in login
    )
    self._login_patterns = tuple(self._compile(step.expect) for step in self.login)
    self.line_terminator = (
        line_terminator.encode()
        if isinstance(line_terminator, str)
        else bytes(line_terminator)
    )
    if not self.line_terminator:
        raise ValueError("line_terminator must not be empty")
    self.error_patterns = tuple(self._compile(value) for value in error_patterns)
    self.status_marker = (
        self._compile(status_marker) if status_marker is not None else None
    )
    self.reliable_status = bool(reliable_status)
    self.can_run = self.reliable_status and self.status_marker is not None
    self.max_buffer = max(1024, int(max_buffer))
    self.read_size = max(1, int(read_size))
    self.wakeup = bytes(wakeup)
    self.echo = bool(echo)
    self._paging_prompt = self._compile(paging_prompt) if paging_prompt else None
    self.paging_continue = (
        paging_continue.encode()
        if isinstance(paging_continue, str)
        else bytes(paging_continue)
    )
    self.paging_disable = (
        paging_disable.encode()
        if isinstance(paging_disable, str)
        else (bytes(paging_disable) if paging_disable is not None else None)
    )
    if max_paging_pages < 0:
        raise ValueError("max_paging_pages must not be negative")
    self.max_paging_pages = int(max_paging_pages)
    self.terminal_setup = terminal_setup
    self.status_parser = status_parser
    self.encoding = "utf-8"

_login_patterns = tuple(self._compile(step.expect) for step in self.login) instance-attribute

_paging_prompt = self._compile(paging_prompt) if paging_prompt else None instance-attribute

_prompt = self._compile(prompt) instance-attribute

can_run = self.reliable_status and self.status_marker is not None instance-attribute

echo = bool(echo) instance-attribute

encoding = 'utf-8' instance-attribute

error_patterns = tuple(self._compile(value) for value in error_patterns) instance-attribute

line_terminator = line_terminator.encode() if isinstance(line_terminator, str) else bytes(line_terminator) instance-attribute

login = tuple(step if isinstance(step, LoginStep) else LoginStep(step[0], step[1]) for step in login) instance-attribute

login_timeout = float(login_timeout) instance-attribute

max_buffer = max(1024, int(max_buffer)) instance-attribute

max_paging_pages = int(max_paging_pages) instance-attribute

paging_continue = paging_continue.encode() if isinstance(paging_continue, str) else bytes(paging_continue) instance-attribute

paging_disable = paging_disable.encode() if isinstance(paging_disable, str) else bytes(paging_disable) if paging_disable is not None else None instance-attribute

read_size = max(1, int(read_size)) instance-attribute

reliable_status = bool(reliable_status) instance-attribute

shell_flavour = None class-attribute instance-attribute

status_marker = self._compile(status_marker) if status_marker is not None else None instance-attribute

status_parser = status_parser instance-attribute

terminal_setup = terminal_setup instance-attribute

wakeup = bytes(wakeup) instance-attribute

_compile(value) staticmethod

Source code in src/hostctl/console/_profiles.py
198
199
200
201
202
203
204
205
206
@staticmethod
def _compile(value: bytes | str) -> typing.Pattern[bytes]:
    raw = value.encode() if isinstance(value, str) else bytes(value)
    if not raw:
        raise ValueError("console expressions must not be empty")
    try:
        return re.compile(raw)
    except re.error as exc:
        raise ValueError(f"invalid console expression: {exc}") from exc

_read(stream, remaining)

One backend read, bounded by what is left of the caller's budget.

Without the bound, run(timeout=) was only checked between reads, so a SerialConfig(timeout=Timeout(read=None)) host -- a supported setting that round-trips through the URI -- blocked inside the backend forever instead of raising TimeoutExpired.

Source code in src/hostctl/console/_profiles.py
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
def _read(self, stream: ConsoleStream, remaining: float | None) -> bytes:
    """One backend read, bounded by what is left of the caller's budget.

    Without the bound, `run(timeout=)` was only checked *between* reads,
    so a `SerialConfig(timeout=Timeout(read=None))` host -- a supported setting
    that round-trips through the URI -- blocked inside the backend
    forever instead of raising `TimeoutExpired`.
    """
    if remaining is None:
        return stream.read(self.read_size)
    try:
        return stream.read(self.read_size, timeout=remaining)
    except TypeError:
        # An injected stream that predates the keyword; the deadline
        # check between reads is then the only bound available.
        return stream.read(self.read_size)

_read_until(stream, expression, *, timeout, initial=b'', paging_stream=None, skip=None, floor=0)

Source code in src/hostctl/console/_profiles.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
def _read_until(
    self,
    stream: ConsoleStream,
    expression: typing.Pattern[bytes],
    *,
    timeout: float | None,
    initial: bytes = b"",
    paging_stream: ConsoleStream | None = None,
    skip: bytes | None = None,
    floor: int = 0,
) -> bytes:
    started = time.monotonic()
    buffer = bytearray(initial)
    pages = 0
    while True:
        start = self._window(buffer, skip, floor)
        if paging_stream is not None and self._paging_prompt is not None:
            paging = self._paging_prompt.search(buffer, start)
            if paging is not None:
                if pages >= self.max_paging_pages:
                    raise ConsoleProtocolError(
                        "serial console paging limit exceeded"
                    )
                pages += 1
                # ONLY the prompt. Deleting through `paging.end()` threw
                # away the page's content as well, so a paged command
                # returned its LAST page and reported success -- the
                # opposite of what paging support is for.
                del buffer[paging.start() : paging.end()]
                paging_stream.write(self.paging_continue)
                continue
        match = expression.search(buffer, start)
        if match:
            return bytes(buffer)
        if len(buffer) > self.max_buffer:
            # An error, not a head truncation: a transcript cut from the
            # front reads as complete, and `error_patterns` would miss a
            # failure printed in the part that was dropped.
            raise ConsoleProtocolError(
                f"serial console output exceeded max_buffer "
                f"({self.max_buffer} bytes); raise it on the profile to "
                "read output this long"
            )
        if timeout is not None and time.monotonic() - started >= timeout:
            error = TimeoutError("serial console prompt timed out")
            error.output = self.redact(bytes(buffer))  # type: ignore[attr-defined]
            raise error
        remaining = (
            None
            if timeout is None
            else max(0.0, timeout - (time.monotonic() - started))
        )
        chunk = self._read(stream, remaining)
        if chunk:
            buffer.extend(chunk)
            continue
        # pyserial timed reads return b""; avoid a busy loop while still
        # respecting a monotonic deadline.
        time.sleep(0.001)

_window(buffer, skip, floor)

Where this exchange's own output can start.

Everything before it is the command this profile just echoed back, or an earlier page boundary -- text that must never terminate, abort or forge the read. find is recomputed each pass because paging edits the buffer underneath.

Source code in src/hostctl/console/_profiles.py
208
209
210
211
212
213
214
215
216
217
218
219
def _window(self, buffer: bytes, skip: bytes | None, floor: int) -> int:
    """Where this exchange's own output can start.

    Everything before it is the command this profile just echoed back, or
    an earlier page boundary -- text that must never terminate, abort or
    forge the read. `find` is recomputed each pass because paging edits
    the buffer underneath.
    """
    if not skip:
        return floor
    index = buffer.find(skip, floor)
    return floor if index < 0 else index + len(skip)

drain(stream)

Discard whatever the previous exchange left in the stream.

Raw serial is one merged stream with no request/response correlation, so bytes left by an earlier command -- a timed-out run(), or the reply to paging_disable that negotiate() never read -- were consumed by the NEXT run() and reported as its output with returncode 0.

Source code in src/hostctl/console/_profiles.py
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
def drain(self, stream: ConsoleStream) -> None:
    """Discard whatever the previous exchange left in the stream.

    Raw serial is one merged stream with no request/response
    correlation, so bytes left by an earlier command -- a timed-out
    `run()`, or the reply to `paging_disable` that `negotiate()` never
    read -- were consumed by the NEXT `run()` and reported as its output
    with returncode 0.
    """
    reset = getattr(stream, "reset_input_buffer", None)
    if callable(reset):
        try:
            reset()
        except (NotImplementedError, ConnectionError):
            pass
    # A driver-level reset does not cover bytes already read into the
    # backend object, and an injected stream may have no reset at all.
    for _ in range(64):
        try:
            if not self._read(stream, 0.0):
                return
        except (TimeoutError, ConnectionError):
            return

negotiate(stream)

Source code in src/hostctl/console/_profiles.py
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
def negotiate(self, stream: ConsoleStream) -> None:
    transcript = b""
    reset = getattr(stream, "reset_input_buffer", None)
    if callable(reset):
        try:
            reset()
        except NotImplementedError:
            pass
    if self.wakeup:
        stream.write(self.wakeup)
    # The patterns were compiled once in `__init__` and then recompiled
    # here on every connect, while `_login_patterns` sat unused -- two
    # compilation sites for one expression, which is one too many to
    # keep in agreement.
    for step, pattern in zip(self.login, self._login_patterns):
        transcript = self._read_until(
            stream, pattern, timeout=self.login_timeout, initial=transcript
        )
        stream.write(step.send + self.line_terminator)
    self._read_until(
        stream, self._prompt, timeout=self.login_timeout, initial=transcript
    )
    if self.paging_disable is not None:
        stream.write(self.paging_disable + self.line_terminator)

redact(transcript)

Remove secret=True login values from a console transcript.

Consoles echo what they are sent, so a transcript captured during negotiate() holds the password itself -- and _read_until attaches that transcript to the TimeoutError it raises, which run() then copies onto TimeoutExpired.output: a value callers routinely log. LoginStep.__repr__ goes out of its way to print <redacted>; this is the same promise on the other exit.

Source code in src/hostctl/console/_profiles.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
def redact(self, transcript: bytes) -> bytes:
    """Remove `secret=True` login values from a console transcript.

    Consoles echo what they are sent, so a transcript captured during
    `negotiate()` holds the password itself -- and `_read_until`
    attaches that transcript to the `TimeoutError` it raises, which
    `run()` then copies onto `TimeoutExpired.output`: a value callers
    routinely log. `LoginStep.__repr__` goes out of its way to print
    `<redacted>`; this is the same promise on the other exit.
    """
    for step in self.login:
        if not step.secret:
            continue
        value = step.send
        if value:
            transcript = transcript.replace(value, b"<redacted>")
    return transcript

resize(stream, columns, rows)

Source code in src/hostctl/console/_profiles.py
351
352
353
354
355
356
def resize(self, stream: ConsoleStream, columns: int, rows: int) -> None:
    if self.terminal_setup is None:
        raise NotImplementedError("console profile does not support terminal setup")
    if columns <= 0 or rows <= 0:
        raise ValueError("terminal columns and rows must be positive")
    self.terminal_setup(stream, columns, rows)

run(stream, command, *, timeout=None)

One framed exchange, in its own window.

The window starts after this command is echoed back and ends at the first prompt following the completion marker. Nothing before it can terminate, abort or forge the exchange: a prompt-shaped or marker-shaped string in the echoed command or in the output belongs to the output, and only the marker the device appended last decides the status. Whatever the previous exchange left behind is discarded before the command is sent, and nothing is ever dropped silently.

Source code in src/hostctl/console/_profiles.py
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
def run(
    self, stream: ConsoleStream, command: typing.Union[str, bytes], *, timeout=None
):
    """One framed exchange, in its own window.

    The window starts after this command is echoed back and ends at the
    first prompt following the completion marker. Nothing before it can
    terminate, abort or forge the exchange: a prompt-shaped or
    marker-shaped string in the echoed command or in the output belongs
    to the output, and only the marker the device appended last decides
    the status. Whatever the previous exchange left behind is discarded
    before the command is sent, and nothing is ever dropped silently.
    """
    if not self.can_run:
        raise NotImplementedError("console profile does not provide reliable run()")
    self.drain(stream)
    encoded = (
        command.encode(self.encoding)
        if isinstance(command, str)
        else bytes(command)
    )
    self.send(stream, command)
    skip = encoded if self.echo else None
    started = time.monotonic()
    assert self.status_marker is not None
    # The marker first: a prompt is not the end of anything until the
    # device says the command finished.
    try:
        transcript = self._read_until(
            stream,
            self.status_marker,
            timeout=timeout,
            paging_stream=stream,
            skip=skip,
        )
    except TimeoutError as exc:
        # The one case the login re-check is for: the device dropped to a
        # login prompt instead of running the command, so no marker is
        # ever coming. Checked HERE rather than against the body, where
        # ordinary output ("last login: root") aborted a command that had
        # in fact completed.
        captured = getattr(exc, "output", b"") or b""
        if any(pattern.search(captured) for pattern in self._login_patterns):
            raise ConsoleProtocolError(
                "serial console requested login again"
            ) from exc
        raise
    remaining = (
        None
        if timeout is None
        else max(0.0, timeout - (time.monotonic() - started))
    )
    window = self._window(transcript, skip, 0)
    last = None
    for candidate in self.status_marker.finditer(transcript, window):
        last = candidate
    assert last is not None
    transcript = self._read_until(
        stream,
        self._prompt,
        timeout=remaining,
        initial=transcript,
        paging_stream=stream,
        floor=last.end(),
    )
    window = self._window(transcript, skip, 0)
    marker = None
    for candidate in self.status_marker.finditer(transcript, window):
        marker = candidate
    if marker is None:
        raise ConsoleProtocolError("command completion marker missing")
    body = transcript[window : marker.start()]
    # Whatever terminal editing left of the echoed line.
    if self.echo and body.startswith(encoded):
        body = body[len(encoded) :]
    body = body.lstrip(b"\r\n")
    status = (
        self.status_parser(marker)
        if self.status_parser
        else (
            1 if any(pattern.search(body) for pattern in self.error_patterns) else 0
        )
    )
    if not isinstance(status, int) or status < 0:
        raise ConsoleProtocolError("console status parser returned an invalid code")
    return body, status

send(stream, command)

Source code in src/hostctl/console/_profiles.py
358
359
360
361
362
363
364
def send(self, stream: ConsoleStream, command: typing.Union[str, bytes]) -> None:
    data = (
        command.encode(self.encoding)
        if isinstance(command, str)
        else bytes(command)
    )
    stream.write(data + self.line_terminator)

hostctl.PosixConsoleProfile(*, login=(), login_timeout=10.0, wakeup=b'\n', read_size=4096, shell='posix', batch=_BATCH)

Framed commands on the terminal of a POSIX shell.

Needs sh-compatible syntax (sh, dash, bash, zsh, busybox ash, ksh) and base64, mktemp, stty, cat and rm on the target. The command runs in the console's own shell through eval, so cd and variables persist between commands and exit ends the session.

shell names that shell's flavour ("posix", "bash", "zsh" or a PosixShellFlavour); a host driving this profile quotes structured commands and embeds cwd/env with it. Any other flavour raises ValueError, since the framing itself is sh syntax.

batch is how many typed bytes may be in flight before the shell acknowledges them (at least 1024, the default; None: no acknowledgements). Keep the default on a serial line, whose 4 KiB input queue drops what overflows it, and on a pty read and written from one thread: a macOS pty stops taking input after about 1.3 KiB while its echo goes unread (a Linux one takes 8 and 16 KiB). A stream that blocks the writer and whose far end keeps reading the terminal (a web shell, an SSH or container session) takes None, saving a round trip per KiB.

Source code in src/hostctl/console/_posix.py
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
def __init__(
    self,
    *,
    login: typing.Iterable[
        typing.Union[
            LoginStep,
            typing.Tuple[typing.Union[bytes, str], typing.Union[bytes, str]],
        ]
    ] = (),
    login_timeout: float = 10.0,
    wakeup: typing.Union[bytes, str] = b"\n",
    read_size: int = 4096,
    shell: ShellFlavourSelection = "posix",
    batch: typing.Optional[int] = _BATCH,
) -> None:
    if not login_timeout > 0:
        raise ValueError("login_timeout must be positive")
    if batch is not None and (
        isinstance(batch, bool) or not isinstance(batch, int) or batch < _MIN_BATCH
    ):
        raise ValueError(
            f"batch must be None or an int of at least {_MIN_BATCH} bytes, "
            f"got {batch!r}"
        )
    #: Typed bytes sent before waiting for the shell to acknowledge them;
    #: `None` types everything at once.
    self.batch = batch
    flavour = shell_flavour(shell)
    if not isinstance(flavour, PosixShellFlavour):
        raise ValueError(
            f"PosixConsoleProfile frames commands in sh syntax; {flavour.name} "
            "is not an sh-family shell flavour"
        )
    #: The grammar of the console's shell: argv quoting, `cd` and `export`.
    self.shell_flavour: PosixShellFlavour = flavour
    #: Seconds each login step, and the shell's first framed answer, may take.
    self.login_timeout = float(login_timeout)
    self.login = tuple(
        step if isinstance(step, LoginStep) else LoginStep(step[0], step[1])
        for step in login
    )
    self._login_patterns = tuple(
        PromptConsoleProfile._compile(step.expect) for step in self.login
    )
    self.wakeup = wakeup.encode() if isinstance(wakeup, str) else bytes(wakeup)
    self.read_size = max(1, int(read_size))

_login_patterns = tuple(PromptConsoleProfile._compile(step.expect) for step in self.login) instance-attribute

batch = batch instance-attribute

can_run = True class-attribute instance-attribute

encoding = 'utf-8' class-attribute instance-attribute

line_terminator = b'\n' class-attribute instance-attribute

login = tuple(step if isinstance(step, LoginStep) else LoginStep(step[0], step[1]) for step in login) instance-attribute

login_timeout = float(login_timeout) instance-attribute

read_size = max(1, int(read_size)) instance-attribute

shell_flavour = flavour instance-attribute

wakeup = wakeup.encode() if isinstance(wakeup, str) else bytes(wakeup) instance-attribute

_acknowledged(stream, marker, deadline, frame)

Read until the shell prints marker; return what followed it.

Source code in src/hostctl/console/_posix.py
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
def _acknowledged(
    self,
    stream: ConsoleStream,
    marker: bytes,
    deadline: typing.Optional[float],
    frame: _Frame,
) -> bytes:
    """Read until the shell prints `marker`; return what followed it."""
    buffer = bytearray()
    while True:
        hit = buffer.find(marker)
        if hit >= 0:
            return bytes(buffer[hit + len(marker) :])
        del buffer[: max(0, len(buffer) - len(marker) + 1)]
        try:
            chunk = self._read(stream, deadline, frame)
        except ConnectionError as exc:
            if isinstance(exc, OperationNotStartedError):
                raise
            raise OperationNotStartedError(
                "console stream failed before the command was typed: "
                f"{cause(exc)}",
                cause=exc,
            ) from exc
        buffer += chunk

_drain(stream)

Read and drop what the console has already sent, without waiting.

Raises OperationNotStartedError when the stream has failed: it is only called before the command line is typed.

Source code in src/hostctl/console/_posix.py
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
def _drain(self, stream: ConsoleStream) -> None:
    """Read and drop what the console has already sent, without waiting.

    Raises `OperationNotStartedError` when the stream has failed: it is
    only called before the command line is typed.
    """
    try:
        for _ in range(_DRAIN_READS):
            if not stream.read(self.read_size, timeout=0):
                return
    except Exception as exc:
        raise OperationNotStartedError(
            "console stream failed before the command was typed: " f"{cause(exc)}",
            cause=exc,
        ) from exc

_emit(sink, data) staticmethod

Source code in src/hostctl/console/_posix.py
465
466
467
468
@staticmethod
def _emit(sink: _Sink, data: bytes) -> None:
    if sink is not None and data:
        sink(data)

_expect(stream, pattern, initial)

Read until pattern matches; return what followed the match.

Source code in src/hostctl/console/_posix.py
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
def _expect(
    self, stream: ConsoleStream, pattern: typing.Pattern[bytes], initial: bytes
) -> bytes:
    """Read until `pattern` matches; return what followed the match."""
    deadline = time.monotonic() + self.login_timeout
    buffer = bytearray(initial)
    while True:
        match = pattern.search(buffer)
        if match:
            return bytes(buffer[match.end() :])
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            error = TimeoutError("console login prompt timed out")
            error.output = self.redact(bytes(buffer))  # type: ignore[attr-defined]
            raise error
        chunk = stream.read(self.read_size, timeout=min(_POLL, remaining))
        if chunk:
            buffer += chunk
            del buffer[:-_KEEP]
        else:
            time.sleep(0.001)

_prepare(stream, command, *, input, merge_stderr, deadline)

Type everything command needs before the line that runs it.

Returns the frame parser, what the console sent after the last acknowledgement, and the command line still to write. Raises OperationNotStartedError when the stream fails, and TimeoutError (after a ^C) when deadline passes waiting for an acknowledgement: either way the command line was never typed.

Source code in src/hostctl/console/_posix.py
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
def _prepare(
    self,
    stream: ConsoleStream,
    command: _Data,
    *,
    input: typing.Optional[_Data],
    merge_stderr: bool,
    deadline: typing.Optional[float],
) -> typing.Tuple[_Frame, bytes, bytes]:
    """Type everything `command` needs before the line that runs it.

    Returns the frame parser, what the console sent after the last
    acknowledgement, and the command line still to write. Raises
    `OperationNotStartedError` when the stream fails, and `TimeoutError`
    (after a ^C) when `deadline` passes waiting for an acknowledgement:
    either way the command line was never typed.
    """
    encoded = (
        command.encode(self.encoding)
        if isinstance(command, str)
        else bytes(command)
    )
    data = (
        None
        if input is None
        else (
            input.encode(self.encoding) if isinstance(input, str) else bytes(input)
        )
    )
    token = uuid.uuid4().hex[:16]
    batches = _typed_batches(
        encoded, token, input=data, merge_stderr=merge_stderr, batch=self.batch
    )
    frame = _Frame(token)
    leftover = b""
    for number, batch in enumerate(batches[:-1], 1):
        self._type(stream, batch)
        leftover = self._acknowledged(
            stream, f"__AK{token}_{number}__".encode(), deadline, frame
        )
    *setup, main = batches[-1]
    self._type(stream, setup, drain_after=True)
    return frame, leftover, main.encode("ascii") + b"\n"

_read(stream, deadline, frame)

One bounded read; past the deadline, interrupt and raise.

Source code in src/hostctl/console/_posix.py
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
def _read(
    self, stream: ConsoleStream, deadline: typing.Optional[float], frame: _Frame
) -> bytes:
    """One bounded read; past the deadline, interrupt and raise."""
    while True:
        remaining = None if deadline is None else deadline - time.monotonic()
        if remaining is not None and remaining <= 0:
            try:
                stream.write(b"\x03")
            except Exception:
                pass
            error = TimeoutError("console command timed out")
            error.output = bytes(frame.out)  # type: ignore[attr-defined]
            error.stderr = bytes(frame.err)  # type: ignore[attr-defined]
            raise error
        wait = _POLL if remaining is None else min(_POLL, remaining)
        chunk = stream.read(self.read_size, timeout=wait)
        if chunk:
            return chunk
        time.sleep(0.001)

_start(stream, command, *, input, merge_stderr, deadline)

Type command through the line that runs it.

Returns the frame parser and the (stdout, stderr) it already framed. Raises what _prepare raises; a failure while typing the command line itself is not a decline, since part of it may be typed.

Source code in src/hostctl/console/_posix.py
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
def _start(
    self,
    stream: ConsoleStream,
    command: _Data,
    *,
    input: typing.Optional[_Data],
    merge_stderr: bool,
    deadline: typing.Optional[float],
) -> typing.Tuple[_Frame, bytes, bytes]:
    """Type `command` through the line that runs it.

    Returns the frame parser and the ``(stdout, stderr)`` it already
    framed. Raises what `_prepare` raises; a failure while typing the
    command line itself is not a decline, since part of it may be typed.
    """
    frame, leftover, line = self._prepare(
        stream, command, input=input, merge_stderr=merge_stderr, deadline=deadline
    )
    stream.write(line)
    out, err = frame.feed(leftover)
    return frame, out, err

_type(stream, lines, *, drain_after=False)

Write lines that come before the command line; a failure ran nothing.

At most _WRITE bytes go in one write, and what the console sent is read before the next (and after the last, with drain_after): a terminal whose echo nobody reads stops reading its input, and the blocked write would never return. Until the command line is typed, that output is echo and prompts only.

Source code in src/hostctl/console/_posix.py
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
def _type(
    self,
    stream: ConsoleStream,
    lines: typing.Sequence[str],
    *,
    drain_after: bool = False,
) -> None:
    """Write lines that come before the command line; a failure ran nothing.

    At most `_WRITE` bytes go in one write, and what the console sent is
    read before the next (and after the last, with `drain_after`): a
    terminal whose echo nobody reads stops reading its input, and the
    blocked write would never return. Until the command line is typed,
    that output is echo and prompts only.
    """
    pieces: typing.List[bytes] = []
    for line in lines:
        data = line.encode("ascii") + b"\n"
        if pieces and len(pieces[-1]) + len(data) <= _WRITE:
            pieces[-1] += data
        else:
            pieces.append(data)
    for index, piece in enumerate(pieces):
        if index:
            self._drain(stream)
        try:
            stream.write(piece)
        except Exception as exc:
            raise OperationNotStartedError(
                "console write failed before the command was typed: "
                f"{cause(exc)}",
                cause=exc,
            ) from exc
    if pieces and drain_after:
        self._drain(stream)

execute(stream, command, *, input=None, merge_stderr=False, timeout=None, on_stdout=None, on_stderr=None)

Run command; return (stdout, stderr, status) as the program wrote them.

input is the command's whole stdin; without it stdin is /dev/null. merge_stderr sends stderr into stdout (2>&1). on_stdout receives stdout as it arrives, on_stderr stderr when the command ends. On timeout a ^C is typed and TimeoutError raised, carrying .output and .stderr so far. A stream failure before the command line was typed raises OperationNotStartedError.

Source code in src/hostctl/console/_posix.py
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
def execute(
    self,
    stream: ConsoleStream,
    command: _Data,
    *,
    input: typing.Optional[_Data] = None,
    merge_stderr: bool = False,
    timeout: typing.Optional[float] = None,
    on_stdout: _Sink = None,
    on_stderr: _Sink = None,
) -> typing.Tuple[bytes, bytes, int]:
    """Run `command`; return ``(stdout, stderr, status)`` as the program wrote them.

    `input` is the command's whole stdin; without it stdin is `/dev/null`.
    `merge_stderr` sends stderr into stdout (``2>&1``). `on_stdout`
    receives stdout as it arrives, `on_stderr` stderr when the command
    ends. On `timeout` a ^C is typed and `TimeoutError` raised, carrying
    `.output` and `.stderr` so far. A stream failure before the command
    line was typed raises `OperationNotStartedError`.
    """
    deadline = None if timeout is None else time.monotonic() + timeout
    frame, out, err = self._start(
        stream, command, input=input, merge_stderr=merge_stderr, deadline=deadline
    )
    self._emit(on_stdout, out)
    self._emit(on_stderr, err)
    while frame.state != "done":
        chunk = self._read(stream, deadline, frame)
        out, err = frame.feed(chunk)
        self._emit(on_stdout, out)
        self._emit(on_stderr, err)
    return bytes(frame.out), bytes(frame.err), typing.cast(int, frame.returncode)

negotiate(stream)

Log in, then prove a POSIX shell answers a framed command.

Raises TimeoutError when a login step or the shell does not answer within login_timeout, and ConsoleProtocolError when the shell answers but the probe fails.

Source code in src/hostctl/console/_posix.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
def negotiate(self, stream: ConsoleStream) -> None:
    """Log in, then prove a POSIX shell answers a framed command.

    Raises `TimeoutError` when a login step or the shell does not answer
    within `login_timeout`, and `ConsoleProtocolError` when the shell
    answers but the probe fails.
    """
    reset = getattr(stream, "reset_input_buffer", None)
    if callable(reset):
        try:
            reset()
        except NotImplementedError:
            pass
    if self.wakeup:
        stream.write(self.wakeup)
    transcript = b""
    for step, pattern in zip(self.login, self._login_patterns):
        transcript = self._expect(stream, pattern, transcript)
        stream.write(step.send + self.line_terminator)
    _, _, status = self.execute(stream, ":", timeout=self.login_timeout)
    if status != 0:
        raise ConsoleProtocolError(
            f"console shell answered the framed probe with status {status}"
        )

redact(transcript)

transcript with every secret=True login value replaced.

Source code in src/hostctl/console/_posix.py
294
295
296
297
298
299
def redact(self, transcript: bytes) -> bytes:
    """`transcript` with every `secret=True` login value replaced."""
    for step in self.login:
        if step.secret and step.send:
            transcript = transcript.replace(step.send, b"<redacted>")
    return transcript

run(stream, command, *, timeout=None)

Run command; return its stdout and stderr merged, and its status.

Source code in src/hostctl/console/_posix.py
353
354
355
356
357
358
359
360
361
362
363
364
def run(
    self,
    stream: ConsoleStream,
    command: _Data,
    *,
    timeout: typing.Optional[float] = None,
) -> typing.Tuple[bytes, int]:
    """Run `command`; return its stdout and stderr merged, and its status."""
    output, _, status = self.execute(
        stream, command, merge_stderr=True, timeout=timeout
    )
    return output, status

send(stream, command)

Type command and a newline, for an interactive session.

Source code in src/hostctl/console/_posix.py
348
349
350
351
def send(self, stream: ConsoleStream, command: _Data) -> None:
    """Type `command` and a newline, for an interactive session."""
    data = command.encode(self.encoding) if isinstance(command, str) else command
    stream.write(bytes(data) + self.line_terminator)

hostctl.ConsoleExecutor(stream_factory, *, profile=None)

Bases: StreamingExecutor

A subprocess-shaped executor over a POSIX shell's console.

stream_factory() returns the ConsoleStream of a console whose shell is ready to type into (log in there, with profile.negotiate(stream), when it needs it); it is called once per command. profile frames each command (default PosixConsoleProfile(); pass one with batch=None for a stream that cannot overflow). Wrap it in an ExecutorProvider: it takes no native args, cwd or env, so a host renders one line of shell text, which runs in a child shell of the console's.

Output is delivered as it arrives: stdout during the command, stderr when it ends. One command holds the terminal from its first typed byte until its status arrives; another call waits for it, within its own timeout. OperationNotStartedError (a provider decline) is raised only before the command line is typed: when stream_factory raises OSError or TimeoutError, or the stream fails while the command's files are typed. A failure after that is a ConnectionError.

After a timeout, and after a failure once the stream was obtained (a decline included), _abandon(request, stream=) types ^C on that stream. A timeout raises TimeoutExpired with orphaned=True: ^C stops only a program that heeds it. Override _abandon to also drop stream, returning False when that ends the command (closing a login session hangs up its jobs). It runs before the terminal is released, so no other command has typed into stream.

Source code in src/hostctl/console/_executor.py
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
def __init__(
    self,
    stream_factory: typing.Callable[[], ConsoleStream],
    *,
    profile: typing.Optional[PosixConsoleProfile] = None,
) -> None:
    if profile is None:
        profile = PosixConsoleProfile()
    if not isinstance(profile, PosixConsoleProfile):
        raise TypeError(
            "ConsoleExecutor frames commands with a PosixConsoleProfile, "
            f"not {type(profile).__name__}"
        )
    self.stream_factory = stream_factory
    self.profile = profile
    self._terminal = threading.Lock()
    self._running: typing.Optional[_Running] = None

_running = None instance-attribute

_terminal = threading.Lock() instance-attribute

executor_capabilities = frozenset() class-attribute instance-attribute

profile = profile instance-attribute

stream_factory = stream_factory instance-attribute

_abandon(request, *, stream=None)

Type ^C on stream, the command's terminal; True: it may still run.

stream defaults to the running command's. A write that fails is ignored: a stream is often abandoned because it already failed.

Source code in src/hostctl/console/_executor.py
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
def _abandon(
    self, request: ExecutorRequest, *, stream: typing.Optional[ConsoleStream] = None
) -> bool:
    """Type ^C on `stream`, the command's terminal; True: it may still run.

    `stream` defaults to the running command's. A write that fails is
    ignored: a stream is often abandoned because it already failed.
    """
    if stream is None:
        running = self._running
        if running is not None and running.request is request:
            stream = running.stream
    if stream is not None:
        try:
            stream.write(b"\x03")
        except Exception:
            pass
    return True

_owned(request)

Source code in src/hostctl/console/_executor.py
165
166
167
168
169
def _owned(self, request: ExecutorRequest) -> _Running:
    running = self._running
    if running is None or running.request is not request:
        raise RuntimeError("the console is not held by this command")
    return running

_receive(request, wait)

Source code in src/hostctl/console/_executor.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def _receive(
    self, request: ExecutorRequest, wait: typing.Optional[float]
) -> typing.Tuple[bytes, bytes, typing.Optional[int]]:
    running = self._owned(request)
    frame = typing.cast(_Frame, running.frame)
    if running.pending is not None:
        (out, err), running.pending = running.pending, None
    else:
        stream = typing.cast(ConsoleStream, running.stream)
        chunk = stream.read(self.profile.read_size, timeout=wait)
        if not chunk:
            # A stream may return at once with nothing; do not spin on it.
            time.sleep(0.001)
            return b"", b"", None
        out, err = frame.feed(chunk)
    if frame.state != "done":
        return out, err, None
    self._release(request)
    return out, err, frame.returncode

_release(request)

Source code in src/hostctl/console/_executor.py
171
172
173
174
175
def _release(self, request: ExecutorRequest) -> None:
    running = self._running
    if running is not None and running.request is request:
        self._running = None
        self._terminal.release()

_send(request)

Source code in src/hostctl/console/_executor.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def _send(self, request: ExecutorRequest) -> None:
    wait = request.remaining()
    if not self._terminal.acquire(timeout=-1 if wait is None else max(wait, 0)):
        raise expired(request.command, request.timeout)
    running = self._running = _Running(request)
    try:
        running.stream = self.stream_factory()
    except OperationNotStartedError:
        self._release(request)
        raise
    except (OSError, TimeoutError) as exc:
        self._release(request)
        raise OperationNotStartedError(
            f"console stream unavailable: {cause(exc)}", cause=exc
        ) from exc
    except BaseException:
        self._release(request)
        raise
    try:
        frame, leftover, line = self.profile._prepare(
            running.stream,
            request.command,
            input=request.input,
            merge_stderr=request.merge_stderr,
            deadline=request.deadline,
        )
    except OperationNotStartedError:
        self._stop(request)
        raise
    except TimeoutError as exc:
        raise expired(request.command, request.timeout) from exc
    running.started = True
    running.stream.write(line)
    running.frame = frame
    running.pending = frame.feed(leftover)

_stop(request)

Source code in src/hostctl/console/_executor.py
151
152
153
154
155
156
157
158
159
160
161
162
163
def _stop(self, request: ExecutorRequest) -> bool:
    running = self._running
    if running is None or running.request is not request:
        # This call never held the terminal: nothing of it ran.
        return False
    try:
        try:
            orphaned = bool(self._abandon(request, stream=running.stream))
        except Exception:
            orphaned = True
        return orphaned and running.started
    finally:
        self._release(request)

hostctl.LoginStep(expect, send, secret=False) dataclass

One bounded expect/send step of a console login.

expect instance-attribute

secret = False class-attribute instance-attribute

send instance-attribute

__post_init__()

Source code in src/hostctl/console/_profiles.py
54
55
56
57
58
59
60
def __post_init__(self) -> None:
    expect = self.expect.encode() if isinstance(self.expect, str) else self.expect
    send = self.send.encode() if isinstance(self.send, str) else self.send
    if not expect:
        raise ValueError("login expect expression must not be empty")
    object.__setattr__(self, "expect", bytes(expect))
    object.__setattr__(self, "send", bytes(send))

__repr__()

Source code in src/hostctl/console/_profiles.py
62
63
64
65
66
def __repr__(self) -> str:
    value = "<redacted>" if self.secret else repr(self.send)
    return (
        f"LoginStep(expect={self.expect!r}, send={value}, secret={self.secret!r})"
    )

Exceptions

hostctl.HostctlError

Bases: Exception

Base of every exception hostctl raises of its own.

hostctl.TransportError

Bases: HostctlError, ConnectionError

The connection to the target failed or was never made.

hostctl.OperationNotStartedError(reason='operation was not started', *, cause=None)

Bases: TransportError

A provider declined before anything was sent to the target.

Nothing ran, so retrying the operation elsewhere is safe; this is the only failure ordered provider fallback moves past. cause is the transport error that made the provider decline, when there was one.

Source code in src/hostctl/exceptions.py
44
45
46
47
48
49
50
51
52
def __init__(
    self,
    reason: str = "operation was not started",
    *,
    cause: typing.Optional[Exception] = None,
) -> None:
    super().__init__(reason)
    self.reason = reason
    self.cause = cause

cause = cause instance-attribute

reason = reason instance-attribute

hostctl.QGADisconnectedError

Bases: TransportError

The guest-agent transport disconnected before producing a reply.

hostctl.ProtocolError

Bases: HostctlError, ConnectionError

The peer answered, but not in the protocol's expected shape.

hostctl.QGAProtocolError

Bases: ProtocolError

The guest agent returned malformed or inconsistent protocol data.

hostctl.ConsoleProtocolError

Bases: ProtocolError

The console did not complete the expected protocol exchange.

hostctl.QGATimeoutError

Bases: HostctlError, TimeoutError

A guest-agent request did not complete before its deadline.

hostctl.QGACommandError(error_class, description, *, data=None)

Bases: HostctlError

The guest agent refused a command with a structured error.

error_class and description are the agent's own fields; data holds any extra detail it sent.

Source code in src/hostctl/exceptions.py
82
83
84
85
86
87
88
89
90
91
92
def __init__(
    self,
    error_class: str,
    description: str,
    *,
    data: typing.Optional[typing.Mapping[str, object]] = None,
) -> None:
    super().__init__(f"{error_class}: {description}")
    self.error_class = error_class
    self.description = description
    self.data = dict(data or {})

data = dict(data or {}) instance-attribute

description = description instance-attribute

error_class = error_class instance-attribute

hostctl.BundleError

Bases: HostctlError, RuntimeError

A deploy bundle cannot be built correctly from what is installed here.

Raised before anything is sent to the target: a distribution with a compiled extension or nothing importable, a missing ignore file, a source tree without an importable package.

hostctl.MissingExtraError(extra, feature, *, python=None, distribution='hostctl')

Bases: HostctlError, ImportError

A feature needs an optional extra that this installation lacks.

extra is the name to install (pip install "DISTRIBUTION[EXTRA]") and feature what needed it. distribution is the package whose extra it is: a library built on hostctl raises this for its own extras. With python, the extra cannot be installed on this interpreter at all: it names the oldest Python the extra supports.

Source code in src/hostctl/exceptions.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def __init__(
    self,
    extra: str,
    feature: str,
    *,
    python: typing.Optional[typing.Tuple[int, int]] = None,
    distribution: str = "hostctl",
) -> None:
    if python is None:
        message = (
            f"{feature} needs the '{extra}' extra: "
            f'pip install "{distribution}[{extra}]"'
        )
    else:
        message = (
            f"{feature} needs Python {python[0]}.{python[1]} or newer "
            f"(the '{extra}' extra of {distribution} supports nothing older)"
        )
    super().__init__(message)
    self.extra = extra
    self.feature = feature
    self.python = python
    self.distribution = distribution

distribution = distribution instance-attribute

extra = extra instance-attribute

feature = feature instance-attribute

python = python instance-attribute

Provider composition

hostctl.ExecutorProvider(name, executor, *, capabilities=None, probe=None)

Bases: _Provider

Named callable executor with a conservative probe hook.

Source code in src/hostctl/provider/_contracts.py
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
def __init__(
    self,
    name: str,
    executor: typing.Callable[..., object],
    *,
    capabilities=None,
    probe=None,
):
    self.executor = executor
    super().__init__(
        name,
        capabilities=(
            getattr(executor, "executor_capabilities", ())
            if capabilities is None
            else capabilities
        ),
        probe=probe,
    )

__call__ = execute class-attribute instance-attribute

executor = executor instance-attribute

execute(command, *args, **options)

Source code in src/hostctl/provider/_contracts.py
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
def execute(self, command, *args, **options):
    # The rendered command routinely carries credentials, so it is redacted
    # on the way to the record and never interpolated eagerly: with no
    # handler listening, `%`-style args mean the redaction never runs.
    # A dispatch whose environment is embedded in the command text carries
    # its masked rendering, which is what is logged.
    display = dispatch_display()
    log.debug(
        "provider %s dispatching: %s",
        _LazyName(self.name),
        (
            _LazyRedaction(command, args)
            if display is None
            else _LazyRedaction(display[0], display[1:])
        ),
    )
    return self.executor(command, *args, **options)

hostctl.PathProvider(name, factory, *, capabilities=None, probe=None)

Bases: _Provider

Named path factory. path must return a pathapi.Path.

Source code in src/hostctl/provider/_contracts.py
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
def __init__(
    self,
    name: str,
    factory: typing.Callable[..., Path],
    *,
    capabilities=None,
    probe=None,
):
    self.factory = factory
    super().__init__(
        name,
        capabilities=(
            self.DEFAULT_CAPABILITIES if capabilities is None else capabilities
        ),
        probe=probe,
    )

DEFAULT_CAPABILITIES = frozenset(('stat', 'scandir', 'open', 'open_read', 'open_write', 'read', 'write', 'exists', 'is_file', 'is_dir', 'mkdir', 'chmod', 'unlink', 'rmdir', 'rename', 'symlink_to', 'readlink', 'utime', 'hardlink_to')) class-attribute instance-attribute

factory = factory instance-attribute

path(*segments)

Source code in src/hostctl/provider/_contracts.py
294
295
296
297
298
299
300
301
302
303
304
305
def path(self, *segments):
    log.debug(
        "path provider %s building: %s",
        _LazyName(self.name),
        _LazyRedaction("", segments),
    )
    value = self.factory(*segments)
    if not isinstance(value, Path):
        raise TypeError(
            f"path provider {self.name!r} returned {type(value).__name__}, expected pathapi.Path"
        )
    return value

hostctl.ProviderProbe(availability, reason='', capabilities=frozenset(), system_hint=None) dataclass

availability instance-attribute

capabilities = frozenset() class-attribute instance-attribute

reason = '' class-attribute instance-attribute

system_hint = None class-attribute instance-attribute

usable property

hostctl.ProviderSelection(provider, trace, generation=0, pinned=False) dataclass

generation = 0 class-attribute instance-attribute

pinned = False class-attribute instance-attribute

provider instance-attribute

trace instance-attribute

hostctl.SelectionStep(provider, availability, reason='', capabilities=(), chosen=False, generation=0, pin=False) dataclass

One candidate a selection considered, in provider precedence order.

provider is the provider's name and reason its refusal or probe reason, both redacted. availability is "unavailable" for a provider that declined before dispatch this generation. capabilities is what the probe stated, sorted.

availability instance-attribute

capabilities = () class-attribute instance-attribute

chosen = False class-attribute instance-attribute

generation = 0 class-attribute instance-attribute

pin = False class-attribute instance-attribute

provider instance-attribute

reason = '' class-attribute instance-attribute

hostctl.ProviderDetail(kind, provider, availability, reason='', capabilities=(), system_hint=None) dataclass

A provider's availability as host.provider_details reports it.

Read without dispatching; provider and reason are redacted, and capabilities is the effective set (declared, narrowed by the probe).

availability instance-attribute

capabilities = () class-attribute instance-attribute

kind instance-attribute

provider instance-attribute

reason = '' class-attribute instance-attribute

system_hint = None class-attribute instance-attribute

hostctl.ProviderSelector(providers=())

Ordered selector which never retries a dispatched operation.

Source code in src/hostctl/provider/_selector.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
def __init__(self, providers=()):
    self.providers = tuple(providers)
    if any(not getattr(p, "name", "") for p in self.providers):
        raise ValueError("providers must have names")
    names = tuple(provider.name for provider in self.providers)
    if len(set(names)) != len(names):
        raise ValueError("provider names must be unique within a selector")
    self._probe_cache: dict[str, ProviderProbe] = {}
    self._declined: dict[str, str] = {}
    self._decline_causes: dict[str, BaseException] = {}
    self._generation = 0
    # One lock over the shared dictionaries. Two threads sharing a host
    # is ordinary, and the probe/decline maps were mutated while another
    # thread iterated them -- reproducibly enough to raise
    # `RuntimeError: dictionary changed size during iteration` under a
    # short switch interval.
    self._lock = threading.RLock()
    # Trace entries accumulated across the failover attempts of one
    # caller-level operation, keyed by provider name so a later, more
    # informative record (a decline) supersedes the earlier optimistic one
    # (chosen).  See `select` for how an attempt sequence is delimited.
    #
    # PER THREAD: an operation's trace describes that operation. Shared,
    # a concurrent call's records leaked into it, so `host.last_selection` and
    # `path.selection_trace` named providers the caller never tried.
    self._traces = threading.local()

_SECRET_ASSIGNMENT = re.compile('(?i)(?<![A-Za-z0-9])((?:[A-Za-z0-9_.$:\\-]*[_.$:\\-])?' + _SECRET_KEYWORD + '[A-Za-z0-9_.\\-]*)\\s*[=:]\\s*((?:\\"[^\\"]*\\"|\'[^\']*\')+|[^&\\s,;]+)') class-attribute instance-attribute

_SECRET_FISH_SET = re.compile('(?i)(?<![A-Za-z0-9])(set(?:\\s+-[A-Za-z]+)*\\s+)((?:[A-Za-z0-9_]*[_])?' + _SECRET_KEYWORD + '[A-Za-z0-9_]*)\\s+((?:\\"(?:\\\\.|[^\\"\\\\])*\\"|\'(?:\\\\.|[^\'\\\\])*\')+|[^&\\s,;]+)') class-attribute instance-attribute

_SECRET_KEYWORD = '(?:password|passwd|pwd|secret|token|api[_-]?key|key|credential)' class-attribute instance-attribute

_SECRET_USERINFO = re.compile('(?i)\\b([a-z][a-z0-9+.\\-]*://[^/\\s:@]+):[^/\\s@]+@') class-attribute instance-attribute

_attempt_trace property writable

This thread's accumulating trace for the operation it is running.

_decline_causes = {} instance-attribute

_declined = {} instance-attribute

_fallback_hook = None class-attribute instance-attribute

_generation = 0 instance-attribute

_lock = threading.RLock() instance-attribute

_probe_cache = {} instance-attribute

_traces = threading.local() instance-attribute

declines property

Providers that refused before dispatch this generation, by name.

Maps provider name to the redacted refusal reason. A copy: mutating the result does not change selector state.

generation property

The current connection generation; probes are cached per value.

providers = tuple(providers) instance-attribute

trace property

The accumulated trace for the operation currently being selected.

_effective(provider, probe) staticmethod

Source code in src/hostctl/provider/_selector.py
210
211
212
213
214
215
216
217
218
@staticmethod
def _effective(provider, probe: ProviderProbe) -> frozenset:
    declared = provider.capabilities
    stated = probe.capabilities
    if not stated:
        return declared
    if "path" in declared:
        return stated
    return declared & stated

_readmit_earlier_declines(excluded, capability)

Forget declines that the running operation did not make.

A name the caller excluded is one it tried and saw refuse, so only declines outside excluded came from an earlier operation.

Source code in src/hostctl/provider/_selector.py
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
def _readmit_earlier_declines(
    self, excluded: typing.AbstractSet[str], capability: str | None
) -> bool:
    """Forget declines that the running operation did not make.

    A name the caller excluded is one it tried and saw refuse, so only
    declines outside `excluded` came from an earlier operation.
    """
    with self._lock:
        stale = [
            provider.name
            for provider in self.providers
            if provider.name in self._declined
            and provider.name not in excluded
            and (capability is None or capability in provider.capabilities)
        ]
        for name in stale:
            del self._declined[name]
            self._decline_causes.pop(name, None)
    for name in stale:
        log.debug(
            "provider %s re-admitted: every other candidate is unavailable",
            _LazyName(name),
        )
    return bool(stale)

_record(step)

Record one step, a later one for a provider superseding the earlier.

Ordering follows first appearance, so the trace still reads in provider precedence order after a decline rewrites an entry.

Source code in src/hostctl/provider/_selector.py
352
353
354
355
356
357
358
def _record(self, step: SelectionStep) -> None:
    """Record one step, a later one for a provider superseding the earlier.

    Ordering follows first appearance, so the trace still reads in
    provider precedence order after a decline rewrites an entry.
    """
    self._attempt_trace[step.provider] = step

_served(provider)

Note that provider, chosen by this thread's last selection, carried the operation; reports to _fallback_hook when providers ranked above it were skipped as unavailable (a capability skip is routing, not a fallback).

Source code in src/hostctl/provider/_selector.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
def _served(self, provider) -> None:
    """Note that `provider`, chosen by this thread's last selection, carried
    the operation; reports to `_fallback_hook` when providers ranked above
    it were skipped as unavailable (a capability skip is routing, not a
    fallback)."""
    hook = self._fallback_hook
    if hook is None:
        return
    name = self.redact(provider.name)
    skipped = []
    for step in self._attempt_trace.values():
        if step.provider == name:
            break
        if step.availability == "unavailable":
            skipped.append(step)
    else:
        return
    if skipped:
        hook(provider, tuple(skipped))

decline(name, reason='declined before dispatch', *, cause=None)

Record that a provider refused before dispatch this generation.

A provider which raised OperationNotStartedError proved that nothing started, so it is safe to skip it for the rest of this generation rather than re-attempting it on every later operation. The record is cleared by :meth:invalidate along with the probe cache.

Source code in src/hostctl/provider/_selector.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
def decline(
    self,
    name: str,
    reason: str = "declined before dispatch",
    *,
    cause: typing.Optional[BaseException] = None,
) -> None:
    """Record that a provider refused *before* dispatch this generation.

    A provider which raised ``OperationNotStartedError`` proved that nothing
    started, so it is safe to skip it for the rest of this generation
    rather than re-attempting it on every later operation.  The record is
    cleared by :meth:`invalidate` along with the probe cache.
    """
    with self._lock:
        self._declined[str(name)] = str(reason)
        if cause is not None:
            self._decline_causes[str(name)] = cause
    log.debug(
        "provider %s declined before dispatch (generation %d): %s",
        _LazyName(name),
        self._generation,
        _LazyName(reason),
    )

effective_capabilities(provider)

What provider can do now: its declared set, narrowed by its probe.

A probe may narrow the declared capabilities and must not invent any, so the answer is the intersection when the (cached) probe states a set, and the declared set when it states none. A provider declaring the path wildcard declares everything, so what its probe states stands.

Source code in src/hostctl/provider/_selector.py
200
201
202
203
204
205
206
207
208
def effective_capabilities(self, provider) -> frozenset:
    """What `provider` can do now: its declared set, narrowed by its probe.

    A probe may narrow the declared capabilities and must not invent any,
    so the answer is the intersection when the (cached) probe states a set,
    and the declared set when it states none. A provider declaring the
    `path` wildcard declares everything, so what its probe states stands.
    """
    return self._effective(provider, self.probe(provider))

invalidate()

Start a new generation, dropping cached probes and declines.

Source code in src/hostctl/provider/_selector.py
385
386
387
388
389
390
391
392
393
def invalidate(self) -> None:
    """Start a new generation, dropping cached probes and declines."""
    with self._lock:
        self._probe_cache.clear()
        self._declined.clear()
        self._decline_causes.clear()
        self._generation += 1
    self._attempt_trace = {}
    log.debug("selector invalidated; generation is now %d", self._generation)

probe(provider)

Return a provider probe cached for this selector generation.

A provider that declined before dispatch this generation reports as unavailable with the refusal reason, overriding the cached probe. The probe describes whether the transport could work; the decline is the newer and stronger evidence that right now it does not, so any caller reading availability (provider_details, capabilities) sees the refusal instead of a stale available.

Source code in src/hostctl/provider/_selector.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
def probe(self, provider) -> ProviderProbe:
    """Return a provider probe cached for this selector generation.

    A provider that declined before dispatch this generation reports as
    ``unavailable`` with the refusal reason, overriding the cached probe.
    The probe describes whether the transport *could* work; the decline is
    the newer and stronger evidence that right now it does not, so any
    caller reading availability (``provider_details``, ``capabilities``)
    sees the refusal instead of a stale ``available``.
    """
    name = provider.name
    declined = self._declined.get(name)
    if declined is not None:
        return ProviderProbe("unavailable", self.redact(declined))
    with self._lock:
        cached = self._probe_cache.get(name)
    if cached is not None:
        return cached
    # Not under the lock: a probe may be a network round trip.
    try:
        result = provider.probe()
    except Exception as exc:
        result = ProviderProbe("unavailable", type(exc).__name__)
        log.debug(
            "provider %s probe raised %s; treating as unavailable",
            _LazyName(name),
            type(exc).__name__,
        )
    with self._lock:
        self._probe_cache[name] = result
    return result

redact(value) classmethod

Return a diagnostic string with credential-like values removed.

Best effort, and deliberately so. This recognizes credentials that announce themselves -- a password=/token=/api_key= assignment, or the userinfo half of a scheme://user:secret@host URI. A bare positional secret (mysql -p hunter2, an argv element that is only a token) carries no marker and cannot be detected by inspection. Treat debug output as sensitive.

Source code in src/hostctl/provider/_selector.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
@classmethod
def redact(cls, value: object) -> str:
    """Return a diagnostic string with credential-like values removed.

    Best effort, and deliberately so.  This recognizes credentials that
    *announce themselves* -- a ``password=``/``token=``/``api_key=``
    assignment, or the userinfo half of a ``scheme://user:secret@host``
    URI.  A bare positional secret (``mysql -p hunter2``, an argv element
    that is only a token) carries no marker and cannot be detected by
    inspection.  Treat debug output as sensitive.
    """
    text = cls._SECRET_ASSIGNMENT.sub(rf"\1={MASK}", str(value))
    text = cls._SECRET_FISH_SET.sub(rf"\1\2 {MASK}", text)
    return cls._SECRET_USERINFO.sub(r"\1:<redacted>@", text)

retry_declined()

Forget every refusal recorded this generation.

Not needed to recover a host: select() already re-admits an earlier refusal when nothing else can serve. This is for a caller that knows the declined provider is back (sshd restarted) and wants it preferred over a fallback that is still serving.

Source code in src/hostctl/provider/_selector.py
76
77
78
79
80
81
82
83
84
85
86
def retry_declined(self) -> None:
    """Forget every refusal recorded this generation.

    Not needed to recover a host: `select()` already re-admits an earlier
    refusal when nothing else can serve. This is for a caller that knows
    the declined provider is back (sshd restarted) and wants it preferred
    over a fallback that is still serving.
    """
    with self._lock:
        self._declined.clear()
        self._decline_causes.clear()

select(*, capability=None, exclude=(), pin=False)

Source code in src/hostctl/provider/_selector.py
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
def select(
    self,
    *,
    capability: typing.Optional[str] = None,
    exclude: typing.Iterable[str] = (),
    pin: bool = False,
) -> ProviderSelection:
    excluded = set(exclude)
    # An empty `exclude` starts a new caller-level operation; a non-empty
    # one is a failover step within the operation that is already running,
    # because the only way a name lands in `exclude` is that this same
    # caller just tried it (`run()`, `_providers_in_order`).  Accumulating
    # across those steps is what makes the FIRST failing call report every
    # provider tried and every refusal, instead of the trace describing
    # only whichever provider happened to win.
    if not excluded:
        self._attempt_trace = {}
    for provider in self.providers:
        # A single dict lookup is atomic, so the loop reads without the lock.
        declined = self._declined.get(provider.name)
        if declined is not None:
            # Recorded even when the caller also excluded this provider:
            # `exclude` says "I already tried it", and the decline is the
            # reason it did not work, which that call's trace must keep.
            self._record(
                SelectionStep(
                    self.redact(provider.name),
                    "unavailable",
                    self.redact(declined),
                    generation=self._generation,
                    pin=bool(pin),
                )
            )
            continue
        if provider.name in excluded:
            continue
        probe = self.probe(provider)
        allowed = probe.usable and (
            capability is None or capability in self._effective(provider, probe)
        )
        capabilities = frozenset(
            getattr(item, "value", item) for item in probe.capabilities
        )
        self._record(
            SelectionStep(
                self.redact(provider.name),
                probe.availability,
                self.redact(probe.reason),
                tuple(sorted(capabilities)),
                chosen=allowed,
                generation=self._generation,
                pin=bool(pin),
            )
        )
        if not allowed:
            log.debug(
                "provider %s skipped: availability=%s capability=%s reason=%s",
                _LazyName(provider.name),
                probe.availability,
                capability or "<any>",
                _LazyName(probe.reason or "<none>"),
            )
            continue
        result = ProviderSelection(
            provider,
            self.trace,
            generation=self._generation,
            pinned=bool(pin),
        )
        log.debug(
            "selected provider %s (generation %d, pin %s) " "after %d candidate(s)",
            _LazyName(provider.name),
            self._generation,
            bool(pin),
            len(self._attempt_trace),
        )
        return result
    if self._readmit_earlier_declines(excluded, capability):
        # Nothing else can serve, so a refusal recorded by an EARLIER
        # operation gets another chance rather than failing this one
        # without dialling: one transient refusal (sshd restarting) on a
        # single-provider host otherwise bricked it until close(). A
        # decline this operation made itself is in `excluded` and stays.
        return self.select(capability=capability, exclude=excluded, pin=pin)
    log.debug(
        "no provider available (generation %d); candidates: %s",
        self._generation,
        ", ".join(
            "%s/%s" % (item.provider, item.availability) for item in self.trace
        )
        or "<none>",
    )
    # Name why: the real refusal -- a rejected host key, a refused
    # connection -- rides as `__cause__` and `.cause`, not only at DEBUG.
    with self._lock:
        causes = dict(self._decline_causes)
        declined_names = list(self._declined)
    detail = "; ".join(
        f"{name}: {reason}" for name, reason in self.declines.items()
    )
    cause = next((causes.get(name) for name in reversed(declined_names)), None)
    raise OperationNotStartedError(
        "no provider is available" + (f" ({detail})" if detail else ""),
        cause=cause,
    ) from cause

hostctl.SessionInitializer(initialize, timeout=None) dataclass

Optional post-connect bootstrap hook receiving the connected system host.

initialize instance-attribute

timeout = None class-attribute instance-attribute

__call__(host, **options)

Run the hook, passing timeout only to a hook that accepts it.

The documented example, SessionInitializer(lambda h: h.run("sudo -v"), timeout=10), takes no timeout keyword, and passing one would raise at connect().

The value is advisory: it is handed to a hook that declares it, and nothing here enforces a deadline. A hook that wants one applies it to the calls it makes (host.run(..., timeout=...)).

Source code in src/hostctl/provider/_contracts.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def __call__(self, host, **options):
    """Run the hook, passing `timeout` only to a hook that accepts it.

    The documented example,
    `SessionInitializer(lambda h: h.run("sudo -v"), timeout=10)`, takes
    no `timeout` keyword, and passing one would raise at `connect()`.

    The value is advisory: it is handed to a hook that declares it, and
    nothing here enforces a deadline. A hook that wants one applies it to
    the calls it makes (`host.run(..., timeout=...)`).
    """
    if self.timeout is not None and _accepts_keyword(self.initialize, "timeout"):
        options.setdefault("timeout", self.timeout)
    return self.initialize(host, **options)

hostctl.register_system_provider(name, resolver, *, replace=False)

Register a provider descriptor every system host accepts.

resolver(host) returns the (executor, path) pair the descriptor offers, either of them None. A class's own provider_resolvers win over this registry. A name registered already raises ValueError unless replace=True.

Source code in src/hostctl/system/_resolvers.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
def register_system_provider(
    name: str,
    resolver: ProviderResolver,
    *,
    replace: bool = False,
) -> None:
    """Register a provider descriptor every system host accepts.

    `resolver(host)` returns the `(executor, path)` pair the descriptor
    offers, either of them `None`. A class's own `provider_resolvers` win over
    this registry. A name registered already raises `ValueError` unless
    `replace=True`.
    """
    key = _key(name)
    if not key or not callable(resolver):
        raise ValueError("provider name and callable resolver are required")
    try:
        inspect.signature(resolver).bind(None)
    except TypeError:
        raise TypeError(
            f"system provider resolver {key!r} must take one argument, the host, "
            "and return (executor provider or None, path provider or None)"
        ) from None
    except ValueError:
        # No introspectable signature (a builtin): judged when it is called.
        pass
    if key in _SYSTEM_PROVIDER_RESOLVERS and not replace:
        raise ValueError(f"system provider is already registered: {key}")
    _SYSTEM_PROVIDER_RESOLVERS[key] = resolver

hostctl.system_provider_names()

Every globally registered provider descriptor, sorted.

The registered names plus the hostctl.system_providers entry points, which are listed and not imported. A host class may add or hide names with its own provider_resolvers.

Source code in src/hostctl/system/_resolvers.py
70
71
72
73
74
75
76
77
78
79
def system_provider_names() -> typing.Tuple[str, ...]:
    """Every globally registered provider descriptor, sorted.

    The registered names plus the `hostctl.system_providers` entry points,
    which are listed and not imported. A host class may add or hide names
    with its own `provider_resolvers`.
    """
    names = set(_SYSTEM_PROVIDER_RESOLVERS)
    names.update(_key(point.name) for point in entry_points_in(_ENTRY_POINT_GROUP))
    return tuple(sorted(names))

hostctl.host_schemes()

Every scheme HostConfig(uri) can dispatch, sorted.

The schemes of the registered configs, plus the names of the hostctl.configs entry points, which are listed and not imported.

Source code in src/hostctl/_registry.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def host_schemes() -> tuple[str, ...]:
    """Every scheme `HostConfig(uri)` can dispatch, sorted.

    The schemes of the registered configs, plus the names of the
    `hostctl.configs` entry points, which are listed and not imported.
    """
    from .host._config import HostConfig

    names = {
        scheme
        for implementation in HostConfig._uri_implementations()
        for scheme in implementation._uri_schemes
    }
    for point in HostConfig._uri_entry_points or ():
        names.add(str(getattr(point, "name", "")).casefold())
    names.discard("")
    return tuple(sorted(names))

hostctl.CompositePosixPath(*segments, **kwargs)

Bases: _CompositePathMixin, PosixPathname, Path

A provider-selecting path with POSIX syntax on every client OS.

Source code in src/hostctl/paths/_mixin.py
72
73
74
def __init__(self, *segments, **kwargs):
    if not hasattr(self, "_raw_paths") and not hasattr(self, "_parts"):
        pathlib.PurePath.__init__(self, *segments)

__slots__ = ('_provider', '_backend_path', '_providers', '_selector', '_factory', '_pinned', '_selection_trace') class-attribute instance-attribute

hostctl.CompositeWindowsPath(*segments, **kwargs)

Bases: _CompositePathMixin, WindowsPathname, Path

A provider-selecting path with Windows syntax on every client OS.

Source code in src/hostctl/paths/_mixin.py
72
73
74
def __init__(self, *segments, **kwargs):
    if not hasattr(self, "_raw_paths") and not hasattr(self, "_parts"):
        pathlib.PurePath.__init__(self, *segments)

__slots__ = ('_provider', '_backend_path', '_providers', '_selector', '_factory', '_pinned', '_selection_trace') class-attribute instance-attribute

Connection strings

hostctl.ConnectionString(value, *, scheme=None, host=None, port=None, username=None, password=None, extras=None, path=None, query=None, fragment=None, defaults=None) dataclass

A connection target parsed from whatever a user typed.

ConnectionString("wss://root:pw@nas:8443/api") parses, and so do a bare host and a host:port once a scheme is supplied from outside the string: ConnectionString("nas", scheme="wss") and ConnectionString("nas:8443", defaults={"scheme": "wss"}). Without one, ConnectionString("nas") and ConnectionString("nas:8443") raise ValueError: nothing in the text says which scheme was meant. An opaque URI with no host, local: or serial:COM3, parses and has an empty host.

Beyond that, the input must be a valid URI, and a malformed one raises -- an unclosed IPv6 literal, a non-numeric port, or a password written with an unescaped /, ? or # (each ends the authority, so what precedes it is read as a port and fails to cast).

Reserved characters in a password must be percent-encoded, and one that is not gets no rescue attempt. There is no rescue to make: a password may itself contain @ and :, so no rule recovers where an unescaped one ended. Those two do parse -- the last @ and the first : win, as in any authority, giving pa@ss and pa:ss -- but that is the URI grammar being applied, not a guess about the secret. Guessing here would be guessing about what to connect to, so a raise is the answer. :func:redact_uri is the other job and keeps its own rules: stripping a secret out of text that may not parse at all, for a log line nothing connects to. It does not over-redact a well-formed URI either -- it guesses only once the text has failed to parse.

Every field can also be supplied directly, and three layers decide each one: an explicit argument wins, then whatever the string carried, then defaults.

So scheme=, port=, and the rest are overrides, not defaults -- ConnectionString("wss://nas", scheme="ssh") is ssh, the same way port= beats a port written in the string. Supplying a value only when the string omits one is exactly what defaults is for:

ConnectionString("nas", scheme="wss")               # wss://nas
ConnectionString("wss://nas", scheme="ssh")         # ssh://nas
ConnectionString("wss://nas", defaults={"scheme": "ssh"})   # wss://nas
ConnectionString("nas:9", scheme="wss", port=1)     # port=1 beats :9

defaults accepts a mapping or another ConnectionString, so a partially filled one expresses "these settings unless the string says otherwise":

profile = ConnectionString("wss://root@nas", port=443)
ConnectionString("other", defaults=profile)      # wss://root@other:443

Only fields a ConnectionString actually carries count as defaults, so an empty path/query/fragment never overrides.

A port carries its own resolution strategy, wherever one is accepted -- as port=, in defaults, or written in the string. It may be:

  • an int, the port itself;
  • a callable scheme -> port | None, asked once the scheme is settled;
  • anything indexable by scheme (a plain mapping included), so a caller passes its whole table without pre-selecting an entry;
  • None, meaning nothing was supplied here, so the next layer decides;
  • False, meaning no port and no lookup -- stop here.

When no layer supplies one, netimps.get_default_port resolves the scheme. It knows schemes the system services database does not (ws/wss, socks5), and an application can register more with netimps.register_port.

ConnectionString("nas", scheme="wss")                       # :443
ConnectionString("nas", scheme="wss", port=8443)            # :8443
ConnectionString("nas", scheme="wss", port={"wss": 9})       # :9
ConnectionString("nas", scheme="wss", port=lambda s: 9)      # :9
ConnectionString("nas", scheme="wss", port=False)            # no port

A scheme a table or callable does not know is not an error -- it means "no conventional port for this one", and the netimps fallback then applies. A resolver that answers False declines that fallback, the same as passing False directly.

The host keeps the spelling it was given. urlsplit().hostname is case-folded, which is right for resolution and wrong for text rendered back to a caller, so nasA stays nasA in host and in str(). DNS treats the two as one name, so nothing about reaching the target changes.

Credentials never render. str() and repr() both emit the redacted form -- the password is removed rather than masked, so the output stays a valid, reusable connection string that cannot round-trip a wrong credential. password is excluded from the generated repr, so a value reaching a log line or a traceback frame does not leak it.

A password may carry credential extras after a newline, exactly as :func:parse_credentials describes; extras holds them and password is just the password. The separator may be written raw, since characters urlsplit would silently delete are percent-encoded before parsing.

Source code in src/hostctl/uri/_connection.py
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
def __init__(
    self,
    value: _ty.Union[str, "ConnectionString"],
    *,
    scheme: _ty.Optional[str] = None,
    host: _ty.Optional[str] = None,
    port: "Port" = None,
    username: _ty.Optional[str] = None,
    password: _ty.Optional[str] = None,
    extras: _ty.Optional[_ty.Mapping[str, str]] = None,
    path: _ty.Optional[str] = None,
    query: _ty.Optional[str] = None,
    fragment: _ty.Optional[str] = None,
    defaults: _ty.Union["ConnectionString", _ty.Mapping[str, object], None] = None,
) -> None:
    overrides = {
        "scheme": scheme,
        "host": host,
        "port": port,
        "username": username,
        "password": password,
        "extras": extras,
        "path": path,
        "query": query,
        "fragment": fragment,
    }
    fallbacks = _as_defaults(defaults)
    unknown = set(fallbacks) - {field.name for field in _dc.fields(self)}
    if unknown:
        raise TypeError(f"unknown default field: {sorted(unknown)[0]}")

    if isinstance(value, ConnectionString):
        parsed_values: _ty.Dict[str, object] = {
            field.name: getattr(value, field.name) for field in _dc.fields(self)
        }
        if not value._explicit_port:
            # Its port was resolved from *its* scheme, so it is not an
            # answer for a different one.
            parsed_values["port"] = None
    else:
        # A scheme may come from the explicit argument or the defaults;
        # either lets a bare host parse, so resolve it before splitting.
        assumed = scheme or fallbacks.get("scheme")
        parsed = _split(value, _ty.cast(_ty.Optional[str], assumed))
        _reject_authority_control_characters(parsed)
        parsed_password, parsed_extras = None, {}
        if parsed.password is not None:
            parsed_password, parsed_extras = parse_credentials(
                _unquote(parsed.password)
            )
        bad_port = False
        try:
            parsed_port = parsed.port
        except ValueError:
            bad_port = True
        if bad_port:
            # Raised outside the handler: urllib's message quotes the text
            # it failed to read as a port, the start of a password cut
            # short by `/`, and a chained context would carry it.
            raise ValueError(
                "connection string has an invalid port; percent-encode "
                "'/', '?' and '#' in a password"
            )
        parsed_values = {
            "scheme": parsed.scheme.casefold() or None,
            "host": uri_hostname(parsed) or None,
            "port": parsed_port,
            "username": _unquote(parsed.username) if parsed.username else None,
            "password": parsed_password,
            "extras": dict(parsed_extras) or None,
            "path": parsed.path or None,
            "query": parsed.query or None,
            "fragment": parsed.fragment or None,
        }

    # Precedence: an explicit argument wins, then what the string carried,
    # then `defaults`. A default therefore fills a gap rather than
    # overriding something the caller actually wrote.
    resolved: _ty.Dict[str, object] = {}
    for name, override in overrides.items():
        for candidate in (override, parsed_values.get(name), fallbacks.get(name)):
            if candidate is not None:
                resolved[name] = candidate
                break
        else:
            resolved[name] = None

    if resolved["scheme"] is None:
        raise ValueError(
            f"connection string has no scheme: {_redacted_target(value)!r}; "
            "pass scheme= to set one, or defaults= to supply it only when "
            "the string omits it"
        )
    from netimps import get_default_port

    scheme_text = _ty.cast(str, resolved["scheme"]).casefold()
    # Only now is the scheme settled, so a callable or table given for the
    # port can be asked. When no layer supplied anything, netimps resolves
    # the scheme -- it knows ones the system services database does not
    # (`ws`/`wss`, `socks5`), and an application can add more with
    # `netimps.register_port`.
    supplied_port = resolved["port"] is not None
    port_value = _resolve_port(scheme_text, _ty.cast("Port", resolved["port"]))
    if port_value is _NO_PORT:
        port_value = None
    elif port_value is None:
        port_value = get_default_port(scheme_text)
    resolved["port"] = port_value
    # Whether the port was *given* or resolved from the scheme. A
    # resolved one must not travel: used as `defaults=`, or copied with a
    # different `scheme=`, it overrode the new scheme's own default, so an
    # SSH leg built from a `wss` profile dialled port 443.
    object.__setattr__(self, "_explicit_port", supplied_port)

    object.__setattr__(self, "scheme", scheme_text)
    object.__setattr__(self, "host", resolved["host"] or "")
    object.__setattr__(self, "port", resolved["port"])
    object.__setattr__(self, "username", resolved["username"])
    object.__setattr__(self, "password", resolved["password"])
    object.__setattr__(self, "extras", dict(resolved["extras"] or {}))
    object.__setattr__(self, "path", resolved["path"] or "")
    object.__setattr__(self, "query", resolved["query"] or "")
    object.__setattr__(self, "fragment", resolved["fragment"] or "")

authority property

The authority as it renders, without any password.

extras = _dc.field(default_factory=dict, repr=False, compare=False) class-attribute instance-attribute

fragment = '' class-attribute instance-attribute

host instance-attribute

is_local property

Whether this names the local machine, without resolving anything.

Deliberately does no I/O: this is called while a configuration is built, and that is network-free. Resolving would block, and would raise on a name that does not resolve -- so it could not even be used defensively.

netimps.is_local_host answers: loopback and addresses assigned to this machine (10.0.0.5 is local when it is one of this host's own), localhost, and this machine's own name. Any other name is not resolved. An empty host (local:, serial:COM3) is local too, which is_local_host would not say.

password = _dc.field(default=None, repr=False) class-attribute instance-attribute

path = '' class-attribute instance-attribute

port = None class-attribute instance-attribute

qsl property

Query pairs in order, keeping repeats.

query = '' class-attribute instance-attribute

scheme instance-attribute

username = None class-attribute instance-attribute

__hash__()

Hashable, as frozen=True advertises.

The generated __hash__ covered extras, a plain dict, so {ConnectionString(t) for t in inventory} raised TypeError: unhashable type: 'dict'. Hashing the rendered target keeps hash and equality consistent: extras is credential material carried alongside the password and is excluded from both.

Source code in src/hostctl/uri/_connection.py
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
def __hash__(self) -> int:
    """Hashable, as `frozen=True` advertises.

    The generated `__hash__` covered `extras`, a plain dict, so
    `{ConnectionString(t) for t in inventory}` raised `TypeError:
    unhashable type: 'dict'`. Hashing the rendered target keeps hash and
    equality consistent: `extras` is credential material carried
    alongside the password and is excluded from both.
    """
    return hash(
        (
            self.scheme,
            self.host,
            self.port,
            self.username,
            self.path,
            self.query,
            self.fragment,
        )
    )

__repr__()

Source code in src/hostctl/uri/_connection.py
341
342
def __repr__(self) -> str:
    return f"{type(self).__name__}({self.geturl()!r})"

__str__()

Source code in src/hostctl/uri/_connection.py
338
339
def __str__(self) -> str:
    return self.geturl()

geturl()

The credential-free connection string, valid and reusable.

Source code in src/hostctl/uri/_connection.py
332
333
334
335
336
def geturl(self) -> str:
    """The credential-free connection string, valid and reusable."""
    return _SplitResult(
        self.scheme, self.authority, self.path, self.query, self.fragment
    ).geturl()

query_val(name, default=None)

The last value for name, or default. Last wins, as in a URI.

Source code in src/hostctl/uri/_connection.py
370
371
372
373
374
375
376
def query_val(self, name: str, default: _ty.Optional[str] = None):
    """The last value for `name`, or `default`. Last wins, as in a URI."""
    found = default
    for key, value in self.qsl:
        if key == name:
            found = value
    return found

replace(**changes)

A copy with changes applied.

Built field by field rather than through dataclasses.replace, which would re-invoke __init__ -- and __init__ parses a string, so it does not accept the field names.

Source code in src/hostctl/uri/_connection.py
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
def replace(self, **changes: object) -> "ConnectionString":
    """A copy with `changes` applied.

    Built field by field rather than through `dataclasses.replace`, which
    would re-invoke `__init__` -- and `__init__` parses a string, so it
    does not accept the field names.
    """
    names = {field.name for field in _dc.fields(self)}
    unknown = set(changes) - names
    if unknown:
        raise TypeError(f"unknown field: {sorted(unknown)[0]}")
    copy = object.__new__(type(self))
    for name in names:
        value = changes.get(name, getattr(self, name))
        if name == "scheme" and value is not None:
            # `__init__` casefolds it, so `replace(scheme="WSS")` does too,
            # to stay equal to the parsed form of the same target.
            value = str(value).casefold()
        if name == "port" and name in changes:
            value = _resolve_port(
                str(changes.get("scheme", self.scheme)).casefold(), value
            )
            value = None if value is _NO_PORT else value
        if name == "extras" and value is not None:
            value = dict(value)
        object.__setattr__(copy, name, value)
    explicit_port = "port" in changes or self._explicit_port
    if "scheme" in changes and not explicit_port:
        # A port that was resolved from the old scheme is not an answer
        # for the new one: `ConnectionString("wss://nas").replace(
        # scheme="ssh")` gets 22, not 443.
        from netimps import get_default_port

        object.__setattr__(
            copy, "port", get_default_port(str(changes["scheme"]).casefold())
        )
    object.__setattr__(copy, "_explicit_port", explicit_port)
    return copy

hostctl.redact_uri(uri)

Strip any password from uri, leaving a valid, reusable URI.

scheme://user:secret@host becomes scheme://user@host. The password is removed rather than masked: a placeholder would make the URI round-trip into a wrong credential if anything fed the rendered form back in, and would not be a real password anyway. What comes out is the same canonical, credential-free string a config renders, so it is safe to log, repr, or hand back to HostConfig.

A URI with no password is returned unchanged.

A well-formed URI is never over-redacted. When the URI parses, what urlsplit says is the authority is the authority, so an @ anywhere else -- a mailbox in a path, ssh://nas:22/mail/user@example.com -- is left alone. Guessing is confined to input that does not parse, where there is nothing better to go on: see below.

This never raises. It is meant for error messages and log records, where failing to render a diagnostic would be worse than rendering an odd one. Characters urlsplit would delete (tab, CR, LF) are percent-encoded first, the same as during dispatch, so a password written with a raw newline is still recognized and removed rather than partly surviving into the output.

Source code in src/hostctl/uri/_parse.py
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
def redact_uri(uri: str) -> str:
    """Strip any password from `uri`, leaving a valid, reusable URI.

    `scheme://user:secret@host` becomes `scheme://user@host`. The password is
    removed rather than masked: a placeholder would make the URI round-trip
    into a *wrong* credential if anything fed the rendered form back in, and
    would not be a real password anyway. What comes out is the same canonical,
    credential-free string a config renders, so it is safe to log, repr, or
    hand back to `HostConfig`.

    A URI with no password is returned unchanged.

    **A well-formed URI is never over-redacted.** When the URI parses, what
    `urlsplit` says is the authority *is* the authority, so an `@` anywhere
    else -- a mailbox in a path, `ssh://nas:22/mail/user@example.com` -- is
    left alone. Guessing is confined to input that does not parse, where
    there is nothing better to go on: see below.

    This never raises. It is meant for error messages and log records, where
    failing to render a diagnostic would be worse than rendering an odd one.
    Characters `urlsplit` would delete (tab, CR, LF) are percent-encoded first,
    the same as during dispatch, so a password written with a raw newline is
    still recognized and removed rather than partly surviving into the output.
    """
    try:
        parsed = _urlsplit(_encode_stripped_characters(uri))
        # Not dead code: `.port` is a property that RAISES, and touching it
        # is what decides "well formed" here. It catches both shapes that
        # matter in one place -- a non-numeric or out-of-range port, and an
        # unencoded `/`, `?` or `#` in a password, since the latter ends the
        # authority and leaves `user:secret` being read as host:port. So the
        # bad port IS the symptom of the hidden password.
        parsed.port  # noqa: B018
    except ValueError:
        pass  # Malformed. The textual fallback below is all that is left.
    else:
        if parsed.password is None:
            # Well formed and carrying no password: there is nothing to
            # strip, and the fallback must not run. It reads the LAST `@` in
            # the whole string as the userinfo delimiter, which would rewrite
            # `ssh://nas:22/mail/user@example.com` -- a URI with no
            # credentials at all -- into `ssh://nas@example.com`, naming a
            # different host.
            return uri
        try:
            return _without_password(parsed).geturl()
        except ValueError:
            # Not reachable through `.port`, which is already validated above;
            # kept because "never raises" is a contract this function is
            # called from inside exception handlers to honour.
            pass

    # Only a malformed URI reaches here. Read the userinfo textually: the last
    # `@` wins, as it does in a real authority. This can over-redact -- but
    # the text has already failed to say where its authority ended, so there
    # is no reading of it that is known to be right, and an odd diagnostic
    # beats a password in a log.
    head, separator, _rest = uri.partition("://")
    start = len(head) + len(separator)
    authority_end = uri.rfind("@")
    if authority_end <= start:
        return uri
    userinfo = uri[start:authority_end]
    username, colon, _secret = userinfo.partition(":")
    if not colon:
        return uri
    return uri[:start] + username + uri[authority_end:]

hostctl.parse_credentials(password)

Split a password field into the password and any trailing extras.

A newline separates the password from additional credential values, one per line, each key:value:

"hunter2"                       -> ("hunter2", {})
"hunter2\notp:123456"           -> ("hunter2", {"otp": "123456"})
"hunter2\notp:123456\nrealm:CORP" -> ("hunter2", {"otp": ..., "realm": ...})

A bare name with no : is a flag -- it maps to the empty string, exactly as name: does:

"hunter2\ninteractive"           -> ("hunter2", {"interactive": ""})

This is how a second factor reaches a transport through a single password field -- a URI's userinfo, an environment variable, a prompt -- without every caller inventing its own encoding. A newline is assumed not to be a valid password character, which is the same assumption pytruenas makes.

Names are casefolded and stripped of surrounding whitespace. A value is everything after the first :, taken verbatim -- it may contain colons, and its leading and trailing whitespace is preserved, because a secret may legitimately begin or end with a space and silently trimming one would fail authentication with no visible cause. Blank lines are ignored.

Source code in src/hostctl/uri/_parse.py
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
def parse_credentials(password: str) -> _ty.Tuple[str, _ty.Dict[str, str]]:
    """Split a password field into the password and any trailing extras.

    A newline separates the password from additional credential values, one
    per line, each `key:value`:

        "hunter2"                       -> ("hunter2", {})
        "hunter2\\notp:123456"           -> ("hunter2", {"otp": "123456"})
        "hunter2\\notp:123456\\nrealm:CORP" -> ("hunter2", {"otp": ..., "realm": ...})

    A bare name with no `:` is a flag -- it maps to the empty string, exactly
    as `name:` does:

        "hunter2\\ninteractive"           -> ("hunter2", {"interactive": ""})

    This is how a second factor reaches a transport through a single password
    field -- a URI's userinfo, an environment variable, a prompt -- without
    every caller inventing its own encoding. A newline is assumed not to be a
    valid password character, which is the same assumption pytruenas makes.

    Names are casefolded and stripped of surrounding whitespace. A value is
    everything after the first `:`, taken **verbatim** -- it may contain
    colons, and its leading and trailing whitespace is preserved, because a
    secret may legitimately begin or end with a space and silently trimming
    one would fail authentication with no visible cause. Blank lines are
    ignored.
    """
    # Split on any line ending: a value pasted from a file or a Windows prompt
    # arrives CRLF-terminated, and a trailing "\r" left on the password would
    # fail authentication with no visible cause.
    lines = password.splitlines()
    if len(lines) <= 1:
        return password, {}
    password, remainder = lines[0], lines[1:]
    extras: _ty.Dict[str, str] = {}
    for line in remainder:
        if not line.strip():
            continue
        # A line with no ":" is a bare flag; `partition` already yields "" for
        # its value, so flags and `name:` need no separate branch.
        key, _, value = line.partition(":")
        key = key.strip().casefold()
        if not key:
            raise ValueError("credential extra names must not be empty")
        extras[key] = value
    return password, extras

hostctl.strict_uri_credentials(credentials, allowed)

Reject credentials which the selected implementation does not accept.

A key whose value is None is not a credential -- nothing was supplied -- so it is ignored rather than refused. Counting keys made HostConfig("local:", password=None) fail, which is exactly the shape a generic caller produces when it forwards an optional argument.

Source code in src/hostctl/uri/_parse.py
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
def strict_uri_credentials(
    credentials: _ty.Mapping[str, object], allowed: _ty.Iterable[str]
) -> None:
    """Reject credentials which the selected implementation does not accept.

    A key whose value is `None` is not a credential -- nothing was supplied --
    so it is ignored rather than refused. Counting keys made
    `HostConfig("local:", password=None)` fail, which is exactly the shape a
    generic caller produces when it forwards an optional argument.
    """
    unknown = {name for name, value in credentials.items() if value is not None} - set(
        allowed
    )
    if unknown:
        raise ValueError(f"unknown credential argument: {sorted(unknown)[0]}")

hostctl.strict_uri_query(parsed, allowed, *, repeatable=())

Parse one selected implementation's query without ambiguity.

A repeated key is an error by default, so ?shell=a&shell=b is not a coin flip; an unknown key is an error too, so a misspelt ?exectuor=ssh fails here rather than as a missing capability later.

repeatable names keys that are ORDERED LISTS rather than settings -- SystemConfig's executor and path -- and their values come back as a tuple in the order they were written. Declaring them here lets every config share this one parser.

Source code in src/hostctl/uri/_parse.py
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
def strict_uri_query(
    parsed: _SplitResult,
    allowed: _ty.Iterable[str],
    *,
    repeatable: _ty.Iterable[str] = (),
) -> _ty.Dict[str, object]:
    """Parse one selected implementation's query without ambiguity.

    A repeated key is an error by default, so `?shell=a&shell=b` is not a
    coin flip; an unknown key is an error too, so a misspelt
    `?exectuor=ssh` fails here rather than as a missing capability later.

    `repeatable` names keys that are ORDERED LISTS rather than settings --
    `SystemConfig`'s `executor` and `path` -- and their values come back as
    a tuple in the order they were written. Declaring them here lets every
    config share this one parser.
    """
    repeated = frozenset(repeatable)
    query: _ty.Dict[str, object] = {}
    for key, value in _parse_qsl(parsed.query, keep_blank_values=True):
        if key in repeated:
            query[key] = _ty.cast(_ty.Tuple[str, ...], query.get(key, ())) + (value,)
        elif key in query:
            raise ValueError(
                f"duplicate connection parameter: {_parameter_label(key, parsed.query)}"
            )
        else:
            query[key] = value
    unknown = set(query) - set(allowed)
    if unknown:
        label = _parameter_label(sorted(unknown)[0], parsed.query)
        raise ValueError(f"unknown connection parameter: {label}")
    return query

hostctl.uri_host(host)

Bracket an IPv6 literal for use in a URI authority.

Source code in src/hostctl/uri/_parse.py
30
31
32
def uri_host(host: str) -> str:
    """Bracket an IPv6 literal for use in a URI authority."""
    return f"[{host}]" if ":" in host and not host.startswith("[") else host

hostctl.uri_hostname(parsed)

The host as it was written, not as urlsplit case-folded it.

urlsplit().hostname is lowercased by design -- DNS is case-insensitive, so folding is right for resolution. It is wrong for text a caller will see again. A config built from a URI stores this value and renders it back through connection_uri, so reading .hostname there would make the library echo a spelling the operator never typed, and nasA would reach logs and HostInfo as nasa.

Use this in _from_parsed_uri wherever a host is stored. Presence checks (if not parsed.hostname) can keep using .hostname -- emptiness does not depend on case. Resolution is unaffected either way: DNS, SSH, and WinRM all treat the two spellings as one name.

Returns "" for a URI with no host, matching hostname or "".

hostname is a case-folded substring of the authority (after any userinfo), so locating it recovers the original text.

Source code in src/hostctl/uri/_parse.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
def uri_hostname(parsed: _SplitResult) -> str:
    """The host as it was written, not as `urlsplit` case-folded it.

    `urlsplit().hostname` is lowercased by design -- DNS is case-insensitive,
    so folding is right for *resolution*. It is wrong for text a caller will
    see again. A config built from a URI stores this value and renders it back
    through `connection_uri`, so reading `.hostname` there would make the
    library echo a spelling the operator never typed, and `nasA` would reach
    logs and `HostInfo` as `nasa`.

    Use this in `_from_parsed_uri` wherever a host is stored. Presence checks
    (`if not parsed.hostname`) can keep using `.hostname` -- emptiness does not
    depend on case. Resolution is unaffected either way: DNS, SSH, and WinRM
    all treat the two spellings as one name.

    Returns `""` for a URI with no host, matching `hostname or ""`.

    `hostname` is a case-folded substring of the authority (after any
    userinfo), so locating it recovers the original text.
    """
    hostname = parsed.hostname or ""
    authority = parsed.netloc.rpartition("@")[2]
    index = authority.casefold().find(hostname)
    if index < 0:
        return hostname
    # Cut at the port separator rather than by `len(hostname)`: lowercasing is
    # not length-preserving (U+0130 folds to two characters), so the slice ran
    # past the host into the ':' of the port. The stored host gained a
    # trailing colon and was then rendered as a bracketed IPv6 literal, which
    # is both unresolvable and malformed.
    tail = authority[index:]
    closing = tail.find("]")
    if closing >= 0:
        # An IPv6 literal: `index` lands inside the brackets, because
        # `hostname` has none.
        return tail[:closing]
    port = tail.rfind(":")
    return tail if port < 0 else tail[:port]

Composing transports

hostctl.ssh_providers(config)

Build an executor and path provider sharing one SSH transport.

This is the supported way to compose SSH into a host you assemble yourself, rather than taking the finished PosixHost that SSHConfig._create_host() returns:

executors, paths = [], []
run_provider, path_provider = ssh_providers(config.ssh)
executors.append(run_provider)
paths.append(path_provider)

Both providers must share one transport: that is what makes them share a connection and a lifecycle, and SSHExecutorProvider is the one that owns close. Assembling them by hand from two transports type-checks and silently opens two connections, only one of which is ever closed -- which is precisely why this returns a pair instead of exposing the transport.

Source code in src/hostctl/transports/ssh/_providers.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def ssh_providers(
    config: SSHConfig,
) -> typing.Tuple[SSHExecutorProvider, SFTPPathProvider]:
    """Build an executor and path provider sharing one SSH transport.

    This is the supported way to compose SSH into a host you assemble
    yourself, rather than taking the finished `PosixHost` that
    `SSHConfig._create_host()` returns:

        executors, paths = [], []
        run_provider, path_provider = ssh_providers(config.ssh)
        executors.append(run_provider)
        paths.append(path_provider)

    Both providers **must** share one transport: that is what makes them share
    a connection and a lifecycle, and `SSHExecutorProvider` is the one that
    owns close. Assembling them by hand from two transports type-checks and
    silently opens two connections, only one of which is ever closed -- which
    is precisely why this returns a pair instead of exposing the transport.
    """
    transport = _SSHTransport(config)
    return SSHExecutorProvider(transport), SFTPPathProvider(transport)

hostctl.winrm_providers(config)

Build an executor and path provider sharing one WinRM transport.

The SSH counterpart, hostctl.transports.ssh.ssh_providers, documents why this returns a pair rather than exposing the transport: both providers must share one, and building them separately silently opens two.

Source code in src/hostctl/transports/winrm/_providers.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
def winrm_providers(
    config: WinRMConfig,
) -> typing.Tuple[WinRMExecutorProvider, WinRMPathProvider]:
    """Build an executor and path provider sharing one WinRM transport.

    The SSH counterpart, `hostctl.transports.ssh.ssh_providers`, documents why this
    returns a pair rather than exposing the transport: both providers must
    share one, and building them separately silently opens two.
    """
    transport = _WinRMTransport(config)
    return WinRMExecutorProvider(transport), WinRMPathProvider(transport)

Transfers and checksums

hostctl.ProgressReader(reader, callback, *, total=None)

Wrap a binary reader and report (bytes_read, total_bytes).

Source code in src/hostctl/sync.py
112
113
114
115
116
117
118
119
120
121
122
123
124
def __init__(
    self,
    reader: typing.BinaryIO,
    callback: typing.Callable[[int, typing.Optional[int]], None],
    *,
    total: typing.Optional[int] = None,
) -> None:
    if not callable(callback):
        raise TypeError("callback must be callable")
    self.reader = reader
    self.callback = callback
    self.total = total
    self.bytes_read = 0

bytes_read = 0 instance-attribute

callback = callback instance-attribute

closed property

reader = reader instance-attribute

total = total instance-attribute

__enter__()

Source code in src/hostctl/sync.py
193
194
195
def __enter__(self) -> ProgressReader:
    self.reader.__enter__()
    return self

__exit__(exc_type, exc_value, traceback)

Source code in src/hostctl/sync.py
197
198
def __exit__(self, exc_type, exc_value, traceback) -> typing.Optional[bool]:
    return self.reader.__exit__(exc_type, exc_value, traceback)

__getattr__(name)

Source code in src/hostctl/sync.py
200
201
def __getattr__(self, name: str) -> object:
    return getattr(self.reader, name)

__iter__()

Source code in src/hostctl/sync.py
174
175
def __iter__(self) -> "ProgressReader":
    return self

__next__()

Source code in src/hostctl/sync.py
177
178
179
180
181
def __next__(self) -> bytes:
    line = self.readline()
    if not line:
        raise StopIteration
    return line

_count(read)

Source code in src/hostctl/sync.py
126
127
128
def _count(self, read: int) -> None:
    self.bytes_read += read
    self.callback(self.bytes_read, self.total)

close()

Source code in src/hostctl/sync.py
190
191
def close(self) -> None:
    self.reader.close()

read(size=-1)

Source code in src/hostctl/sync.py
130
131
132
133
def read(self, size: int = -1) -> bytes:
    data = self.reader.read(size)
    self._count(len(data))
    return data

read1(size=-1)

Source code in src/hostctl/sync.py
139
140
141
142
143
def read1(self, size: int = -1) -> bytes:
    read1 = getattr(self.reader, "read1", None)
    data = read1(size) if callable(read1) else self.reader.read(size)
    self._count(len(data))
    return data

readable()

Source code in src/hostctl/sync.py
183
184
def readable(self) -> bool:
    return True

readinto(buffer)

Source code in src/hostctl/sync.py
145
146
147
148
149
150
151
152
153
154
def readinto(self, buffer) -> int:
    readinto = getattr(self.reader, "readinto", None)
    if callable(readinto):
        read = readinto(buffer)
    else:
        data = self.reader.read(len(buffer))
        read = len(data)
        buffer[:read] = data
    self._count(read or 0)
    return read or 0

readinto1(buffer)

Source code in src/hostctl/sync.py
156
157
158
159
160
161
162
def readinto1(self, buffer) -> int:
    readinto1 = getattr(self.reader, "readinto1", None)
    if not callable(readinto1):
        return self.readinto(buffer)
    read = readinto1(buffer)
    self._count(read or 0)
    return read or 0

readline(size=-1)

Source code in src/hostctl/sync.py
164
165
166
167
def readline(self, size: int = -1) -> bytes:
    data = self.reader.readline(size)
    self._count(len(data))
    return data

readlines(hint=-1)

Source code in src/hostctl/sync.py
169
170
171
172
def readlines(self, hint: int = -1) -> "list[bytes]":
    lines = self.reader.readlines(hint)
    self._count(sum(len(line) for line in lines))
    return lines

hostctl.host_checksum(*hosts, algorithm='md5', chunk_size=1024 * 1024)

Build a PathSyncer checksum using execution beside owned paths.

More than one host may be supplied for a cross-host sync. Paths which do not belong to any supplied host are hashed through their binary open() contract, preserving interoperability with arbitrary pathapi implementations.

Source code in src/hostctl/sync.py
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
def host_checksum(
    *hosts: Host,
    algorithm: str = "md5",
    chunk_size: int = 1024 * 1024,
) -> typing.Callable[[PathAndStat], str]:
    """Build a ``PathSyncer`` checksum using execution beside owned paths.

    More than one host may be supplied for a cross-host sync. Paths which do
    not belong to any supplied host are hashed through their binary ``open()``
    contract, preserving interoperability with arbitrary ``pathapi``
    implementations.
    """
    normalized = algorithm.casefold().replace("-", "")
    if normalized not in _REMOTE_ALGORITHMS:
        raise ValueError(
            f"unsupported remote checksum algorithm: {algorithm!r}; "
            f"choose one of {', '.join(sorted(_REMOTE_ALGORITHMS))}"
        )
    if not hosts:
        raise ValueError("host_checksum requires at least one host")
    if chunk_size <= 0:
        raise ValueError("chunk_size must be positive")

    owners = tuple((host, _host_path_token(host)) for host in hosts)

    def checksum(entry: PathAndStat) -> str:
        # A local file needs no process. Spawning `md5sum` per file cost a
        # fork, an exec and a shell parse to hash bytes this process can read
        # directly -- and on Windows it went through certutil, which is slower
        # still. Decided from the path's backend type, whichever host owns it.
        if _hashes_in_process(entry.path):
            return _stream_checksum(entry.path, normalized, chunk_size)
        for host, owner_token in owners:
            if _host_owns_path(host, entry.path, owner_token):
                try:
                    return _remote_checksum(host, entry.path, normalized)
                except _UNAVAILABLE_REMOTE_TOOL:
                    # The host owns the path but cannot hash it in place: the
                    # tool is missing, refused, or answered unintelligibly.
                    # Reading the content is slower but still correct, so a
                    # sync degrades rather than aborting.
                    break
        return _stream_checksum(entry.path, normalized, chunk_size)

    return checksum

hostctl.stat_checksum(entry)

Return the cached size and modification time without reading content.

This is rsync's quick check: no content is read and no command is run. Two caveats decide whether it suits a given sync.

It can miss a change which preserves both size and modification time.

It settles only where the target can take a modification time. PathSyncer copies the source's modification time onto a target that supports pathapi's Utime, so a repeated sync there is a no-op: local, SSH and WinRM paths do, a QEMU guest path with a guest helper does, and a container path does when the container has touch or powershell.exe. Elsewhere a file copied lands with a fresh modification time and compares unequal on the next run -- the sync re-copies it.

Where the target cannot hold the time, use :func:host_checksum when hostctl's own copies must converge to a no-op.

Source code in src/hostctl/sync.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def stat_checksum(entry: PathAndStat) -> tuple[int, float]:
    """Return the cached size and modification time without reading content.

    This is rsync's quick check: no content is read and no command is run.
    Two caveats decide whether it suits a given sync.

    It can *miss* a change which preserves both size and modification time.

    It settles only where the target can take a modification time.
    ``PathSyncer`` copies the source's modification time onto a target that
    supports pathapi's ``Utime``, so a repeated sync there is a no-op: local,
    SSH and WinRM paths do, a QEMU guest path with a guest helper does, and a
    container path does when the container has ``touch`` or
    ``powershell.exe``.  Elsewhere a file copied lands with a fresh
    modification time and compares unequal on the next run -- the sync
    re-copies it.

    Where the target cannot hold the time, use :func:`host_checksum` when
    hostctl's own copies must converge to a no-op.
    """
    if entry.stat is None:
        raise FileNotFoundError(entry.path)
    return entry.stat.st_size, entry.stat.st_mtime

Deploying Python

hostctl.deploy needs the deploy extra; see Deploying Python.

hostctl.deploy.deploy(host, source, path, *, mode='pyz', package=None, extras=(), skip=(), force=False, python='python3')

Install source at path on a POSIX host and say how to run it.

For a distribution name, python on the target is asked which distributions it has, and only the rest of the closure ships, markers evaluated for the target. mode="pyz" installs one zipapp file at path; mode="dir" a tree at path with bin/<package>. The payload is reproducible: when path already holds a deploy with the same digest nothing is written, unless force. A path that exists but was not written by a deploy is never replaced, force or not. package, extras and skip are as for :func:bundle; skip does not apply to a :class:SourceTree.

Raises ValueError for a relative path or a bad mode/package, BundleError before anything is sent when the bundle cannot be built, NotImplementedError for a host without POSIX paths, FileExistsError for a foreign path, and subprocess.CalledProcessError when a step on the target fails.

Source code in src/hostctl/deploy/__init__.py
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
def deploy(
    host: "Host",
    source: typing.Union[str, SourceTree],
    path: str,
    *,
    mode: str = "pyz",
    package: typing.Optional[str] = None,
    extras: typing.Sequence[str] = (),
    skip: typing.Sequence[str] = (),
    force: bool = False,
    python: str = "python3",
) -> Deployment:
    """Install ``source`` at ``path`` on a POSIX ``host`` and say how to run it.

    For a distribution name, ``python`` on the target is asked which
    distributions it has, and only the rest of the closure ships, markers
    evaluated for the target. ``mode="pyz"`` installs one zipapp file at
    ``path``; ``mode="dir"`` a tree at ``path`` with ``bin/<package>``. The
    payload is reproducible: when ``path`` already holds a deploy with the same
    digest nothing is written, unless ``force``. A ``path`` that exists but
    was not written by a deploy is never replaced, ``force`` or not.
    ``package``, ``extras`` and ``skip`` are as for :func:`bundle`; ``skip``
    does not apply to a :class:`SourceTree`.

    Raises `ValueError` for a relative ``path`` or a bad ``mode``/``package``,
    `BundleError` before anything is sent when the bundle cannot be built,
    `NotImplementedError` for a host without POSIX paths, `FileExistsError`
    for a foreign ``path``, and `subprocess.CalledProcessError` when a step on
    the target fails.
    """
    from ._archive import tar_digest
    from ._install import install_pyz, install_tree, probe, require_posix

    _require("deploy", "deploy()")
    _check(source, mode)
    package = _package(source, package)
    if not PurePosixPath(path).is_absolute():
        raise ValueError(f"deploy path must be absolute: {path!r}")
    # A trailing slash would put `<path>.new` inside the target.
    target = path.rstrip("/") or path
    require_posix(host)

    installed: typing.Sequence[str] = ()
    if not isinstance(source, SourceTree):
        installed = probe(host, python)
    payload = _payload(source, package, extras, skip, installed, host.logger)

    with tempfile.TemporaryDirectory(prefix="hostctl-deploy-") as scratch:
        staged = Path(scratch) / PurePosixPath(target).name
        if mode == "pyz":
            data = _write(payload, staged, mode, package, python).read_bytes()
            digest = hashlib.sha256(data).hexdigest()
        else:
            digest, data = tar_digest(_write(payload, staged, mode, package, python))
    if mode == "pyz":
        written = install_pyz(host, data, digest, target, force)
        command: typing.Tuple[str, ...] = (python, target)
    else:
        written = install_tree(host, data, digest, target, force)
        command = (f"{target}/bin/{package}",)
    host.logger.info("%s %s", "deployed" if written else "verified", target)
    return Deployment(target, digest, written, command)

hostctl.deploy.bundle(source, destination, *, mode='pyz', package=None, extras=(), skip=(), python='python3')

Write a self-contained bundle of source at destination.

source is an installed distribution's name (its whole dependency closure ships, markers evaluated for this interpreter) or a :class:SourceTree. mode="pyz" writes an executable zipapp file; mode="dir" writes a directory holding lib/ and the launcher bin/<package>. package is the import name run (default: the distribution name with - as _; required for a tree). extras are the distribution's extras to include; skip names distributions to leave out. python is the interpreter the bundle names. Returns the written path. Raises BundleError for a distribution with a compiled extension or nothing importable, ValueError for a bad mode or package.

Source code in src/hostctl/deploy/__init__.py
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
def bundle(
    source: typing.Union[str, SourceTree],
    destination: typing.Union[str, "os.PathLike[str]"],
    *,
    mode: str = "pyz",
    package: typing.Optional[str] = None,
    extras: typing.Sequence[str] = (),
    skip: typing.Sequence[str] = (),
    python: str = "python3",
) -> Path:
    """Write a self-contained bundle of ``source`` at ``destination``.

    ``source`` is an installed distribution's name (its whole dependency
    closure ships, markers evaluated for this interpreter) or a
    :class:`SourceTree`. ``mode="pyz"`` writes an executable zipapp file;
    ``mode="dir"`` writes a directory holding ``lib/`` and the launcher
    ``bin/<package>``. ``package`` is the import name run (default: the
    distribution name with ``-`` as ``_``; required for a tree). ``extras``
    are the distribution's extras to include; ``skip`` names distributions to
    leave out. ``python`` is the interpreter the bundle names. Returns the
    written path. Raises `BundleError` for a distribution with a compiled
    extension or nothing importable, `ValueError` for a bad ``mode`` or
    ``package``.
    """
    _require("deploy", "bundle()")
    _check(source, mode)
    package = _package(source, package)
    payload = _payload(source, package, extras, skip, (), _LOGGER)
    return _write(payload, Path(destination), mode, package, python)

hostctl.deploy.Deployment(path, digest, written, command) dataclass

What :func:deploy did.

command is the argv that runs the deployed program on the target: host.run(Exec(*deployment.command, *args)).

command instance-attribute

digest instance-attribute

path instance-attribute

written instance-attribute

hostctl.deploy.SourceTree(root, *, ignore_files=None, ignore_patterns=(), pythonpath=None) dataclass

A working tree to ship as-is, filtered as a clone of it would be.

Its own dependencies are not vendored: that needs them installed here, which a tree source exists to avoid. A tree ships only as mode="dir".

Source code in src/hostctl/deploy/__init__.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def __init__(
    self,
    root: typing.Union[str, "os.PathLike[str]"],
    *,
    ignore_files: typing.Optional[typing.Iterable[str]] = None,
    ignore_patterns: typing.Iterable[str] = (),
    pythonpath: typing.Optional[typing.Iterable[str]] = None,
) -> None:
    object.__setattr__(self, "root", Path(root))
    object.__setattr__(
        self, "ignore_files", None if ignore_files is None else tuple(ignore_files)
    )
    object.__setattr__(self, "ignore_patterns", tuple(ignore_patterns))
    object.__setattr__(
        self, "pythonpath", None if pythonpath is None else tuple(pythonpath)
    )

ignore_files instance-attribute

ignore_patterns instance-attribute

pythonpath instance-attribute

root instance-attribute

_import_roots(package)

The tree's import roots for package, relative to lib/.

Source code in src/hostctl/deploy/__init__.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def _import_roots(self, package: str) -> typing.List[str]:
    """The tree's import roots for ``package``, relative to ``lib/``."""
    from ._tree import PYTHONPATH_CANDIDATES, detect_pythonpath

    resolved = self.root.resolve()
    entries = list(self.pythonpath or ())
    if self.pythonpath is None:
        entries = detect_pythonpath(resolved, package)
        if not entries:
            checked = ", ".join(c or "." for c in PYTHONPATH_CANDIDATES)
            raise BundleError(
                f"could not find an importable {package!r} under {resolved} "
                f"(checked {checked}); name its directory in pythonpath"
            )
    return [
        f"{resolved.name}/{entry}" if entry else resolved.name for entry in entries
    ]

files()

[(arcname, source)], each arcname under the root's own name.

Source code in src/hostctl/deploy/__init__.py
66
67
68
69
70
71
72
73
74
def files(self) -> typing.List[typing.Tuple[str, Path]]:
    """``[(arcname, source)]``, each arcname under the root's own name."""
    from ._tree import collect_tree

    return collect_tree(
        self.root,
        ignore_files=self.ignore_files,
        extra_ignores=self.ignore_patterns,
    )

requirements(extras=(), *, include=(), exclude=())

The dependency names the tree declares, read without installing.

From pyproject.toml (dependencies plus each of extras), else requirements.txt. include/exclude take a name or a bracketed "[extra]"; exclude wins.

Source code in src/hostctl/deploy/__init__.py
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def requirements(
    self,
    extras: typing.Sequence[str] = (),
    *,
    include: typing.Sequence[str] = (),
    exclude: typing.Sequence[str] = (),
) -> typing.List[str]:
    """The dependency names the tree declares, read without installing.

    From ``pyproject.toml`` (``dependencies`` plus each of ``extras``),
    else ``requirements.txt``. ``include``/``exclude`` take a name or a
    bracketed ``"[extra]"``; ``exclude`` wins.
    """
    from ._tree import tree_requirements

    return tree_requirements(
        self.root, tuple(extras), include=tuple(include), exclude=tuple(exclude)
    )

Aliases and constants

Every usable host.path() returns a pathapi.Path; annotate with that.

CommandLike (str | pathlib.PurePath | pathapi.Pathname) is the command type accepted by Executor. ExecutorCapability is the enum whose .ARGS, .CWD, and .ENV members declare native executor support. Its members subclass str, so ExecutorCapability.CWD == "cwd": capability sets are compared as strings throughout, which lets providers advertise transport-specific tokens ("runspace") alongside the named members in one vocabulary.

POSIX_SHELL and POWERSHELL are the built-in shell-flavour instances; BASH, ZSH, FISH, CMD, and PWSH are the other ready-to-use flavours. Any of them can be passed wherever a ShellFlavour is expected.

has_ssh() and has_psrp() report whether the ssh and psrp extras are installed, without importing them; a missing extra otherwise raises MissingExtraError (an ImportError) naming it where it is used. __version__ contains the installed package version.

ShellSelection and PathnameSelection (in hostctl.host) are what every config's shell= and pathname= accept.