manifold-cli 0.0.39__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 (102) hide show
  1. manifold_cli/__init__.py +3 -0
  2. manifold_cli/__main__.py +4 -0
  3. manifold_cli/bridge.py +70 -0
  4. manifold_cli/cli.py +54 -0
  5. manifold_cli/clients/__init__.py +0 -0
  6. manifold_cli/clients/auth_flow.py +169 -0
  7. manifold_cli/clients/credentials.py +53 -0
  8. manifold_cli/clients/docker_engine.py +43 -0
  9. manifold_cli/clients/image.py +130 -0
  10. manifold_cli/clients/manifold.py +1288 -0
  11. manifold_cli/clients/runner_home.py +165 -0
  12. manifold_cli/clients/workos.py +99 -0
  13. manifold_cli/commands/__init__.py +0 -0
  14. manifold_cli/commands/auth.py +101 -0
  15. manifold_cli/commands/benchmark.py +539 -0
  16. manifold_cli/commands/organization.py +154 -0
  17. manifold_cli/commands/policy.py +749 -0
  18. manifold_cli/commands/run.py +337 -0
  19. manifold_cli/commands/run_log.py +291 -0
  20. manifold_cli/commands/runner.py +852 -0
  21. manifold_cli/commands/table.py +101 -0
  22. manifold_cli/commands/token.py +144 -0
  23. manifold_cli/dependencies.py +116 -0
  24. manifold_cli/models/__init__.py +0 -0
  25. manifold_cli/models/auth.py +49 -0
  26. manifold_cli/models/benchmark.py +87 -0
  27. manifold_cli/models/catalog.py +189 -0
  28. manifold_cli/models/credentials.py +19 -0
  29. manifold_cli/models/errors.py +121 -0
  30. manifold_cli/models/log.py +114 -0
  31. manifold_cli/models/observability.py +121 -0
  32. manifold_cli/models/organization.py +19 -0
  33. manifold_cli/models/pagination.py +59 -0
  34. manifold_cli/models/policy.py +49 -0
  35. manifold_cli/models/run.py +381 -0
  36. manifold_cli/models/runner.py +186 -0
  37. manifold_cli/models/runner_home.py +69 -0
  38. manifold_cli/models/task_queue.py +40 -0
  39. manifold_cli/models/token.py +39 -0
  40. manifold_cli/replay/__init__.py +68 -0
  41. manifold_cli/replay/log.py +814 -0
  42. manifold_cli/runner/__init__.py +72 -0
  43. manifold_cli/runner/api_retry.py +36 -0
  44. manifold_cli/runner/benchmark/__init__.py +800 -0
  45. manifold_cli/runner/benchmark/live_view.py +248 -0
  46. manifold_cli/runner/benchmark/preassigned_scheduling.py +325 -0
  47. manifold_cli/runner/benchmark/recording.py +1208 -0
  48. manifold_cli/runner/benchmark/scheduling.py +256 -0
  49. manifold_cli/runner/benchmark/task_queue_scheduling.py +717 -0
  50. manifold_cli/runner/benchmark/task_server.py +123 -0
  51. manifold_cli/runner/benchmark/video.py +124 -0
  52. manifold_cli/runner/benchmark_steps.py +178 -0
  53. manifold_cli/runner/cancellation.py +22 -0
  54. manifold_cli/runner/capture.py +282 -0
  55. manifold_cli/runner/container.py +370 -0
  56. manifold_cli/runner/current_dispatch.py +25 -0
  57. manifold_cli/runner/daemon.py +520 -0
  58. manifold_cli/runner/dispatch.py +38 -0
  59. manifold_cli/runner/gpu.py +182 -0
  60. manifold_cli/runner/heartbeat.py +202 -0
  61. manifold_cli/runner/loopback.py +102 -0
  62. manifold_cli/runner/loopback_bench.py +183 -0
  63. manifold_cli/runner/loopback_policy.py +39 -0
  64. manifold_cli/runner/output.py +218 -0
  65. manifold_cli/runner/pairing_acceptance.py +57 -0
  66. manifold_cli/runner/plan.py +369 -0
  67. manifold_cli/runner/policy.py +350 -0
  68. manifold_cli/runner/policy_steps.py +164 -0
  69. manifold_cli/runner/preparation.py +47 -0
  70. manifold_cli/runner/process.py +219 -0
  71. manifold_cli/runner/ram.py +17 -0
  72. manifold_cli/runner/resource_usage.py +265 -0
  73. manifold_cli/runner/served.py +218 -0
  74. manifold_cli/runner/service.py +120 -0
  75. manifold_cli/runner/side_failure.py +68 -0
  76. manifold_cli/runner/signature.py +98 -0
  77. manifold_cli/runner/source.py +66 -0
  78. manifold_cli/runner/stages.py +241 -0
  79. manifold_cli/runner/step_timing.py +229 -0
  80. manifold_cli/runner/worker.py +534 -0
  81. manifold_cli/runner/workload.py +401 -0
  82. manifold_cli/services/__init__.py +0 -0
  83. manifold_cli/services/auth.py +89 -0
  84. manifold_cli/services/benchmark.py +357 -0
  85. manifold_cli/services/organization.py +22 -0
  86. manifold_cli/services/policy.py +234 -0
  87. manifold_cli/services/run.py +304 -0
  88. manifold_cli/services/runner.py +207 -0
  89. manifold_cli/services/runner_daemon.py +146 -0
  90. manifold_cli/services/token.py +52 -0
  91. manifold_cli/settings.py +30 -0
  92. manifold_cli/transport/__init__.py +31 -0
  93. manifold_cli/transport/diagnostics.py +72 -0
  94. manifold_cli/transport/interfaces.py +69 -0
  95. manifold_cli/transport/iroh.py +317 -0
  96. manifold_cli/transport/registry.py +34 -0
  97. manifold_cli/transport/tcp.py +146 -0
  98. manifold_cli-0.0.39.dist-info/METADATA +406 -0
  99. manifold_cli-0.0.39.dist-info/RECORD +102 -0
  100. manifold_cli-0.0.39.dist-info/WHEEL +4 -0
  101. manifold_cli-0.0.39.dist-info/entry_points.txt +2 -0
  102. manifold_cli-0.0.39.dist-info/licenses/LICENSE +373 -0
