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
hostctl/host/_ssh.py ADDED
@@ -0,0 +1,587 @@
1
+ """SSH host implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import importlib
7
+ import logging
8
+ import os
9
+ import subprocess
10
+ import threading
11
+ import typing
12
+ from pathlib import PurePath, PurePosixPath, PureWindowsPath
13
+ from urllib.parse import quote, unquote, urlencode
14
+
15
+ from pathlib_next import Pathname, PosixPathname, WindowsPathname
16
+
17
+ from ..executor import SshConnection, SshExecutor
18
+ from ..provider import (
19
+ ExecutorProvider,
20
+ OperationNotStarted,
21
+ PathProvider,
22
+ ProviderProbe,
23
+ ProviderSelector,
24
+ )
25
+
26
+ log = logging.getLogger("hostctl.host.ssh")
27
+ from ..process import Process, SshProcess, TerminalRequest
28
+ from ._common import (
29
+ CaptureOutput,
30
+ Command,
31
+ Environment,
32
+ FileHandle,
33
+ HostConfig,
34
+ HostInfo,
35
+ HostPath,
36
+ Input,
37
+ PathLike,
38
+ parse_host_info,
39
+ starts_direct_command,
40
+ strict_uri_credentials,
41
+ strict_uri_query,
42
+ uri_host,
43
+ reject_stdin_conflict,
44
+ )
45
+ from ..shell import (
46
+ BASH,
47
+ CMD,
48
+ FISH,
49
+ POWERSHELL,
50
+ POSIX_SHELL,
51
+ PWSH,
52
+ ZSH,
53
+ ShellFlavour,
54
+ ShellFlavourSelection,
55
+ shell_flavour,
56
+ )
57
+
58
+ SshKeySource = typing.Union[str, bytes, os.PathLike[str]]
59
+ SshClientKeys = typing.Optional[
60
+ typing.Union[SshKeySource, typing.Sequence[SshKeySource]]
61
+ ]
62
+ SshKnownHosts = typing.Optional[
63
+ typing.Union[SshKeySource, typing.Sequence[SshKeySource]]
64
+ ]
65
+ PathnameConstructor = typing.Type[typing.Union[PurePath, Pathname]]
66
+ SshShellSelection = typing.Union[ShellFlavourSelection, typing.Literal["auto"]]
67
+
68
+
69
+ def _path_flavor_from_connection_string(value: str) -> PathnameConstructor:
70
+ try:
71
+ return {
72
+ "posix": PosixPathname,
73
+ "windows": WindowsPathname,
74
+ }[value]
75
+ except KeyError as exc:
76
+ raise ValueError(
77
+ "path_flavor must be 'posix' or 'windows' in a connection string"
78
+ ) from exc
79
+
80
+
81
+ @dataclasses.dataclass
82
+ class SshConfig(HostConfig, schemes=("ssh",)):
83
+ """Explicit SSH transport, authentication, and target-shell settings."""
84
+
85
+ host: str
86
+ port: int = 22
87
+ username: str = "root"
88
+ password: typing.Optional[str] = dataclasses.field(default=None, repr=False)
89
+ client_keys: SshClientKeys = dataclasses.field(default=None, repr=False)
90
+ executable: typing.Optional[str] = None
91
+ known_hosts: SshKnownHosts = ()
92
+ dialect: SshShellSelection = POSIX_SHELL
93
+ path_flavor: PathnameConstructor = PosixPathname
94
+
95
+ def __post_init__(self) -> None:
96
+ HostConfig.__init__(self)
97
+ if not self.host:
98
+ raise ValueError("host must not be empty")
99
+ if not 1 <= self.port <= 65535:
100
+ raise ValueError("port must be between 1 and 65535")
101
+ if self.dialect != "auto":
102
+ self.dialect = shell_flavour(self.dialect)
103
+ elif self.executable is not None:
104
+ raise ValueError("executable cannot be combined with dialect='auto'")
105
+ if not isinstance(self.path_flavor, type) or not issubclass(
106
+ self.path_flavor, (Pathname, PurePath)
107
+ ):
108
+ raise TypeError("path_flavor must be a pure-path class")
109
+ if self.path_flavor is PurePath:
110
+ raise TypeError(
111
+ "bare PurePath uses the local OS; choose a concrete path flavor"
112
+ )
113
+ if not issubclass(self.path_flavor, (PurePosixPath, PureWindowsPath)):
114
+ raise TypeError("path_flavor must use POSIX or Windows path semantics")
115
+
116
+ def connect_opts(self) -> typing.Dict[str, object]:
117
+ opts: typing.Dict[str, object] = {"username": self.username or "root"}
118
+ if self.password is not None:
119
+ opts["password"] = self.password
120
+ if self.client_keys is not None:
121
+ keys = self.client_keys
122
+ if isinstance(keys, (str, bytes, os.PathLike)):
123
+ keys = [keys]
124
+ opts["client_keys"] = keys
125
+ if self.known_hosts != ():
126
+ opts["known_hosts"] = self.known_hosts
127
+ return opts
128
+
129
+ @property
130
+ def connection_uri(self) -> str:
131
+ user = f"{quote(self.username, safe='')}@" if self.username else ""
132
+ path_flavor = (
133
+ "windows" if issubclass(self.path_flavor, PureWindowsPath) else "posix"
134
+ )
135
+ query = {"dialect": self.dialect, "path_flavor": path_flavor}
136
+ if self.executable:
137
+ query["executable"] = self.executable
138
+ return (
139
+ f"ssh://{user}{uri_host(self.host)}:{self.port or 22}"
140
+ f"?{urlencode(query)}"
141
+ )
142
+
143
+ @classmethod
144
+ def _from_parsed_uri(cls, parsed, **credentials: object) -> SshConfig:
145
+ strict_uri_credentials(credentials, ("password", "client_keys", "known_hosts"))
146
+ query = strict_uri_query(parsed, {"dialect", "path_flavor", "executable"})
147
+ if not parsed.hostname or parsed.path not in ("", "/"):
148
+ raise ValueError("SSH URI requires a host and no path")
149
+ return cls(
150
+ host=parsed.hostname,
151
+ port=parsed.port or 22,
152
+ username=unquote(parsed.username or "") or "root",
153
+ password=typing.cast(typing.Optional[str], credentials.get("password")),
154
+ client_keys=typing.cast(SshClientKeys, credentials.get("client_keys")),
155
+ executable=query.get("executable") or None,
156
+ known_hosts=typing.cast(SshKnownHosts, credentials.get("known_hosts", ())),
157
+ dialect=(
158
+ "auto"
159
+ if query.get("dialect") == "auto"
160
+ else shell_flavour(query.get("dialect", POSIX_SHELL.name))
161
+ ),
162
+ path_flavor=_path_flavor_from_connection_string(
163
+ query.get("path_flavor", "posix")
164
+ ),
165
+ )
166
+
167
+ def _create_host(self):
168
+ from .system import PosixHost, WindowsHost
169
+
170
+ transport = self._create_transport()
171
+ host_type = (
172
+ WindowsHost if issubclass(self.path_flavor, PureWindowsPath) else PosixHost
173
+ )
174
+ return host_type(
175
+ self,
176
+ executor_providers=(SshExecutorProvider(transport),),
177
+ path_providers=(SftpPathProvider(transport),),
178
+ shell=(
179
+ self.dialect
180
+ if self.dialect != "auto"
181
+ else (lambda: transport.shell_flavour)
182
+ ),
183
+ )
184
+
185
+ def _create_transport(self) -> _SshTransport:
186
+ """Create the private SSH service shared by executor/path providers."""
187
+ return _SshTransport(self)
188
+
189
+
190
+ class _SshTransport:
191
+ """A host reached over SSH, with an explicitly configured command dialect."""
192
+
193
+ def __init__(self, config: SshConfig) -> None:
194
+ self.config = config
195
+ self._ssh: typing.Optional[SshConnection] = None
196
+ self._ssh_lock = threading.RLock()
197
+ self._executor = SshExecutor(lambda: self.ssh)
198
+ self._resolved_dialect: typing.Optional[
199
+ typing.Tuple[typing.Tuple[object, ...], ShellFlavour, typing.Optional[str]]
200
+ ] = None
201
+ self._sftp_backend: typing.Optional[object] = None
202
+ self._sftp_sources: typing.Set[object] = set()
203
+
204
+ @property
205
+ def capabilities(self) -> typing.FrozenSet[str]:
206
+ return frozenset(("run", "path", "spawn", "tty"))
207
+
208
+ @property
209
+ def shell_flavour(self) -> ShellFlavour:
210
+ selection = self.config.dialect
211
+ if selection != "auto":
212
+ return typing.cast(ShellFlavour, selection)
213
+ key = (selection, self.config.path_flavor, self.config.executable)
214
+ with self._ssh_lock:
215
+ if self._resolved_dialect is not None and self._resolved_dialect[0] == key:
216
+ return self._resolved_dialect[1]
217
+ resolved, executable = (
218
+ self._detect_windows_shell()
219
+ if issubclass(self.config.path_flavor, PureWindowsPath)
220
+ else self._detect_posix_shell()
221
+ )
222
+ self._resolved_dialect = (key, resolved, executable)
223
+ return resolved
224
+
225
+ def _detect_posix_shell(self) -> typing.Tuple[ShellFlavour, str]:
226
+ result = self.executor(
227
+ "printf '%s\\n' \"$SHELL\"",
228
+ check=False,
229
+ text=True,
230
+ timeout=5,
231
+ )
232
+ if result.returncode:
233
+ raise RuntimeError("unable to detect the remote POSIX login shell")
234
+ name = (result.stdout or "").strip().replace("\\", "/").rsplit("/", 1)[-1]
235
+ executable = (result.stdout or "").strip()
236
+ try:
237
+ return {
238
+ "sh": POSIX_SHELL,
239
+ "dash": POSIX_SHELL,
240
+ "bash": BASH,
241
+ "zsh": ZSH,
242
+ "fish": FISH,
243
+ }[name.casefold()], executable
244
+ except KeyError as exc:
245
+ raise RuntimeError(
246
+ f"unsupported or undetected remote POSIX shell: {name or '<empty>'}"
247
+ ) from exc
248
+
249
+ def _detect_windows_shell(self) -> typing.Tuple[ShellFlavour, str]:
250
+ probes = (
251
+ (
252
+ PWSH,
253
+ "pwsh -NoProfile -NonInteractive -Command "
254
+ '"Write-Output HOSTCTL_PWSH_7"',
255
+ "HOSTCTL_PWSH_7",
256
+ ),
257
+ (
258
+ POWERSHELL,
259
+ "powershell.exe -NoProfile -NonInteractive -Command "
260
+ '"Write-Output HOSTCTL_POWERSHELL_5"',
261
+ "HOSTCTL_POWERSHELL_5",
262
+ ),
263
+ (CMD, 'cmd.exe /d /s /c "echo HOSTCTL_CMD"', "HOSTCTL_CMD"),
264
+ )
265
+ for flavour, command, marker in probes:
266
+ try:
267
+ result = self.executor(
268
+ command,
269
+ check=False,
270
+ text=True,
271
+ timeout=5,
272
+ )
273
+ except subprocess.TimeoutExpired:
274
+ continue
275
+ if result.returncode == 0 and (result.stdout or "").strip() == marker:
276
+ executable = {
277
+ PWSH: "pwsh",
278
+ POWERSHELL: "powershell.exe",
279
+ CMD: "cmd.exe",
280
+ }[flavour]
281
+ return flavour, executable
282
+ raise RuntimeError("unable to detect a supported remote Windows shell")
283
+
284
+ @property
285
+ def executor(self) -> SshExecutor:
286
+ return self._executor
287
+
288
+ def info(self) -> HostInfo:
289
+ flavour = self.shell_flavour
290
+ result = self.run(
291
+ flavour.info_script,
292
+ check=False,
293
+ encoding="utf-8",
294
+ )
295
+ return parse_host_info(result.stdout)
296
+
297
+ @property
298
+ def ssh(self) -> SshConnection:
299
+ """The lazily opened and reused asyncssh connection."""
300
+ with self._ssh_lock:
301
+ if self._ssh is None or self._ssh.is_closed():
302
+ from .. import _async
303
+
304
+ log.debug(
305
+ "opening SSH connection to %s",
306
+ ProviderSelector.redact(self.config.connection_uri),
307
+ )
308
+ try:
309
+ self._ssh = _async.async_to_sync(
310
+ _async.asyncssh().connect(
311
+ self.config.host,
312
+ port=self.config.port or 22,
313
+ **self.config.connect_opts(),
314
+ )
315
+ )
316
+ except Exception as exc:
317
+ log.debug(
318
+ "SSH connection to %s failed: %s",
319
+ ProviderSelector.redact(self.config.connection_uri),
320
+ type(exc).__name__,
321
+ )
322
+ normalized = _async.normalize_asyncssh_error(exc)
323
+ if normalized is exc:
324
+ raise
325
+ raise normalized from exc
326
+ log.debug(
327
+ "SSH connection to %s established",
328
+ ProviderSelector.redact(self.config.connection_uri),
329
+ )
330
+ return self._ssh
331
+
332
+ def connect(self) -> None:
333
+ _ = self.ssh
334
+
335
+ def close(self) -> None:
336
+ with self._ssh_lock:
337
+ self._invalidate_sftp()
338
+ if self._ssh is None:
339
+ return
340
+ connection, self._ssh = self._ssh, None
341
+ log.debug(
342
+ "closing SSH connection to %s",
343
+ ProviderSelector.redact(self.config.connection_uri),
344
+ )
345
+ from .. import _async
346
+
347
+ async def close_connection() -> None:
348
+ connection.close()
349
+ await connection.wait_closed()
350
+
351
+ try:
352
+ _async.async_to_sync(close_connection())
353
+ except Exception as exc:
354
+ normalized = _async.normalize_asyncssh_error(exc)
355
+ if normalized is exc:
356
+ raise
357
+ raise normalized from exc
358
+
359
+ def _invalidate_sftp(self) -> None:
360
+ backend, sources = self._sftp_backend, tuple(self._sftp_sources)
361
+ self._sftp_backend = None
362
+ self._sftp_sources.clear()
363
+ if backend is None:
364
+ return
365
+ invalidate = getattr(backend, "invalidate", None)
366
+ if callable(invalidate):
367
+ for source in sources:
368
+ invalidate(source)
369
+ return
370
+ # pathlib_next 0.8.x exposes cache invalidation internally while its
371
+ # public backend API remains intentionally small. Use that hook when
372
+ # available; dropping our backend reference is the safe fallback.
373
+ try:
374
+ module = importlib.import_module("pathlib_next.uri.schemes.sftp._asyncssh")
375
+ cache = getattr(module, "_CACHE", None)
376
+ invalidate_cache = getattr(cache, "invalidate", None)
377
+ if callable(invalidate_cache):
378
+ for source in sources:
379
+ invalidate_cache((backend, source))
380
+ except (ImportError, AttributeError):
381
+ pass
382
+
383
+ def path(self, *segments: PathLike) -> HostPath:
384
+ from pathlib_next.uri.schemes.sftp import AsyncsshSftpBackend, SftpPath
385
+
386
+ remote_path = self.config.path_flavor(*segments).as_posix()
387
+ if not remote_path.startswith("/"):
388
+ remote_path = "/" + remote_path
389
+ with self._ssh_lock:
390
+ if self._sftp_backend is None:
391
+ self._sftp_backend = AsyncsshSftpBackend(
392
+ connect_opts=self.config.connect_opts()
393
+ )
394
+ path = SftpPath(
395
+ f"sftp://{uri_host(self.config.host)}:{self.config.port or 22}{remote_path}",
396
+ backend=self._sftp_backend,
397
+ )
398
+ self._sftp_sources.add(path.source)
399
+ return path
400
+
401
+ def run(
402
+ self,
403
+ *cmds: Command,
404
+ bufsize: int = -1,
405
+ executable: typing.Optional[str] = None,
406
+ stdin: typing.Optional[FileHandle] = None,
407
+ stdout: typing.Optional[FileHandle] = None,
408
+ stderr: typing.Optional[FileHandle] = None,
409
+ cwd: typing.Optional[PathLike] = None,
410
+ env: typing.Optional[Environment] = None,
411
+ capture_output: CaptureOutput = True,
412
+ check: bool = True,
413
+ encoding: typing.Optional[str] = None,
414
+ errors: typing.Optional[str] = None,
415
+ input: Input = None,
416
+ timeout: typing.Optional[float] = None,
417
+ text: typing.Optional[bool] = None,
418
+ ) -> subprocess.CompletedProcess:
419
+ reject_stdin_conflict(input, stdin)
420
+ direct = starts_direct_command(cmds)
421
+ if direct is not None:
422
+ command, args = direct
423
+ cmds = ((command, *args),)
424
+ if text and encoding is None:
425
+ encoding = "utf-8"
426
+ selected_flavour = self.shell_flavour
427
+ selected_executable = executable or self.config.executable
428
+ if self.config.dialect == "auto" and selected_executable is None:
429
+ assert self._resolved_dialect is not None
430
+ selected_executable = self._resolved_dialect[2]
431
+ shell_command = selected_flavour.command(
432
+ cmds,
433
+ executable=selected_executable,
434
+ cwd=cwd,
435
+ env=env,
436
+ )
437
+ remote_command = shell_command.command
438
+ remote_env = shell_command.environment
439
+
440
+ return self.executor(
441
+ remote_command,
442
+ bufsize=bufsize,
443
+ stdin=stdin,
444
+ stdout=stdout,
445
+ stderr=stderr,
446
+ env=remote_env,
447
+ capture_output=capture_output,
448
+ check=check,
449
+ encoding=encoding,
450
+ errors=errors,
451
+ input=input,
452
+ timeout=timeout,
453
+ text=text,
454
+ )
455
+
456
+ def spawn(
457
+ self,
458
+ *cmds: Command,
459
+ executable: typing.Optional[str] = None,
460
+ cwd: typing.Optional[PathLike] = None,
461
+ env: typing.Optional[Environment] = None,
462
+ terminal: TerminalRequest = None,
463
+ encoding: typing.Optional[str] = None,
464
+ errors: typing.Optional[str] = None,
465
+ ) -> Process:
466
+ """Start a persistent SSH process, optionally allocating a PTY."""
467
+ if cmds:
468
+ selected_flavour = self.shell_flavour
469
+ selected_executable = executable or self.config.executable
470
+ if self.config.dialect == "auto" and selected_executable is None:
471
+ assert self._resolved_dialect is not None
472
+ selected_executable = self._resolved_dialect[2]
473
+ shell_command = selected_flavour.command(
474
+ cmds,
475
+ executable=selected_executable,
476
+ cwd=cwd,
477
+ env=env,
478
+ )
479
+ command: typing.Optional[str] = shell_command.command
480
+ remote_env = shell_command.environment
481
+ else:
482
+ if cwd is not None or env is not None:
483
+ raise ValueError("cwd and env require a command when spawning")
484
+ command = executable
485
+ remote_env = None
486
+
487
+ from ..process import terminal_options
488
+
489
+ selected_terminal = terminal_options(terminal)
490
+ options: typing.Dict[str, object] = {"env": remote_env, "encoding": encoding}
491
+ if encoding is not None or errors is not None:
492
+ options["encoding"] = encoding or "utf-8"
493
+ if errors is not None:
494
+ options["errors"] = errors
495
+ if selected_terminal is not None:
496
+ options.update(
497
+ request_pty=True,
498
+ term_type=selected_terminal.term_type,
499
+ term_size=selected_terminal.size,
500
+ )
501
+
502
+ from .. import _async
503
+
504
+ try:
505
+ process = _async.async_to_sync(self.ssh.create_process(command, **options))
506
+ except Exception as exc:
507
+ normalized = _async.normalize_asyncssh_error(exc, command=command)
508
+ if normalized is exc:
509
+ raise
510
+ raise normalized from exc
511
+ return SshProcess(typing.cast(typing.Any, process), command)
512
+
513
+
514
+ class SshExecutorProvider(ExecutorProvider):
515
+ """Lifecycle-owning SSH command provider used by :class:`PosixHost`."""
516
+
517
+ def __init__(self, transport: _SshTransport):
518
+ self.transport = transport
519
+ # SSH receives one finalized command string; argv arguments are
520
+ # rendered by the host shell before dispatch.
521
+ super().__init__("ssh", transport.executor)
522
+
523
+ def probe(self):
524
+ return ProviderProbe("available", capabilities=self.capabilities)
525
+
526
+ def connect(self):
527
+ try:
528
+ self.transport.connect()
529
+ except (ConnectionError, TimeoutError) as exc:
530
+ log.debug(
531
+ "SSH provider declining before dispatch: %s: %s",
532
+ type(exc).__name__,
533
+ ProviderSelector.redact(exc),
534
+ )
535
+ raise OperationNotStarted(
536
+ "SSH connection failed before dispatch", cause=exc
537
+ ) from exc
538
+
539
+ def close(self):
540
+ self.transport.close()
541
+
542
+ def info(self):
543
+ return self.transport.info()
544
+
545
+ @property
546
+ def shell_executable(self):
547
+ resolved = self.transport._resolved_dialect
548
+ return resolved[2] if resolved is not None else None
549
+
550
+ def spawn(self, *args, **options):
551
+ return self.transport.spawn(*args, **options)
552
+
553
+
554
+ class SftpPathProvider(PathProvider):
555
+ """SFTP path provider sharing the SSH transport lifecycle."""
556
+
557
+ def __init__(self, transport: _SshTransport):
558
+ self.transport = transport
559
+ super().__init__(
560
+ "sftp", lambda *segments: transport.path(*segments), capabilities=("path",)
561
+ )
562
+
563
+ # Lifecycle is owned by SshExecutorProvider for the shared transport.
564
+
565
+
566
+ def ssh_providers(
567
+ config: SshConfig,
568
+ ) -> typing.Tuple[SshExecutorProvider, SftpPathProvider]:
569
+ """Build an executor and path provider sharing one SSH transport.
570
+
571
+ This is the supported way to compose SSH into a host you assemble
572
+ yourself, rather than taking the finished `PosixHost` that
573
+ `SshConfig._create_host()` returns:
574
+
575
+ executors, paths = [], []
576
+ run_provider, path_provider = ssh_providers(config.ssh)
577
+ executors.append(run_provider)
578
+ paths.append(path_provider)
579
+
580
+ Both providers **must** share one transport: that is what makes them share
581
+ a connection and a lifecycle, and `SshExecutorProvider` is the one that
582
+ owns close. Assembling them by hand from two transports type-checks and
583
+ silently opens two connections, only one of which is ever closed -- which
584
+ is precisely why this returns a pair instead of exposing the transport.
585
+ """
586
+ transport = _SshTransport(config)
587
+ return SshExecutorProvider(transport), SftpPathProvider(transport)