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,171 @@
1
+ """Docker Engine command executor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import typing
7
+
8
+ from ._common import (
9
+ CaptureOutput,
10
+ CommandArgument,
11
+ Environment,
12
+ Executor,
13
+ ExecutorCapability,
14
+ ExecutorCommand,
15
+ FileHandle,
16
+ Input,
17
+ PathLike,
18
+ dispatch_output,
19
+ normalize_environment,
20
+ capture_streams,
21
+ )
22
+
23
+
24
+ class ContainerLike(typing.Protocol):
25
+ """Small Docker SDK container surface used by :class:`ContainerExecutor`."""
26
+
27
+ def exec_run(self, command: typing.Sequence[str], **options: object) -> object: ...
28
+
29
+
30
+ class ContainerExecutor(Executor[subprocess.CompletedProcess]):
31
+ """Execute argv directly through a Docker Engine container exec."""
32
+
33
+ executor_capabilities = frozenset(
34
+ (ExecutorCapability.ARGS, ExecutorCapability.CWD, ExecutorCapability.ENV)
35
+ )
36
+
37
+ def __init__(
38
+ self,
39
+ container: typing.Callable[[], ContainerLike],
40
+ *,
41
+ user: typing.Optional[str] = None,
42
+ workdir: typing.Optional[str] = None,
43
+ ) -> None:
44
+ self._container = container
45
+ self._user = user
46
+ self._workdir = workdir
47
+
48
+ def __call__(
49
+ self,
50
+ command: ExecutorCommand,
51
+ *args: CommandArgument,
52
+ bufsize: int = -1,
53
+ stdin: typing.Optional[FileHandle] = None,
54
+ stdout: typing.Optional[FileHandle] = None,
55
+ stderr: typing.Optional[FileHandle] = None,
56
+ cwd: typing.Optional[PathLike] = None,
57
+ env: typing.Optional[Environment] = None,
58
+ capture_output: CaptureOutput = True,
59
+ check: bool = True,
60
+ encoding: typing.Optional[str] = None,
61
+ errors: typing.Optional[str] = None,
62
+ input: Input = None,
63
+ timeout: typing.Optional[float] = None,
64
+ text: typing.Optional[bool] = None,
65
+ **options: object,
66
+ ) -> subprocess.CompletedProcess:
67
+ del bufsize
68
+ if options:
69
+ raise TypeError(
70
+ f"unsupported container executor option: {sorted(options)[0]}"
71
+ )
72
+ if stdin is not None or input is not None:
73
+ raise NotImplementedError(
74
+ "buffered container execution does not yet support stdin/input"
75
+ )
76
+ if timeout is not None:
77
+ raise NotImplementedError(
78
+ "Docker Engine exec does not provide a cancellable command timeout"
79
+ )
80
+
81
+ stdout, stderr = capture_streams(capture_output, stdout, stderr)
82
+ argv = [str(command)]
83
+ argv.extend(
84
+ value.decode() if isinstance(value, bytes) else str(value) for value in args
85
+ )
86
+ merge_stderr = stderr is subprocess.STDOUT
87
+ exec_options: typing.Dict[str, object] = {
88
+ "stdout": True,
89
+ "stderr": True,
90
+ # Docker's demux=False stream preserves stdout/stderr ordering,
91
+ # which is required when callers request STDERR->STDOUT.
92
+ "demux": not merge_stderr,
93
+ }
94
+ normalized_env = normalize_environment(env)
95
+ if normalized_env is not None:
96
+ exec_options["environment"] = normalized_env
97
+ selected_workdir = str(cwd) if cwd is not None else self._workdir
98
+ if selected_workdir is not None:
99
+ exec_options["workdir"] = selected_workdir
100
+ if self._user is not None:
101
+ exec_options["user"] = self._user
102
+
103
+ try:
104
+ result = self._container().exec_run(argv, **exec_options)
105
+ except Exception as exc:
106
+ if _is_not_found(exc):
107
+ raise ConnectionError("container not found") from exc
108
+ normalized = normalize_container_error(exc)
109
+ if normalized is exc:
110
+ raise
111
+ raise normalized from exc
112
+
113
+ returncode = typing.cast(int, getattr(result, "exit_code"))
114
+ output = getattr(result, "output")
115
+ if isinstance(output, tuple):
116
+ out, err = output
117
+ else:
118
+ out, err = output, None
119
+ if out is None:
120
+ out = b""
121
+ if err is None and not merge_stderr:
122
+ err = b""
123
+ if text or encoding is not None or errors is not None:
124
+ codec = encoding or "utf-8"
125
+ out = (
126
+ out.decode(codec, errors or "strict") if isinstance(out, bytes) else out
127
+ )
128
+ err = (
129
+ err.decode(codec, errors or "strict") if isinstance(err, bytes) else err
130
+ )
131
+ if merge_stderr:
132
+ if err:
133
+ out = out + err
134
+ err = None
135
+ out, err = dispatch_output(
136
+ stdout, stderr, out, err, encoding=encoding, errors=errors
137
+ )
138
+ completed = subprocess.CompletedProcess(argv, returncode, out, err)
139
+ if check:
140
+ completed.check_returncode()
141
+ return completed
142
+
143
+
144
+ def normalize_container_error(exc: Exception) -> Exception:
145
+ """Map Docker SDK and HTTP failures without importing the optional SDK."""
146
+ name = type(exc).__name__
147
+ status_code = getattr(exc, "status_code", None)
148
+ if status_code is None:
149
+ response = getattr(exc, "response", None)
150
+ status_code = getattr(response, "status_code", None)
151
+ if status_code == 404 or name in ("NotFound",):
152
+ return FileNotFoundError(str(exc))
153
+ if status_code in (401, 403) or name in ("AuthenticationError",):
154
+ return PermissionError(str(exc))
155
+ if name in ("ReadTimeout", "ConnectTimeout", "Timeout"):
156
+ return TimeoutError(str(exc))
157
+ if name in (
158
+ "APIError",
159
+ "ConnectionError",
160
+ "DockerException",
161
+ "MaxRetryError",
162
+ ):
163
+ return ConnectionError(str(exc))
164
+ return exc
165
+
166
+
167
+ def _is_not_found(exc: Exception) -> bool:
168
+ status_code = getattr(exc, "status_code", None)
169
+ if status_code is None:
170
+ status_code = getattr(getattr(exc, "response", None), "status_code", None)
171
+ return status_code == 404 or type(exc).__name__ == "NotFound"
@@ -0,0 +1,91 @@
1
+ """Local subprocess executor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import typing
8
+
9
+ from ._common import (
10
+ CaptureOutput,
11
+ CommandArgument,
12
+ Environment,
13
+ Executor,
14
+ ExecutorCapability,
15
+ ExecutorCommand,
16
+ FileHandle,
17
+ Input,
18
+ normalize_environment,
19
+ normalize_input,
20
+ capture_streams,
21
+ reject_stdin_conflict,
22
+ )
23
+
24
+
25
+ class LocalExecutor(Executor[subprocess.CompletedProcess]):
26
+ """Execute direct argv or finalized shell invocations locally."""
27
+
28
+ executor_capabilities = frozenset(
29
+ (ExecutorCapability.ARGS, ExecutorCapability.CWD, ExecutorCapability.ENV)
30
+ )
31
+
32
+ def __call__(
33
+ self,
34
+ command: ExecutorCommand,
35
+ *args: CommandArgument,
36
+ bufsize: int = -1,
37
+ stdin: typing.Optional[FileHandle] = None,
38
+ stdout: typing.Optional[FileHandle] = None,
39
+ stderr: typing.Optional[FileHandle] = None,
40
+ cwd: typing.Optional[typing.Union[str, os.PathLike[str]]] = None,
41
+ env: typing.Optional[Environment] = None,
42
+ capture_output: CaptureOutput = True,
43
+ check: bool = True,
44
+ encoding: typing.Optional[str] = None,
45
+ errors: typing.Optional[str] = None,
46
+ input: Input = None,
47
+ timeout: typing.Optional[float] = None,
48
+ text: typing.Optional[bool] = None,
49
+ **options: object,
50
+ ) -> subprocess.CompletedProcess:
51
+ if options:
52
+ raise TypeError(f"unsupported local executor option: {sorted(options)[0]}")
53
+ reject_stdin_conflict(input, stdin)
54
+ stdout, stderr = capture_streams(capture_output, stdout, stderr)
55
+ argv = [
56
+ os.fspath(command),
57
+ *[
58
+ (
59
+ value.decode()
60
+ if isinstance(value, bytes)
61
+ else (
62
+ os.fspath(value)
63
+ if isinstance(value, os.PathLike)
64
+ else str(value)
65
+ )
66
+ )
67
+ for value in args
68
+ ],
69
+ ]
70
+ return subprocess.run(
71
+ argv,
72
+ bufsize=bufsize,
73
+ stdin=stdin,
74
+ stdout=stdout,
75
+ stderr=stderr,
76
+ cwd=os.fspath(cwd) if cwd is not None else None,
77
+ env=normalize_environment(env),
78
+ check=check,
79
+ encoding=encoding,
80
+ errors=errors,
81
+ # `encoding`/`errors`/`text` put subprocess's stdin in text mode,
82
+ # where bytes would kill the writer thread and hang the call.
83
+ input=normalize_input(
84
+ input,
85
+ text_mode=bool(encoding or errors or text),
86
+ encoding=encoding,
87
+ errors=errors,
88
+ ),
89
+ timeout=timeout,
90
+ text=text,
91
+ )
@@ -0,0 +1,145 @@
1
+ """Optional PowerShell Remoting Protocol execution provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import subprocess
7
+ import typing
8
+ import types
9
+
10
+ from ._common import (
11
+ CaptureOutput,
12
+ CommandArgument,
13
+ Executor,
14
+ ExecutorCapability,
15
+ ExecutorCommand,
16
+ FileHandle,
17
+ Input,
18
+ capture_streams,
19
+ dispatch_output,
20
+ )
21
+
22
+ if typing.TYPE_CHECKING:
23
+ from ..process import RunspaceSession
24
+
25
+
26
+ def pypsrp_available() -> bool:
27
+ """Return whether the optional PSRP dependency can be imported."""
28
+ if __import__("sys").version_info < (3, 10):
29
+ return False
30
+ try:
31
+ importlib.import_module("pypsrp")
32
+ except ImportError:
33
+ return False
34
+ return True
35
+
36
+
37
+ def require_pypsrp() -> types.ModuleType:
38
+ """Import pypsrp with an actionable, version-aware error."""
39
+ import sys
40
+
41
+ if sys.version_info < (3, 10):
42
+ raise ImportError(
43
+ "PSRP support requires Python 3.10 or newer; use the pywinrm "
44
+ "provider on Python 3.9"
45
+ )
46
+ try:
47
+ return importlib.import_module("pypsrp")
48
+ except ImportError as exc:
49
+ raise ImportError(
50
+ "PSRP support requires the 'psrp' extra: " "pip install hostctl[psrp]"
51
+ ) from exc
52
+
53
+
54
+ class PsrpExecutor(Executor[subprocess.CompletedProcess]):
55
+ """Execute finalized PowerShell scripts in fresh PSRP pipelines."""
56
+
57
+ # PSRP receives a finalized PowerShell script; cwd/env are embedded by
58
+ # the host shell rather than forwarded as unsupported executor options.
59
+ executor_capabilities = frozenset(
60
+ (ExecutorCapability.SCRIPT, ExecutorCapability.MANAGES_STATUS)
61
+ )
62
+
63
+ def __init__(self, session: typing.Callable[[], RunspaceSession]) -> None:
64
+ self._session = session
65
+
66
+ def __call__(
67
+ self,
68
+ command: ExecutorCommand,
69
+ *args: CommandArgument,
70
+ bufsize: int = -1,
71
+ stdin: typing.Optional[FileHandle] = None,
72
+ stdout: typing.Optional[FileHandle] = None,
73
+ stderr: typing.Optional[FileHandle] = None,
74
+ capture_output: CaptureOutput = True,
75
+ check: bool = True,
76
+ encoding: typing.Optional[str] = None,
77
+ errors: typing.Optional[str] = None,
78
+ input: Input = None,
79
+ timeout: typing.Optional[float] = None,
80
+ text: typing.Optional[bool] = None,
81
+ **options: object,
82
+ ) -> subprocess.CompletedProcess:
83
+ del bufsize
84
+ if options:
85
+ raise TypeError(f"unsupported PSRP executor option: {sorted(options)[0]}")
86
+ if args:
87
+ raise NotImplementedError("PSRP executor receives a finalized script")
88
+ if stdin is not None or input is not None:
89
+ raise NotImplementedError("PSRP does not provide raw stdin for run()")
90
+ if timeout is not None:
91
+ raise NotImplementedError(
92
+ "PSRP pipeline timeout is unsupported; configure WinRM timeouts"
93
+ )
94
+ stdout, stderr = capture_streams(capture_output, stdout, stderr)
95
+ result = self._session().invoke(str(command), raw=False, capture_exit=True)
96
+ codec = encoding or "utf-8"
97
+ # Preserve PowerShell's object-pipeline line orientation when
98
+ # projecting objects to subprocess-compatible text.
99
+ projected_output = []
100
+ returncode = result.returncode
101
+ marker = "__HOSTCTL_LASTEXITCODE__:"
102
+ for item in result.output:
103
+ value = str(item)
104
+ if value.startswith(marker):
105
+ try:
106
+ parsed_returncode = int(value[len(marker) :])
107
+ returncode = (
108
+ 1
109
+ if result.had_errors and parsed_returncode == 0
110
+ else parsed_returncode
111
+ )
112
+ except ValueError:
113
+ returncode = 1
114
+ continue
115
+ projected_output.append(item)
116
+ out_text = "\n".join(str(item) for item in projected_output)
117
+ err_text = "\n".join(str(item) for item in result.streams.error)
118
+ if out_text:
119
+ out_text += "\n"
120
+ out: typing.Union[str, bytes] = (
121
+ out_text if text or encoding else out_text.encode(codec)
122
+ )
123
+ err: typing.Union[str, bytes] = (
124
+ err_text if text or encoding else err_text.encode(codec)
125
+ )
126
+ if stderr is subprocess.STDOUT:
127
+ out = out + err
128
+ err = None
129
+ out, err = dispatch_output(
130
+ stdout,
131
+ stderr,
132
+ out,
133
+ err,
134
+ encoding=encoding,
135
+ errors=errors,
136
+ )
137
+ completed = subprocess.CompletedProcess(
138
+ args=str(command),
139
+ returncode=returncode,
140
+ stdout=out,
141
+ stderr=err,
142
+ )
143
+ if check:
144
+ completed.check_returncode()
145
+ return completed