hostctl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. hostctl/AGENTS.md +292 -0
  2. hostctl/README.md +248 -0
  3. hostctl/__init__.py +194 -0
  4. hostctl/__main__.py +6 -0
  5. hostctl/_async.py +169 -0
  6. hostctl/_cli.py +232 -0
  7. hostctl/executor/__init__.py +82 -0
  8. hostctl/executor/_common.py +234 -0
  9. hostctl/executor/_qga.py +646 -0
  10. hostctl/executor/container.py +171 -0
  11. hostctl/executor/local.py +91 -0
  12. hostctl/executor/psrp.py +145 -0
  13. hostctl/executor/qemu.py +305 -0
  14. hostctl/executor/serial.py +312 -0
  15. hostctl/executor/ssh.py +226 -0
  16. hostctl/executor/winrm.py +255 -0
  17. hostctl/host/__init__.py +93 -0
  18. hostctl/host/_common.py +804 -0
  19. hostctl/host/_local.py +258 -0
  20. hostctl/host/_ssh.py +587 -0
  21. hostctl/host/_winrm.py +1002 -0
  22. hostctl/host/composite_path.py +567 -0
  23. hostctl/host/container.py +606 -0
  24. hostctl/host/container_path.py +664 -0
  25. hostctl/host/qemu.py +1078 -0
  26. hostctl/host/serial.py +401 -0
  27. hostctl/host/system.py +809 -0
  28. hostctl/process/__init__.py +49 -0
  29. hostctl/process/_common.py +113 -0
  30. hostctl/process/container.py +265 -0
  31. hostctl/process/psrp.py +208 -0
  32. hostctl/process/qemu_serial.py +247 -0
  33. hostctl/process/serial.py +257 -0
  34. hostctl/process/ssh.py +165 -0
  35. hostctl/provider/__init__.py +33 -0
  36. hostctl/provider/_common.py +452 -0
  37. hostctl/provider/transports.py +303 -0
  38. hostctl/py.typed +0 -0
  39. hostctl/serial/__init__.py +285 -0
  40. hostctl/shell/__init__.py +123 -0
  41. hostctl/shell/_common.py +616 -0
  42. hostctl/shell/cmd.py +167 -0
  43. hostctl/shell/fish.py +63 -0
  44. hostctl/shell/posix.py +83 -0
  45. hostctl/shell/powershell.py +120 -0
  46. hostctl/sync.py +245 -0
  47. hostctl-0.1.0.dist-info/METADATA +302 -0
  48. hostctl-0.1.0.dist-info/RECORD +51 -0
  49. hostctl-0.1.0.dist-info/WHEEL +4 -0
  50. hostctl-0.1.0.dist-info/entry_points.txt +2 -0
  51. hostctl-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,804 @@
