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,420 @@
|
|
|
1
|
+
"""Private pack state: catalog, managed cache layout, locking and verified tree capture.
|
|
2
|
+
|
|
3
|
+
Everything under the state directory is data. Metadata is validated strictly on every read and
|
|
4
|
+
never repaired silently; cache paths are derived from validated names and commits, never stored.
|
|
5
|
+
Trees are copied through descriptor-pinned ``safeio`` primitives while being hashed, so the bytes
|
|
6
|
+
that were reviewed are the bytes that are kept or executed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import fcntl
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import secrets
|
|
16
|
+
import stat
|
|
17
|
+
from collections.abc import Iterator, Mapping
|
|
18
|
+
from contextlib import ExitStack, contextmanager, suppress
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Literal
|
|
22
|
+
from urllib.parse import urlsplit
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
25
|
+
|
|
26
|
+
from patchground.packs.discovery import ChallengeSummary
|
|
27
|
+
from patchground.packs.models import PackError, PackIdentity
|
|
28
|
+
from patchground.safeio import SafetyError, open_directory, open_entry, safe_path
|
|
29
|
+
from patchground.spec.models import SemVer, Slug
|
|
30
|
+
|
|
31
|
+
REGISTRY_FILE = "registry.json"
|
|
32
|
+
LOCK_FILE = ".lock"
|
|
33
|
+
PACKS_DIR = "packs"
|
|
34
|
+
MAX_FILES = 10_000
|
|
35
|
+
MAX_BYTES = 100 * 1024 * 1024
|
|
36
|
+
MAX_STATE_BYTES = 8 * 1024 * 1024
|
|
37
|
+
_COMMIT = r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$"
|
|
38
|
+
_CHUNK = 1024 * 1024
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _error(message: str) -> PackError:
|
|
42
|
+
return PackError(message)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def state_root(root: Path | None) -> Path:
|
|
46
|
+
"""Canonical state directory. Resolving here is the one trusted step; nothing below may link."""
|
|
47
|
+
base = Path(root) if root is not None else Path("~/.patchground")
|
|
48
|
+
return base.expanduser().absolute().resolve()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def packs_dir(root: Path) -> Path:
|
|
52
|
+
return root / PACKS_DIR
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cache_path(root: Path, name: str, commit: str) -> Path:
|
|
56
|
+
return packs_dir(root) / f"{name}@{commit}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def make_identity(name: str, version: str, kind: str, source: str, commit: str, digest: str) -> str:
|
|
60
|
+
payload = json.dumps([name, version, kind, source, commit, digest], separators=(",", ":"))
|
|
61
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _Model(BaseModel):
|
|
65
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Version(_Model):
|
|
69
|
+
version: SemVer
|
|
70
|
+
commit: str = Field(pattern=_COMMIT)
|
|
71
|
+
digest: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
72
|
+
identity: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
73
|
+
consented: bool = False
|
|
74
|
+
challenges: tuple[ChallengeSummary, ...]
|
|
75
|
+
folders: dict[str, str]
|
|
76
|
+
"""Per-challenge content digest, so updates can report changed challenges."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Entry(_Model):
|
|
80
|
+
source: str = Field(min_length=1, max_length=4096)
|
|
81
|
+
source_kind: Literal["https", "local"]
|
|
82
|
+
ref: str = Field(min_length=1, max_length=256, pattern=r"^[^\x00-\x1f\x7f]+$")
|
|
83
|
+
active: str = Field(pattern=_COMMIT)
|
|
84
|
+
versions: dict[str, Version]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class State(_Model):
|
|
88
|
+
format: Literal[1] = 1
|
|
89
|
+
packs: dict[Slug, Entry] = Field(default_factory=dict)
|
|
90
|
+
|
|
91
|
+
@model_validator(mode="after")
|
|
92
|
+
def _consistent(self) -> State:
|
|
93
|
+
for name, entry in self.packs.items():
|
|
94
|
+
_check_source(entry.source_kind, entry.source)
|
|
95
|
+
if entry.active not in entry.versions:
|
|
96
|
+
raise ValueError("active version is not registered")
|
|
97
|
+
for commit, ver in entry.versions.items():
|
|
98
|
+
expected = make_identity(
|
|
99
|
+
name, ver.version, entry.source_kind, entry.source, commit, ver.digest
|
|
100
|
+
)
|
|
101
|
+
if ver.commit != commit or ver.identity != expected:
|
|
102
|
+
raise ValueError("version identity does not match its record")
|
|
103
|
+
names = [summary.id for summary in ver.challenges]
|
|
104
|
+
if len(set(names)) != len(names) or set(names) != set(ver.folders):
|
|
105
|
+
raise ValueError("challenge index is inconsistent")
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _check_source(kind: str, source: str) -> None:
|
|
110
|
+
if "\x00" in source:
|
|
111
|
+
raise ValueError("unsafe source")
|
|
112
|
+
if kind == "local":
|
|
113
|
+
if not Path(source).is_absolute():
|
|
114
|
+
raise ValueError("local source must be absolute")
|
|
115
|
+
return
|
|
116
|
+
parts = urlsplit(source)
|
|
117
|
+
if parts.scheme != "https" or not parts.hostname or parts.username or parts.password:
|
|
118
|
+
raise ValueError("source must be a credential-free HTTPS URL")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def identity_of(name: str, entry: Entry, ver: Version) -> PackIdentity:
|
|
122
|
+
return PackIdentity(
|
|
123
|
+
name=name,
|
|
124
|
+
version=ver.version,
|
|
125
|
+
source=entry.source,
|
|
126
|
+
source_kind=entry.source_kind,
|
|
127
|
+
commit=ver.commit,
|
|
128
|
+
digest=ver.digest,
|
|
129
|
+
identity=ver.identity,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def load_state(root: Path) -> State:
|
|
134
|
+
"""Read the catalog without creating anything; missing state is simply empty."""
|
|
135
|
+
path = root / REGISTRY_FILE
|
|
136
|
+
try:
|
|
137
|
+
path.lstat()
|
|
138
|
+
except FileNotFoundError:
|
|
139
|
+
return State()
|
|
140
|
+
except OSError as exc:
|
|
141
|
+
raise _error("pack registry is unavailable") from exc
|
|
142
|
+
try:
|
|
143
|
+
safe_path(path, directory=False, error=PackError, label="pack registry")
|
|
144
|
+
with (
|
|
145
|
+
open_directory(path.parent, error=PackError, label="pack registry") as parent,
|
|
146
|
+
open_entry(parent, path.name, error=PackError, label="pack registry") as fd,
|
|
147
|
+
):
|
|
148
|
+
data = _read_all(fd, MAX_STATE_BYTES)
|
|
149
|
+
return State.model_validate_json(data)
|
|
150
|
+
except (OSError, ValueError, SafetyError, PackError) as exc:
|
|
151
|
+
raise _error("pack registry is corrupt or unsafe") from exc
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _read_all(fd: int, limit: int) -> bytes:
|
|
155
|
+
chunks: list[bytes] = []
|
|
156
|
+
total = 0
|
|
157
|
+
while True:
|
|
158
|
+
chunk = os.read(fd, min(_CHUNK, limit + 1 - total))
|
|
159
|
+
if not chunk:
|
|
160
|
+
return b"".join(chunks)
|
|
161
|
+
chunks.append(chunk)
|
|
162
|
+
total += len(chunk)
|
|
163
|
+
if total > limit:
|
|
164
|
+
raise _error("file exceeds its size limit")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def read_limited(path: Path, limit: int) -> bytes:
|
|
168
|
+
"""Read one regular file through pinned descriptors; oversize is an error, never truncation."""
|
|
169
|
+
safe_path(path, directory=False, error=PackError, label="pack")
|
|
170
|
+
with (
|
|
171
|
+
open_directory(path.parent, error=PackError, label="pack") as parent,
|
|
172
|
+
open_entry(parent, path.name, error=PackError, label="pack") as fd,
|
|
173
|
+
):
|
|
174
|
+
return _read_all(fd, limit)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def ensure_dirs(root: Path) -> None:
|
|
178
|
+
try:
|
|
179
|
+
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
180
|
+
packs_dir(root).mkdir(exist_ok=True, mode=0o700)
|
|
181
|
+
safe_path(root, directory=True, error=PackError, label="pack state")
|
|
182
|
+
safe_path(packs_dir(root), directory=True, error=PackError, label="pack state")
|
|
183
|
+
except OSError as exc:
|
|
184
|
+
raise _error("pack state directory cannot be prepared") from exc
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@contextmanager
|
|
188
|
+
def locked(root: Path) -> Iterator[None]:
|
|
189
|
+
"""Exclusive cross-process lock for every state mutation; initializes the state directory."""
|
|
190
|
+
ensure_dirs(root)
|
|
191
|
+
try:
|
|
192
|
+
fd = os.open(root / LOCK_FILE, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
193
|
+
except OSError as exc:
|
|
194
|
+
raise _error("pack state lock is unavailable") from exc
|
|
195
|
+
try:
|
|
196
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
197
|
+
yield
|
|
198
|
+
finally:
|
|
199
|
+
os.close(fd)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class PublishedStateError(PackError):
|
|
203
|
+
"""Catalog publication happened; deleting its newly referenced cache would corrupt state."""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def save_state(root: Path, state: State) -> None:
|
|
207
|
+
"""Publish through a pinned directory; distinguish pre- and post-publication failures."""
|
|
208
|
+
data = state.model_dump_json(indent=2).encode()
|
|
209
|
+
if len(data) > MAX_STATE_BYTES:
|
|
210
|
+
raise PackError("pack registry would exceed its size limit; state was not changed")
|
|
211
|
+
tmp = f".registry-{os.getpid()}-{secrets.token_hex(6)}.tmp"
|
|
212
|
+
published = False
|
|
213
|
+
try:
|
|
214
|
+
with open_directory(root, error=PackError, label="pack state") as directory:
|
|
215
|
+
created = False
|
|
216
|
+
try:
|
|
217
|
+
fd = os.open(
|
|
218
|
+
tmp,
|
|
219
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
220
|
+
0o600,
|
|
221
|
+
dir_fd=directory,
|
|
222
|
+
)
|
|
223
|
+
created = True
|
|
224
|
+
with os.fdopen(fd, "wb") as handle:
|
|
225
|
+
handle.write(data)
|
|
226
|
+
handle.flush()
|
|
227
|
+
os.fsync(handle.fileno())
|
|
228
|
+
os.replace(tmp, REGISTRY_FILE, src_dir_fd=directory, dst_dir_fd=directory)
|
|
229
|
+
published = True
|
|
230
|
+
os.fsync(directory)
|
|
231
|
+
finally:
|
|
232
|
+
if created and not published:
|
|
233
|
+
with suppress(OSError):
|
|
234
|
+
os.unlink(tmp, dir_fd=directory)
|
|
235
|
+
except (OSError, PackError) as exc:
|
|
236
|
+
if published:
|
|
237
|
+
raise PublishedStateError(
|
|
238
|
+
"pack state was published but durability could not be confirmed; "
|
|
239
|
+
"inspect pack list before retrying; cached files may remain"
|
|
240
|
+
) from exc
|
|
241
|
+
raise PackError("pack registry could not be written") from exc
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass(frozen=True)
|
|
245
|
+
class Capture:
|
|
246
|
+
digest: str
|
|
247
|
+
files: Mapping[str, str]
|
|
248
|
+
"""Relative path -> file executable flag/content digest or a directory marker."""
|
|
249
|
+
|
|
250
|
+
def folder_digests(self, names: tuple[str, ...]) -> dict[str, str]:
|
|
251
|
+
result: dict[str, str] = {}
|
|
252
|
+
for name in names:
|
|
253
|
+
h = hashlib.sha256()
|
|
254
|
+
for rel, value in sorted(self.files.items()):
|
|
255
|
+
if rel.startswith(f"{name}/"):
|
|
256
|
+
h.update(f"{rel}\n{value}\n".encode())
|
|
257
|
+
result[name] = h.hexdigest()
|
|
258
|
+
return result
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def capture_tree(source: Path, destination: Path | None, *, seal: bool) -> Capture:
|
|
262
|
+
"""Hash regular files and directories while optionally copying exactly the bytes read.
|
|
263
|
+
|
|
264
|
+
``destination`` must be an existing empty directory, or ``None`` to only verify (the digest is
|
|
265
|
+
the same either way). ``seal`` makes a copy read-only (cache); otherwise it is private and
|
|
266
|
+
owner-writable (execution copy). Symlinks, special files, racing replacements and over-limit
|
|
267
|
+
trees fail with a safe error.
|
|
268
|
+
"""
|
|
269
|
+
files: dict[str, str] = {}
|
|
270
|
+
file_count = 0
|
|
271
|
+
total = 0
|
|
272
|
+
|
|
273
|
+
def copy(parent: int, target: int | None, prefix: str) -> None:
|
|
274
|
+
nonlocal total, file_count
|
|
275
|
+
for name in sorted(os.listdir(parent)):
|
|
276
|
+
rel = f"{prefix}{name}"
|
|
277
|
+
with open_entry(parent, name, error=PackError, label="pack") as fd:
|
|
278
|
+
info = os.fstat(fd)
|
|
279
|
+
if stat.S_ISDIR(info.st_mode):
|
|
280
|
+
files[f"{rel}/"] = "d"
|
|
281
|
+
if target is None:
|
|
282
|
+
copy(fd, None, f"{rel}/")
|
|
283
|
+
continue
|
|
284
|
+
os.mkdir(name, mode=0o700, dir_fd=target)
|
|
285
|
+
with open_entry(
|
|
286
|
+
target, name, os.O_RDONLY | os.O_DIRECTORY, error=PackError, label="pack"
|
|
287
|
+
) as child:
|
|
288
|
+
copy(fd, child, f"{rel}/")
|
|
289
|
+
os.fchmod(child, 0o555 if seal else 0o700)
|
|
290
|
+
if seal:
|
|
291
|
+
os.fsync(child)
|
|
292
|
+
continue
|
|
293
|
+
if file_count >= MAX_FILES:
|
|
294
|
+
raise _error("pack exceeds the file count limit")
|
|
295
|
+
file_count += 1
|
|
296
|
+
executable = bool(info.st_mode & 0o111)
|
|
297
|
+
digest = hashlib.sha256()
|
|
298
|
+
writer = None
|
|
299
|
+
if target is not None:
|
|
300
|
+
output = os.open(
|
|
301
|
+
name,
|
|
302
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
303
|
+
0o600,
|
|
304
|
+
dir_fd=target,
|
|
305
|
+
)
|
|
306
|
+
writer = os.fdopen(output, "wb")
|
|
307
|
+
try:
|
|
308
|
+
while chunk := os.read(fd, _CHUNK):
|
|
309
|
+
total += len(chunk)
|
|
310
|
+
if total > MAX_BYTES:
|
|
311
|
+
raise _error("pack exceeds the size limit")
|
|
312
|
+
digest.update(chunk)
|
|
313
|
+
if writer is not None:
|
|
314
|
+
writer.write(chunk)
|
|
315
|
+
if writer is not None:
|
|
316
|
+
mode = (
|
|
317
|
+
(0o555 if executable else 0o444)
|
|
318
|
+
if seal
|
|
319
|
+
else (0o700 if executable else 0o600)
|
|
320
|
+
)
|
|
321
|
+
os.fchmod(writer.fileno(), mode)
|
|
322
|
+
if seal:
|
|
323
|
+
writer.flush()
|
|
324
|
+
os.fsync(writer.fileno())
|
|
325
|
+
finally:
|
|
326
|
+
if writer is not None:
|
|
327
|
+
writer.close()
|
|
328
|
+
files[rel] = f"{'x' if executable else '-'}{digest.hexdigest()}"
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
with ExitStack() as stack:
|
|
332
|
+
source_fd = stack.enter_context(open_directory(source, error=PackError, label="pack"))
|
|
333
|
+
target_fd = (
|
|
334
|
+
stack.enter_context(open_directory(destination, error=PackError, label="pack"))
|
|
335
|
+
if destination is not None
|
|
336
|
+
else None
|
|
337
|
+
)
|
|
338
|
+
copy(source_fd, target_fd, "")
|
|
339
|
+
if target_fd is not None:
|
|
340
|
+
os.fchmod(target_fd, 0o555 if seal else 0o700)
|
|
341
|
+
if seal:
|
|
342
|
+
os.fsync(target_fd)
|
|
343
|
+
except OSError as exc:
|
|
344
|
+
raise _error("pack tree cannot be read or copied safely") from exc
|
|
345
|
+
overall = hashlib.sha256()
|
|
346
|
+
for rel in sorted(files):
|
|
347
|
+
encoded = rel.encode()
|
|
348
|
+
overall.update(len(encoded).to_bytes(8, "big") + encoded + files[rel].encode())
|
|
349
|
+
return Capture(overall.hexdigest(), files)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def cache_problem(root: Path, name: str, ver: Version) -> str | None:
|
|
353
|
+
"""Why a registered cache directory cannot be trusted (None when bytes match the catalog)."""
|
|
354
|
+
path = cache_path(root, name, ver.commit)
|
|
355
|
+
try:
|
|
356
|
+
safe_path(path, directory=True, error=PackError, label="installed pack")
|
|
357
|
+
actual = capture_tree(path, None, seal=False).digest
|
|
358
|
+
except (OSError, PackError):
|
|
359
|
+
return "installed pack content is missing or unsafe"
|
|
360
|
+
return None if actual == ver.digest else "installed pack content failed integrity verification"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@contextmanager
|
|
364
|
+
def hold_stage(path: Path) -> Iterator[tuple[int, int]]:
|
|
365
|
+
"""Keep stage ownership live through cleanup; see packs/SPEC.md for identity rules."""
|
|
366
|
+
try:
|
|
367
|
+
with open_directory(path.parent, error=PackError, label="pack state") as parent:
|
|
368
|
+
fd = os.open(path.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
|
|
369
|
+
except OSError as exc:
|
|
370
|
+
raise _error("pack stage cannot be opened") from exc
|
|
371
|
+
try:
|
|
372
|
+
info = os.fstat(fd)
|
|
373
|
+
yield info.st_dev, info.st_ino
|
|
374
|
+
finally:
|
|
375
|
+
os.close(fd)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def remove_tree(path: Path, *, expect: tuple[int, int] | None = None) -> None:
|
|
379
|
+
"""Remove one directory tree without following links, restoring owner access as needed.
|
|
380
|
+
|
|
381
|
+
With ``expect`` (device, inode) only that exact directory is removed; anything that replaced
|
|
382
|
+
it since is left alone.
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
def clear(fd: int) -> None:
|
|
386
|
+
os.fchmod(fd, 0o700)
|
|
387
|
+
for name in os.listdir(fd):
|
|
388
|
+
if stat.S_ISDIR(os.stat(name, dir_fd=fd, follow_symlinks=False).st_mode):
|
|
389
|
+
child = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
|
|
390
|
+
try:
|
|
391
|
+
clear(child)
|
|
392
|
+
finally:
|
|
393
|
+
os.close(child)
|
|
394
|
+
os.rmdir(name, dir_fd=fd)
|
|
395
|
+
else:
|
|
396
|
+
os.unlink(name, dir_fd=fd)
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
with open_directory(path.parent, error=PackError, label="pack state") as parent:
|
|
400
|
+
try:
|
|
401
|
+
found = os.stat(path.name, dir_fd=parent, follow_symlinks=False)
|
|
402
|
+
mode = found.st_mode
|
|
403
|
+
except FileNotFoundError:
|
|
404
|
+
return
|
|
405
|
+
if expect is not None and (found.st_dev, found.st_ino) != expect:
|
|
406
|
+
return
|
|
407
|
+
if not stat.S_ISDIR(mode):
|
|
408
|
+
os.unlink(path.name, dir_fd=parent)
|
|
409
|
+
return
|
|
410
|
+
fd = os.open(path.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
|
|
411
|
+
try:
|
|
412
|
+
opened = os.fstat(fd)
|
|
413
|
+
if (opened.st_dev, opened.st_ino) != (found.st_dev, found.st_ino):
|
|
414
|
+
return # swapped between stat and open: not the directory that was vetted
|
|
415
|
+
clear(fd)
|
|
416
|
+
finally:
|
|
417
|
+
os.close(fd)
|
|
418
|
+
os.rmdir(path.name, dir_fd=parent)
|
|
419
|
+
except OSError as exc:
|
|
420
|
+
raise _error("pack content could not be removed") from exc
|
patchground/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Deterministic verified outcomes; optional coaching never overrides these facts."""
|
|
2
|
+
|
|
3
|
+
from patchground.report.models import VerifiedReport
|
|
4
|
+
from patchground.report.workflow import saved_report, submit_session
|
|
5
|
+
|
|
6
|
+
__all__ = ["VerifiedReport", "saved_report", "submit_session"]
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Bounded file-scope and structural-test facts; never infer semantic test quality."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
from difflib import SequenceMatcher
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict
|
|
14
|
+
|
|
15
|
+
from patchground.sessions.files import open_directory, open_entry, tree
|
|
16
|
+
from patchground.verify.pytest_adapter import UnsupportedPytest, test_definitions
|
|
17
|
+
from patchground.verify.runners import Runner, is_vitest_path, runner_for, vitest_identity
|
|
18
|
+
from patchground.verify.vitest_adapter import UnsupportedVitest, parse_module
|
|
19
|
+
|
|
20
|
+
TEXT_LIMIT = 1024 * 1024
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Fact(BaseModel):
|
|
24
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FileChange(Fact):
|
|
28
|
+
path: str
|
|
29
|
+
kind: Literal["added", "deleted", "modified"]
|
|
30
|
+
added_lines: int | None
|
|
31
|
+
removed_lines: int | None
|
|
32
|
+
outside_expected: bool | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DiffEvidence(Fact):
|
|
36
|
+
expected_paths: tuple[str, ...]
|
|
37
|
+
files: tuple[FileChange, ...]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AssertionReduction(Fact):
|
|
41
|
+
test: str
|
|
42
|
+
before: int
|
|
43
|
+
after: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StructureEvidence(Fact):
|
|
47
|
+
status: Literal["established", "not_established"]
|
|
48
|
+
reason: str
|
|
49
|
+
removed_files: tuple[str, ...] = ()
|
|
50
|
+
removed_tests: tuple[str, ...] = ()
|
|
51
|
+
assertion_reductions: tuple[AssertionReduction, ...] = ()
|
|
52
|
+
unsupported_files: tuple[str, ...] = ()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _file_paths(root: Path) -> dict[str, Path]:
|
|
56
|
+
result: dict[str, Path] = {}
|
|
57
|
+
for path in tree(root, skip=True):
|
|
58
|
+
with open_directory(path.parent) as parent, open_entry(parent, path.name) as descriptor:
|
|
59
|
+
if stat.S_ISREG(os.fstat(descriptor).st_mode):
|
|
60
|
+
result[path.relative_to(root).as_posix()] = path
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _content(path: Path | None) -> tuple[str | None, list[str] | None]:
|
|
65
|
+
if path is None:
|
|
66
|
+
return None, []
|
|
67
|
+
digest = hashlib.sha256()
|
|
68
|
+
chunks: list[bytes] = []
|
|
69
|
+
length = 0
|
|
70
|
+
with (
|
|
71
|
+
open_directory(path.parent) as parent,
|
|
72
|
+
open_entry(parent, path.name) as descriptor,
|
|
73
|
+
os.fdopen(os.dup(descriptor), "rb") as stream,
|
|
74
|
+
):
|
|
75
|
+
mode = os.fstat(descriptor).st_mode
|
|
76
|
+
digest.update(b"executable:" if mode & 0o111 else b"ordinary:")
|
|
77
|
+
while chunk := stream.read(65536):
|
|
78
|
+
digest.update(chunk)
|
|
79
|
+
length += len(chunk)
|
|
80
|
+
if length <= TEXT_LIMIT:
|
|
81
|
+
chunks.append(chunk)
|
|
82
|
+
if length > TEXT_LIMIT:
|
|
83
|
+
return digest.hexdigest(), None
|
|
84
|
+
content = b"".join(chunks)
|
|
85
|
+
if b"\0" in content:
|
|
86
|
+
return digest.hexdigest(), None
|
|
87
|
+
try:
|
|
88
|
+
return digest.hexdigest(), content.decode("utf-8").splitlines(keepends=True)
|
|
89
|
+
except UnicodeError:
|
|
90
|
+
return digest.hexdigest(), None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def diff_evidence(original: Path, final: Path, expected_paths: tuple[str, ...]) -> DiffEvidence:
|
|
94
|
+
before, after = _file_paths(original), _file_paths(final)
|
|
95
|
+
changes: list[FileChange] = []
|
|
96
|
+
for name in sorted(before.keys() | after.keys()):
|
|
97
|
+
old_hash, old_lines = _content(before.get(name))
|
|
98
|
+
new_hash, new_lines = _content(after.get(name))
|
|
99
|
+
if old_hash == new_hash:
|
|
100
|
+
continue
|
|
101
|
+
added: int | None = None
|
|
102
|
+
removed: int | None = None
|
|
103
|
+
if old_lines is not None and new_lines is not None:
|
|
104
|
+
added = removed = 0
|
|
105
|
+
for operation, i, j, a, b in SequenceMatcher(None, old_lines, new_lines).get_opcodes():
|
|
106
|
+
if operation != "equal":
|
|
107
|
+
removed += j - i
|
|
108
|
+
added += b - a
|
|
109
|
+
outside = None
|
|
110
|
+
if expected_paths:
|
|
111
|
+
outside = not any(
|
|
112
|
+
name == expected.rstrip("/") or name.startswith(expected.rstrip("/") + "/")
|
|
113
|
+
for expected in expected_paths
|
|
114
|
+
)
|
|
115
|
+
changes.append(
|
|
116
|
+
FileChange(
|
|
117
|
+
path=name,
|
|
118
|
+
kind="added"
|
|
119
|
+
if name not in before
|
|
120
|
+
else "deleted"
|
|
121
|
+
if name not in after
|
|
122
|
+
else "modified",
|
|
123
|
+
added_lines=added,
|
|
124
|
+
removed_lines=removed,
|
|
125
|
+
outside_expected=outside,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
return DiffEvidence(expected_paths=expected_paths, files=tuple(changes))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _assertion_counts(path: Path, runner: Runner, relative: str) -> dict[str, int]:
|
|
132
|
+
if runner == "vitest":
|
|
133
|
+
definitions = parse_module(path).definitions
|
|
134
|
+
for name in definitions:
|
|
135
|
+
vitest_identity(relative, name)
|
|
136
|
+
return {name: definition.assertions for name, definition in definitions.items()}
|
|
137
|
+
return {
|
|
138
|
+
name: sum(isinstance(node, ast.Assert) for node in ast.walk(definition))
|
|
139
|
+
for name, definition in test_definitions(path).items()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def structural_evidence(original: Path, final: Path, command: str) -> StructureEvidence:
|
|
144
|
+
runner = runner_for(command)
|
|
145
|
+
if runner is None:
|
|
146
|
+
return StructureEvidence(status="not_established", reason="unsupported test runner")
|
|
147
|
+
before, after = _file_paths(original), _file_paths(final)
|
|
148
|
+
removed_files: list[str] = []
|
|
149
|
+
removed_tests: list[str] = []
|
|
150
|
+
reductions: list[AssertionReduction] = []
|
|
151
|
+
unsupported: list[str] = []
|
|
152
|
+
for name in sorted(before.keys() | after.keys()):
|
|
153
|
+
path = Path(name)
|
|
154
|
+
if runner == "vitest":
|
|
155
|
+
if not is_vitest_path(path):
|
|
156
|
+
continue
|
|
157
|
+
elif path.suffix != ".py" or not (
|
|
158
|
+
path.name.startswith("test_") or path.name.endswith("_test.py")
|
|
159
|
+
):
|
|
160
|
+
continue
|
|
161
|
+
try:
|
|
162
|
+
old = _assertion_counts(before[name], runner, name) if name in before else {}
|
|
163
|
+
new = _assertion_counts(after[name], runner, name) if name in after else {}
|
|
164
|
+
except (UnsupportedPytest, UnsupportedVitest, ValueError):
|
|
165
|
+
unsupported.append(name)
|
|
166
|
+
continue
|
|
167
|
+
if old and name not in after:
|
|
168
|
+
removed_files.append(name)
|
|
169
|
+
removed_tests.extend(f"{name}::{test}" for test in sorted(old.keys() - new.keys()))
|
|
170
|
+
for test in sorted(old.keys() & new.keys()):
|
|
171
|
+
previous = old[test]
|
|
172
|
+
current = new[test]
|
|
173
|
+
if current < previous:
|
|
174
|
+
reductions.append(
|
|
175
|
+
AssertionReduction(test=f"{name}::{test}", before=previous, after=current)
|
|
176
|
+
)
|
|
177
|
+
return StructureEvidence(
|
|
178
|
+
status="not_established" if unsupported else "established",
|
|
179
|
+
reason="some test source is unsupported"
|
|
180
|
+
if unsupported
|
|
181
|
+
else "structural canonical expect-call counts only"
|
|
182
|
+
if runner == "vitest"
|
|
183
|
+
else "structural counts only",
|
|
184
|
+
removed_files=tuple(removed_files),
|
|
185
|
+
removed_tests=tuple(removed_tests),
|
|
186
|
+
assertion_reductions=tuple(reductions),
|
|
187
|
+
unsupported_files=tuple(unsupported),
|
|
188
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Versioned, round-trippable verified-outcome records; no overall score."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal, Self
|
|
6
|
+
|
|
7
|
+
from pydantic import AwareDatetime, Field, model_validator
|
|
8
|
+
|
|
9
|
+
from patchground.report.analysis import DiffEvidence, Fact, StructureEvidence
|
|
10
|
+
from patchground.report.regression import RegressionEvidence
|
|
11
|
+
from patchground.sandbox import RunOutcome
|
|
12
|
+
from patchground.sessions import CheckResult
|
|
13
|
+
from patchground.sessions.models import CheckTest
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CheckEvidence(Fact):
|
|
17
|
+
outcome: RunOutcome
|
|
18
|
+
tests: tuple[CheckTest, ...]
|
|
19
|
+
duration_seconds: float = Field(ge=0, allow_inf_nan=False)
|
|
20
|
+
limit: Literal["timeout", "memory"] | None
|
|
21
|
+
passed: bool
|
|
22
|
+
counts: dict[str, int]
|
|
23
|
+
|
|
24
|
+
@model_validator(mode="after")
|
|
25
|
+
def consistent(self) -> Self:
|
|
26
|
+
canonical = CheckResult(
|
|
27
|
+
outcome=self.outcome,
|
|
28
|
+
tests=self.tests,
|
|
29
|
+
duration_seconds=self.duration_seconds,
|
|
30
|
+
limit=self.limit,
|
|
31
|
+
)
|
|
32
|
+
if self.passed != canonical.passed or self.counts != canonical.counts:
|
|
33
|
+
raise ValueError("inconsistent check evidence")
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_result(cls, result: CheckResult) -> Self:
|
|
38
|
+
return cls.model_validate(result.model_dump(mode="json", include=set(cls.model_fields)))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class EffortEvidence(Fact):
|
|
42
|
+
timer_enabled: bool
|
|
43
|
+
elapsed_seconds: float | None = Field(default=None, ge=0, allow_inf_nan=False)
|
|
44
|
+
timebox_minutes: int = Field(gt=0)
|
|
45
|
+
practice_checks: int = Field(ge=0)
|
|
46
|
+
hints_revealed: int = Field(ge=0)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class VerifiedReport(Fact):
|
|
50
|
+
schema_version: Literal[1] = 1
|
|
51
|
+
challenge_id: str
|
|
52
|
+
challenge_version: str
|
|
53
|
+
image_id: str
|
|
54
|
+
submitted_at: AwareDatetime
|
|
55
|
+
checks: CheckEvidence
|
|
56
|
+
regression: RegressionEvidence
|
|
57
|
+
diff: DiffEvidence
|
|
58
|
+
test_structure: StructureEvidence
|
|
59
|
+
effort: EffortEvidence
|