puremethod 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 (171) hide show
  1. app/__init__.py +2 -0
  2. app/__main__.py +31 -0
  3. app/_web/404/index.html +1 -0
  4. app/_web/404.html +1 -0
  5. app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
  6. app/_web/__next._full.txt +36 -0
  7. app/_web/__next._tree.txt +4 -0
  8. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
  9. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
  10. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
  11. app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
  12. app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
  13. app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  14. app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
  15. app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
  16. app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
  17. app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
  18. app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
  19. app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
  20. app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
  21. app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
  22. app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
  23. app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
  24. app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
  25. app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
  26. app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
  27. app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
  28. app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
  29. app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
  30. app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
  31. app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
  32. app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
  33. app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
  34. app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
  35. app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
  36. app/_web/_not-found/__next._full.txt +24 -0
  37. app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
  38. app/_web/_not-found/__next._tree.txt +4 -0
  39. app/_web/_not-found/index.html +1 -0
  40. app/_web/_not-found/index.txt +24 -0
  41. app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
  42. app/_web/credentials/__next._full.txt +39 -0
  43. app/_web/credentials/__next._tree.txt +5 -0
  44. app/_web/credentials/index.html +1 -0
  45. app/_web/credentials/index.txt +39 -0
  46. app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
  47. app/_web/executors/__next._full.txt +39 -0
  48. app/_web/executors/__next._tree.txt +5 -0
  49. app/_web/executors/index.html +1 -0
  50. app/_web/executors/index.txt +39 -0
  51. app/_web/index.html +1 -0
  52. app/_web/index.txt +36 -0
  53. app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
  54. app/_web/new/__next._full.txt +39 -0
  55. app/_web/new/__next._tree.txt +5 -0
  56. app/_web/new/index.html +1 -0
  57. app/_web/new/index.txt +39 -0
  58. app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
  59. app/_web/profiles/__next._full.txt +39 -0
  60. app/_web/profiles/__next._tree.txt +5 -0
  61. app/_web/profiles/index.html +1 -0
  62. app/_web/profiles/index.txt +39 -0
  63. app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
  64. app/_web/run/__next._full.txt +40 -0
  65. app/_web/run/__next._tree.txt +5 -0
  66. app/_web/run/index.html +1 -0
  67. app/_web/run/index.txt +40 -0
  68. app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
  69. app/_web/services/__next._full.txt +39 -0
  70. app/_web/services/__next._tree.txt +5 -0
  71. app/_web/services/index.html +1 -0
  72. app/_web/services/index.txt +39 -0
  73. app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
  74. app/_web/setup/__next._full.txt +39 -0
  75. app/_web/setup/__next._tree.txt +5 -0
  76. app/_web/setup/index.html +1 -0
  77. app/_web/setup/index.txt +39 -0
  78. app/_web/signin/__next._full.txt +30 -0
  79. app/_web/signin/__next._tree.txt +4 -0
  80. app/_web/signin/__next.signin.__PAGE__.txt +36 -0
  81. app/_web/signin/index.html +1 -0
  82. app/_web/signin/index.txt +30 -0
  83. app/accounts.py +137 -0
  84. app/addresses.py +30 -0
  85. app/api/__init__.py +61 -0
  86. app/api/auth_routes.py +54 -0
  87. app/api/common.py +102 -0
  88. app/api/config_routes.py +562 -0
  89. app/api/links.py +22 -0
  90. app/api/run_routes.py +311 -0
  91. app/api/setup_routes.py +267 -0
  92. app/bundled.py +521 -0
  93. app/compose.py +229 -0
  94. app/db.py +186 -0
  95. app/executor_config.py +95 -0
  96. app/keys.py +29 -0
  97. app/launcher.py +389 -0
  98. app/leases.py +40 -0
  99. app/literature_sources.py +131 -0
  100. app/model_endpoints.py +104 -0
  101. app/model_search.py +252 -0
  102. app/outputs.py +77 -0
  103. app/paths.py +93 -0
  104. app/readonly.py +70 -0
  105. app/repo.py +746 -0
  106. app/scan.py +65 -0
  107. app/server.py +24 -0
  108. app/settings.py +125 -0
  109. app/setup.py +117 -0
  110. app/ssh_chain.py +102 -0
  111. app/ssh_hosts.py +244 -0
  112. app/ssh_machines.py +101 -0
  113. app/statuses.py +4 -0
  114. app/supervisor.py +87 -0
  115. app/tunnels.py +222 -0
  116. app/unseal.py +27 -0
  117. app/worker.py +657 -0
  118. executor/__init__.py +47 -0
  119. executor/base.py +161 -0
  120. executor/common.py +139 -0
  121. executor/local_docker.py +239 -0
  122. executor/local_process.py +247 -0
  123. executor/ssh.py +481 -0
  124. executor/types.py +128 -0
  125. executor/watchdog.py +108 -0
  126. ledger/__init__.py +46 -0
  127. ledger/artifacts.py +62 -0
  128. ledger/canonical.py +48 -0
  129. ledger/core.py +337 -0
  130. ledger/errors.py +36 -0
  131. ledger/model.py +107 -0
  132. ledger/payloads.py +228 -0
  133. ledger/rules.py +204 -0
  134. ledger/storage.py +226 -0
  135. llm/__init__.py +45 -0
  136. llm/backends/__init__.py +0 -0
  137. llm/backends/anthropic_backend.py +145 -0
  138. llm/backends/base.py +87 -0
  139. llm/backends/openai_backend.py +211 -0
  140. llm/core.py +416 -0
  141. llm/prices.py +98 -0
  142. llm/tokens.py +18 -0
  143. llm/types.py +106 -0
  144. loop/__init__.py +19 -0
  145. loop/config.py +108 -0
  146. loop/core.py +1160 -0
  147. loop/decide.py +61 -0
  148. loop/harness.py +132 -0
  149. loop/prompts.py +88 -0
  150. loop/view.py +202 -0
  151. puremethod-0.1.0.dist-info/METADATA +79 -0
  152. puremethod-0.1.0.dist-info/RECORD +171 -0
  153. puremethod-0.1.0.dist-info/WHEEL +4 -0
  154. puremethod-0.1.0.dist-info/entry_points.txt +2 -0
  155. puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
  156. puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
  157. purescholar/__init__.py +43 -0
  158. purescholar/client.py +380 -0
  159. purescholar/types.py +164 -0
  160. puretrace/__init__.py +49 -0
  161. puretrace/client.py +320 -0
  162. puretrace/types.py +166 -0
  163. writer/__init__.py +8 -0
  164. writer/core.py +430 -0
  165. writer/facts.py +162 -0
  166. writer/latex.py +62 -0
  167. writer/lint.py +85 -0
  168. writer/macros.py +114 -0
  169. writer/render.py +120 -0
  170. writer/tectonic.py +79 -0
  171. writer/types.py +61 -0
