patchground 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 (194) hide show
  1. patchground/__init__.py +10 -0
  2. patchground/__main__.py +3 -0
  3. patchground/authoring/__init__.py +5 -0
  4. patchground/authoring/create.py +179 -0
  5. patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
  6. patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
  7. patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  8. patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
  9. patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
  10. patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
  11. patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
  12. patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
  13. patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
  14. patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
  15. patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  16. patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
  17. patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
  18. patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
  19. patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
  20. patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
  21. patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
  22. patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
  23. patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  24. patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
  25. patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
  26. patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
  27. patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
  28. patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
  29. patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
  30. patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  31. patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
  32. patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
  33. patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
  34. patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
  35. patchground/automation/__init__.py +5 -0
  36. patchground/automation/__main__.py +131 -0
  37. patchground/automation/workflow.py +214 -0
  38. patchground/catalog/README.md +10 -0
  39. patchground/catalog/cache-expiry/TICKET.md +49 -0
  40. patchground/catalog/cache-expiry/challenge.yaml +32 -0
  41. patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
  42. patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
  43. patchground/catalog/cache-expiry/debrief/questions.md +4 -0
  44. patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
  45. patchground/catalog/cache-expiry/solution/reference.patch +41 -0
  46. patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
  47. patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
  48. patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
  49. patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
  50. patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
  51. patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
  52. patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
  53. patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
  54. patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
  55. patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
  56. patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
  57. patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
  58. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
  59. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
  60. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
  61. patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
  62. patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
  63. patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
  64. patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
  65. patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
  66. patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
  67. patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
  68. patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
  69. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
  70. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
  71. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
  72. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
  73. patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
  74. patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
  75. patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
  76. patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
  77. patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
  78. patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
  79. patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
  80. patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
  81. patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
  82. patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
  83. patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
  84. patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
  85. patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
  86. patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
  87. patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
  88. patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
  89. patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
  90. patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
  91. patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
  92. patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
  93. patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
  94. patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
  95. patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
  96. patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
  97. patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
  98. patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
  99. patchground/catalog/pagination-last-page/TICKET.md +44 -0
  100. patchground/catalog/pagination-last-page/challenge.yaml +31 -0
  101. patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
  102. patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
  103. patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
  104. patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
  105. patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
  106. patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
  107. patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
  108. patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
  109. patchground/catalog/release-version-ordering/TICKET.md +72 -0
  110. patchground/catalog/release-version-ordering/challenge.yaml +31 -0
  111. patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
  112. patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
  113. patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
  114. patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
  115. patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
  116. patchground/catalog/release-version-ordering/environment/package.json +13 -0
  117. patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
  118. patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
  119. patchground/catalog/release-version-ordering/workspace/README.md +7 -0
  120. patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
  121. patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
  122. patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
  123. patchground/cli/__init__.py +1 -0
  124. patchground/cli/app.py +59 -0
  125. patchground/cli/authoring.py +41 -0
  126. patchground/cli/consent.py +77 -0
  127. patchground/cli/discovery.py +189 -0
  128. patchground/cli/doctor.py +249 -0
  129. patchground/cli/packs.py +142 -0
  130. patchground/cli/practice.py +141 -0
  131. patchground/cli/render.py +58 -0
  132. patchground/cli/reports.py +53 -0
  133. patchground/cli/sessions.py +195 -0
  134. patchground/cli/validate.py +117 -0
  135. patchground/packs/__init__.py +23 -0
  136. patchground/packs/builtin.py +53 -0
  137. patchground/packs/discovery.py +285 -0
  138. patchground/packs/execution.py +420 -0
  139. patchground/packs/models.py +98 -0
  140. patchground/packs/registry.py +318 -0
  141. patchground/packs/source.py +230 -0
  142. patchground/packs/store.py +420 -0
  143. patchground/py.typed +0 -0
  144. patchground/report/__init__.py +6 -0
  145. patchground/report/analysis.py +188 -0
  146. patchground/report/models.py +59 -0
  147. patchground/report/regression.py +597 -0
  148. patchground/report/render.py +92 -0
  149. patchground/report/workflow.py +84 -0
  150. patchground/runner_assets/Dockerfile +7 -0
  151. patchground/runner_assets/__init__.py +16 -0
  152. patchground/runner_assets/package-lock.json +1054 -0
  153. patchground/runner_assets/package.json +13 -0
  154. patchground/runner_assets/vitest-runner.mjs +124 -0
  155. patchground/safeio/__init__.py +115 -0
  156. patchground/sandbox/__init__.py +39 -0
  157. patchground/sandbox/_engine.py +23 -0
  158. patchground/sandbox/build.py +202 -0
  159. patchground/sandbox/errors.py +23 -0
  160. patchground/sandbox/junit.py +76 -0
  161. patchground/sandbox/probe.py +118 -0
  162. patchground/sandbox/run.py +535 -0
  163. patchground/sessions/__init__.py +44 -0
  164. patchground/sessions/diagnostics.py +145 -0
  165. patchground/sessions/files.py +113 -0
  166. patchground/sessions/git.py +70 -0
  167. patchground/sessions/history.py +386 -0
  168. patchground/sessions/history_ignore.py +279 -0
  169. patchground/sessions/models.py +126 -0
  170. patchground/sessions/practice.py +202 -0
  171. patchground/sessions/reset.py +121 -0
  172. patchground/sessions/store.py +203 -0
  173. patchground/sessions/submission.py +344 -0
  174. patchground/sessions/workflow.py +297 -0
  175. patchground/spec/__init__.py +35 -0
  176. patchground/spec/_schema.py +53 -0
  177. patchground/spec/loader.py +713 -0
  178. patchground/spec/models.py +394 -0
  179. patchground/spec/schema.py +50 -0
  180. patchground/verify/__init__.py +29 -0
  181. patchground/verify/errors.py +10 -0
  182. patchground/verify/pytest_adapter.py +95 -0
  183. patchground/verify/record.py +94 -0
  184. patchground/verify/reference.py +158 -0
  185. patchground/verify/result.py +199 -0
  186. patchground/verify/runners.py +47 -0
  187. patchground/verify/tampering.py +106 -0
  188. patchground/verify/validate.py +686 -0
  189. patchground/verify/vitest_adapter.py +252 -0
  190. patchground-0.1.0.dist-info/METADATA +258 -0
  191. patchground-0.1.0.dist-info/RECORD +194 -0
  192. patchground-0.1.0.dist-info/WHEEL +4 -0
  193. patchground-0.1.0.dist-info/entry_points.txt +3 -0
  194. patchground-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,118 @@