1
+ """Protocol-independent host contracts and shared implementation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import abc as _abc
6
+ import dataclasses as _dc
7
+ import importlib.metadata as _metadata
8
+ import subprocess as _subprocess
9
+ import threading as _threading
10
+ import typing as _ty
11
+ import types as _types
12
+ from pathlib import PurePath as _PurePath
13
+ from urllib.parse import (
14
+ SplitResult as _SplitResult,
15
+ parse_qsl as _parse_qsl,
16
+ quote as _quote,
17
+ unquote as _unquote,
18
+ urlsplit as _urlsplit,
19
+ )
20
+
21
+ from pathlib_next import Path as HostPath, Pathname as _Pathname
22
+
23
+ from ..executor import (
24
+ CaptureOutput,
25
+ Environment,
26
+ FileHandle,
27
+ Input,
28
+ PathLike,
29
+ capture_streams,
30
+ reject_stdin_conflict,
31
+ )
32
+
33
+ if _ty.TYPE_CHECKING:
34
+ from ..executor import Executor, ExecutorCapability
35
+ from ..process import Process, TerminalRequest
36
+ from ..shell import Shell, ShellFlavour
37
+
38
+ Command = _ty.Union[str, PathLike, _ty.Sequence[object]]
39
+
40
+
41
+ def starts_direct_command(
42
+ cmds: _ty.Sequence[Command],
43
+ ) -> _ty.Optional[_ty.Tuple[_ty.Union[_PurePath, _Pathname], _ty.Tuple[object, ...]]]:
44
+ """Split a path-led call into one executable and its argv arguments.
45
+
46
+ A leading path object is the explicit direct-execution marker. Every
47
+ trailing value is an argv scalar; nested command sequences are rejected so
48
+ callers cannot accidentally mix shell-command and argv semantics.
49
+ """
50
+ if not cmds or not isinstance(cmds[0], (_PurePath, _Pathname)):
51
+ return None
52
+ command = cmds[0]
53
+ argv = tuple(cmds[1:])
54
+ for value in argv:
55
+ if isinstance(value, (tuple, list)):
56
+ raise TypeError("direct command arguments must be scalar values")
57
+ if not isinstance(value, (str, bytes, _PurePath, _Pathname)):
58
+ raise TypeError(
59
+ "direct command arguments must be str, bytes, or path values"
60
+ )
61
+ return command, argv
62
+
63
+
64
+ @_dc.dataclass(frozen=True)
65
+ class HostInfo:
66
+ """Normalized system information; unavailable values remain ``None``."""
67
+
68
+ hostname: _ty.Optional[str] = None
69
+ os_family: _ty.Optional[str] = None
70
+ os_name: _ty.Optional[str] = None
71
+ os_version: _ty.Optional[str] = None
72
+ architecture: _ty.Optional[str] = None
73
+
74
+
75
+ def parse_host_info(output: _ty.Union[bytes, str, None]) -> HostInfo:
76
+ """Parse newline-delimited ``HostInfo`` fields from a transport response."""
77
+ if isinstance(output, bytes):
78
+ output = output.decode("utf-8", "replace")
79
+ values = {}
80
+ for line in (output or "").splitlines():
81
+ key, separator, value = line.partition("=")
82
+ key, value = key.strip(), value.strip()
83
+ if separator and key in HostInfo.__dataclass_fields__ and value:
84
+ values[key] = value
85
+ if "os_family" in values:
86
+ values["os_family"] = normalize_os_family(values["os_family"])
87
+ return HostInfo(**values)
88
+
89
+
90
+ def normalize_os_family(value: _ty.Optional[str]) -> _ty.Optional[str]:
91
+ """Normalize a directly reported OS family without inferring one."""
92
+ if not value:
93
+ return None
94
+ normalized = value.casefold()
95
+ aliases = {
96
+ "win32nt": "windows",
97
+ "windows": "windows",
98
+ "linux": "linux",
99
+ "darwin": "macos",
100
+ "macos": "macos",
101
+ }
102
+ return aliases.get(normalized, normalized)
103
+
104
+
105
+ def uri_host(host: str) -> str:
106
+ """Bracket an IPv6 literal for use in a URI authority."""
107
+ return f"[{host}]" if ":" in host and not host.startswith("[") else host
108
+
109
+
110
+ #: Characters `urllib.parse` deletes from a URI before parsing it (WHATWG
111
+ #: requires the removal, and CPython adopted it for CVE-2022-0391). They are
112
+ #: removed *silently*, which is what makes them dangerous unencoded.
113
+ _URI_STRIPPED_CHARACTERS = {"\t": "%09", "\n": "%0A", "\r": "%0D"}
114
+
115
+
116
+ def _encode_stripped_characters(uri: str) -> str:
117
+ """Percent-encode the characters `urlsplit` would silently delete.
118
+
119
+ A raw tab, CR, or LF never survives `urlsplit`: it is removed before
120
+ parsing, which changes what the URI means rather than failing. That is
121
+ dangerous in two different ways, and they need different answers.
122
+
123
+ In the **userinfo** it swallows data the caller meant to pass:
124
+ `ssh://user:pw<LF>otp:1@host` would authenticate as `pwotp:1`, losing the
125
+ credential extras. Writing the separator raw is the natural thing to do --
126
+ a password read from a file or a prompt arrives with a real newline in it
127
+ -- so encode it here and let it through.
128
+
129
+ In the **authority** the same deletion rewrites the target:
130
+ `ssh://host<LF>.other.example/` would resolve to `host.other.example`. No
131
+ encoding makes that safe, because the caller cannot have meant a hostname
132
+ containing a newline. `_reject_authority_control_characters` refuses those
133
+ after parsing, once the userinfo has been separated out.
134
+
135
+ Encoding happens before `urlsplit` sees the string, so the characters are
136
+ preserved as data and `unquote` restores them when the password is read.
137
+ """
138
+ for character, encoded in _URI_STRIPPED_CHARACTERS.items():
139
+ if character in uri:
140
+ uri = uri.replace(character, encoded)
141
+ return uri
142
+
143
+
144
+ def _reject_authority_control_characters(parsed: _SplitResult) -> None:
145
+ """Refuse control characters in the host portion of an authority.
146
+
147
+ These arrive percent-encoded (see `_encode_stripped_characters`), so the
148
+ check is against the encoded spelling -- `urlsplit` leaves `%0A` in the
149
+ hostname verbatim. A hostname cannot legitimately contain one, and
150
+ allowing it would let a URI that reads as one target resolve to another.
151
+ """
152
+ host = parsed.hostname or ""
153
+ if not host:
154
+ return
155
+ upper = host.upper()
156
+ if any(encoded in upper for encoded in _URI_STRIPPED_CHARACTERS.values()):
157
+ raise ValueError(
158
+ "connection URI host contains a control character; only the "
159
+ "userinfo may carry one"
160
+ )
161
+
162
+
163
+ def _rebuild_authority(parsed: _SplitResult, password: _ty.Optional[str]) -> str:
164
+ """Rebuild a URI authority with `password` in place of the original."""
165
+ host = uri_host(parsed.hostname or "")
166
+ if parsed.port is not None:
167
+ host = f"{host}:{parsed.port}"
168
+ if not parsed.username:
169
+ return host
170
+ userinfo = _quote(parsed.username, safe="")
171
+ if password is not None:
172
+ userinfo = f"{userinfo}:{password}"
173
+ return f"{userinfo}@{host}"
174
+
175
+
176
+ def _without_password(parsed: _SplitResult) -> _SplitResult:
177
+ """Return `parsed` with any password removed from its authority."""
178
+ return parsed._replace(netloc=_rebuild_authority(parsed, None))
179
+
180
+
181
+ def redact_uri(uri: str) -> str:
182
+ """Strip any password from `uri`, leaving a valid, reusable URI.
183
+
184
+ `scheme://user:secret@host` becomes `scheme://user@host`. The password is
185
+ removed rather than masked: a placeholder would make the URI round-trip
186
+ into a *wrong* credential if anything fed the rendered form back in, and
187
+ would not be a real password anyway. What comes out is the same canonical,
188
+ credential-free string a config renders, so it is safe to log, repr, or
189
+ hand back to `HostConfig`.
190
+
191
+ A URI with no password is returned unchanged.
192
+
193
+ This never raises. It is meant for error messages and log records, where
194
+ failing to render a diagnostic would be worse than rendering an odd one.
195
+ Characters `urlsplit` would delete (tab, CR, LF) are percent-encoded first,
196
+ the same as during dispatch, so a password written with a raw newline is
197
+ still recognized and removed rather than partly surviving into the output.
198
+ """
199
+ parsed = _urlsplit(_encode_stripped_characters(uri))
200
+ if parsed.password is None:
201
+ return uri
202
+ return _without_password(parsed).geturl()
203
+
204
+
205
+ def parse_credentials(password: str) -> _ty.Tuple[str, _ty.Dict[str, str]]:
206
+ """Split a password field into the password and any trailing extras.
207
+
208
+ A newline separates the password from additional credential values, one
209
+ per line, each `key:value`:
210
+
211
+ "hunter2" -> ("hunter2", {})
212
+ "hunter2\\notp:123456" -> ("hunter2", {"otp": "123456"})
213
+ "hunter2\\notp:123456\\nrealm:CORP" -> ("hunter2", {"otp": ..., "realm": ...})
214
+
215
+ A bare name with no `:` is a flag -- it maps to the empty string, exactly
216
+ as `name:` does:
217
+
218
+ "hunter2\\ninteractive" -> ("hunter2", {"interactive": ""})
219
+
220
+ This is how a second factor reaches a transport through a single password
221
+ field -- a URI's userinfo, an environment variable, a prompt -- without
222
+ every caller inventing its own encoding. A newline is assumed not to be a
223
+ valid password character, which is the same assumption pytruenas makes.
224
+
225
+ Names are casefolded and stripped of surrounding whitespace. A value is
226
+ everything after the first `:`, taken **verbatim** -- it may contain
227
+ colons, and its leading and trailing whitespace is preserved, because a
228
+ secret may legitimately begin or end with a space and silently trimming
229
+ one would fail authentication with no visible cause. Blank lines are
230
+ ignored.
231
+ """
232
+ # Split on any line ending: a value pasted from a file or a Windows prompt
233
+ # arrives CRLF-terminated, and a trailing "\r" left on the password would
234
+ # fail authentication with no visible cause.
235
+ lines = password.splitlines()
236
+ if len(lines) <= 1:
237
+ return password, {}
238
+ password, remainder = lines[0], lines[1:]
239
+ extras: _ty.Dict[str, str] = {}
240
+ for line in remainder:
241
+ if not line.strip():
242
+ continue
243
+ # A line with no ":" is a bare flag; `partition` already yields "" for
244
+ # its value, so flags and `name:` need no separate branch.
245
+ key, _, value = line.partition(":")
246
+ key = key.strip().casefold()
247
+ if not key:
248
+ raise ValueError("credential extra names must not be empty")
249
+ extras[key] = value
250
+ return password, extras
251
+
252
+
253
+ class _HostConfigMeta(_abc.ABCMeta):
254
+ def __call__(cls, *args: object, **options: object) -> HostConfig:
255
+ if cls is HostConfig:
256
+ if len(args) != 1 or not isinstance(args[0], str):
257
+ raise TypeError(
258
+ "HostConfig() requires one connection string positional argument"
259
+ )
260
+ return cls._from_uri(args[0], **options)
261
+ return super().__call__(*args, **options)
262
+
263
+
264
+ class HostConfig(_abc.ABC, metaclass=_HostConfigMeta):
265
+ """Secret-safe connection configuration and extensible URI dispatch."""
266
+
267
+ #: Credential names `HostConfig(uri, **credentials)` may pass to this
268
+ #: config. Declaring it makes dispatch reject anything else *before*
269
+ #: construction, so a typo (`passwrd=`) fails loudly instead of silently
270
+ #: producing a config with no password. `None` (the default) skips the
271
+ #: check, for configs that validate credentials themselves.
272
+ uri_credentials: _ty.ClassVar[_ty.Optional[_ty.Tuple[str, ...]]] = None
273
+
274
+ _uri_schemes: _ty.ClassVar[_ty.Tuple[str, ...]] = ()
275
+ _uri_registry_cache: _ty.ClassVar[
276
+ _ty.Optional[_ty.Tuple[_ty.Type["HostConfig"], ...]]
277
+ ] = None
278
+ _uri_entry_points: _ty.ClassVar[_ty.Optional[tuple[object, ...]]] = None
279
+ _uri_plugin_failures: _ty.ClassVar[dict[str, Exception]] = {}
280
+ _uri_registry_generation: _ty.ClassVar[int] = 0
281
+ _uri_registry_lock: _ty.ClassVar[_threading.RLock] = _threading.RLock()
282
+
283
+ def __init__(self) -> None:
284
+ self._opened_host: _ty.Optional[Host] = None
285
+ self._lifecycle_lock = _threading.Lock()
286
+
287
+ def __init_subclass__(
288
+ cls,
289
+ *,
290
+ schemes: _ty.Iterable[str] = (),
291
+ **kwargs: object,
292
+ ) -> None:
293
+ super().__init_subclass__(**kwargs)
294
+ cls._uri_schemes = tuple(scheme.casefold() for scheme in schemes)
295
+ HostConfig._refresh_uri_registry()
296
+
297
+ @property
298
+ def scheme(self) -> str:
299
+ return _urlsplit(self.connection_uri).scheme.casefold()
300
+
301
+ def __str__(self) -> str:
302
+ return self.connection_uri
303
+
304
+ @property
305
+ @_abc.abstractmethod
306
+ def connection_uri(self) -> str:
307
+ """Credential-safe canonical connection URI."""
308
+
309
+ @_abc.abstractmethod
310
+ def _create_host(self) -> Host:
311
+ """Create the operational host represented by this configuration."""
312
+
313
+ def open(self) -> Host:
314
+ """Return a host context manager for this configuration."""
315
+ return self._create_host()
316
+
317
+ def __enter__(self) -> Host:
318
+ with self._lifecycle_lock:
319
+ if self._opened_host is not None:
320
+ raise RuntimeError("host configuration is already open")
321
+ host = self._create_host()
322
+ self._opened_host = host
323
+ try:
324
+ return host.__enter__()
325
+ except BaseException:
326
+ try:
327
+ host.close()
328
+ except BaseException:
329
+ pass
330
+ finally:
331
+ with self._lifecycle_lock:
332
+ if self._opened_host is host:
333
+ self._opened_host = None
334
+ raise
335
+
336
+ def __exit__(
337
+ self,
338
+ exc_type: _ty.Optional[_ty.Type[BaseException]],
339
+ exc_value: _ty.Optional[BaseException],
340
+ traceback: _ty.Optional[_types.TracebackType],
341
+ ) -> _ty.Optional[bool]:
342
+ with self._lifecycle_lock:
343
+ host, self._opened_host = self._opened_host, None
344
+ if host is not None:
345
+ return host.__exit__(exc_type, exc_value, traceback)
346
+ return False
347
+
348
+ @classmethod
349
+ def _from_uri(
350
+ cls,
351
+ uri: str,
352
+ **credentials: object,
353
+ ) -> HostConfig:
354
+ """Dispatch a connection URI to a registered configuration.
355
+
356
+ A `scheme://user:secret@host` URI is accepted: the password is
357
+ extracted into the credential arguments and stripped from the parsed
358
+ authority, so it is never stored where `connection_uri` or `repr()`
359
+ would render it. Use :func:`redact_uri` before showing a URI that may
360
+ still carry one.
361
+
362
+ The password field is parsed by :func:`parse_credentials`, so a newline
363
+ separates trailing `key:value` extras -- an OTP or other second factor
364
+ travels through the same field. The newline may be written raw: it is
365
+ percent-encoded before parsing (`urlsplit` would otherwise delete it),
366
+ and decoded again when the password is read. A control character in the
367
+ *host* is still refused, because no encoding makes that meaningful.
368
+ """
369
+ parsed = _urlsplit(_encode_stripped_characters(uri))
370
+ _reject_authority_control_characters(parsed)
371
+ if parsed.fragment:
372
+ raise ValueError("connection URI fragments are not supported")
373
+ if parsed.password is not None:
374
+ # `scheme://user:secret@host` is a valid URI, so accept it: extract
375
+ # the password into the credential arguments and strip it from the
376
+ # parsed authority. The password therefore never reaches a config
377
+ # field that `connection_uri`/`repr` render, which is what keeps
378
+ # the canonical form credential-free.
379
+ if credentials.get("password") is not None:
380
+ raise ValueError(
381
+ "password given both in the connection URI and as an argument"
382
+ )
383
+ password, extras = parse_credentials(_unquote(parsed.password))
384
+ credentials["password"] = password
385
+ for key, value in extras.items():
386
+ if key in credentials:
387
+ raise ValueError(
388
+ f"credential {key!r} given both in the connection URI "
389
+ "and as an argument"
390
+ )
391
+ credentials[key] = value
392
+ parsed = _without_password(parsed)
393
+ matches = [
394
+ implementation
395
+ for implementation in cls._uri_implementations(parsed.scheme)
396
+ if implementation._matches_uri(parsed)
397
+ ]
398
+ if not matches:
399
+ raise ValueError(
400
+ f"unsupported host scheme: {parsed.scheme.casefold() or '<missing>'}"
401
+ )
402
+ if len(matches) > 1:
403
+ names = ", ".join(item.__name__ for item in matches)
404
+ raise ValueError(f"ambiguous host URI matched: {names}")
405
+ implementation = matches[0]
406
+ # Fail closed on an unknown credential *here*, so a config gets the
407
+ # safe behaviour by declaring `uri_credentials` rather than by
408
+ # remembering to call a helper. A typo like `passwrd=` would otherwise
409
+ # build a config with no password and no complaint, surfacing much
410
+ # later as an unexplained authentication failure.
411
+ if implementation.uri_credentials is not None:
412
+ strict_uri_credentials(credentials, implementation.uri_credentials)
413
+ return implementation._from_parsed_uri(parsed, **credentials)
414
+
415
+ @classmethod
416
+ def _matches_uri(cls, parsed: _SplitResult) -> bool:
417
+ """Whether this implementation accepts a parsed URI."""
418
+ return parsed.scheme.casefold() in cls._uri_schemes
419
+
420
+ @classmethod
421
+ def _from_parsed_uri(
422
+ cls, parsed: _SplitResult, **credentials: object
423
+ ) -> HostConfig:
424
+ """Construct from a URI selected by :meth:`_matches_uri`."""
425
+ raise NotImplementedError(f"{cls.__name__} does not implement URI construction")
426
+
427
+ @classmethod
428
+ def _refresh_uri_registry(cls) -> None:
429
+ """Clear URI implementation discovery for tests and newly loaded plugins."""
430
+ with HostConfig._uri_registry_lock:
431
+ HostConfig._uri_registry_generation += 1
432
+ HostConfig._uri_registry_cache = None
433
+ HostConfig._uri_entry_points = None
434
+ HostConfig._uri_plugin_failures = {}
435
+
436
+ @classmethod
437
+ def _uri_implementations(
438
+ cls,
439
+ requested_scheme: _ty.Optional[str] = None,
440
+ ) -> _ty.Tuple[_ty.Type[HostConfig], ...]:
441
+ scheme = requested_scheme.casefold() if requested_scheme else None
442
+ with HostConfig._uri_registry_lock:
443
+ if HostConfig._uri_registry_cache is None:
444
+ from . import (
445
+ container as _container,
446
+ _local,
447
+ qemu as _qemu,
448
+ serial as _serial,
449
+ _ssh,
450
+ _winrm,
451
+ )
452
+
453
+ del _container, _local, _qemu, _serial, _ssh, _winrm
454
+ while HostConfig._uri_registry_cache is None:
455
+ generation = HostConfig._uri_registry_generation
456
+ discovered = list(_recursive_subclasses(HostConfig))
457
+ if generation != HostConfig._uri_registry_generation:
458
+ continue
459
+ HostConfig._uri_registry_cache = tuple(
460
+ item for item in discovered if item._uri_schemes
461
+ )
462
+ if HostConfig._uri_entry_points is None:
463
+ points = _metadata.entry_points()
464
+ if hasattr(points, "select"):
465
+ points = points.select(group="hostctl.configs")
466
+ elif hasattr(points, "get"):
467
+ points = points.get("hostctl.configs", ())
468
+ else:
469
+ points = tuple(
470
+ item
471
+ for item in points
472
+ if getattr(item, "group", None) == "hostctl.configs"
473
+ )
474
+ HostConfig._uri_entry_points = tuple(points)
475
+ candidates = list(HostConfig._uri_registry_cache)
476
+ if scheme is not None:
477
+ for entry_point in HostConfig._uri_entry_points:
478
+ name = str(getattr(entry_point, "name", "")).casefold()
479
+ if name != scheme:
480
+ continue
481
+ failure = HostConfig._uri_plugin_failures.get(name)
482
+ if failure is not None:
483
+ raise failure
484
+ try:
485
+ implementation = entry_point.load()
486
+ if not isinstance(implementation, type) or not issubclass(
487
+ implementation, HostConfig
488
+ ):
489
+ raise TypeError(
490
+ f"hostctl.configs entry point {name!r} "
491
+ "must load a HostConfig subclass"
492
+ )
493
+ except Exception as exc:
494
+ HostConfig._uri_plugin_failures[name] = exc
495
+ import warnings
496
+
497
+ warnings.warn(
498
+ f"unable to load hostctl.configs entry point {name!r}: {exc}",
499
+ RuntimeWarning,
500
+ stacklevel=2,
501
+ )
502
+ raise
503
+ candidates.append(implementation)
504
+ break
505
+ return tuple(
506
+ item
507
+ for item in dict.fromkeys(candidates)
508
+ if issubclass(item, cls) and item._uri_schemes
509
+ )
510
+
511
+
512
+ class _HostMeta(_abc.ABCMeta):
513
+ def __call__(cls, *args: object, **options: object) -> Host:
514
+ if cls is Host:
515
+ if len(args) != 1 or not isinstance(args[0], str):
516
+ raise TypeError(
517
+ "Host() requires one connection string positional argument"
518
+ )
519
+ config = HostConfig._from_uri(args[0], **options)
520
+ return config._create_host()
521
+ return super().__call__(*args, **options)
522
+
523
+
524
+ class _ShellAccessor:
525
+ """Expose `host.shell` as both the shell itself and a configuring call.
526
+
527
+ `host.shell` must keep working as the bound `Shell` -- `host.shell.run()`,
528
+ `host.shell.session()`, `with host.shell as session:` all predate this.
529
+ `host.shell(cwd=...)` must additionally return a shell carrying defaults.
530
+
531
+ A plain property cannot do both, and overloading `Shell.__call__` is not an
532
+ option: that is the `Executor` protocol's execute entry point, so making it
533
+ mean "configure" when called without a command would make every executor
534
+ call site ambiguous. This descriptor instead returns the built shell for
535
+ attribute access and, because a `Shell` is itself callable, routes a
536
+ keyword-only call through `Shell.configure`.
537
+ """
538
+
539
+ def __init__(self, build):
540
+ self._build = build
541
+ self.__doc__ = build.__doc__
542
+
543
+ def __set_name__(self, owner, name):
544
+ self._name = name
545
+
546
+ def __get__(self, instance, owner=None):
547
+ if instance is None:
548
+ return self
549
+ return _ConfigurableShell(self._build(instance))
550
+
551
+
552
+ class _ConfigurableShell:
553
+ """A `Shell` proxy whose keyword-only call returns a configured shell."""
554
+
555
+ __slots__ = ("_shell",)
556
+
557
+ def __init__(self, shell):
558
+ object.__setattr__(self, "_shell", shell)
559
+
560
+ def __call__(self, *args, **options):
561
+ if args:
562
+ # A positional argument means the caller is using the `Executor`
563
+ # protocol (`shell(command, ...)`); defer to the real shell.
564
+ return self._shell(*args, **options)
565
+ return self._shell.configure(**options)
566
+
567
+ def __getattr__(self, name):
568
+ return getattr(self._shell, name)
569
+
570
+ def __setattr__(self, name, value):
571
+ setattr(self._shell, name, value)
572
+
573
+ def __enter__(self):
574
+ return self._shell.__enter__()
575
+
576
+ def __exit__(self, exc_type, exc_value, traceback):
577
+ return self._shell.__exit__(exc_type, exc_value, traceback)
578
+
579
+ def __repr__(self):
580
+ return repr(self._shell)
581
+
582
+
583
+ class Host(_abc.ABC, metaclass=_HostMeta):
584
+ """Protocol-independent operational interface to a machine."""
585
+
586
+ config: HostConfig
587
+
588
+ @property
589
+ def scheme(self) -> str:
590
+ return self.config.scheme
591
+
592
+ @property
593
+ def connection_uri(self) -> str:
594
+ return self.config.connection_uri
595
+
596
+ @property
597
+ def shell_flavour(self) -> ShellFlavour:
598
+ """The explicitly known shell language used by this host."""
599
+ raise NotImplementedError(
600
+ f"{type(self).__name__} does not identify a shell flavour"
601
+ )
602
+
603
+ @property
604
+ def executor(self) -> Executor[_subprocess.CompletedProcess]:
605
+ """The command executor used when binding this host's shell."""
606
+ host = self
607
+
608
+ class _HostExecutor:
609
+ executor_capabilities = host.executor_capabilities
610
+
611
+ def __call__(self, command, *args, **options):
612
+ return host.run(command, *args, **options)
613
+
614
+ return _HostExecutor()
615
+
616
+ @property
617
+ def executor_capabilities(self) -> _ty.FrozenSet[ExecutorCapability]:
618
+ """Native context/argument features of the underlying executor.
619
+
620
+ Derived from the host's own executor so a host that does not compose
621
+ providers still reports truthfully. `Host.executor`'s default wrapper
622
+ reads this property, so it is skipped here to avoid recursing; a host
623
+ that overrides `executor` with a real executor reports that executor's
624
+ capabilities.
625
+ """
626
+ executor = type(self).executor
627
+ if executor is Host.executor:
628
+ return frozenset()
629
+ return frozenset(getattr(self.executor, "executor_capabilities", ()))
630
+
631
+ @_ShellAccessor
632
+ def shell(self) -> Shell[_subprocess.CompletedProcess]:
633
+ """A shell bound to this host's executor.
634
+
635
+ Used directly -- `host.shell.run(...)`, `host.shell.session(...)`, or
636
+ `with host.shell as session:` -- it carries no defaults.
637
+
638
+ Called with keywords -- `host.shell(cwd="/srv/app", env={"TZ": "UTC"})`
639
+ -- it returns a shell carrying those defaults, applied to every later
640
+ `run`, `execute`, and `session` that does not pass its own value.
641
+ `env` merges per key; `cwd`, `encoding`, and `errors` override.
642
+ """
643
+ from ..shell import Shell
644
+
645
+ return Shell(self.shell_flavour, self)
646
+
647
+ def _run_selector(self) -> _ty.Optional[object]:
648
+ """The :class:`~hostctl.provider.ProviderSelector` backing :meth:`run`.
649
+
650
+ ``None`` for a host that does not select a command provider at all.
651
+ The default finds the attribute used by the provider-composed system
652
+ hosts; assemblies that name theirs differently override this.
653
+ """
654
+ return getattr(self, "_executor_selector", None)
655
+
656
+ @property
657
+ def last_selection(self) -> _ty.Tuple[_ty.Dict[str, object], ...]:
658
+ """The redacted provider trace for the most recent :meth:`run`.
659
+
660
+ The run-side counterpart of ``CompositePosixPath.selection_trace``.
661
+ Entries appear in provider precedence order and carry ``provider``,
662
+ ``availability``, ``reason``, ``capabilities``, ``chosen``,
663
+ ``generation``, ``policy``, and ``pin``.
664
+
665
+ The trace accumulates across the failover attempts of a single
666
+ ``run()``, so a call that fell through from one provider to another
667
+ reports every provider tried and every refusal reason -- including on
668
+ the very call that suffered them.
669
+
670
+ Empty for a host whose ``run()`` does not select between providers
671
+ (``QemuHost``, ``SerialHost``), and empty before the first ``run()``.
672
+ Values are redacted on the way in; see
673
+ :meth:`~hostctl.provider.ProviderSelector.redact` for the limits of
674
+ that guarantee.
675
+ """
676
+ selector = self._run_selector()
677
+ if selector is None:
678
+ return ()
679
+ return getattr(selector, "trace", ())
680
+
681
+ @property
682
+ @_abc.abstractmethod
683
+ def capabilities(self) -> _ty.FrozenSet[str]:
684
+ """Operations supported by this host."""
685
+
686
+ @_abc.abstractmethod
687
+ def info(self) -> HostInfo:
688
+ """Return normalized system information without inferred values."""
689
+
690
+ def connect(self) -> None:
691
+ """Open transport resources; local/default implementations are no-op."""
692
+
693
+ def close(self) -> None:
694
+ """Close transport resources; must be safe to call repeatedly."""
695
+
696
+ def __enter__(self) -> Host:
697
+ try:
698
+ self.connect()
699
+ except BaseException:
700
+ try:
701
+ self.close()
702
+ except BaseException:
703
+ pass
704
+ raise
705
+ return self
706
+
707
+ def __exit__(
708
+ self,
709
+ exc_type: _ty.Optional[_ty.Type[BaseException]],
710
+ exc_value: _ty.Optional[BaseException],
711
+ traceback: _ty.Optional[_types.TracebackType],
712
+ ) -> bool:
713
+ self.close()
714
+ return False
715
+
716
+ def path(self, *segments: PathLike, backend: _ty.Optional[str] = None) -> HostPath:
717
+ """Return a pathlib-compatible path for this host."""
718
+ raise NotImplementedError(
719
+ f"{type(self).__name__} does not provide the 'path' capability"
720
+ )
721
+
722
+ def spawn(
723
+ self,
724
+ *cmds: Command,
725
+ executable: _ty.Optional[str] = None,
726
+ cwd: _ty.Optional[PathLike] = None,
727
+ env: _ty.Optional[Environment] = None,
728
+ terminal: TerminalRequest = None,
729
+ encoding: _ty.Optional[str] = None,
730
+ errors: _ty.Optional[str] = None,
731
+ ) -> Process:
732
+ """Start a persistent process controlled through a synchronous facade."""
733
+ raise NotImplementedError(
734
+ f"{type(self).__name__} does not provide the 'spawn' capability"
735
+ )
736
+
737
+ def run(
738
+ self,
739
+ *cmds: Command,
740
+ bufsize: int = -1,
741
+ executable: _ty.Optional[str] = None,
742
+ stdin: _ty.Optional[FileHandle] = None,
743
+ stdout: _ty.Optional[FileHandle] = None,
744
+ stderr: _ty.Optional[FileHandle] = None,
745
+ cwd: _ty.Optional[PathLike] = None,
746
+ env: _ty.Optional[Environment] = None,
747
+ capture_output: CaptureOutput = True,
748
+ check: bool = True,
749
+ encoding: _ty.Optional[str] = None,
750
+ errors: _ty.Optional[str] = None,
751
+ input: Input = None,
752
+ timeout: _ty.Optional[float] = None,
753
+ text: _ty.Optional[bool] = None,
754
+ ) -> _subprocess.CompletedProcess:
755
+ """Run commands and return a subprocess-compatible result.
756
+
757
+ A string is verbatim shell text. A tuple/list is one quoted argv
758
+ command. A leading :class:`pathlib.PurePath`/``pathlib_next`` path is
759
+ a direct executable and all trailing values are its argv arguments.
760
+ Otherwise multiple top-level commands are joined by the selected
761
+ shell's command separator.
762
+ """
763
+ raise NotImplementedError(
764
+ f"{type(self).__name__} does not provide the 'run' capability"
765
+ )
766
+
767
+
768
+ def strict_uri_query(
769
+ parsed: _SplitResult, allowed: _ty.Iterable[str]
770
+ ) -> _ty.Dict[str, str]:
771
+ """Parse one selected implementation's query without ambiguity."""
772
+ query = {}
773
+ for key, value in _parse_qsl(parsed.query, keep_blank_values=True):
774
+ if key in query:
775
+ raise ValueError(f"duplicate connection parameter: {key}")
776
+ query[key] = value
777
+ unknown = set(query) - set(allowed)
778
+ if unknown:
779
+ raise ValueError(f"unknown connection parameter: {sorted(unknown)[0]}")
780
+ return query
781
+
782
+
783
+ def strict_uri_credentials(
784
+ credentials: _ty.Mapping[str, object], allowed: _ty.Iterable[str]
785
+ ) -> None:
786
+ """Reject credentials which the selected implementation does not accept."""
787
+ unknown = set(credentials) - set(allowed)
788
+ if unknown:
789
+ raise ValueError(f"unknown credential argument: {sorted(unknown)[0]}")
790
+
791
+
792
+ def _query_int(query: _ty.Mapping[str, str], name: str, default: int) -> int:
793
+ try:
794
+ return int(query.get(name, default))
795
+ except ValueError as exc:
796
+ raise ValueError(f"{name} must be an integer") from exc
797
+
798
+
799
+ def _recursive_subclasses(
800
+ base: _ty.Type[HostConfig],
801
+ ) -> _ty.Iterator[_ty.Type[HostConfig]]:
802
+ for subclass in base.__subclasses__():
803
+ yield subclass
804
+ yield from _recursive_subclasses(subclass)