@@ -0,0 +1,3 @@
1
+ from manifold_cli.cli import main
2
+
3
+ __all__ = ["main"]
@@ -0,0 +1,4 @@
1
+ from manifold_cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
manifold_cli/bridge.py ADDED
@@ -0,0 +1,70 @@
1
+ """A byte-identical relay between two `ByteStream`s over any transport.
2
+
3
+ Used to make a policy server on a plain local TCP socket reachable over our
4
+ transport (tcp/iroh) and back, without either end knowing a bridge sits
5
+ in between. No frame decoding: bytes are copied through verbatim.
6
+ """
7
+
8
+ import asyncio
9
+ import contextlib
10
+
11
+ from manifold_cli.transport import ByteStream, Connector, Listener, TransportError
12
+
13
+ RELAY_CHUNK_SIZE = 64 * 1024
14
+
15
+
16
+ async def pump(src: ByteStream, dst: ByteStream) -> None:
17
+ """Copy `src` to `dst` until EOF, then close both (unblocks the other pump)."""
18
+ try:
19
+ while True:
20
+ chunk = await src.read(RELAY_CHUNK_SIZE)
21
+ if not chunk:
22
+ break
23
+ try:
24
+ await dst.write(chunk)
25
+ except TransportError:
26
+ # dst was already closed by the other pump's EOF teardown; a
27
+ # failed write there just means this direction is done too.
28
+ break
29
+ finally:
30
+ # First EOF (or error) tears both directions down. A session that ends
31
+ # with a clean close loses nothing; a hard truncation mid-flight could
32
+ # drop unread bytes, which is acceptable here.
33
+ await src.close()
34
+ await dst.close()
35
+
36
+
37
+ async def splice(a: ByteStream, b: ByteStream) -> None:
38
+ """Pump a->b and b->a concurrently until either direction hits EOF."""
39
+ async with asyncio.TaskGroup() as group:
40
+ group.create_task(pump(a, b))
41
+ group.create_task(pump(b, a))
42
+
43
+
44
+ async def run_bridge(listener: Listener, connector: Connector) -> None:
45
+ """Accept connections on `listener`, dial `connector`, splice each pair.
46
+
47
+ Each accepted stream gets its own spliced pair, so several shards can be
48
+ bridged at once. On cancellation, outstanding splices and the listener are
49
+ torn down. Returns silently; nothing noisy on normal shutdown.
50
+ """
51
+ tasks: set[asyncio.Task[None]] = set()
52
+ try:
53
+ while True:
54
+ stream = await listener.accept()
55
+ try:
56
+ peer = await connector.connect()
57
+ except BaseException:
58
+ # A failed dial must not leak the accepted stream: close it so
59
+ # the peer sees EOF instead of a connection frozen mid-air.
60
+ await stream.close()
61
+ raise
62
+ task = asyncio.create_task(splice(stream, peer))
63
+ tasks.add(task)
64
+ task.add_done_callback(tasks.discard)
65
+ finally:
66
+ for task in tasks:
67
+ task.cancel()
68
+ with contextlib.suppress(Exception):
69
+ await asyncio.gather(*tasks, return_exceptions=True)
70
+ await listener.close()
manifold_cli/cli.py ADDED
@@ -0,0 +1,54 @@
1
+ from importlib.metadata import version
2
+
3
+ import typer
4
+
5
+ from manifold_cli.commands import auth as auth_commands
6
+ from manifold_cli.commands import benchmark as benchmark_commands
7
+ from manifold_cli.commands import organization as organization_commands
8
+ from manifold_cli.commands import policy as policy_commands
9
+ from manifold_cli.commands import run as run_commands
10
+ from manifold_cli.commands import runner as runner_commands
11
+ from manifold_cli.commands import token as token_commands
12
+ from manifold_cli.dependencies import Dependencies, build_dependencies
13
+
14
+
15
+ def _version_callback(value: bool) -> None:
16
+ if value:
17
+ typer.echo(f"manifold-cli {version('manifold-cli')}")
18
+ raise typer.Exit()
19
+
20
+
21
+ def build_app(deps: Dependencies) -> typer.Typer:
22
+ app = typer.Typer(
23
+ name="manifold",
24
+ help="Manifold CLI.",
25
+ no_args_is_help=True,
26
+ )
27
+
28
+ @app.callback()
29
+ def _main(
30
+ version: bool = typer.Option(
31
+ False,
32
+ "--version",
33
+ callback=_version_callback,
34
+ is_eager=True,
35
+ help="Show version and exit.",
36
+ ),
37
+ ) -> None:
38
+ pass
39
+
40
+ auth_commands.register(app, deps)
41
+ organization_commands.register(app, deps)
42
+ policy_commands.register(app, deps)
43
+ benchmark_commands.register(app, deps)
44
+ run_commands.register(app, deps)
45
+ runner_commands.register(app, deps)
46
+ token_commands.register(app, deps)
47
+
48
+ return app
49
+
50
+
51
+ def main() -> None:
52
+ deps = build_dependencies()
53
+ app = build_app(deps)
54
+ app()
File without changes
@@ -0,0 +1,169 @@
1
+ """Provide httpx authentication flows for Manifold API clients."""
2
+
3
+ from collections.abc import AsyncGenerator, Generator
4
+ from http import HTTPStatus
5
+ from typing import Protocol
6
+
7
+ import httpx
8
+
9
+ from manifold_cli.models.auth import TokenResponse
10
+ from manifold_cli.models.credentials import Credentials
11
+ from manifold_cli.models.errors import (
12
+ AccessTokenRejectedError,
13
+ InvalidAccessTokenError,
14
+ NoRunnerCredentialError,
15
+ NotLoggedInError,
16
+ RunnerCredentialRejectedError,
17
+ SessionExpiredError,
18
+ WorkOSAuthError,
19
+ )
20
+ from manifold_cli.models.runner_home import RunnerHome
21
+
22
+
23
+ class CredentialStore(Protocol):
24
+ """Persistent home for the stored credentials WorkOSAuth reads and rotates."""
25
+
26
+ def load(self) -> Credentials | None: ...
27
+
28
+ def save(self, credentials: Credentials) -> None: ...
29
+
30
+ def clear(self) -> None: ...
31
+
32
+
33
+ class RunnerHomeStore(Protocol):
34
+ """The runner home holding the minted token RunnerAuth presents."""
35
+
36
+ def load(self) -> RunnerHome | None: ...
37
+
38
+
39
+ class TokenRefresher(Protocol):
40
+ """Exchanges a refresh token for a new token pair, or raises WorkOSAuthError."""
41
+
42
+ def refresh(self, refresh_token: str) -> TokenResponse: ...
43
+
44
+
45
+ class WorkOSAuth(httpx.Auth):
46
+ def __init__(self, credentials: CredentialStore, refresher: TokenRefresher):
47
+ self._credentials = credentials
48
+ self._refresher = refresher
49
+
50
+ def sync_auth_flow(
51
+ self, request: httpx.Request
52
+ ) -> Generator[httpx.Request, httpx.Response]:
53
+ creds = self._credentials.load()
54
+ if creds is None:
55
+ raise NotLoggedInError
56
+ request.headers["Authorization"] = f"Bearer {creds.access_token}"
57
+ response = yield request
58
+ if response.status_code != HTTPStatus.UNAUTHORIZED:
59
+ return
60
+ rotated = self._refresh(creds)
61
+ request.headers["Authorization"] = f"Bearer {rotated.access_token}"
62
+ response = yield request
63
+ if response.status_code == HTTPStatus.UNAUTHORIZED:
64
+ raise SessionExpiredError
65
+
66
+ def _refresh(self, creds: Credentials) -> Credentials:
67
+ """Rotate and persist the token pair, or fail terminally.
68
+
69
+ Clears the stored credentials and raises SessionExpiredError when the
70
+ refresh itself is rejected: nothing short of that means the session is
71
+ dead. A rotation that succeeds is persisted even if the retried request
72
+ still fails; the caller decides what a lingering 401 means.
73
+ """
74
+ try:
75
+ new_tokens = self._refresher.refresh(creds.refresh_token)
76
+ except WorkOSAuthError as exc:
77
+ self._credentials.clear()
78
+ raise SessionExpiredError from exc
79
+ rotated = creds.model_copy(
80
+ update={
81
+ "access_token": new_tokens.access_token,
82
+ "refresh_token": new_tokens.refresh_token,
83
+ }
84
+ )
85
+ self._credentials.save(rotated)
86
+ return rotated
87
+
88
+
89
+ class RunnerAuth(httpx.Auth):
90
+ """Presents a minted runner token on every request.
91
+
92
+ There is no refresh half: a runner token is long-lived and revoked rather
93
+ than rotated, so a 401 is terminal rather than the cue to rotate that it is
94
+ for `WorkOSAuth`.
95
+ """
96
+
97
+ def __init__(self, home: RunnerHomeStore):
98
+ self._home = home
99
+
100
+ def sync_auth_flow(
101
+ self, request: httpx.Request
102
+ ) -> Generator[httpx.Request, httpx.Response]:
103
+ self._authorise(request)
104
+ response = yield request
105
+ self._raise_for_rejection(response)
106
+
107
+ async def async_auth_flow(
108
+ self, request: httpx.Request
109
+ ) -> AsyncGenerator[httpx.Request, httpx.Response]:
110
+ self._authorise(request)
111
+ response = yield request
112
+ self._raise_for_rejection(response)
113
+
114
+ def _authorise(self, request: httpx.Request) -> None:
115
+ home = self._home.load()
116
+ if home is None:
117
+ raise NoRunnerCredentialError
118
+ request.headers["Authorization"] = f"Bearer {home.credential.token}"
119
+
120
+ def _raise_for_rejection(self, response: httpx.Response) -> None:
121
+ if response.status_code == HTTPStatus.UNAUTHORIZED:
122
+ raise RunnerCredentialRejectedError
123
+
124
+
125
+ class InMemoryRunnerTokenAuth(httpx.Auth):
126
+ """Send a runner token that is kept in memory.
127
+
128
+ `manifold policy serve` uses this. Its token is never written to disk, so
129
+ `RunnerAuth`, which reads the token from a runner home, does not apply.
130
+ """
131
+
132
+ def __init__(self, token: str):
133
+ self._token = token
134
+
135
+ def sync_auth_flow(
136
+ self, request: httpx.Request
137
+ ) -> Generator[httpx.Request, httpx.Response]:
138
+ request.headers["Authorization"] = f"Bearer {self._token}"
139
+ response = yield request
140
+ self._raise_for_rejection(response)
141
+
142
+ async def async_auth_flow(
143
+ self, request: httpx.Request
144
+ ) -> AsyncGenerator[httpx.Request, httpx.Response]:
145
+ request.headers["Authorization"] = f"Bearer {self._token}"
146
+ response = yield request
147
+ self._raise_for_rejection(response)
148
+
149
+ def _raise_for_rejection(self, response: httpx.Response) -> None:
150
+ if response.status_code == HTTPStatus.UNAUTHORIZED:
151
+ raise RunnerCredentialRejectedError
152
+
153
+
154
+ class StaticTokenAuth(httpx.Auth):
155
+ """Present one access token without loading or refreshing a session."""
156
+
157
+ def __init__(self, token: str):
158
+ # ensure token contains only printable ASCII chars
159
+ if any(ord(character) < 0x20 or ord(character) > 0x7E for character in token):
160
+ raise InvalidAccessTokenError
161
+ self._token = token
162
+
163
+ def sync_auth_flow(
164
+ self, request: httpx.Request
165
+ ) -> Generator[httpx.Request, httpx.Response]:
166
+ request.headers["Authorization"] = f"Bearer {self._token}"
167
+ response = yield request
168
+ if response.status_code == HTTPStatus.UNAUTHORIZED:
169
+ raise AccessTokenRejectedError
@@ -0,0 +1,53 @@
1
+ import logging
2
+ import os
3
+ import tempfile
4
+ from pathlib import Path
5
+
6
+ from pydantic import ValidationError
7
+
8
+ from manifold_cli.models.credentials import Credentials
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class CredentialsClient:
14
+ def __init__(self, path: Path):
15
+ self._path = path
16
+
17
+ def load(self) -> Credentials | None:
18
+ try:
19
+ raw = self._path.read_text()
20
+ except FileNotFoundError:
21
+ return None
22
+ try:
23
+ return Credentials.model_validate_json(raw)
24
+ except ValidationError:
25
+ logger.warning("credentials file at %s is malformed; ignoring", self._path)
26
+ return None
27
+
28
+ def save(self, credentials: Credentials) -> None:
29
+ write_private_file(self._path, credentials.model_dump_json())
30
+
31
+ def clear(self) -> None:
32
+ self._path.unlink(missing_ok=True)
33
+
34
+
35
+ def write_private_file(path: Path, payload: str) -> None:
36
+ """Replace `path` with `payload`, readable only by its owner.
37
+
38
+ Written to a temporary file in the same directory and renamed, so a reader
39
+ never sees a half-written credential and a failed write leaves the previous
40
+ one intact.
41
+ """
42
+ path.parent.mkdir(parents=True, exist_ok=True)
43
+ fd, tmp_name = tempfile.mkstemp(
44
+ dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
45
+ )
46
+ try:
47
+ with os.fdopen(fd, "w") as f:
48
+ f.write(payload)
49
+ os.chmod(tmp_name, 0o600)
50
+ os.replace(tmp_name, path)
51
+ except BaseException:
52
+ Path(tmp_name).unlink(missing_ok=True)
53
+ raise
@@ -0,0 +1,43 @@
1
+ """Read the Docker Engine API over the Docker daemon's Unix socket.
2
+
3
+ The `docker` command prints Docker image sizes rounded for display, and the engine
4
+ API reports them in bytes.
5
+ """
6
+
7
+ from datetime import timedelta
8
+
9
+ import httpx
10
+
11
+ DOCKER_SOCKET_PATH = "/var/run/docker.sock"
12
+ # The Docker daemon computes the Docker image disk usage over every Docker
13
+ # image's layers.
14
+ DOCKER_IMAGE_USAGE_TIMEOUT = timedelta(seconds=30)
15
+
16
+
17
+ class DockerEngineClient:
18
+ def __init__(self, socket_path: str):
19
+ self._client = httpx.Client(
20
+ base_url="http://docker",
21
+ transport=httpx.HTTPTransport(uds=socket_path),
22
+ )
23
+
24
+ def close(self) -> None:
25
+ self._client.close()
26
+
27
+ def get_root_dir(self) -> str:
28
+ """Return the directory where Docker stores Docker images and containers."""
29
+ response = self._client.get("/info")
30
+ response.raise_for_status()
31
+ return response.json()["DockerRootDir"]
32
+
33
+ def get_docker_image_usage(self) -> tuple[int, int]:
34
+ """Return the number of local Docker images and the bytes their layers use."""
35
+ response = self._client.get(
36
+ "/v1.52/system/df",
37
+ params={"type": "image"},
38
+ timeout=DOCKER_IMAGE_USAGE_TIMEOUT.total_seconds(),
39
+ )
40
+ response.raise_for_status()
41
+ image_usage = response.json()["ImageUsage"]
42
+ # The Docker daemon leaves out `TotalCount` and `TotalSize` when they are 0.
43
+ return image_usage.get("TotalCount", 0), image_usage.get("TotalSize", 0)
@@ -0,0 +1,130 @@
1
+ """Read one file out of a container image, using the `docker` command on $PATH.
2
+
3
+ `benchmark init` reads an image's rubric this way. `docker create` makes the
4
+ image's files available without starting the container. Starting the container
5
+ would run the image's entrypoint, and a benchmark entrypoint needs a GPU and a
6
+ connection to a policy. `docker cp` then copies the file out.
7
+
8
+ Only an image already on this machine is read. `docker create` downloads an
9
+ image it cannot find, and a benchmark image is tens of gigabytes. A missing
10
+ image raises ImageInspectError instead. The caller reports that as a file it
11
+ could not read, which is different from an image that does not carry the file.
12
+ """
13
+
14
+ import contextlib
15
+ import shutil
16
+ import subprocess
17
+ import tarfile
18
+ from io import BytesIO
19
+
20
+ # Reading files from a local image takes only as long as the disk needs. These
21
+ # limits exist so that a docker daemon that stops responding cannot hold up a
22
+ # registration that has already succeeded.
23
+ INSPECT_TIMEOUT_SECONDS = 30.0
24
+ COPY_TIMEOUT_SECONDS = 120.0
25
+
26
+ # The message docker prints when `docker cp` asks for a path the container does
27
+ # not have.
28
+ _MISSING_PATH = "could not find the file"
29
+
30
+
31
+ class ImageInspectError(Exception):
32
+ """The image's files could not be read.
33
+
34
+ The message says what stopped the read. An image that can be read but does
35
+ not carry the path returns None instead. The caller reports these two cases
36
+ differently: one file could not be checked, the other was checked and is
37
+ not there.
38
+ """
39
+
40
+
41
+ class DockerImageClient:
42
+ """Reads files out of local container images."""
43
+
44
+ def read_file(self, image: str, path: str) -> bytes | None:
45
+ """Return `path` from `image`, or None when the image does not carry it.
46
+
47
+ Raises ImageInspectError when docker is not installed, when the image
48
+ is not on this machine, or when the copy fails for any reason other
49
+ than the path being absent.
50
+ """
51
+ if shutil.which("docker") is None:
52
+ raise ImageInspectError("docker is not on $PATH")
53
+ self._require_local(image)
54
+ container = self._create(image)
55
+ try:
56
+ return self._copy(container, path)
57
+ finally:
58
+ self._remove(container)
59
+
60
+ def _require_local(self, image: str) -> None:
61
+ result = self._run(
62
+ ["docker", "image", "inspect", image], INSPECT_TIMEOUT_SECONDS
63
+ )
64
+ if result.returncode != 0:
65
+ raise ImageInspectError(f"'{image}' is not on this box")
66
+
67
+ def _create(self, image: str) -> str:
68
+ result = self._run(["docker", "create", image], INSPECT_TIMEOUT_SECONDS)
69
+ if result.returncode != 0:
70
+ raise ImageInspectError(_said(result) or f"docker could not open '{image}'")
71
+ container = result.stdout.decode(errors="replace").strip()
72
+ if not container:
73
+ raise ImageInspectError(f"docker named no container for '{image}'")
74
+ return container
75
+
76
+ def _copy(self, container: str, path: str) -> bytes | None:
77
+ result = self._run(
78
+ ["docker", "cp", f"{container}:{path}", "-"], COPY_TIMEOUT_SECONDS
79
+ )
80
+ if result.returncode != 0:
81
+ said = _said(result)
82
+ if _MISSING_PATH in said.lower():
83
+ return None
84
+ raise ImageInspectError(said or f"docker could not read {path}")
85
+ return _only_member(result.stdout, path)
86
+
87
+ def _remove(self, container: str) -> None:
88
+ # This runs in a `finally`, after the read has already happened. A
89
+ # removal that fails or times out must not turn a successful read into
90
+ # an error, so a failure here is ignored. Nothing else in this module
91
+ # removes a container that is left behind.
92
+ with contextlib.suppress(ImageInspectError):
93
+ self._run(["docker", "rm", "--force", container], INSPECT_TIMEOUT_SECONDS)
94
+
95
+ def _run(
96
+ self, argv: list[str], timeout: float
97
+ ) -> subprocess.CompletedProcess[bytes]:
98
+ try:
99
+ return subprocess.run(argv, capture_output=True, timeout=timeout)
100
+ except OSError as exc:
101
+ raise ImageInspectError(f"could not run {argv[0]}: {exc}") from exc
102
+ except subprocess.TimeoutExpired as exc:
103
+ raise ImageInspectError(
104
+ f"`{' '.join(argv[:2])}` did not finish within {timeout:g} s"
105
+ ) from exc
106
+
107
+
108
+ def _said(result: subprocess.CompletedProcess[bytes]) -> str:
109
+ return result.stderr.decode(errors="replace").strip()
110
+
111
+
112
+ def _only_member(archive: bytes, path: str) -> bytes:
113
+ """Return the first file in the archive that `docker cp` produced.
114
+
115
+ An archive holding no file at all raises an error rather than returning
116
+ None, because None would say the image has no rubric and hide a broken
117
+ image. Copying a directory would return the first file inside it, and the
118
+ caller always names a file rather than a directory.
119
+ """
120
+ try:
121
+ with tarfile.open(fileobj=BytesIO(archive)) as tar:
122
+ for member in tar:
123
+ if not member.isfile():
124
+ continue
125
+ extracted = tar.extractfile(member)
126
+ if extracted is not None:
127
+ return extracted.read()
128
+ except tarfile.TarError as exc:
129
+ raise ImageInspectError(f"docker streamed no readable archive: {exc}") from exc
130
+ raise ImageInspectError(f"{path} is not a file in the image")