dr-docker 0.4.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.
dr_docker/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """Typed Docker runtime contracts and subprocess adapter."""
2
+
3
+ from .adapters import RuntimeAdapter, RuntimePrimitiveError
4
+ from .docker_contract import (
5
+ DockerMount,
6
+ DockerRuntimeRequest,
7
+ DockerRuntimeResult,
8
+ ResourceLimits,
9
+ SecurityProfile,
10
+ TmpfsMount,
11
+ )
12
+ from .errors import ErrorCode, ErrorEnvelope
13
+ from .subprocess_adapter import SubprocessDockerAdapter
14
+ from .version import CONTRACT_VERSION, __version__
15
+
16
+ __all__ = [
17
+ "CONTRACT_VERSION",
18
+ "DockerMount",
19
+ "DockerRuntimeRequest",
20
+ "DockerRuntimeResult",
21
+ "ErrorCode",
22
+ "ErrorEnvelope",
23
+ "ResourceLimits",
24
+ "RuntimeAdapter",
25
+ "RuntimePrimitiveError",
26
+ "SecurityProfile",
27
+ "SubprocessDockerAdapter",
28
+ "TmpfsMount",
29
+ "__version__",
30
+ ]
@@ -0,0 +1,25 @@
1
+ """Internal helpers for strict JSON boundary validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ from pydantic import JsonValue
8
+
9
+
10
+ def ensure_finite_json_value(value: JsonValue, *, path: str) -> None:
11
+ """Reject non-finite numbers to preserve deterministic JSON contracts."""
12
+
13
+ if isinstance(value, float):
14
+ if not math.isfinite(value):
15
+ raise ValueError(f"{path} must not contain NaN or infinity")
16
+ return
17
+
18
+ if isinstance(value, list):
19
+ for index, item in enumerate(value):
20
+ ensure_finite_json_value(item, path=f"{path}[{index}]")
21
+ return
22
+
23
+ if isinstance(value, dict):
24
+ for key, item in value.items():
25
+ ensure_finite_json_value(item, path=f"{path}.{key}")
dr_docker/adapters.py ADDED
@@ -0,0 +1,23 @@
1
+ """Adapter protocol and error type for runtime primitive consumers."""
2
+
3
+ from typing import Protocol, runtime_checkable
4
+
5
+ from .docker_contract import DockerRuntimeRequest, DockerRuntimeResult
6
+ from .errors import ErrorEnvelope
7
+
8
+
9
+ class RuntimePrimitiveError(Exception):
10
+ """Raised when a primitive operation fails with a typed infra envelope."""
11
+
12
+ def __init__(self, error: ErrorEnvelope) -> None:
13
+ super().__init__(error.message)
14
+ self.error = error
15
+
16
+
17
+ @runtime_checkable
18
+ class RuntimeAdapter(Protocol):
19
+ """Primitive Docker runtime executor."""
20
+
21
+ def execute_in_runtime(
22
+ self, request: DockerRuntimeRequest
23
+ ) -> DockerRuntimeResult: ...
dr_docker/cidfile.py ADDED
@@ -0,0 +1,76 @@
1
+ """Container ID file management for Docker subprocess execution."""
2
+
3
+ import logging
4
+ import os
5
+ import tempfile
6
+ from contextlib import suppress
7
+ from pathlib import Path
8
+
9
+ PRIVATE_CID_DIR_PREFIX = "dr_docker_cid_dir_"
10
+ _LOGGER = logging.getLogger(__name__)
11
+
12
+
13
+ def is_private_cidfile_dir(path: Path) -> bool:
14
+ """Return True when path is a managed private cidfile directory."""
15
+ temp_root = Path(tempfile.gettempdir()).resolve()
16
+ candidate = path.resolve(strict=False)
17
+ return candidate.parent == temp_root and candidate.name.startswith(
18
+ PRIVATE_CID_DIR_PREFIX
19
+ )
20
+
21
+
22
+ def new_cidfile_path(
23
+ *,
24
+ prefix: str = "dr_docker_cid_",
25
+ suffix: str = ".txt",
26
+ ) -> Path:
27
+ """Return a unique cidfile path that does not already exist."""
28
+ private_dir: Path | None = None
29
+ try:
30
+ private_dir = Path(tempfile.mkdtemp(prefix=PRIVATE_CID_DIR_PREFIX))
31
+ private_dir.chmod(0o700)
32
+ except OSError:
33
+ _LOGGER.exception("Failed to create secure CID temporary directory")
34
+ if private_dir is not None:
35
+ with suppress(OSError):
36
+ private_dir.rmdir()
37
+ raise
38
+
39
+ cidfile_fd = -1
40
+ if private_dir is None:
41
+ raise RuntimeError("Private CID directory was not created")
42
+ cidfile_path = private_dir / f"{prefix}pending{suffix}"
43
+ try:
44
+ # Allocate temp file; clean up dir on failure.
45
+ try:
46
+ cidfile_fd, cidfile_name = tempfile.mkstemp(
47
+ prefix=prefix,
48
+ suffix=suffix,
49
+ dir=private_dir,
50
+ text=True,
51
+ )
52
+ cidfile_path = Path(cidfile_name)
53
+ except BaseException:
54
+ _LOGGER.exception(
55
+ "Failed to allocate CID file in temporary directory: %s",
56
+ private_dir,
57
+ )
58
+ with suppress(OSError):
59
+ private_dir.rmdir()
60
+ raise
61
+ finally:
62
+ if cidfile_fd >= 0:
63
+ os.close(cidfile_fd)
64
+
65
+ try:
66
+ cidfile_path.unlink()
67
+ except OSError:
68
+ _LOGGER.exception(
69
+ "Failed to unlink placeholder CID file: %s",
70
+ cidfile_path,
71
+ )
72
+ with suppress(OSError):
73
+ private_dir.rmdir()
74
+ raise
75
+
76
+ return cidfile_path
dr_docker/cleanup.py ADDED
@@ -0,0 +1,59 @@
1
+ """Container cleanup utilities for Docker subprocess execution."""
2
+
3
+ import logging
4
+ import re
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from .cidfile import is_private_cidfile_dir
9
+ from .errors import ErrorCode, ErrorEnvelope
10
+
11
+ _CID_PATTERN = re.compile(r"[0-9a-f]{64}", flags=re.IGNORECASE)
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+
15
+ def _is_valid_container_id(identifier: str) -> bool:
16
+ return _CID_PATTERN.fullmatch(identifier) is not None
17
+
18
+
19
+ def _docker_rm(identifier: str) -> None:
20
+ try:
21
+ subprocess.run(
22
+ ["docker", "rm", "-f", identifier],
23
+ check=False,
24
+ stdout=subprocess.DEVNULL,
25
+ stderr=subprocess.DEVNULL,
26
+ )
27
+ except (
28
+ FileNotFoundError,
29
+ OSError,
30
+ subprocess.SubprocessError,
31
+ ):
32
+ return
33
+
34
+
35
+ def cleanup_container_from_cidfile(cidfile: Path) -> None:
36
+ """Remove the container referenced by cidfile, then clean up the file."""
37
+ try:
38
+ cid = cidfile.read_text(encoding="utf-8").strip()
39
+ except OSError:
40
+ cid = ""
41
+ if cid and _is_valid_container_id(cid):
42
+ _docker_rm(cid)
43
+ try:
44
+ cidfile.unlink(missing_ok=True)
45
+ except OSError as exc:
46
+ envelope = ErrorEnvelope(
47
+ code=ErrorCode.INTERNAL_ERROR,
48
+ message=f"Failed to remove cidfile {cidfile}: {exc}",
49
+ details={"cidfile": str(cidfile)},
50
+ )
51
+ _LOGGER.warning(
52
+ "best-effort cleanup failed: %s", envelope.model_dump(mode="json")
53
+ )
54
+ parent_dir = cidfile.parent
55
+ if is_private_cidfile_dir(parent_dir):
56
+ try:
57
+ parent_dir.rmdir()
58
+ except OSError:
59
+ pass
@@ -0,0 +1,82 @@
1
+ """Docker primitive runtime contract models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
6
+
7
+ from .errors import ErrorEnvelope
8
+
9
+
10
+ class DockerMount(BaseModel):
11
+ """Docker bind mount settings."""
12
+
13
+ source: str = Field(min_length=1)
14
+ target: str = Field(min_length=1)
15
+ read_only: bool = False
16
+
17
+
18
+ class SecurityProfile(BaseModel):
19
+ """Container security hardening."""
20
+
21
+ read_only: bool = True
22
+ cap_drop: str = "ALL"
23
+ no_new_privileges: bool = True
24
+ network_disabled: bool = True
25
+
26
+
27
+ class ResourceLimits(BaseModel):
28
+ """Container resource constraints."""
29
+
30
+ memory: str = "256m"
31
+ cpus: float = Field(default=0.5, gt=0)
32
+ pids_limit: int = Field(default=64, gt=0)
33
+ cpu_seconds: int | None = Field(default=None, gt=0)
34
+ fsize_bytes: int | None = Field(default=None, gt=0)
35
+ nofile: int | None = Field(default=None, gt=0)
36
+ nproc: int | None = Field(default=None, gt=0)
37
+
38
+
39
+ class TmpfsMount(BaseModel):
40
+ """Tmpfs mount specification."""
41
+
42
+ model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
43
+
44
+ target: str = "/tmp"
45
+ size: str = "16m"
46
+ exec_: bool = Field(default=False, alias="exec", serialization_alias="exec")
47
+
48
+
49
+ class DockerRuntimeRequest(BaseModel):
50
+ """Input payload for a primitive Docker runtime execution."""
51
+
52
+ image: str = Field(min_length=1)
53
+ command: list[str] = Field(default_factory=list)
54
+ entrypoint: str | None = None
55
+ env: dict[str, str] = Field(default_factory=dict)
56
+ mounts: list[DockerMount] = Field(default_factory=list)
57
+ tmpfs: list[TmpfsMount] = Field(default_factory=list)
58
+ timeout_seconds: int = Field(gt=0)
59
+ working_dir: str | None = None
60
+ stdin_payload: bytes | None = None
61
+ security: SecurityProfile = Field(default_factory=SecurityProfile)
62
+ resources: ResourceLimits = Field(default_factory=ResourceLimits)
63
+
64
+
65
+ class DockerRuntimeResult(BaseModel):
66
+ """Output payload for a primitive Docker runtime execution."""
67
+
68
+ ok: bool
69
+ exit_code: int | None = None
70
+ stdout: str = ""
71
+ stderr: str = ""
72
+ duration_seconds: float | None = Field(default=None, ge=0)
73
+ container_id: str | None = None
74
+ error: ErrorEnvelope | None = None
75
+
76
+ @model_validator(mode="after")
77
+ def _reject_success_with_error(self) -> DockerRuntimeResult:
78
+ if self.ok and self.error is not None:
79
+ raise ValueError("error must be null when ok is true")
80
+ if not self.ok and self.error is None:
81
+ raise ValueError("error must be present when ok is false")
82
+ return self
dr_docker/errors.py ADDED
@@ -0,0 +1,33 @@
1
+ """Shared typed error envelope contracts."""
2
+
3
+ from enum import Enum
4
+
5
+ from pydantic import BaseModel, Field, JsonValue, field_validator
6
+
7
+ from ._json_validation import ensure_finite_json_value
8
+
9
+
10
+ class ErrorCode(str, Enum):
11
+ """Canonical error codes for primitive runtime integrations."""
12
+
13
+ TIMEOUT = "timeout"
14
+ UNAVAILABLE = "unavailable"
15
+ INTERNAL_ERROR = "internal_error"
16
+
17
+
18
+ class ErrorEnvelope(BaseModel):
19
+ """Standard error payload shared across primitive contracts."""
20
+
21
+ code: ErrorCode
22
+ message: str = Field(min_length=1)
23
+ retriable: bool = False
24
+ details: dict[str, JsonValue] = Field(default_factory=dict)
25
+
26
+ @field_validator("details")
27
+ @classmethod
28
+ def _ensure_json_safe_details(
29
+ cls, details: dict[str, JsonValue]
30
+ ) -> dict[str, JsonValue]:
31
+ for key, value in details.items():
32
+ ensure_finite_json_value(value, path=f"details.{key}")
33
+ return details
@@ -0,0 +1,362 @@
1
+ """Concrete RuntimeAdapter using subprocess + Docker CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import math
7
+ import os
8
+ import selectors
9
+ import shutil
10
+ import subprocess
11
+ import time
12
+ from contextlib import suppress
13
+ from pathlib import Path
14
+ from threading import Thread
15
+ from typing import BinaryIO
16
+
17
+ from .cidfile import new_cidfile_path
18
+ from .cleanup import cleanup_container_from_cidfile
19
+ from .docker_contract import (
20
+ DockerRuntimeRequest,
21
+ DockerRuntimeResult,
22
+ )
23
+ from .errors import ErrorCode, ErrorEnvelope
24
+
25
+ _LOGGER = logging.getLogger(__name__)
26
+
27
+
28
+ def _build_docker_cmd(
29
+ request: DockerRuntimeRequest,
30
+ cidfile: Path,
31
+ ) -> list[str]:
32
+ """Translate a DockerRuntimeRequest into docker run CLI args."""
33
+ sec = request.security
34
+ res = request.resources
35
+
36
+ cmd: list[str] = [
37
+ "docker",
38
+ "run",
39
+ "--interactive",
40
+ "--rm",
41
+ f"--cidfile={cidfile}",
42
+ ]
43
+
44
+ if request.entrypoint is not None:
45
+ cmd.extend(["--entrypoint", request.entrypoint])
46
+
47
+ # Security profile
48
+ if sec.read_only:
49
+ cmd.append("--read-only")
50
+ if sec.cap_drop:
51
+ cmd.append(f"--cap-drop={sec.cap_drop}")
52
+ if sec.no_new_privileges:
53
+ cmd.append("--security-opt=no-new-privileges")
54
+ if sec.network_disabled:
55
+ cmd.append("--network=none")
56
+
57
+ # Resource limits
58
+ cmd.append(f"--memory={res.memory}")
59
+ cmd.append(f"--cpus={res.cpus}")
60
+ cmd.append(f"--pids-limit={res.pids_limit}")
61
+ if res.cpu_seconds is not None:
62
+ cmd.extend(["--ulimit", f"cpu={res.cpu_seconds}:{res.cpu_seconds}"])
63
+ if res.fsize_bytes is not None:
64
+ cmd.extend(["--ulimit", f"fsize={res.fsize_bytes}:{res.fsize_bytes}"])
65
+ if res.nofile is not None:
66
+ cmd.extend(["--ulimit", f"nofile={res.nofile}:{res.nofile}"])
67
+ if res.nproc is not None:
68
+ cmd.extend(["--ulimit", f"nproc={res.nproc}:{res.nproc}"])
69
+
70
+ # Tmpfs mounts
71
+ for tmpfs in request.tmpfs:
72
+ exec_flag = ",exec" if tmpfs.exec_ else ""
73
+ opts = f"{tmpfs.target}:rw,nosuid{exec_flag},size={tmpfs.size}"
74
+ cmd.extend(["--tmpfs", opts])
75
+
76
+ # Bind mounts
77
+ for mount in request.mounts:
78
+ ro = ",readonly" if mount.read_only else ""
79
+ cmd.extend(
80
+ [
81
+ "--mount",
82
+ f"type=bind,source={mount.source},target={mount.target}{ro}",
83
+ ]
84
+ )
85
+
86
+ # Environment
87
+ for key, value in request.env.items():
88
+ cmd.extend(["-e", f"{key}={value}"])
89
+
90
+ # Working directory
91
+ if request.working_dir is not None:
92
+ cmd.extend(["-w", request.working_dir])
93
+
94
+ # Image + command
95
+ cmd.append(request.image)
96
+ cmd.extend(request.command)
97
+
98
+ return cmd
99
+
100
+
101
+ def _collect_capped_process_output(
102
+ proc: subprocess.Popen[bytes],
103
+ *,
104
+ cmd: list[str],
105
+ timeout_seconds: float,
106
+ stdout_limit: int,
107
+ stderr_limit: int,
108
+ ) -> tuple[int, str, str]:
109
+ """Selector-based concurrent stdout/stderr reader with byte caps."""
110
+ stdout = proc.stdout
111
+ stderr = proc.stderr
112
+ if stdout is None or stderr is None:
113
+ missing = []
114
+ if stdout is None:
115
+ missing.append("stdout")
116
+ if stderr is None:
117
+ missing.append("stderr")
118
+ raise RuntimeError(
119
+ f"Missing subprocess {' and '.join(missing)} pipe(s) for command: {cmd!r}"
120
+ )
121
+
122
+ stream_buffers: dict[int, bytearray] = {
123
+ stdout.fileno(): bytearray(),
124
+ stderr.fileno(): bytearray(),
125
+ }
126
+ stream_limits = {
127
+ stdout.fileno(): stdout_limit,
128
+ stderr.fileno(): stderr_limit,
129
+ }
130
+ stream_totals = {
131
+ stdout.fileno(): 0,
132
+ stderr.fileno(): 0,
133
+ }
134
+ stream_truncated = {
135
+ stdout.fileno(): False,
136
+ stderr.fileno(): False,
137
+ }
138
+
139
+ with selectors.DefaultSelector() as selector:
140
+ selector.register(stdout, selectors.EVENT_READ)
141
+ selector.register(stderr, selectors.EVENT_READ)
142
+ start = time.monotonic()
143
+
144
+ while selector.get_map():
145
+ remaining = timeout_seconds - (time.monotonic() - start)
146
+ if remaining <= 0:
147
+ proc.kill()
148
+ proc.wait()
149
+ raise subprocess.TimeoutExpired(cmd=cmd, timeout=timeout_seconds)
150
+ events = selector.select(timeout=remaining)
151
+ for key, _ in events:
152
+ chunk = os.read(key.fd, 65536)
153
+ if not chunk:
154
+ selector.unregister(key.fileobj)
155
+ continue
156
+ stream_totals[key.fd] += len(chunk)
157
+ buffer = stream_buffers[key.fd]
158
+ limit = stream_limits[key.fd]
159
+ remaining_cap = limit - len(buffer)
160
+ if remaining_cap > 0:
161
+ kept = chunk[:remaining_cap]
162
+ buffer.extend(kept)
163
+ if len(kept) < len(chunk):
164
+ stream_truncated[key.fd] = True
165
+ else:
166
+ stream_truncated[key.fd] = True
167
+
168
+ remaining = timeout_seconds - (time.monotonic() - start)
169
+ if remaining <= 0:
170
+ proc.kill()
171
+ proc.wait()
172
+ raise subprocess.TimeoutExpired(cmd=cmd, timeout=timeout_seconds)
173
+ try:
174
+ returncode = proc.wait(timeout=remaining)
175
+ except subprocess.TimeoutExpired:
176
+ proc.kill()
177
+ proc.wait()
178
+ raise
179
+
180
+ stdout_text = stream_buffers[stdout.fileno()].decode("utf-8", errors="replace")
181
+ stderr_text = stream_buffers[stderr.fileno()].decode("utf-8", errors="replace")
182
+ if stream_truncated[stdout.fileno()]:
183
+ stdout_text += (
184
+ "\n[stdout truncated: "
185
+ f"{stream_totals[stdout.fileno()]} bytes total, "
186
+ f"capped at {stdout_limit} bytes]"
187
+ )
188
+ if stream_truncated[stderr.fileno()]:
189
+ stderr_text += (
190
+ "\n[stderr truncated: "
191
+ f"{stream_totals[stderr.fileno()]} bytes total, "
192
+ f"capped at {stderr_limit} bytes]"
193
+ )
194
+
195
+ return returncode, stdout_text, stderr_text
196
+
197
+
198
+ class SubprocessDockerAdapter:
199
+ """Execute Docker containers via subprocess with stream capping."""
200
+
201
+ def __init__(
202
+ self,
203
+ *,
204
+ max_stdout_bytes: int = 1_048_576,
205
+ max_stderr_bytes: int = 1_048_576,
206
+ ) -> None:
207
+ self._max_stdout_bytes = max_stdout_bytes
208
+ self._max_stderr_bytes = max_stderr_bytes
209
+
210
+ def execute_in_runtime(self, request: DockerRuntimeRequest) -> DockerRuntimeResult:
211
+ """Build docker run CLI, execute, return result."""
212
+ if not shutil.which("docker"):
213
+ return DockerRuntimeResult(
214
+ ok=False,
215
+ error=ErrorEnvelope(
216
+ code=ErrorCode.UNAVAILABLE,
217
+ message="Docker CLI not found on PATH",
218
+ ),
219
+ )
220
+
221
+ # Compute cpu_seconds from timeout if not explicitly set
222
+ resources = request.resources
223
+ if resources.cpu_seconds is None:
224
+ cpu_seconds = max(1, math.ceil(request.timeout_seconds))
225
+ resources = resources.model_copy(update={"cpu_seconds": cpu_seconds})
226
+ request = request.model_copy(update={"resources": resources})
227
+
228
+ try:
229
+ cidfile = new_cidfile_path()
230
+ cmd = _build_docker_cmd(request, cidfile)
231
+ except OSError as exc:
232
+ return DockerRuntimeResult(
233
+ ok=False,
234
+ error=ErrorEnvelope(
235
+ code=ErrorCode.UNAVAILABLE,
236
+ message=f"Failed to prepare Docker runtime: {exc}",
237
+ ),
238
+ )
239
+ start = time.monotonic()
240
+
241
+ try:
242
+ return self._run(cmd, request, cidfile, start)
243
+ finally:
244
+ cleanup_container_from_cidfile(cidfile)
245
+
246
+ def _run(
247
+ self,
248
+ cmd: list[str],
249
+ request: DockerRuntimeRequest,
250
+ cidfile: Path,
251
+ start: float,
252
+ ) -> DockerRuntimeResult:
253
+ try:
254
+ with subprocess.Popen(
255
+ cmd,
256
+ stdin=subprocess.PIPE,
257
+ stdout=subprocess.PIPE,
258
+ stderr=subprocess.PIPE,
259
+ ) as proc:
260
+ stdin = proc.stdin
261
+ if stdin is None:
262
+ raise RuntimeError(
263
+ f"Missing subprocess stdin pipe for command: {cmd!r}"
264
+ )
265
+
266
+ writer = Thread(
267
+ target=self._write_stdin_and_close,
268
+ args=(stdin, request.stdin_payload),
269
+ daemon=True,
270
+ )
271
+ writer.start()
272
+ try:
273
+ returncode, stdout_text, stderr_text = (
274
+ _collect_capped_process_output(
275
+ proc,
276
+ cmd=cmd,
277
+ timeout_seconds=float(request.timeout_seconds),
278
+ stdout_limit=self._max_stdout_bytes,
279
+ stderr_limit=self._max_stderr_bytes,
280
+ )
281
+ )
282
+ finally:
283
+ writer.join()
284
+
285
+ duration = time.monotonic() - start
286
+ container_id = self._read_cidfile(cidfile)
287
+
288
+ if returncode == 0:
289
+ return DockerRuntimeResult(
290
+ ok=True,
291
+ exit_code=returncode,
292
+ stdout=stdout_text,
293
+ stderr=stderr_text,
294
+ duration_seconds=duration,
295
+ container_id=container_id,
296
+ )
297
+ return DockerRuntimeResult(
298
+ ok=False,
299
+ exit_code=returncode,
300
+ stdout=stdout_text,
301
+ stderr=stderr_text,
302
+ duration_seconds=duration,
303
+ container_id=container_id,
304
+ error=ErrorEnvelope(
305
+ code=ErrorCode.INTERNAL_ERROR,
306
+ message=f"Container exited with code {returncode}",
307
+ ),
308
+ )
309
+
310
+ except subprocess.TimeoutExpired:
311
+ duration = time.monotonic() - start
312
+ return DockerRuntimeResult(
313
+ ok=False,
314
+ duration_seconds=duration,
315
+ container_id=self._read_cidfile(cidfile),
316
+ error=ErrorEnvelope(
317
+ code=ErrorCode.TIMEOUT,
318
+ message=(f"Container timed out after {request.timeout_seconds}s"),
319
+ retriable=True,
320
+ ),
321
+ )
322
+ except (FileNotFoundError, OSError) as exc:
323
+ duration = time.monotonic() - start
324
+ return DockerRuntimeResult(
325
+ ok=False,
326
+ duration_seconds=duration,
327
+ error=ErrorEnvelope(
328
+ code=ErrorCode.UNAVAILABLE,
329
+ message=f"Docker execution failed: {exc}",
330
+ ),
331
+ )
332
+ except RuntimeError as exc:
333
+ duration = time.monotonic() - start
334
+ return DockerRuntimeResult(
335
+ ok=False,
336
+ duration_seconds=duration,
337
+ error=ErrorEnvelope(
338
+ code=ErrorCode.INTERNAL_ERROR,
339
+ message=str(exc),
340
+ ),
341
+ )
342
+
343
+ @staticmethod
344
+ def _read_cidfile(cidfile: Path) -> str | None:
345
+ try:
346
+ cid = cidfile.read_text(encoding="utf-8").strip()
347
+ return cid if cid else None
348
+ except OSError:
349
+ return None
350
+
351
+ @staticmethod
352
+ def _write_stdin_and_close(
353
+ stdin_pipe: BinaryIO,
354
+ stdin_payload: bytes | None,
355
+ ) -> None:
356
+ try:
357
+ if stdin_payload is not None:
358
+ with suppress(BrokenPipeError):
359
+ stdin_pipe.write(stdin_payload)
360
+ finally:
361
+ with suppress(BrokenPipeError, OSError):
362
+ stdin_pipe.close()
dr_docker/version.py ADDED
@@ -0,0 +1,4 @@
1
+ """Version constants for dr_docker."""
2
+
3
+ __version__ = "0.4.0"
4
+ CONTRACT_VERSION = "0.4.0"
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: dr-docker
3
+ Version: 0.4.0
4
+ Summary: Docker runtime contracts and subprocess adapter
5
+ Project-URL: Homepage, https://github.com/drothermel/dr-docker
6
+ Project-URL: Repository, https://github.com/drothermel/dr-docker
7
+ Project-URL: Issues, https://github.com/drothermel/dr-docker/issues
8
+ Author: NL Stack
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Danielle Rothermel
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: contracts,docker,runtime,sandbox,subprocess
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.10
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Typing :: Typed
40
+ Requires-Python: >=3.10
41
+ Requires-Dist: pydantic<3.0,>=2.0
42
+ Description-Content-Type: text/markdown
43
+
44
+ # dr-docker
45
+
46
+ Reusable Docker execution contracts and adapters.
47
+
48
+ ## Purpose
49
+
50
+ This repo provides Docker runtime contracts and a concrete subprocess adapter:
51
+ - Docker runtime request/result contracts with security and resource profiles
52
+ - Runtime adapter protocol
53
+ - Subprocess-based Docker adapter with stream capping and cidfile cleanup
54
+ - Typed error envelopes
55
+
56
+ ## Public Surface
57
+
58
+ ```python
59
+ from dr_docker import (
60
+ CONTRACT_VERSION,
61
+ DockerMount,
62
+ DockerRuntimeRequest,
63
+ DockerRuntimeResult,
64
+ ErrorCode,
65
+ ErrorEnvelope,
66
+ ResourceLimits,
67
+ RuntimeAdapter,
68
+ RuntimePrimitiveError,
69
+ SecurityProfile,
70
+ SubprocessDockerAdapter,
71
+ TmpfsMount,
72
+ __version__,
73
+ )
74
+ ```
75
+
76
+ ## Contract Guarantees
77
+
78
+ - `DockerRuntimeResult(ok=False)` requires `error`
79
+ - Successful result envelopes must not include `error`
80
+ - Error envelopes are typed (`ErrorCode`) with non-empty message and JSON-safe details
81
+ - Supported `ErrorCode` values are `timeout`, `unavailable`, and `internal_error`
82
+
83
+ ## Development
84
+
85
+ ```bash
86
+ uv sync --group dev
87
+ uv run pytest -q
88
+ uv run ruff format --check
89
+ uv run ruff check
90
+ uv run ty check
91
+ ```
92
+
93
+ ## Publishing
94
+
95
+ ```bash
96
+ cp .env.example .env
97
+ # set PYPI_API_TOKEN in .env
98
+ set -a; source .env; set +a
99
+ uv build
100
+ uvx twine check dist/*
101
+ uvx twine upload -u __token__ -p "$PYPI_API_TOKEN" dist/*
102
+ ```
@@ -0,0 +1,13 @@
1
+ dr_docker/__init__.py,sha256=tkZXsRzZkMvrWcsIkMSQysgHW65MG-_gjLP8MQkIChQ,747
2
+ dr_docker/_json_validation.py,sha256=u-3aF6QVXwdVvHZ6pcQ5AoYEBCg09ukmGWpcpbMyL_g,749
3
+ dr_docker/adapters.py,sha256=VHZC8H_SOGU4_0aOQyQ8Y9bzWuvbB45xMpPRX99wmN4,673
4
+ dr_docker/cidfile.py,sha256=bmAvOqRcRLVn9EekrjiB3zdc7uKBox2JK7znpD65E3Y,2243
5
+ dr_docker/cleanup.py,sha256=VAGa9e50oupLpzMDWgWW4_t1Xz0ACkAUUa8xiT2pUDE,1655
6
+ dr_docker/docker_contract.py,sha256=juRuUZ5xQL1vExfa2gA-3_BTKPprGYe7IUIoMnkfwf0,2587
7
+ dr_docker/errors.py,sha256=m9zYukhar7OExyNBOn_So3DSFxnR36tJYuosqwzbcIE,930
8
+ dr_docker/subprocess_adapter.py,sha256=t6eGDHPiZG6gAId5dppenPtiZL-6oujiRBTSF5YOAVM,11808
9
+ dr_docker/version.py,sha256=fIxJcQUd7SPNcA6WpYQwPNvBo3VigOhykDYIYGB1P_0,89
10
+ dr_docker-0.4.0.dist-info/METADATA,sha256=LZpIH7mMWM8ZMXY5gKGKOAEuT8u0Xahr_fu4ALIDg9s,3424
11
+ dr_docker-0.4.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
+ dr_docker-0.4.0.dist-info/licenses/LICENSE,sha256=COcMQ9vaxs7t9tuV-1VpdoDgunQqK1fdIfedkmmKLh4,1075
13
+ dr_docker-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Danielle Rothermel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.