1
+ """Engine probing for `doctor`: reachable, version. Never builds, pulls or runs anything."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+ import socket
7
+ from dataclasses import dataclass
8
+ from enum import StrEnum
9
+ from typing import Any
10
+
11
+ import docker
12
+ import docker.errors
13
+ import requests
14
+
15
+ DEFAULT_PROBE_TIMEOUT = 5.0
16
+
17
+
18
+ class EngineProblem(StrEnum):
19
+ """Why the engine could not be used; a category, never the raw engine error."""
20
+
21
+ NOT_RUNNING = "not_running" # no socket, connection refused: engine absent or stopped
22
+ PERMISSION = "permission" # socket exists but this user may not use it
23
+ TIMEOUT = "timeout" # engine did not answer within the probe budget
24
+ MISCONFIGURED = "misconfigured" # DOCKER_HOST / context could not be parsed or used
25
+ UNHEALTHY = "unhealthy" # engine answered, but with an error
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class EngineProbe:
30
+ """Structured engine readiness. ``problem`` is None exactly when ``reachable``."""
31
+
32
+ reachable: bool
33
+ problem: EngineProblem | None = None
34
+ engine: str | None = None # "Docker" or "Podman"
35
+ version: str | None = None
36
+ api_version: str | None = None
37
+
38
+
39
+ def probe_engine(*, timeout: float = DEFAULT_PROBE_TIMEOUT) -> EngineProbe:
40
+ """Ping the configured engine and read its version, within ``timeout`` seconds per request.
41
+
42
+ Never raises for an unusable engine; the client is always closed.
43
+ """
44
+ if timeout <= 0:
45
+ raise ValueError("timeout must be positive")
46
+ client: docker.DockerClient | None = None
47
+ try:
48
+ client = docker.from_env(timeout=max(1, round(timeout)))
49
+ client.ping()
50
+ info: dict[str, Any] = client.version()
51
+ except (docker.errors.DockerException, requests.exceptions.RequestException, OSError) as exc:
52
+ return EngineProbe(reachable=False, problem=_classify(exc))
53
+ finally:
54
+ if client is not None:
55
+ client.close()
56
+ return EngineProbe(
57
+ reachable=True,
58
+ engine=_engine_name(info),
59
+ version=_text(info.get("Version")),
60
+ api_version=_text(info.get("ApiVersion")),
61
+ )
62
+
63
+
64
+ def _text(value: object) -> str | None:
65
+ return value[:64] if isinstance(value, str) and value else None
66
+
67
+
68
+ def _engine_name(info: dict[str, Any]) -> str:
69
+ components = info.get("Components")
70
+ if isinstance(components, list):
71
+ for component in components:
72
+ name = component.get("Name") if isinstance(component, dict) else None
73
+ if isinstance(name, str) and "podman" in name.lower():
74
+ return "Podman"
75
+ return "Docker"
76
+
77
+
78
+ def _chain(exc: BaseException) -> list[BaseException]:
79
+ """The exception, its causes/contexts and exceptions nested in args (bounded)."""
80
+ seen: list[BaseException] = []
81
+ pending: list[BaseException] = [exc]
82
+ while pending and len(seen) < 32:
83
+ current = pending.pop()
84
+ if any(current is known for known in seen):
85
+ continue
86
+ seen.append(current)
87
+ for linked in (current.__cause__, current.__context__):
88
+ if linked is not None:
89
+ pending.append(linked)
90
+ for arg in current.args:
91
+ nested = arg if isinstance(arg, tuple) else (arg,)
92
+ pending.extend(item for item in nested if isinstance(item, BaseException))
93
+ return seen
94
+
95
+
96
+ def _classify(exc: BaseException) -> EngineProblem:
97
+ chain = _chain(exc)
98
+ for item in chain:
99
+ if isinstance(item, PermissionError) or (
100
+ isinstance(item, OSError) and item.errno in (errno.EACCES, errno.EPERM)
101
+ ):
102
+ return EngineProblem.PERMISSION
103
+ for item in chain:
104
+ if isinstance(item, (requests.exceptions.Timeout, socket.timeout, TimeoutError)):
105
+ return EngineProblem.TIMEOUT
106
+ for item in chain:
107
+ if isinstance(item, (FileNotFoundError, ConnectionRefusedError, ConnectionResetError)):
108
+ return EngineProblem.NOT_RUNNING
109
+ if isinstance(item, requests.exceptions.ConnectionError):
110
+ return EngineProblem.NOT_RUNNING
111
+ if isinstance(exc, docker.errors.APIError):
112
+ return EngineProblem.UNHEALTHY
113
+ if (
114
+ isinstance(exc, docker.errors.DockerException)
115
+ and "fetching server api version" in str(exc).lower()
116
+ ):
117
+ return EngineProblem.NOT_RUNNING
118
+ return EngineProblem.MISCONFIGURED
@@ -0,0 +1,535 @@
1
+ """Run phase: execute a command against a copy of some files inside a hardened container.
2
+
3
+ Every run gets a fresh container on a fresh ``internal`` bridge network (no route out), a
4
+ read-only root filesystem, and two fresh volumes, ``/workspace`` and ``/results``, which are the
5
+ only writable paths besides a small ``/tmp`` tmpfs. Files are copied into the volumes with
6
+ ``put_archive`` through a never-started "seed" container before the run container is created
7
+ (see :func:`_create_seed`). Results come from ``/results/junit.xml`` only; the exit code is
8
+ informative. Containers, volumes and network are removed in a ``finally``, also on timeout,
9
+ errors and ``KeyboardInterrupt``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import io
16
+ import os
17
+ import re
18
+ import stat
19
+ import tarfile
20
+ import time
21
+ import uuid
22
+ from collections.abc import Iterable, Iterator, Sequence
23
+ from dataclasses import dataclass
24
+ from enum import StrEnum
25
+ from pathlib import Path
26
+
27
+ import docker.errors
28
+ import requests
29
+ import urllib3.exceptions
30
+ from docker import DockerClient
31
+ from docker.models.containers import Container
32
+ from docker.models.networks import Network
33
+ from docker.models.volumes import Volume
34
+ from docker.types import LogConfig, Mount
35
+
36
+ from patchground.sandbox._engine import connect
37
+ from patchground.sandbox.errors import JUnitError, SandboxError
38
+ from patchground.sandbox.junit import TestResult, TestStatus, parse_junit
39
+
40
+ RUN_UID = 1000
41
+ RUN_GID = 1000
42
+ WORKSPACE = "/workspace"
43
+ RESULTS = "/results"
44
+ JUNIT_PATH = f"{RESULTS}/junit.xml"
45
+ #: Largest junit.xml accepted from a container.
46
+ MAX_JUNIT_BYTES = 50 * 1024**2
47
+ LOG_TAIL_LINES = 200
48
+ #: Most bytes of sanitized combined output ``run_command`` returns (the tail of the output).
49
+ MAX_OUTPUT_TAIL_BYTES = 64 * 1024
50
+ #: Raw log bytes kept while streaming; sanitizing only shrinks them, so the tail still fills.
51
+ _RAW_TAIL_BYTES = 4 * MAX_OUTPUT_TAIL_BYTES
52
+ #: The engine keeps at most ``max-size * max-file`` of a container's output, whatever it prints.
53
+ LOG_CONFIG = LogConfig(type="json-file", config={"max-size": "1m", "max-file": "2"})
54
+ #: Exit codes the OCI runtime uses for "found but not executable" / "not found".
55
+ _EXEC_FAILURE_CODES = (126, 127)
56
+ #: A connection error this close to the wall-clock limit counts as the limit being hit.
57
+ TIMEOUT_TOLERANCE_SECONDS = 1.0
58
+ LABELS = {"patchground": "1"}
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Limits:
63
+ """Per-run resource limits (defaults from ``patchground-contract-execution``)."""
64
+
65
+ cpus: float = 2.0
66
+ memory_bytes: int = 2 * 1024**3
67
+ pids: int = 512
68
+ timeout_seconds: float = 600.0
69
+
70
+ def __post_init__(self) -> None:
71
+ if self.cpus <= 0 or self.memory_bytes <= 0 or self.pids <= 0 or self.timeout_seconds <= 0:
72
+ raise ValueError(f"limits must be positive: {self}")
73
+
74
+
75
+ DEFAULT_LIMITS = Limits()
76
+
77
+
78
+ class RunOutcome(StrEnum):
79
+ """Overall outcome of a run, derived from JUnit results (never from the exit code)."""
80
+
81
+ PASSED = "passed"
82
+ FAILED = "failed"
83
+ ERRORED = "errored"
84
+ MISSING_RESULTS = "missing_results"
85
+ LIMIT_EXCEEDED = "limit_exceeded"
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class RunResult:
90
+ """Result of :func:`run_check`.
91
+
92
+ ``limit`` is ``"timeout"`` or ``"memory"`` when ``outcome`` is ``LIMIT_EXCEEDED``.
93
+ ``log_tail`` is the last ~200 lines of combined stdout/stderr, followed by any
94
+ ``[patchground]`` notes explaining the outcome.
95
+
96
+ ``log_tail`` is raw test-runner output and may contain test sources (assertion rewrites,
97
+ tracebacks), including those of hidden checks. Callers must not display it for runs that
98
+ included hidden layers. Prefixed note lines are also untrusted; tests can spoof them.
99
+ """
100
+
101
+ outcome: RunOutcome
102
+ tests: tuple[TestResult, ...]
103
+ exit_code: int | None
104
+ limit: str | None
105
+ log_tail: str
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class CommandResult:
110
+ """Result of :func:`run_command`.
111
+
112
+ ``output_tail`` is the sanitized tail (at most :data:`MAX_OUTPUT_TAIL_BYTES` bytes of UTF-8)
113
+ of the combined stdout/stderr of the command; earlier output may have been dropped, which
114
+ ``output_tail_only`` (always true) states. It is arbitrary command output: untrusted text with
115
+ terminal control sequences removed, never engine error text. ``limit`` is ``"timeout"`` or
116
+ ``"memory"`` when a resource limit ended the run; ``exit_code`` is ``None`` on timeout.
117
+ """
118
+
119
+ exit_code: int | None
120
+ limit: str | None
121
+ output_tail: str
122
+ output_tail_only: bool = True
123
+
124
+
125
+ # --------------------------------------------------------------------------- tar building
126
+
127
+
128
+ def _tarinfo(name: str, mode: int, mtime: float, *, size: int = 0, is_dir: bool) -> tarfile.TarInfo:
129
+ info = tarfile.TarInfo(name)
130
+ info.type = tarfile.DIRTYPE if is_dir else tarfile.REGTYPE
131
+ info.mode = mode
132
+ info.size = size
133
+ info.mtime = int(mtime)
134
+ info.uid = RUN_UID
135
+ info.gid = RUN_GID
136
+ info.uname = ""
137
+ info.gname = ""
138
+ return info
139
+
140
+
141
+ def _collect(layer: Path) -> dict[str, tuple[Path, bool]]:
142
+ """Map relative POSIX path -> (path, is_dir) for one layer; reject non-regular files."""
143
+ if not layer.is_dir() or layer.is_symlink():
144
+ raise SandboxError(f"layer is not a directory: {layer}")
145
+ entries: dict[str, tuple[Path, bool]] = {}
146
+ for dirpath, dirnames, filenames in os.walk(layer):
147
+ base = Path(dirpath)
148
+ for name in [*dirnames, *filenames]:
149
+ path = base / name
150
+ st = path.lstat()
151
+ rel = path.relative_to(layer).as_posix()
152
+ if stat.S_ISDIR(st.st_mode):
153
+ entries[rel] = (path, True)
154
+ elif stat.S_ISREG(st.st_mode):
155
+ entries[rel] = (path, False)
156
+ else:
157
+ kind = "symlink" if stat.S_ISLNK(st.st_mode) else "special file"
158
+ raise SandboxError(f"refusing to copy {kind} into the sandbox: {path}")
159
+ return entries
160
+
161
+
162
+ def build_seed_tar(layers: Sequence[Path]) -> bytes:
163
+ """Build the in-memory tar that seeds the ``workspace`` and ``results`` volumes.
164
+
165
+ It is extracted at ``/`` of the seed container, so it holds a ``workspace/`` directory with
166
+ ``layers`` (directories, applied in order) and an empty ``results/`` directory. Every entry is
167
+ owned by uid/gid 1000 and keeps its permission bits (setuid/setgid/sticky stripped); the two
168
+ top-level entries make the volume roots themselves owned by 1000 and mode 0755.
169
+
170
+ Directories may appear in several layers; any other path clash between layers is a caller
171
+ bug and raises :class:`SandboxError`, as do symlinks and special files anywhere in a layer
172
+ and files that cannot be read.
173
+ """
174
+ try:
175
+ return _build_seed_tar(layers)
176
+ except OSError as exc:
177
+ raise SandboxError(f"cannot read files to copy into the sandbox: {exc}") from exc
178
+
179
+
180
+ def _build_seed_tar(layers: Sequence[Path]) -> bytes:
181
+ merged: dict[str, tuple[Path, bool]] = {}
182
+ for layer in layers:
183
+ for rel, (path, is_dir) in _collect(layer).items():
184
+ previous = merged.get(rel)
185
+ if previous is not None and not (previous[1] and is_dir):
186
+ raise SandboxError(f"path {rel!r} is provided by more than one layer")
187
+ merged.setdefault(rel, (path, is_dir))
188
+ now = time.time()
189
+ ws = WORKSPACE.lstrip("/")
190
+ buf = io.BytesIO()
191
+ with tarfile.open(fileobj=buf, mode="w", format=tarfile.PAX_FORMAT) as tar:
192
+ tar.addfile(_tarinfo(f"{ws}/", 0o755, now, is_dir=True))
193
+ for rel in sorted(merged):
194
+ path, is_dir = merged[rel]
195
+ st = path.stat()
196
+ mode = stat.S_IMODE(st.st_mode) & 0o777
197
+ if is_dir:
198
+ tar.addfile(_tarinfo(f"{ws}/{rel}/", mode, st.st_mtime, is_dir=True))
199
+ else:
200
+ with path.open("rb") as fh:
201
+ info = _tarinfo(f"{ws}/{rel}", mode, st.st_mtime, size=st.st_size, is_dir=False)
202
+ tar.addfile(info, fh)
203
+ tar.addfile(_tarinfo(f"{RESULTS.lstrip('/')}/", 0o755, now, is_dir=True))
204
+ return buf.getvalue()
205
+
206
+
207
+ def _extract_single_file(chunks: Iterable[bytes], limit: int) -> bytes:
208
+ """Return the bytes of the one regular file in a ``get_archive`` tar stream."""
209
+ buf = io.BytesIO()
210
+ for chunk in chunks:
211
+ buf.write(chunk)
212
+ if buf.tell() > limit + 64 * 1024: # file plus tar headers/padding
213
+ raise JUnitError(f"junit.xml is larger than {limit} bytes")
214
+ buf.seek(0)
215
+ try:
216
+ with tarfile.open(fileobj=buf, mode="r:") as tar:
217
+ members = tar.getmembers()
218
+ if len(members) != 1 or not members[0].isreg():
219
+ raise JUnitError("junit.xml is not a regular file")
220
+ if members[0].size > limit:
221
+ raise JUnitError(f"junit.xml is larger than {limit} bytes")
222
+ fh = tar.extractfile(members[0])
223
+ if fh is None:
224
+ raise JUnitError("junit.xml could not be read")
225
+ return fh.read()
226
+ except tarfile.TarError as exc:
227
+ raise JUnitError(f"could not read junit.xml archive: {exc}") from exc
228
+
229
+
230
+ # --------------------------------------------------------------------------- outcome rules
231
+
232
+
233
+ def outcome_for(tests: Sequence[TestResult]) -> tuple[RunOutcome, str | None]:
234
+ """Derive the run outcome from parsed tests; returns (outcome, explanatory note or None)."""
235
+ if not tests:
236
+ return RunOutcome.ERRORED, "junit.xml contains no test cases"
237
+ statuses = {t.status for t in tests}
238
+ if TestStatus.ERRORED in statuses:
239
+ return RunOutcome.ERRORED, None
240
+ if TestStatus.FAILED in statuses:
241
+ return RunOutcome.FAILED, None
242
+ return RunOutcome.PASSED, None
243
+
244
+
245
+ def _with_notes(log: str, notes: Sequence[str]) -> str:
246
+ if not notes:
247
+ return log
248
+ suffix = "\n".join(f"[patchground] {note}" for note in notes)
249
+ if log and not log.endswith("\n"):
250
+ log += "\n"
251
+ return log + suffix + "\n"
252
+
253
+
254
+ # --------------------------------------------------------------------------- run
255
+
256
+
257
+ @contextlib.contextmanager
258
+ def _hardened_container(
259
+ image_id: str,
260
+ layers: Sequence[Path],
261
+ *,
262
+ entrypoint: list[str],
263
+ command: list[str],
264
+ limits: Limits,
265
+ ) -> Iterator[Container]:
266
+ """Yield a created, not yet started hardened container seeded with a copy of ``layers``.
267
+
268
+ This is the single run lifecycle of the module: fresh internal network and volumes, seed
269
+ archive, the hardened container, and teardown of all of it on every exit path, including
270
+ errors and ``KeyboardInterrupt``. Engine failures become :class:`SandboxError`.
271
+ """
272
+ seed_tar = build_seed_tar(layers)
273
+ run_id = f"patchground-{uuid.uuid4().hex}"
274
+ client = connect()
275
+ network: Network | None = None
276
+ volumes: list[Volume] = []
277
+ seed: Container | None = None
278
+ container: Container | None = None
279
+ try:
280
+ network = client.networks.create(run_id, driver="bridge", internal=True, labels=LABELS)
281
+ for suffix in ("workspace", "results"):
282
+ volumes.append(client.volumes.create(name=f"{run_id}-{suffix}", labels=LABELS))
283
+ mounts = [
284
+ Mount(WORKSPACE, _name(volumes[0]), type="volume", no_copy=True),
285
+ Mount(RESULTS, _name(volumes[1]), type="volume", no_copy=True),
286
+ ]
287
+ seed = _create_seed(client, image_id, mounts)
288
+ if not seed.put_archive("/", seed_tar):
289
+ raise SandboxError("could not copy files into the sandbox")
290
+ _remove_container(seed)
291
+ seed = None
292
+ container = _create_container(
293
+ client, image_id, entrypoint, command, mounts, _name(network), limits
294
+ )
295
+ yield container
296
+ except (
297
+ docker.errors.DockerException,
298
+ requests.exceptions.RequestException,
299
+ urllib3.exceptions.HTTPError,
300
+ ) as exc:
301
+ raise SandboxError(f"sandbox run failed: {exc}") from exc
302
+ finally:
303
+ _teardown([c for c in (seed, container) if c is not None], volumes, network)
304
+ client.close()
305
+
306
+
307
+ def run_check(
308
+ image_id: str,
309
+ layers: Sequence[Path],
310
+ command: str,
311
+ *,
312
+ limits: Limits = DEFAULT_LIMITS,
313
+ ) -> RunResult:
314
+ """Run ``command`` (via ``/bin/sh -c``) in ``image_id`` against a copy of ``layers``.
315
+
316
+ ``layers`` are directories merged into ``/workspace`` in order (see :func:`build_seed_tar`).
317
+ The command runs as uid/gid 1000 with ``PG_RESULTS=/results`` and must write
318
+ ``/results/junit.xml``. The image's ENTRYPOINT is overridden.
319
+
320
+ Raises :class:`EngineUnavailable` if the engine is unreachable and :class:`SandboxError`
321
+ for invalid layers or engine failures; test outcomes are reported in the result.
322
+ """
323
+ with _hardened_container(
324
+ image_id, layers, entrypoint=["/bin/sh", "-c"], command=[command], limits=limits
325
+ ) as container:
326
+ container.start()
327
+ return _wait_and_collect(container, limits)
328
+
329
+
330
+ def run_command(
331
+ image_id: str,
332
+ layers: Sequence[Path],
333
+ argv: Sequence[str],
334
+ *,
335
+ limits: Limits = DEFAULT_LIMITS,
336
+ ) -> CommandResult:
337
+ """Run ``argv`` literally (no shell unless ``argv`` names one) against a copy of ``layers``.
338
+
339
+ Same container, hardening, limits and teardown as :func:`run_check`; the image's ENTRYPOINT is
340
+ replaced by ``argv[0]``. Nothing from the host environment is passed in, and no JUnit is read.
341
+ A command that cannot be started (missing or non-executable program) reports exit code 126 or
342
+ 127 with empty output. Raises :class:`SandboxError` for an empty or NUL-containing ``argv``,
343
+ invalid layers or engine failures.
344
+ """
345
+ if not argv or not argv[0]:
346
+ raise SandboxError("command is empty")
347
+ if any("\0" in arg for arg in argv):
348
+ raise SandboxError("command arguments cannot contain NUL bytes")
349
+ with _hardened_container(
350
+ image_id, layers, entrypoint=[argv[0]], command=list(argv[1:]), limits=limits
351
+ ) as container:
352
+ try:
353
+ container.start()
354
+ except docker.errors.APIError:
355
+ # An unrunnable program is the command's outcome, not an infrastructure failure;
356
+ # the engine's message is deliberately dropped.
357
+ container.reload()
358
+ state = container.attrs.get("State", {})
359
+ code = state.get("ExitCode")
360
+ if code not in _EXEC_FAILURE_CODES or state.get("Running"):
361
+ raise
362
+ return CommandResult(code, None, "")
363
+ exit_code = _wait(container, limits)
364
+ container.reload()
365
+ oom_killed = bool(container.attrs.get("State", {}).get("OOMKilled", False))
366
+ output = _output_tail(container)
367
+ if exit_code is None:
368
+ return CommandResult(None, "timeout", output)
369
+ return CommandResult(exit_code, "memory" if oom_killed else None, output)
370
+
371
+
372
+ _ANSI = re.compile(
373
+ r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?" # OSC (titles, hyperlinks), also unterminated
374
+ r"|\x1b[PX^_][^\x1b]*(?:\x1b\\)?" # DCS / SOS / PM / APC strings
375
+ r"|\x1b\[[0-?]*[ -/]*[@-~]" # CSI
376
+ r"|\x1b[ -/]*[0-~]?" # other escapes, or a lone/truncated ESC
377
+ )
378
+ _CONTROLS = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
379
+
380
+
381
+ def sanitize_output(raw: bytes) -> str:
382
+ """Decode command output and drop terminal control sequences, keeping ``\\n`` and ``\\t``.
383
+
384
+ Carriage returns are removed (``\\r\\n`` becomes ``\\n``), so output cannot overwrite what
385
+ precedes it on a terminal line. The result is at most :data:`MAX_OUTPUT_TAIL_BYTES` bytes of
386
+ UTF-8, keeping the end of the output.
387
+ """
388
+ text = raw.decode("utf-8", errors="replace")
389
+ text = _CONTROLS.sub("", _ANSI.sub("", text))
390
+ encoded = text.encode("utf-8")
391
+ if len(encoded) <= MAX_OUTPUT_TAIL_BYTES:
392
+ return text
393
+ return encoded[-MAX_OUTPUT_TAIL_BYTES:].decode("utf-8", errors="ignore")
394
+
395
+
396
+ def _output_tail(container: Container) -> str:
397
+ """Stream the container's (engine-bounded) log, keeping only a rolling raw tail in memory."""
398
+ raw = bytearray()
399
+ for chunk in container.logs(stdout=True, stderr=True, stream=True, follow=False):
400
+ raw += chunk
401
+ if len(raw) > 2 * _RAW_TAIL_BYTES:
402
+ del raw[:-_RAW_TAIL_BYTES]
403
+ return sanitize_output(bytes(raw[-_RAW_TAIL_BYTES:]))
404
+
405
+
406
+ def _name(resource: Network | Volume) -> str:
407
+ name = resource.name
408
+ if name is None:
409
+ raise SandboxError("engine returned a resource without a name")
410
+ return name
411
+
412
+
413
+ def _create_seed(client: DockerClient, image_id: str, mounts: list[Mount]) -> Container:
414
+ """Create (never start) a container whose only job is to receive the seed archive.
415
+
416
+ The engine ignores metadata of a tar's root entry (``.``) and refuses archives into a
417
+ read-only rootfs, so the volume roots cannot be chowned through the run container. This
418
+ writable, never-started container mounts the same volumes and takes the archive at ``/``,
419
+ where ``workspace/`` and ``results/`` are ordinary entries whose ownership is applied.
420
+ """
421
+ try:
422
+ return client.containers.create(
423
+ image_id,
424
+ ["true"],
425
+ entrypoint=["/bin/true"],
426
+ network_mode="none",
427
+ mounts=mounts,
428
+ labels=LABELS,
429
+ )
430
+ except docker.errors.ImageNotFound as exc:
431
+ raise SandboxError(f"image not found: {image_id}") from exc
432
+
433
+
434
+ def _create_container(
435
+ client: DockerClient,
436
+ image_id: str,
437
+ entrypoint: list[str],
438
+ command: list[str],
439
+ mounts: list[Mount],
440
+ network_name: str,
441
+ limits: Limits,
442
+ ) -> Container:
443
+ return client.containers.create(
444
+ image_id,
445
+ command,
446
+ entrypoint=entrypoint,
447
+ user=f"{RUN_UID}:{RUN_GID}",
448
+ working_dir=WORKSPACE,
449
+ environment={"PG_RESULTS": RESULTS, "HOME": "/tmp"}, # noqa: S108 - container path
450
+ read_only=True,
451
+ mounts=mounts,
452
+ tmpfs={"/tmp": "rw,exec,size=256m,mode=1777"}, # noqa: S108 - container path
453
+ network=network_name,
454
+ cap_drop=["ALL"],
455
+ security_opt=["no-new-privileges"],
456
+ nano_cpus=int(limits.cpus * 1e9),
457
+ mem_limit=limits.memory_bytes,
458
+ memswap_limit=limits.memory_bytes,
459
+ pids_limit=limits.pids,
460
+ log_config=LOG_CONFIG,
461
+ labels=LABELS,
462
+ )
463
+
464
+
465
+ def _wait(container: Container, limits: Limits) -> int | None:
466
+ """Wait for ``container`` to exit; its exit code, or ``None`` if it hit the wall clock.
467
+
468
+ On timeout the container is killed. A connection error well before the deadline means the
469
+ engine went away mid-run and raises :class:`SandboxError` (the caller still tears down).
470
+ """
471
+ started = time.monotonic()
472
+ try:
473
+ status = container.wait(timeout=limits.timeout_seconds)
474
+ except (requests.exceptions.ReadTimeout, requests.exceptions.ConnectionError) as exc:
475
+ # docker-py surfaces a wait() timeout as ReadTimeout or, depending on the transport,
476
+ # as ConnectionError (docker/docker-py#1966); only near the deadline is it a timeout.
477
+ elapsed = time.monotonic() - started
478
+ if elapsed < limits.timeout_seconds - TIMEOUT_TOLERANCE_SECONDS:
479
+ raise SandboxError(f"engine connection lost during run: {exc}") from exc
480
+ with contextlib.suppress(docker.errors.DockerException):
481
+ container.kill()
482
+ return None
483
+ return int(status["StatusCode"])
484
+
485
+
486
+ def _wait_and_collect(container: Container, limits: Limits) -> RunResult:
487
+ exit_code = _wait(container, limits)
488
+ timed_out = exit_code is None
489
+ container.reload()
490
+ oom_killed = bool(container.attrs.get("State", {}).get("OOMKilled", False))
491
+ log = container.logs(stdout=True, stderr=True, tail=LOG_TAIL_LINES).decode(
492
+ "utf-8", errors="replace"
493
+ )
494
+
495
+ if timed_out:
496
+ note = f"wall-clock limit of {limits.timeout_seconds:g}s exceeded; container killed"
497
+ return RunResult(RunOutcome.LIMIT_EXCEEDED, (), None, "timeout", _with_notes(log, [note]))
498
+ if oom_killed:
499
+ note = f"memory limit of {limits.memory_bytes} bytes exceeded (OOM killed)"
500
+ return RunResult(
501
+ RunOutcome.LIMIT_EXCEEDED, (), exit_code, "memory", _with_notes(log, [note])
502
+ )
503
+
504
+ try:
505
+ chunks, _ = container.get_archive(JUNIT_PATH)
506
+ tests = parse_junit(_extract_single_file(chunks, MAX_JUNIT_BYTES))
507
+ except docker.errors.NotFound:
508
+ note = f"{JUNIT_PATH} was not written by the test command"
509
+ return RunResult(RunOutcome.MISSING_RESULTS, (), exit_code, None, _with_notes(log, [note]))
510
+ except JUnitError as exc:
511
+ note = f"{JUNIT_PATH} is unusable: {exc}"
512
+ return RunResult(RunOutcome.MISSING_RESULTS, (), exit_code, None, _with_notes(log, [note]))
513
+
514
+ outcome, explanation = outcome_for(tests)
515
+ notes = [explanation] if explanation else []
516
+ return RunResult(outcome, tests, exit_code, None, _with_notes(log, notes))
517
+
518
+
519
+ def _remove_container(container: Container) -> None:
520
+ container.remove(v=True, force=True)
521
+
522
+
523
+ def _teardown(
524
+ containers: Sequence[Container], volumes: Sequence[Volume], network: Network | None
525
+ ) -> None:
526
+ """Remove containers, then volumes, then the network; each step guarded, never raises."""
527
+ for container in containers:
528
+ with contextlib.suppress(Exception):
529
+ _remove_container(container)
530
+ for volume in volumes:
531
+ with contextlib.suppress(Exception):
532
+ volume.remove(force=True)
533
+ if network is not None:
534
+ with contextlib.suppress(Exception):
535
+ network.remove()
@@ -0,0 +1,44 @@
1
+ """Local authored challenge sessions: no CLI, verification or pack dependencies."""
2
+
3
+ from patchground.sessions.history import history_for_session
4
+ from patchground.sessions.models import (
5
+ CheckResult,
6
+ HistoryEntry,
7
+ HistoryResult,
8
+ ResetResult,
9
+ SessionError,
10
+ SessionStatus,
11
+ SnapshotStatus,
12
+ StartedSession,
13
+ )
14
+ from patchground.sessions.reset import reset_session
15
+ from patchground.sessions.submission import (
16
+ SubmissionSnapshot,
17
+ check_submission,
18
+ commit_submission,
19
+ prepare_submission,
20
+ read_submission,
21
+ )
22
+ from patchground.sessions.workflow import Sandbox, check_session, session_status, start_session
23
+
24
+ __all__ = [
25
+ "CheckResult",
26
+ "HistoryEntry",
27
+ "HistoryResult",
28
+ "ResetResult",
29
+ "Sandbox",
30
+ "SessionError",
31
+ "SessionStatus",
32
+ "SnapshotStatus",
33
+ "StartedSession",
34
+ "SubmissionSnapshot",
35
+ "check_session",
36
+ "check_submission",
37
+ "commit_submission",
38
+ "history_for_session",
39
+ "prepare_submission",
40
+ "read_submission",
41
+ "reset_session",
42
+ "session_status",
43
+ "start_session",
44
+ ]