executor/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """executor: runs experiment code on a backend the user chooses
2
+ (docs/knowledge_base/05-executor.md). v1 backends: local-docker, local-process, ssh."""
3
+
4
+ from .base import CredentialLookup, Executor
5
+ from .local_docker import LocalDockerExecutor
6
+ from .local_process import LocalProcessExecutor
7
+ from .ssh import SSHExecutor
8
+ from .types import (
9
+ DEFAULT_IMAGE,
10
+ METRICS_FILE,
11
+ DataMount,
12
+ EnvRef,
13
+ EnvSpec,
14
+ ExecutorError,
15
+ ExecutorInfo,
16
+ JobHandle,
17
+ JobResult,
18
+ JobSpec,
19
+ JobState,
20
+ NotDone,
21
+ PreflightReport,
22
+ Resources,
23
+ UnknownJob,
24
+ )
25
+
26
+ __all__ = [
27
+ "DEFAULT_IMAGE",
28
+ "METRICS_FILE",
29
+ "CredentialLookup",
30
+ "DataMount",
31
+ "EnvRef",
32
+ "EnvSpec",
33
+ "Executor",
34
+ "ExecutorError",
35
+ "ExecutorInfo",
36
+ "JobHandle",
37
+ "JobResult",
38
+ "JobSpec",
39
+ "JobState",
40
+ "LocalDockerExecutor",
41
+ "LocalProcessExecutor",
42
+ "NotDone",
43
+ "PreflightReport",
44
+ "Resources",
45
+ "SSHExecutor",
46
+ "UnknownJob",
47
+ ]
executor/base.py ADDED
@@ -0,0 +1,161 @@
1
+ """Shared executor behaviour: handles, queueing beyond the concurrency limit (§5 step 8),
2
+ and the `poll` / `result` / `kill` contract. Backends implement the `_` hooks."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import uuid
7
+ from abc import ABC, abstractmethod
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ from .common import check_tag, redact, sanitized_tar
13
+ from .types import (
14
+ EnvRef,
15
+ EnvSpec,
16
+ ExecutorInfo,
17
+ JobHandle,
18
+ JobResult,
19
+ JobSpec,
20
+ JobState,
21
+ NotDone,
22
+ PreflightReport,
23
+ Status,
24
+ UnknownJob,
25
+ )
26
+
27
+ CredentialLookup = Callable[[str], str]
28
+
29
+
30
+ @dataclass
31
+ class _Job:
32
+ spec: JobSpec
33
+ state: JobState = "queued"
34
+ backend: Any = None
35
+ killed: bool = False
36
+ result: JobResult | None = None
37
+ secrets: dict[str, str] = field(default_factory=dict)
38
+
39
+
40
+ class Executor(ABC):
41
+ """One per configured backend, built from its configuration and a credential lookup
42
+ provided by the caller (05-executor.md §3, KB-015)."""
43
+
44
+ def __init__(self, credentials: CredentialLookup) -> None:
45
+ self._credentials = credentials
46
+ self._jobs: dict[str, _Job] = {}
47
+ self._charged_envs: set[str] = set()
48
+
49
+ # ---- interface ---------------------------------------------------------------------
50
+
51
+ @abstractmethod
52
+ def describe(self) -> ExecutorInfo: ...
53
+
54
+ @abstractmethod
55
+ def preflight(self) -> PreflightReport: ...
56
+
57
+ @abstractmethod
58
+ def build_env(self, spec: EnvSpec) -> EnvRef: ...
59
+
60
+ @abstractmethod
61
+ def reap(self, tags: list[str]) -> list[str]:
62
+ """Stops and removes every job, container and workspace carrying one of these
63
+ tags, whether or not this instance started it; returns the tags found (INV-9)."""
64
+
65
+ def submit(self, job: JobSpec) -> JobHandle:
66
+ if job.env.status != "ok":
67
+ raise ValueError("the job's environment failed to build")
68
+ if job.deadline_s <= 0:
69
+ raise ValueError("deadline_s must be positive")
70
+ sanitized_tar(job.code) # refuse an unsafe bundle now, not when it is dequeued
71
+ if job.tag is not None:
72
+ check_tag(job.tag)
73
+ secrets = {name: self._credentials(name) for name in job.credentials}
74
+ job_id = uuid.uuid4().hex[:16]
75
+ self._jobs[job_id] = _Job(spec=job, secrets=secrets)
76
+ self._start_queued()
77
+ return JobHandle(job_id=job_id, executor_id=self.describe().id)
78
+
79
+ def poll(self, handle: JobHandle) -> JobState:
80
+ job = self._job(handle)
81
+ if job.state == "running" and self._done(job.backend):
82
+ job.result = self._finish(handle.job_id, job)
83
+ job.state = "done"
84
+ self._start_queued()
85
+ return job.state
86
+
87
+ def result(self, handle: JobHandle) -> JobResult:
88
+ job = self._job(handle)
89
+ if job.state != "done" or job.result is None:
90
+ raise NotDone(handle.job_id)
91
+ return job.result
92
+
93
+ def kill(self, handle: JobHandle) -> None:
94
+ job = self._job(handle)
95
+ if job.state == "done":
96
+ return
97
+ job.killed = True
98
+ if job.state == "queued":
99
+ job.result = self._never_started(job)
100
+ job.state = "done"
101
+ return
102
+ self._kill(job.backend)
103
+
104
+ # ---- internals ---------------------------------------------------------------------
105
+
106
+ def _job(self, handle: JobHandle) -> _Job:
107
+ job = self._jobs.get(handle.job_id)
108
+ if job is None:
109
+ raise UnknownJob(handle.job_id)
110
+ return job
111
+
112
+ def _start_queued(self) -> None:
113
+ running = sum(1 for j in self._jobs.values() if j.state == "running")
114
+ for job_id, job in self._jobs.items():
115
+ if running >= self.describe().concurrency:
116
+ break
117
+ if job.state == "queued" and not job.killed:
118
+ try:
119
+ job.backend = self._start(job_id, job.spec, job.secrets)
120
+ except Exception as exc: # a backend fault ends this job, not the queue
121
+ job.result = self._never_started(job, status="crashed",
122
+ reason=f"start failed: "
123
+ f"{type(exc).__name__}: {exc}")
124
+ job.state = "done"
125
+ continue
126
+ job.state = "running"
127
+ running += 1
128
+
129
+ def _finish(self, job_id: str, job: _Job) -> JobResult:
130
+ result = self._collect(job.backend, job.spec, job.secrets, killed=job.killed)
131
+ env = job.spec.env
132
+ if env.built and env.digest not in self._charged_envs:
133
+ # §5 step 6: a fresh environment build is charged to the first job using it.
134
+ self._charged_envs.add(env.digest)
135
+ rate = self.describe().rate_usd_per_hour
136
+ if result.cost_usd is not None and rate is not None:
137
+ result.cost_usd += rate * env.build_seconds / 3600.0
138
+ return result
139
+
140
+ def _never_started(self, job: _Job, status: Status = "killed",
141
+ reason: str = "") -> JobResult:
142
+ stderr, _ = redact(reason.encode(), job.secrets)
143
+ return JobResult(status=status, exit_code=None, stdout=b"", stderr=stderr, outputs={},
144
+ metrics=None, started_at=None, ended_at=None, duration_s=0.0,
145
+ cost_usd=0.0 if self.describe().metered else None,
146
+ metered=self.describe().metered, env_digest=job.spec.env.digest,
147
+ code_digest=job.spec.code_digest, redactions=[],
148
+ output_cap_bytes=0)
149
+
150
+ @abstractmethod
151
+ def _start(self, job_id: str, spec: JobSpec, secrets: dict[str, str]) -> Any: ...
152
+
153
+ @abstractmethod
154
+ def _done(self, backend: Any) -> bool: ...
155
+
156
+ @abstractmethod
157
+ def _collect(self, backend: Any, spec: JobSpec, secrets: dict[str, str], *,
158
+ killed: bool) -> JobResult: ...
159
+
160
+ @abstractmethod
161
+ def _kill(self, backend: Any) -> None: ...
executor/common.py ADDED
@@ -0,0 +1,139 @@
1
+ """Helpers shared by the backends: workspaces, redaction, outputs, status, cost."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import os
7
+ import tarfile
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+
11
+ from .types import Status
12
+
13
+ TAG_FILE = "pm_tag" # in the job directory, outside the workspace the job can write
14
+ _TAG_OK = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.:-")
15
+
16
+
17
+ def check_tag(tag: str) -> str:
18
+ """Tags travel into container labels and shell commands: a closed character set."""
19
+ if not tag or len(tag) > 200 or not set(tag) <= _TAG_OK:
20
+ raise ValueError(f"invalid job tag {tag!r}")
21
+ return tag
22
+
23
+ OUTPUT_CAP = 2 * 1024 * 1024 # bytes kept per captured stream or text output
24
+ EXIT_TIMEOUT = 124 # coreutils/busybox `timeout` when the deadline passes
25
+
26
+
27
+ def now_iso() -> str:
28
+ return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
29
+
30
+
31
+ def unpack(code: bytes, dest: Path) -> None:
32
+ """Unpack a code bundle with the `data` filter: no absolute paths, no `..`, no links
33
+ out of the workspace, no device files (§5 step 1)."""
34
+ dest.mkdir(parents=True, exist_ok=True)
35
+ try:
36
+ with tarfile.open(fileobj=io.BytesIO(code), mode="r:*") as tar:
37
+ tar.extractall(dest, filter="data")
38
+ except (tarfile.FilterError, tarfile.ReadError) as exc:
39
+ raise ValueError(f"unsafe or unreadable code bundle: {type(exc).__name__}") from None
40
+ (dest / "outputs").mkdir(exist_ok=True)
41
+
42
+
43
+ def sanitized_tar(code: bytes) -> bytes:
44
+ """Re-pack a bundle after the same checks, for hosts whose `tar` may be less strict."""
45
+ out = io.BytesIO()
46
+ try:
47
+ with tarfile.open(fileobj=io.BytesIO(code), mode="r:*") as src, \
48
+ tarfile.open(fileobj=out, mode="w") as dst:
49
+ for member in src.getmembers():
50
+ safe = tarfile.data_filter(member, "/nonexistent-root")
51
+ # The filter clears ownership ("don't apply"); a new archive needs values.
52
+ safe = safe.replace(uid=0, gid=0, uname="", gname="", deep=False)
53
+ if safe.isfile():
54
+ dst.addfile(safe, src.extractfile(member))
55
+ elif safe.isdir():
56
+ dst.addfile(safe)
57
+ except (tarfile.FilterError, tarfile.ReadError) as exc:
58
+ raise ValueError(f"unsafe or unreadable code bundle: {type(exc).__name__}") from None
59
+ return out.getvalue()
60
+
61
+
62
+ def redact(data: bytes, secrets: dict[str, str]) -> tuple[bytes, list[str]]:
63
+ """Replace every value of the job's credentials (§5 step 5). Returns names, never values."""
64
+ hit: list[str] = []
65
+ for name, value in secrets.items():
66
+ if value and value.encode() in data:
67
+ data = data.replace(value.encode(), f"[REDACTED:{name}]".encode())
68
+ hit.append(name)
69
+ return data, hit
70
+
71
+
72
+ def cap(data: bytes, limit: int = OUTPUT_CAP) -> tuple[bytes, bool]:
73
+ return (data[:limit], True) if len(data) > limit else (data, False)
74
+
75
+
76
+ def read_outputs(outputs_dir: Path, secrets: dict[str, str]
77
+ ) -> tuple[dict[str, bytes], list[str], list[str]]:
78
+ """Files under `outputs/`, with text files redacted and capped."""
79
+ files: dict[str, bytes] = {}
80
+ redacted: list[str] = []
81
+ truncated: list[str] = []
82
+ if not outputs_dir.is_dir():
83
+ return files, redacted, truncated
84
+ for path in sorted(outputs_dir.rglob("*")):
85
+ if not path.is_file() or path.is_symlink():
86
+ continue
87
+ rel = "outputs/" + path.relative_to(outputs_dir).as_posix()
88
+ data = path.read_bytes()
89
+ files[rel], names, cut = process_text(data, secrets)
90
+ redacted += names
91
+ if cut:
92
+ truncated.append(rel)
93
+ return files, sorted(set(redacted)), truncated
94
+
95
+
96
+ def process_text(data: bytes, secrets: dict[str, str]) -> tuple[bytes, list[str], bool]:
97
+ """Redact and cap a file if it is text; binary files pass through unchanged."""
98
+ try:
99
+ data.decode("utf-8")
100
+ except UnicodeDecodeError:
101
+ return data, [], False
102
+ data, names = redact(data, secrets)
103
+ data, cut = cap(data)
104
+ return data, names, cut
105
+
106
+
107
+ def classify(exit_code: int | None, *, killed: bool, timed_out: bool,
108
+ crashed: bool) -> Status:
109
+ """§4 statuses. A kill by the user wins; then the deadline; then crashes."""
110
+ if killed:
111
+ return "killed"
112
+ if timed_out or exit_code == EXIT_TIMEOUT:
113
+ return "timeout"
114
+ if crashed:
115
+ return "crashed"
116
+ if exit_code is None:
117
+ return "lost"
118
+ if exit_code == 0:
119
+ return "ok"
120
+ if exit_code < 0 or exit_code in (137, 139, 143): # ended by a signal
121
+ return "crashed"
122
+ return "failed"
123
+
124
+
125
+ def cost(rate_usd_per_hour: float | None, seconds: float | None) -> tuple[float | None, bool]:
126
+ """§5 step 6: rate times wall-clock, or unknown and unmetered. Never a default 0."""
127
+ if rate_usd_per_hour is None or seconds is None:
128
+ return None, False
129
+ return rate_usd_per_hour * seconds / 3600.0, True
130
+
131
+
132
+ def minimal_env(extra: dict[str, str]) -> dict[str, str]:
133
+ """A small environment for agent-written code. The engine's own environment (which may
134
+ hold provider keys) is never inherited."""
135
+ keep = ("PATH", "SYSTEMROOT", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT", "TEMP", "TMP",
136
+ "LANG", "LC_ALL", "TZ")
137
+ env = {k: os.environ[k] for k in keep if k in os.environ}
138
+ env.update(extra)
139
+ return env
@@ -0,0 +1,239 @@
1
+ """`local-docker` backend: isolation `container` (05-executor.md §5, §6).
2
+
3
+ The container sees only its workspace (read-write) and the configured data mounts
4
+ (read-only); no host home directory, Docker socket or host credentials (§5 step 2). The
5
+ deadline is enforced inside the container by `timeout`, so it holds without the engine
6
+ (INV-2). Credentials are passed as `-e NAME` with the value in the Docker CLI's own
7
+ environment, never on its command line or on disk (INV-7).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import tempfile
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any, Literal
21
+
22
+ from .base import CredentialLookup, Executor
23
+ from .common import TAG_FILE, cap, check_tag, cost, minimal_env, read_outputs, redact, unpack
24
+ from .common import classify as classify_status
25
+ from .local_process import _tar
26
+ from .types import (
27
+ DEFAULT_IMAGE,
28
+ METRICS_FILE,
29
+ EnvRef,
30
+ EnvSpec,
31
+ ExecutorInfo,
32
+ JobResult,
33
+ JobSpec,
34
+ Network,
35
+ PreflightReport,
36
+ Resources,
37
+ )
38
+
39
+ LABEL = "puremethod.job"
40
+ TAG_LABEL = "puremethod.tag"
41
+
42
+
43
+ @dataclass
44
+ class _Container:
45
+ name: str
46
+ ws: Path
47
+ deadline_s: float
48
+ started: float
49
+ killed: bool = False
50
+ info: dict[str, Any] = field(default_factory=dict)
51
+
52
+
53
+ class LocalDockerExecutor(Executor):
54
+ def __init__(self, credentials: CredentialLookup, *, executor_id: str = "local-docker",
55
+ root: str | Path, concurrency: int = 1,
56
+ rate_usd_per_hour: float | None = None, network: Network = "on",
57
+ grace_s: float = 30.0, resources: Resources | None = None,
58
+ docker: str = "docker") -> None:
59
+ super().__init__(credentials)
60
+ self._info = ExecutorInfo(id=executor_id, kind="local-docker", isolation="container",
61
+ network=network, concurrency=concurrency,
62
+ resources=resources or Resources(),
63
+ rate_usd_per_hour=rate_usd_per_hour)
64
+ self._root = Path(root)
65
+ self._grace = grace_s
66
+ self._docker = docker
67
+
68
+ def describe(self) -> ExecutorInfo:
69
+ return self._info
70
+
71
+ def _cli(self, *args: str, env: dict[str, str] | None = None, check: bool = True,
72
+ timeout: float = 120) -> subprocess.CompletedProcess[str]:
73
+ return subprocess.run([self._docker, *args], capture_output=True, text=True, # noqa: S603
74
+ check=check, env=env, timeout=timeout)
75
+
76
+ def preflight(self) -> PreflightReport:
77
+ try:
78
+ self._cli("version", "--format", "{{.Server.Version}}", timeout=30)
79
+ except (OSError, subprocess.SubprocessError) as exc:
80
+ return PreflightReport(False, [f"docker unavailable: {type(exc).__name__}"])
81
+ env = self.build_env(EnvSpec())
82
+ if env.status != "ok":
83
+ return PreflightReport(False, [f"environment: {env.log[-500:]}"])
84
+ handle = self.submit(JobSpec(code=_tar({"main.py": b"print('ok')\n"}),
85
+ entrypoint="python main.py", env=env, seed=0,
86
+ deadline_s=60))
87
+ deadline = time.monotonic() + 120
88
+ while self.poll(handle) != "done" and time.monotonic() < deadline:
89
+ time.sleep(0.5)
90
+ if self.poll(handle) != "done" or self.result(handle).status != "ok":
91
+ return PreflightReport(False, ["a trivial job did not run"])
92
+ return PreflightReport(True, [])
93
+
94
+ def build_env(self, spec: EnvSpec) -> EnvRef:
95
+ digest = spec.digest()
96
+ tag = "pm-env:" + digest.removeprefix("sha256:")[:24]
97
+ if self._cli("image", "inspect", tag, check=False).returncode == 0:
98
+ return EnvRef(digest=digest, status="ok", ref=tag)
99
+ lines = [f"FROM {spec.base_image or DEFAULT_IMAGE}"]
100
+ if spec.system_packages:
101
+ pkgs = " ".join(spec.system_packages)
102
+ lines.append("RUN apt-get update && apt-get install -y --no-install-recommends "
103
+ f"{pkgs} && rm -rf /var/lib/apt/lists/*")
104
+ if spec.requirements_lock.strip():
105
+ lines += ["COPY requirements.lock /tmp/requirements.lock",
106
+ "RUN pip install --no-cache-dir -r /tmp/requirements.lock"]
107
+ lines.append(f'LABEL puremethod.env="{digest}"')
108
+ t0 = time.monotonic()
109
+ with tempfile.TemporaryDirectory(prefix="pm-env-") as ctx:
110
+ Path(ctx, "Dockerfile").write_text("\n".join(lines) + "\n", encoding="utf-8")
111
+ Path(ctx, "requirements.lock").write_text(spec.requirements_lock, encoding="utf-8")
112
+ out = self._cli("build", "-t", tag, ctx, check=False, timeout=3600)
113
+ log = (out.stdout + out.stderr)[-20000:]
114
+ status: Literal["ok", "failed"] = "ok" if out.returncode == 0 else "failed"
115
+ return EnvRef(digest=digest, status=status, ref=tag if status == "ok" else "",
116
+ log=log, build_seconds=time.monotonic() - t0, built=status == "ok")
117
+
118
+ # ---- backend hooks -----------------------------------------------------------------
119
+
120
+ def _start(self, job_id: str, spec: JobSpec, secrets: dict[str, str]) -> _Container:
121
+ ws = (self._root / "jobs" / job_id / "ws").resolve()
122
+ unpack(spec.code, ws)
123
+ name = f"pm-job-{job_id}"
124
+ args = ["run", "-d", "--name", name, "--label", f"{LABEL}={job_id}",
125
+ "-v", f"{ws}:/work", "-w", "/work", "-e", f"PM_SEED={spec.seed}"]
126
+ if spec.tag is not None:
127
+ (ws.parent / TAG_FILE).write_text(spec.tag, encoding="utf-8")
128
+ args += ["--label", f"{TAG_LABEL}={spec.tag}"]
129
+ if self._info.network == "off":
130
+ args += ["--network", "none"]
131
+ r = spec.resources
132
+ if r.cpus:
133
+ args += ["--cpus", str(r.cpus)]
134
+ if r.memory_mb:
135
+ args += ["--memory", f"{r.memory_mb}m"]
136
+ if r.gpus:
137
+ args += ["--gpus", str(r.gpus)]
138
+ for m in spec.data_mounts:
139
+ args += ["-v", f"{Path(m.source).resolve()}:/data/{m.name}:ro"]
140
+ for cred in secrets:
141
+ args += ["-e", cred] # value comes from the CLI's environment below
142
+ grace = str(int(max(self._grace, 1)))
143
+ deadline = str(max(int(spec.deadline_s), 1))
144
+ args += [spec.env.ref, "timeout", "-s", "TERM", "-k", grace, deadline,
145
+ "sh", "-c", spec.entrypoint]
146
+ self._cli(*args, env=minimal_env(secrets) | _docker_env())
147
+ return _Container(name=name, ws=ws, deadline_s=spec.deadline_s,
148
+ started=time.monotonic())
149
+
150
+ def _inspect(self, c: _Container) -> dict[str, Any] | None:
151
+ out = self._cli("inspect", c.name, check=False)
152
+ if out.returncode != 0:
153
+ return None
154
+ data = json.loads(out.stdout)
155
+ return data[0] if data else None
156
+
157
+ def _done(self, backend: Any) -> bool:
158
+ c: _Container = backend
159
+ info = self._inspect(c)
160
+ if info is None:
161
+ return True # container gone: lost
162
+ c.info = info
163
+ return info["State"]["Status"] in ("exited", "dead")
164
+
165
+ def _collect(self, backend: Any, spec: JobSpec, secrets: dict[str, str], *,
166
+ killed: bool) -> JobResult:
167
+ c: _Container = backend
168
+ state = c.info.get("State", {}) if c.info else {}
169
+ logs = self._cli("logs", c.name, check=False, timeout=300)
170
+ stdout, n1 = redact(logs.stdout.encode(), secrets)
171
+ stderr, n2 = redact(logs.stderr.encode(), secrets)
172
+ stdout, t1 = cap(stdout)
173
+ stderr, t2 = cap(stderr)
174
+ outputs, n3, truncated = read_outputs(c.ws / "outputs", secrets)
175
+ truncated += [n for n, t in (("stdout", t1), ("stderr", t2)) if t]
176
+ started, ended = state.get("StartedAt"), state.get("FinishedAt")
177
+ duration = _duration(started, ended)
178
+ exit_code = state.get("ExitCode") if state else None
179
+ timed_out = duration is not None and duration >= c.deadline_s and exit_code != 0
180
+ status = classify_status(exit_code, killed=killed or c.killed, timed_out=timed_out,
181
+ crashed=bool(state.get("OOMKilled")))
182
+ if not state:
183
+ status = "lost"
184
+ self._cli("rm", "-f", c.name, check=False)
185
+ # Outputs are captured and redacted above; the workspace may hold whatever the job
186
+ # wrote, credentials included, so it does not outlive the job.
187
+ shutil.rmtree(c.ws.parent, ignore_errors=True)
188
+ usd, metered = cost(self._info.rate_usd_per_hour, duration)
189
+ return JobResult(status=status, exit_code=exit_code, stdout=stdout, stderr=stderr,
190
+ outputs=outputs, metrics=outputs.get(METRICS_FILE),
191
+ started_at=started, ended_at=ended, duration_s=duration,
192
+ cost_usd=usd, metered=metered, env_digest=spec.env.digest,
193
+ code_digest=spec.code_digest, redactions=sorted(set(n1 + n2 + n3)),
194
+ output_cap_bytes=2 * 1024 * 1024, truncated=truncated)
195
+
196
+ def _kill(self, backend: Any) -> None:
197
+ c: _Container = backend
198
+ c.killed = True
199
+ self._cli("kill", c.name, check=False)
200
+
201
+ def reap(self, tags: list[str]) -> list[str]:
202
+ found: set[str] = set()
203
+ for tag in map(check_tag, tags):
204
+ out = self._cli("ps", "-aq", "--filter", f"label={TAG_LABEL}={tag}", check=False)
205
+ ids = out.stdout.split()
206
+ if ids:
207
+ self._cli("rm", "-f", *ids, check=False)
208
+ found.add(tag)
209
+ jobs = self._root / "jobs"
210
+ for marker in jobs.glob(f"*/{TAG_FILE}") if jobs.is_dir() else []:
211
+ tag = marker.read_text(encoding="utf-8").strip()
212
+ if tag in tags:
213
+ shutil.rmtree(marker.parent, ignore_errors=True)
214
+ found.add(tag)
215
+ return sorted(found)
216
+
217
+
218
+ def _docker_env() -> dict[str, str]:
219
+ """Variables the Docker CLI itself needs to reach the daemon."""
220
+ keep = ("DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_CERT_PATH",
221
+ "DOCKER_TLS_VERIFY", "USERPROFILE", "HOME", "APPDATA", "LOCALAPPDATA",
222
+ "PROGRAMDATA", "ProgramFiles")
223
+ return {k: os.environ[k] for k in keep if k in os.environ}
224
+
225
+
226
+ def _duration(started: str | None, ended: str | None) -> float | None:
227
+ from datetime import datetime
228
+
229
+ if not started or not ended or ended.startswith("0001"):
230
+ return None
231
+
232
+ def parse(s: str) -> datetime:
233
+ s = s.rstrip("Z")
234
+ if "." in s:
235
+ head, frac = s.split(".", 1)
236
+ s = f"{head}.{frac[:6]}"
237
+ return datetime.fromisoformat(s)
238
+
239
+ return max((parse(ended) - parse(started)).total_seconds(), 0.0)