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.
- patchground/__init__.py +10 -0
- patchground/__main__.py +3 -0
- patchground/authoring/__init__.py +5 -0
- patchground/authoring/create.py +179 -0
- patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
- patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
- patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
- patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
- patchground/automation/__init__.py +5 -0
- patchground/automation/__main__.py +131 -0
- patchground/automation/workflow.py +214 -0
- patchground/catalog/README.md +10 -0
- patchground/catalog/cache-expiry/TICKET.md +49 -0
- patchground/catalog/cache-expiry/challenge.yaml +32 -0
- patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
- patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
- patchground/catalog/cache-expiry/debrief/questions.md +4 -0
- patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
- patchground/catalog/cache-expiry/solution/reference.patch +41 -0
- patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
- patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
- patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
- patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
- patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
- patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
- patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
- patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
- patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
- patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
- patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
- patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
- patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
- patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
- patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
- patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
- patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
- patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
- patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
- patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
- patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
- patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
- patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
- patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
- patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
- patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
- patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
- patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
- patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
- patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
- patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
- patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
- patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
- patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
- patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
- patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
- patchground/catalog/pagination-last-page/TICKET.md +44 -0
- patchground/catalog/pagination-last-page/challenge.yaml +31 -0
- patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
- patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
- patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
- patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
- patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
- patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
- patchground/catalog/release-version-ordering/TICKET.md +72 -0
- patchground/catalog/release-version-ordering/challenge.yaml +31 -0
- patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
- patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
- patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
- patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
- patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
- patchground/catalog/release-version-ordering/environment/package.json +13 -0
- patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
- patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
- patchground/catalog/release-version-ordering/workspace/README.md +7 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
- patchground/cli/__init__.py +1 -0
- patchground/cli/app.py +59 -0
- patchground/cli/authoring.py +41 -0
- patchground/cli/consent.py +77 -0
- patchground/cli/discovery.py +189 -0
- patchground/cli/doctor.py +249 -0
- patchground/cli/packs.py +142 -0
- patchground/cli/practice.py +141 -0
- patchground/cli/render.py +58 -0
- patchground/cli/reports.py +53 -0
- patchground/cli/sessions.py +195 -0
- patchground/cli/validate.py +117 -0
- patchground/packs/__init__.py +23 -0
- patchground/packs/builtin.py +53 -0
- patchground/packs/discovery.py +285 -0
- patchground/packs/execution.py +420 -0
- patchground/packs/models.py +98 -0
- patchground/packs/registry.py +318 -0
- patchground/packs/source.py +230 -0
- patchground/packs/store.py +420 -0
- patchground/py.typed +0 -0
- patchground/report/__init__.py +6 -0
- patchground/report/analysis.py +188 -0
- patchground/report/models.py +59 -0
- patchground/report/regression.py +597 -0
- patchground/report/render.py +92 -0
- patchground/report/workflow.py +84 -0
- patchground/runner_assets/Dockerfile +7 -0
- patchground/runner_assets/__init__.py +16 -0
- patchground/runner_assets/package-lock.json +1054 -0
- patchground/runner_assets/package.json +13 -0
- patchground/runner_assets/vitest-runner.mjs +124 -0
- patchground/safeio/__init__.py +115 -0
- patchground/sandbox/__init__.py +39 -0
- patchground/sandbox/_engine.py +23 -0
- patchground/sandbox/build.py +202 -0
- patchground/sandbox/errors.py +23 -0
- patchground/sandbox/junit.py +76 -0
- patchground/sandbox/probe.py +118 -0
- patchground/sandbox/run.py +535 -0
- patchground/sessions/__init__.py +44 -0
- patchground/sessions/diagnostics.py +145 -0
- patchground/sessions/files.py +113 -0
- patchground/sessions/git.py +70 -0
- patchground/sessions/history.py +386 -0
- patchground/sessions/history_ignore.py +279 -0
- patchground/sessions/models.py +126 -0
- patchground/sessions/practice.py +202 -0
- patchground/sessions/reset.py +121 -0
- patchground/sessions/store.py +203 -0
- patchground/sessions/submission.py +344 -0
- patchground/sessions/workflow.py +297 -0
- patchground/spec/__init__.py +35 -0
- patchground/spec/_schema.py +53 -0
- patchground/spec/loader.py +713 -0
- patchground/spec/models.py +394 -0
- patchground/spec/schema.py +50 -0
- patchground/verify/__init__.py +29 -0
- patchground/verify/errors.py +10 -0
- patchground/verify/pytest_adapter.py +95 -0
- patchground/verify/record.py +94 -0
- patchground/verify/reference.py +158 -0
- patchground/verify/result.py +199 -0
- patchground/verify/runners.py +47 -0
- patchground/verify/tampering.py +106 -0
- patchground/verify/validate.py +686 -0
- patchground/verify/vitest_adapter.py +252 -0
- patchground-0.1.0.dist-info/METADATA +258 -0
- patchground-0.1.0.dist-info/RECORD +194 -0
- patchground-0.1.0.dist-info/WHEEL +4 -0
- patchground-0.1.0.dist-info/entry_points.txt +3 -0
- patchground-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Private frozen inputs and atomic, independently readable session closure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Literal
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
from pydantic import AwareDatetime, Field, ValidationError
|
|
20
|
+
|
|
21
|
+
from patchground import sandbox as engine
|
|
22
|
+
from patchground.sandbox import SandboxError
|
|
23
|
+
from patchground.sessions import store
|
|
24
|
+
from patchground.sessions.files import copy_tree, open_directory, open_entry
|
|
25
|
+
from patchground.sessions.models import CheckResult, Event, Model, SessionError, SessionRecord, now
|
|
26
|
+
from patchground.sessions.workflow import Sandbox, _root, _sanitized
|
|
27
|
+
from patchground.spec import ChallengeManifest
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class SubmissionSnapshot:
|
|
32
|
+
session: Path
|
|
33
|
+
original: Path
|
|
34
|
+
final: Path
|
|
35
|
+
hidden: Path
|
|
36
|
+
record: SessionRecord
|
|
37
|
+
manifest: ChallengeManifest
|
|
38
|
+
events: tuple[Event, ...]
|
|
39
|
+
requested_at: datetime
|
|
40
|
+
saved_report: dict[str, object] | None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SubmissionAudit(Model):
|
|
44
|
+
prefix_size: int = Field(ge=0)
|
|
45
|
+
prefix_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
|
|
46
|
+
event: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Receipt(Model):
|
|
50
|
+
schema_version: Literal[1]
|
|
51
|
+
requested_at: AwareDatetime
|
|
52
|
+
final_check: CheckResult
|
|
53
|
+
artifacts: dict[str, str]
|
|
54
|
+
audit: SubmissionAudit
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _inventory(path: Path) -> dict[str, str]:
|
|
58
|
+
result: dict[str, str] = {}
|
|
59
|
+
|
|
60
|
+
def visit(parent: int, prefix: str) -> None:
|
|
61
|
+
for name in sorted(os.listdir(parent)):
|
|
62
|
+
if not prefix and name == "receipt.json":
|
|
63
|
+
continue
|
|
64
|
+
with open_entry(parent, name) as descriptor:
|
|
65
|
+
info = os.fstat(descriptor)
|
|
66
|
+
key = prefix + name
|
|
67
|
+
if stat.S_ISDIR(info.st_mode):
|
|
68
|
+
result[key + "/"] = f"directory:{stat.S_IMODE(info.st_mode):o}"
|
|
69
|
+
visit(descriptor, key + "/")
|
|
70
|
+
else:
|
|
71
|
+
if info.st_nlink != 1:
|
|
72
|
+
raise SessionError("submission artifacts must not have hardlinks")
|
|
73
|
+
digest = hashlib.sha256(stat.S_IMODE(info.st_mode).to_bytes(2, "big"))
|
|
74
|
+
with os.fdopen(os.dup(descriptor), "rb") as stream:
|
|
75
|
+
for block in iter(lambda: stream.read(65536), b""):
|
|
76
|
+
digest.update(block)
|
|
77
|
+
result[key] = digest.hexdigest()
|
|
78
|
+
|
|
79
|
+
with open_directory(path) as parent:
|
|
80
|
+
visit(parent, "")
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def completed(meta: Path) -> tuple[dict[str, object], Receipt] | None:
|
|
85
|
+
"""Validate committed artifacts without consulting live workspace files."""
|
|
86
|
+
with open_directory(meta) as parent:
|
|
87
|
+
if "submission" not in os.listdir(parent): # noqa: PTH208 -- pinned descriptor
|
|
88
|
+
return None
|
|
89
|
+
bundle = meta / "submission"
|
|
90
|
+
try:
|
|
91
|
+
with open_directory(bundle) as parent:
|
|
92
|
+
values: dict[str, str] = {}
|
|
93
|
+
for name in ("receipt.json", "report.json", "report.md"):
|
|
94
|
+
with (
|
|
95
|
+
store.metadata_file(parent, name) as descriptor,
|
|
96
|
+
os.fdopen(os.dup(descriptor), "r", encoding="utf-8") as stream,
|
|
97
|
+
):
|
|
98
|
+
values[name] = stream.read()
|
|
99
|
+
if set(os.listdir(parent)) != { # noqa: PTH208 -- pinned descriptor
|
|
100
|
+
"receipt.json",
|
|
101
|
+
"report.json",
|
|
102
|
+
"report.md",
|
|
103
|
+
"final",
|
|
104
|
+
}:
|
|
105
|
+
raise ValueError("unexpected bundle entry")
|
|
106
|
+
with open_directory(bundle / "final"):
|
|
107
|
+
pass
|
|
108
|
+
receipt = Receipt.model_validate_json(values["receipt.json"])
|
|
109
|
+
event = Event.model_validate_json(receipt.audit.event)
|
|
110
|
+
if (
|
|
111
|
+
not receipt.audit.event.endswith("\n")
|
|
112
|
+
or len(receipt.audit.event.splitlines()) != 1
|
|
113
|
+
or event.type != "submitted"
|
|
114
|
+
or event.data != {"requested_at": receipt.requested_at.isoformat()}
|
|
115
|
+
):
|
|
116
|
+
raise ValueError("inconsistent submission audit receipt")
|
|
117
|
+
if _inventory(bundle) != receipt.artifacts:
|
|
118
|
+
raise ValueError("artifact integrity mismatch")
|
|
119
|
+
report = json.loads(values["report.json"])
|
|
120
|
+
if not isinstance(report, dict):
|
|
121
|
+
raise ValueError("invalid report")
|
|
122
|
+
return report, receipt
|
|
123
|
+
except (OSError, UnicodeError, ValueError, ValidationError) as exc:
|
|
124
|
+
raise SessionError("corrupt or unsafe submission bundle") from exc
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def require_active(meta: Path) -> None:
|
|
128
|
+
if completed(meta) is not None:
|
|
129
|
+
raise SessionError("session already submitted: start a new session to practice again")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def audit_contents(raw: bytes, receipt: Receipt) -> tuple[bytes, bytes]:
|
|
133
|
+
"""Return parseable audit bytes and a proven missing append suffix; never discard bytes."""
|
|
134
|
+
audit = receipt.audit
|
|
135
|
+
prefix = raw[: audit.prefix_size]
|
|
136
|
+
if (
|
|
137
|
+
len(prefix) != audit.prefix_size
|
|
138
|
+
or hashlib.sha256(prefix).hexdigest() != audit.prefix_sha256
|
|
139
|
+
):
|
|
140
|
+
raise SessionError("corrupt submission audit: history does not match receipt")
|
|
141
|
+
expected = audit.event.encode("utf-8")
|
|
142
|
+
suffix = raw[audit.prefix_size :]
|
|
143
|
+
if len(suffix) < len(expected) and expected.startswith(suffix):
|
|
144
|
+
return prefix, expected[len(suffix) :]
|
|
145
|
+
if not suffix.startswith(expected):
|
|
146
|
+
raise SessionError("corrupt submission audit: unexpected submission append")
|
|
147
|
+
if not raw.endswith(b"\n"):
|
|
148
|
+
raise SessionError("corrupt submission audit: incomplete unrelated event")
|
|
149
|
+
return raw, b""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _journal(meta: Path) -> bytes:
|
|
153
|
+
with (
|
|
154
|
+
open_directory(meta) as parent,
|
|
155
|
+
store.metadata_file(parent, "events.jsonl") as descriptor,
|
|
156
|
+
os.fdopen(os.dup(descriptor), "rb") as stream,
|
|
157
|
+
):
|
|
158
|
+
return stream.read()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _reconcile(meta: Path, receipt: Receipt) -> None:
|
|
162
|
+
try:
|
|
163
|
+
raw = _journal(meta)
|
|
164
|
+
_, missing = audit_contents(raw, receipt)
|
|
165
|
+
if missing:
|
|
166
|
+
store.append_bytes(meta, missing)
|
|
167
|
+
except (OSError, UnicodeError, SessionError) as exc:
|
|
168
|
+
raise SessionError(
|
|
169
|
+
"submission bundle may already be published; retry submit to reconcile its audit"
|
|
170
|
+
) from exc
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@contextmanager
|
|
174
|
+
def prepare_submission(
|
|
175
|
+
session: Path | None = None, *, root: Path | None = None
|
|
176
|
+
) -> Iterator[SubmissionSnapshot]:
|
|
177
|
+
requested_at = now()
|
|
178
|
+
try:
|
|
179
|
+
path = store.resolve(session, _root(root))
|
|
180
|
+
meta = path / ".session"
|
|
181
|
+
with store.locked(meta):
|
|
182
|
+
record, manifest, events = store.load(path)
|
|
183
|
+
saved = completed(meta)
|
|
184
|
+
if saved is not None:
|
|
185
|
+
report, receipt = saved
|
|
186
|
+
_reconcile(meta, receipt)
|
|
187
|
+
yield SubmissionSnapshot(
|
|
188
|
+
path,
|
|
189
|
+
meta / "original",
|
|
190
|
+
meta / "submission/final",
|
|
191
|
+
meta / "hidden",
|
|
192
|
+
record,
|
|
193
|
+
manifest,
|
|
194
|
+
tuple(events),
|
|
195
|
+
receipt.requested_at,
|
|
196
|
+
report,
|
|
197
|
+
)
|
|
198
|
+
return
|
|
199
|
+
with tempfile.TemporaryDirectory(
|
|
200
|
+
prefix="patchground-submit-", dir=Path(tempfile.gettempdir()).resolve(strict=True)
|
|
201
|
+
) as temporary:
|
|
202
|
+
captured = Path(temporary)
|
|
203
|
+
for name, source in (
|
|
204
|
+
("original", meta / "original"),
|
|
205
|
+
("final", path / "workspace"),
|
|
206
|
+
("hidden", meta / "hidden"),
|
|
207
|
+
):
|
|
208
|
+
(captured / name).mkdir(mode=0o700)
|
|
209
|
+
copy_tree(source, captured / name)
|
|
210
|
+
yield SubmissionSnapshot(
|
|
211
|
+
path,
|
|
212
|
+
captured / "original",
|
|
213
|
+
captured / "final",
|
|
214
|
+
captured / "hidden",
|
|
215
|
+
record,
|
|
216
|
+
manifest,
|
|
217
|
+
tuple(events),
|
|
218
|
+
requested_at,
|
|
219
|
+
None,
|
|
220
|
+
)
|
|
221
|
+
except (OSError, UnicodeError, SandboxError) as exc:
|
|
222
|
+
raise SessionError("submission unavailable: storage or frozen image unavailable") from exc
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def check_submission(snapshot: SubmissionSnapshot, *, sandbox: Sandbox = engine) -> CheckResult:
|
|
226
|
+
if snapshot.saved_report is not None:
|
|
227
|
+
raise SessionError("session already submitted")
|
|
228
|
+
started = time.monotonic()
|
|
229
|
+
try:
|
|
230
|
+
with tempfile.TemporaryDirectory(
|
|
231
|
+
prefix="patchground-submit-check-", dir=Path(tempfile.gettempdir()).resolve(strict=True)
|
|
232
|
+
) as temporary:
|
|
233
|
+
clean = Path(temporary)
|
|
234
|
+
copy_tree(snapshot.final, clean)
|
|
235
|
+
copy_tree(snapshot.hidden, clean)
|
|
236
|
+
raw = sandbox.run_check(
|
|
237
|
+
snapshot.record.image_id, [clean], snapshot.manifest.environment.test_command
|
|
238
|
+
)
|
|
239
|
+
return _sanitized(raw, time.monotonic() - started)
|
|
240
|
+
except (OSError, SandboxError) as exc:
|
|
241
|
+
raise SessionError(
|
|
242
|
+
"submission check unavailable: storage or frozen image unavailable"
|
|
243
|
+
) from exc
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _write(path: Path, text: str) -> None:
|
|
247
|
+
with open_directory(path.parent) as parent:
|
|
248
|
+
descriptor = os.open(
|
|
249
|
+
path.name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=parent
|
|
250
|
+
)
|
|
251
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
252
|
+
stream.write(text)
|
|
253
|
+
stream.flush()
|
|
254
|
+
os.fsync(stream.fileno())
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _sync_tree(path: Path) -> None:
|
|
258
|
+
with open_directory(path) as parent:
|
|
259
|
+
for name in os.listdir(parent): # noqa: PTH208 -- pinned descriptor
|
|
260
|
+
with open_entry(parent, name) as descriptor:
|
|
261
|
+
if stat.S_ISDIR(os.fstat(descriptor).st_mode):
|
|
262
|
+
_sync_tree(path / name)
|
|
263
|
+
else:
|
|
264
|
+
os.fsync(descriptor)
|
|
265
|
+
os.fsync(parent)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def commit_submission(
|
|
269
|
+
snapshot: SubmissionSnapshot, report: dict[str, object], markdown: str, final_check: CheckResult
|
|
270
|
+
) -> None:
|
|
271
|
+
meta = snapshot.session / ".session"
|
|
272
|
+
existing = completed(meta)
|
|
273
|
+
if existing is not None:
|
|
274
|
+
store.load(snapshot.session)
|
|
275
|
+
_reconcile(meta, existing[1])
|
|
276
|
+
return
|
|
277
|
+
published = False
|
|
278
|
+
try:
|
|
279
|
+
# Failed/incomplete staging is deliberately left private and is never treated as closure.
|
|
280
|
+
# Never clean it via a pathname that an editor may have replaced.
|
|
281
|
+
name = ".submission-" + uuid4().hex
|
|
282
|
+
with open_directory(meta) as parent:
|
|
283
|
+
os.mkdir(name, mode=0o700, dir_fd=parent)
|
|
284
|
+
stage = meta / name
|
|
285
|
+
with open_directory(stage) as staged:
|
|
286
|
+
os.mkdir("final", mode=0o700, dir_fd=staged)
|
|
287
|
+
copy_tree(snapshot.final, stage / "final")
|
|
288
|
+
_write(
|
|
289
|
+
stage / "report.json",
|
|
290
|
+
json.dumps(report, ensure_ascii=False, allow_nan=False) + "\n",
|
|
291
|
+
)
|
|
292
|
+
_write(stage / "report.md", markdown)
|
|
293
|
+
store.load(snapshot.session)
|
|
294
|
+
prefix = _journal(meta)
|
|
295
|
+
if not prefix.endswith(b"\n"):
|
|
296
|
+
raise SessionError("corrupt session audit before submission")
|
|
297
|
+
audit_event = (
|
|
298
|
+
Event(
|
|
299
|
+
ts=now(),
|
|
300
|
+
type="submitted",
|
|
301
|
+
data={"requested_at": snapshot.requested_at.isoformat()},
|
|
302
|
+
).model_dump_json()
|
|
303
|
+
+ "\n"
|
|
304
|
+
)
|
|
305
|
+
receipt = Receipt(
|
|
306
|
+
schema_version=1,
|
|
307
|
+
requested_at=snapshot.requested_at,
|
|
308
|
+
final_check=final_check,
|
|
309
|
+
artifacts=_inventory(stage),
|
|
310
|
+
audit=SubmissionAudit(
|
|
311
|
+
prefix_size=len(prefix),
|
|
312
|
+
prefix_sha256=hashlib.sha256(prefix).hexdigest(),
|
|
313
|
+
event=audit_event,
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
_write(
|
|
317
|
+
stage / "receipt.json",
|
|
318
|
+
receipt.model_dump_json(exclude={"final_check": {"passed", "counts"}}) + "\n",
|
|
319
|
+
)
|
|
320
|
+
_sync_tree(stage)
|
|
321
|
+
os.rename(name, "submission", src_dir_fd=parent, dst_dir_fd=parent)
|
|
322
|
+
published = True
|
|
323
|
+
# Closure has committed: any subsequent failure must never reopen the session.
|
|
324
|
+
os.fsync(parent)
|
|
325
|
+
store.load(snapshot.session)
|
|
326
|
+
_reconcile(meta, receipt)
|
|
327
|
+
except (OSError, UnicodeError, SessionError) as exc:
|
|
328
|
+
if published:
|
|
329
|
+
raise SessionError(
|
|
330
|
+
"submission bundle may already be published; retry submit to finish finalization"
|
|
331
|
+
) from exc
|
|
332
|
+
raise
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def read_submission(session: Path | None = None, *, root: Path | None = None) -> dict[str, object]:
|
|
336
|
+
try:
|
|
337
|
+
path = store.resolve(session, _root(root))
|
|
338
|
+
store.load(path)
|
|
339
|
+
saved = completed(path / ".session")
|
|
340
|
+
if saved is None:
|
|
341
|
+
raise SessionError("session not submitted: no final report exists")
|
|
342
|
+
return saved[0]
|
|
343
|
+
except (OSError, UnicodeError) as exc:
|
|
344
|
+
raise SessionError("submission unavailable: invalid session storage") from exc
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Start, check and status; sandbox is injected at the execution boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tempfile
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
from patchground import sandbox as engine
|
|
12
|
+
from patchground.packs.models import ConsentRequired, PackError, PreparedChallenge
|
|
13
|
+
from patchground.sandbox import BuildResult, RunOutcome, RunResult, SandboxError, TestStatus
|
|
14
|
+
from patchground.sessions import git, store
|
|
15
|
+
from patchground.sessions.files import copy_tree, readonly, remove_partial, safe_path
|
|
16
|
+
from patchground.sessions.history import capture
|
|
17
|
+
from patchground.sessions.history_ignore import resolve as resolve_history_ignores
|
|
18
|
+
from patchground.sessions.models import (
|
|
19
|
+
CheckResult,
|
|
20
|
+
CheckTest,
|
|
21
|
+
Event,
|
|
22
|
+
SessionError,
|
|
23
|
+
SessionRecord,
|
|
24
|
+
SessionStatus,
|
|
25
|
+
StartedSession,
|
|
26
|
+
now,
|
|
27
|
+
)
|
|
28
|
+
from patchground.spec import ManifestError, load_challenge
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Sandbox(Protocol):
|
|
32
|
+
"""Only execution capabilities sessions require; no engine SDK leaks upward."""
|
|
33
|
+
|
|
34
|
+
def build_image(self, dockerfile: Path, *, name: str) -> BuildResult: ...
|
|
35
|
+
|
|
36
|
+
def run_check(self, image_id: str, layers: Sequence[Path], command: str) -> RunResult: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _root(root: Path | None) -> Path:
|
|
40
|
+
return (root if root is not None else store.default_root()).absolute()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def start_session(
|
|
44
|
+
path: Path | str,
|
|
45
|
+
*,
|
|
46
|
+
root: Path | None = None,
|
|
47
|
+
timer: bool = True,
|
|
48
|
+
history: bool = True,
|
|
49
|
+
sandbox: Sandbox = engine,
|
|
50
|
+
yes: bool = False,
|
|
51
|
+
expected_identity: str | None = None,
|
|
52
|
+
pack_root: Path | None = None,
|
|
53
|
+
) -> StartedSession:
|
|
54
|
+
"""Resolve and authorize a source before any build or session mutation."""
|
|
55
|
+
from patchground.packs.registry import prepare_execution
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
with prepare_execution(
|
|
59
|
+
path, yes=yes, expected_identity=expected_identity, root=pack_root
|
|
60
|
+
) as prepared:
|
|
61
|
+
return _start_session(
|
|
62
|
+
prepared.path,
|
|
63
|
+
root=root,
|
|
64
|
+
timer=timer,
|
|
65
|
+
history=history,
|
|
66
|
+
sandbox=sandbox,
|
|
67
|
+
prepared=prepared,
|
|
68
|
+
)
|
|
69
|
+
except ConsentRequired:
|
|
70
|
+
raise
|
|
71
|
+
except PackError as exc:
|
|
72
|
+
raise SessionError(str(exc)) from exc
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _start_session(
|
|
76
|
+
path: Path,
|
|
77
|
+
*,
|
|
78
|
+
root: Path | None = None,
|
|
79
|
+
timer: bool = True,
|
|
80
|
+
history: bool = True,
|
|
81
|
+
sandbox: Sandbox = engine,
|
|
82
|
+
prepared: PreparedChallenge,
|
|
83
|
+
) -> StartedSession:
|
|
84
|
+
"""Freeze an authorized source into an independent session."""
|
|
85
|
+
partial: Path | None = None
|
|
86
|
+
try:
|
|
87
|
+
try:
|
|
88
|
+
challenge = load_challenge(path)
|
|
89
|
+
except ManifestError as exc:
|
|
90
|
+
# Keep file/line diagnostics, but do not echo arbitrary manifest values.
|
|
91
|
+
locations = ", ".join(f"{issue.file.name}:{issue.line or 1}" for issue in exc.issues)
|
|
92
|
+
raise SessionError(f"invalid challenge manifest or unsafe paths ({locations})") from exc
|
|
93
|
+
manifest = challenge.manifest
|
|
94
|
+
if manifest.environment.services:
|
|
95
|
+
raise SessionError("services are not supported by local sessions yet")
|
|
96
|
+
partial = store.allocate(_root(root), manifest.id)
|
|
97
|
+
workspace = partial / "workspace"
|
|
98
|
+
meta = partial / ".session"
|
|
99
|
+
for folder in (workspace, meta, meta / "hidden", meta / "original"):
|
|
100
|
+
folder.mkdir(mode=0o700)
|
|
101
|
+
copy_tree(challenge.root / "workspace", workspace)
|
|
102
|
+
if manifest.checks.visible is not None:
|
|
103
|
+
copy_tree(challenge.root / manifest.checks.visible, workspace)
|
|
104
|
+
copy_tree(workspace, meta / "original")
|
|
105
|
+
readonly(meta / "original")
|
|
106
|
+
copy_tree(challenge.root / manifest.checks.hidden, meta / "hidden")
|
|
107
|
+
safe_path(challenge.root / "TICKET.md", directory=False)
|
|
108
|
+
ticket = (challenge.root / "TICKET.md").read_text(encoding="utf-8")
|
|
109
|
+
(meta / "TICKET.md").write_text(ticket, encoding="utf-8")
|
|
110
|
+
(meta / "TICKET.md").chmod(0o600)
|
|
111
|
+
store.write_json(meta / "manifest.json", manifest.model_dump(mode="json"))
|
|
112
|
+
if prepared.pack is None:
|
|
113
|
+
git.initialize(workspace, source=challenge.root)
|
|
114
|
+
else:
|
|
115
|
+
git.initialize(workspace, source=challenge.root, inputs=prepared.input_roots)
|
|
116
|
+
built = sandbox.build_image(
|
|
117
|
+
challenge.root / manifest.environment.dockerfile, name=manifest.id
|
|
118
|
+
)
|
|
119
|
+
record = SessionRecord(
|
|
120
|
+
challenge_id=manifest.id,
|
|
121
|
+
challenge_version=manifest.version,
|
|
122
|
+
source=str(prepared.source_path if prepared.pack else challenge.root),
|
|
123
|
+
trust="unofficial" if prepared.pack else "local-authored",
|
|
124
|
+
pack=prepared.pack,
|
|
125
|
+
image_id=built.image_id,
|
|
126
|
+
started_at=now(),
|
|
127
|
+
timer=timer,
|
|
128
|
+
history_enabled=history,
|
|
129
|
+
)
|
|
130
|
+
store.write_json(meta / "session.json", record.model_dump(mode="json"))
|
|
131
|
+
event = Event(
|
|
132
|
+
ts=record.started_at, type="session_started", data={"challenge_id": manifest.id}
|
|
133
|
+
)
|
|
134
|
+
(meta / "events.jsonl").write_text(event.model_dump_json() + "\n", encoding="utf-8")
|
|
135
|
+
(meta / "events.jsonl").chmod(0o600)
|
|
136
|
+
(meta / "lock").touch(mode=0o600)
|
|
137
|
+
checkpoint = capture(
|
|
138
|
+
meta,
|
|
139
|
+
meta / "original",
|
|
140
|
+
enabled=history,
|
|
141
|
+
kind="initial",
|
|
142
|
+
rules=resolve_history_ignores(
|
|
143
|
+
workspace, source=challenge.root, inputs=prepared.input_roots
|
|
144
|
+
)
|
|
145
|
+
if history
|
|
146
|
+
else None,
|
|
147
|
+
)
|
|
148
|
+
store.append_event(meta, "history_recorded", checkpoint.model_dump(mode="json"))
|
|
149
|
+
return StartedSession(
|
|
150
|
+
session=partial,
|
|
151
|
+
workspace=workspace,
|
|
152
|
+
challenge_id=manifest.id,
|
|
153
|
+
timer=timer,
|
|
154
|
+
ticket=ticket,
|
|
155
|
+
pack=prepared.pack,
|
|
156
|
+
history=checkpoint,
|
|
157
|
+
)
|
|
158
|
+
except BaseException as exc:
|
|
159
|
+
if partial is not None:
|
|
160
|
+
remove_partial(partial)
|
|
161
|
+
if isinstance(exc, OSError | UnicodeError | SandboxError):
|
|
162
|
+
raise SessionError(
|
|
163
|
+
"could not start session: filesystem, Git or image build unavailable"
|
|
164
|
+
) from exc
|
|
165
|
+
raise
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _sanitized(result: RunResult, duration: float) -> CheckResult:
|
|
169
|
+
tests = tuple(
|
|
170
|
+
CheckTest(id="".join(c for c in test.id if c.isprintable())[:240], status=test.status)
|
|
171
|
+
for test in result.tests
|
|
172
|
+
)
|
|
173
|
+
outcome = result.outcome
|
|
174
|
+
if outcome == RunOutcome.PASSED:
|
|
175
|
+
if not tests or any(t.status == TestStatus.ERRORED for t in tests):
|
|
176
|
+
outcome = RunOutcome.ERRORED
|
|
177
|
+
elif any(t.status != TestStatus.PASSED for t in tests):
|
|
178
|
+
outcome = RunOutcome.FAILED
|
|
179
|
+
return CheckResult(
|
|
180
|
+
outcome=outcome,
|
|
181
|
+
tests=tests,
|
|
182
|
+
duration_seconds=duration,
|
|
183
|
+
limit=result.limit if result.limit in ("timeout", "memory") else None,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def check_session(
|
|
188
|
+
session: Path | None = None, *, root: Path | None = None, sandbox: Sandbox = engine
|
|
189
|
+
) -> CheckResult:
|
|
190
|
+
"""Check captured input; checkpoint only editable files in private storage."""
|
|
191
|
+
try:
|
|
192
|
+
path = store.resolve(session, _root(root))
|
|
193
|
+
store.load(path)
|
|
194
|
+
meta = path / ".session"
|
|
195
|
+
with store.locked(meta):
|
|
196
|
+
record, manifest, _events = store.load(path)
|
|
197
|
+
from patchground.sessions.submission import require_active
|
|
198
|
+
|
|
199
|
+
require_active(meta)
|
|
200
|
+
started = time.monotonic()
|
|
201
|
+
try:
|
|
202
|
+
# Canonicalize only the system temp parent (e.g. macOS /var),
|
|
203
|
+
# before creating our private directory; never resolve user trees.
|
|
204
|
+
with tempfile.TemporaryDirectory(
|
|
205
|
+
prefix="patchground-check-",
|
|
206
|
+
dir=Path(tempfile.gettempdir()).resolve(strict=True),
|
|
207
|
+
) as temp:
|
|
208
|
+
editable = Path(temp) / "editable"
|
|
209
|
+
clean = Path(temp) / "check"
|
|
210
|
+
editable.mkdir()
|
|
211
|
+
clean.mkdir()
|
|
212
|
+
rules = (
|
|
213
|
+
resolve_history_ignores(
|
|
214
|
+
path / "workspace",
|
|
215
|
+
source=Path(record.source),
|
|
216
|
+
inputs=(Path(record.pack.source),)
|
|
217
|
+
if record.pack and record.pack.source_kind == "local"
|
|
218
|
+
else (),
|
|
219
|
+
)
|
|
220
|
+
if record.history_enabled
|
|
221
|
+
else None
|
|
222
|
+
)
|
|
223
|
+
copy_tree(path / "workspace", editable)
|
|
224
|
+
copy_tree(editable, clean)
|
|
225
|
+
copy_tree(meta / "hidden", clean)
|
|
226
|
+
raw = sandbox.run_check(
|
|
227
|
+
record.image_id, [clean], manifest.environment.test_command
|
|
228
|
+
)
|
|
229
|
+
result = _sanitized(raw, time.monotonic() - started)
|
|
230
|
+
checkpoint = capture(
|
|
231
|
+
meta,
|
|
232
|
+
editable,
|
|
233
|
+
enabled=record.history_enabled,
|
|
234
|
+
kind="check",
|
|
235
|
+
check_outcome=result.outcome.value,
|
|
236
|
+
rules=rules,
|
|
237
|
+
)
|
|
238
|
+
result = result.model_copy(update={"history": checkpoint})
|
|
239
|
+
except (SandboxError, OSError, SessionError, KeyboardInterrupt) as exc:
|
|
240
|
+
store.append_event(
|
|
241
|
+
meta,
|
|
242
|
+
"check_run",
|
|
243
|
+
{
|
|
244
|
+
"outcome": "interrupted"
|
|
245
|
+
if isinstance(exc, KeyboardInterrupt)
|
|
246
|
+
else "environment_error",
|
|
247
|
+
"duration_seconds": time.monotonic() - started,
|
|
248
|
+
},
|
|
249
|
+
)
|
|
250
|
+
raise
|
|
251
|
+
store.append_event(meta, "check_run", result.model_dump(mode="json"))
|
|
252
|
+
return result
|
|
253
|
+
except (OSError, UnicodeError, SandboxError) as exc:
|
|
254
|
+
raise SessionError(
|
|
255
|
+
"check unavailable: session storage or frozen image unavailable"
|
|
256
|
+
) from exc
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def session_status(session: Path | None = None, *, root: Path | None = None) -> SessionStatus:
|
|
260
|
+
"""Read session state without Docker, lock creation or any filesystem writes."""
|
|
261
|
+
try:
|
|
262
|
+
path = store.resolve(session, _root(root))
|
|
263
|
+
record, manifest, events = store.load(path)
|
|
264
|
+
from patchground.sessions.submission import completed
|
|
265
|
+
|
|
266
|
+
saved = completed(path / ".session")
|
|
267
|
+
receipt = saved[1] if saved else None
|
|
268
|
+
last = next((event for event in reversed(events) if event.type == "check_run"), None)
|
|
269
|
+
return SessionStatus(
|
|
270
|
+
session=path,
|
|
271
|
+
workspace=path / "workspace",
|
|
272
|
+
challenge_id=record.challenge_id,
|
|
273
|
+
timer=record.timer,
|
|
274
|
+
state="submitted" if receipt else "active",
|
|
275
|
+
submitted_at=receipt.requested_at if receipt else None,
|
|
276
|
+
elapsed_seconds=max(
|
|
277
|
+
0.0,
|
|
278
|
+
((receipt.requested_at if receipt else now()) - record.started_at).total_seconds(),
|
|
279
|
+
)
|
|
280
|
+
if record.timer
|
|
281
|
+
else 0,
|
|
282
|
+
timebox_minutes=manifest.timebox_minutes,
|
|
283
|
+
last_check=receipt.final_check
|
|
284
|
+
if receipt
|
|
285
|
+
else store.check_from_event(last)
|
|
286
|
+
if last
|
|
287
|
+
else None,
|
|
288
|
+
last_outcome=receipt.final_check.outcome.value
|
|
289
|
+
if receipt
|
|
290
|
+
else str(last.data["outcome"])
|
|
291
|
+
if last
|
|
292
|
+
else None,
|
|
293
|
+
)
|
|
294
|
+
except (OSError, UnicodeError) as exc:
|
|
295
|
+
raise SessionError(
|
|
296
|
+
"status unavailable: invalid session storage or no session selected"
|
|
297
|
+
) from exc
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""patchground.spec — see SPEC.md in this folder."""
|
|
2
|
+
|
|
3
|
+
from patchground.spec._schema import export_schemas
|
|
4
|
+
from patchground.spec.loader import (
|
|
5
|
+
Challenge,
|
|
6
|
+
ManifestError,
|
|
7
|
+
ManifestIssue,
|
|
8
|
+
Pack,
|
|
9
|
+
load_challenge,
|
|
10
|
+
load_pack,
|
|
11
|
+
)
|
|
12
|
+
from patchground.spec.models import (
|
|
13
|
+
AIPolicy,
|
|
14
|
+
ChallengeManifest,
|
|
15
|
+
ChallengeType,
|
|
16
|
+
Difficulty,
|
|
17
|
+
PackManifest,
|
|
18
|
+
Provenance,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AIPolicy",
|
|
23
|
+
"Challenge",
|
|
24
|
+
"ChallengeManifest",
|
|
25
|
+
"ChallengeType",
|
|
26
|
+
"Difficulty",
|
|
27
|
+
"ManifestError",
|
|
28
|
+
"ManifestIssue",
|
|
29
|
+
"Pack",
|
|
30
|
+
"PackManifest",
|
|
31
|
+
"Provenance",
|
|
32
|
+
"export_schemas",
|
|
33
|
+
"load_challenge",
|
|
34
|
+
"load_pack",
|
|
35
|
+
]
|