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,394 @@
|
|
|
1
|
+
"""Pydantic v2 models for ``challenge.yaml`` and ``pack.yaml`` v0.1.
|
|
2
|
+
|
|
3
|
+
These models are the executable form of ``patchground-contract-challenge-format``. If the contract
|
|
4
|
+
and the models disagree, the contract wins and the models are fixed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from typing import Annotated, Any, Literal, Self
|
|
12
|
+
|
|
13
|
+
from pydantic import (
|
|
14
|
+
AfterValidator,
|
|
15
|
+
BaseModel,
|
|
16
|
+
BeforeValidator,
|
|
17
|
+
ConfigDict,
|
|
18
|
+
Field,
|
|
19
|
+
ValidationError,
|
|
20
|
+
WithJsonSchema,
|
|
21
|
+
field_validator,
|
|
22
|
+
model_validator,
|
|
23
|
+
)
|
|
24
|
+
from pydantic_core import InitErrorDetails, PydanticCustomError
|
|
25
|
+
|
|
26
|
+
SPEC_VERSION = "0.1"
|
|
27
|
+
|
|
28
|
+
EXTENSION_PREFIX = "x-"
|
|
29
|
+
"""Keys starting with this prefix are accepted and ignored at every mapping level."""
|
|
30
|
+
|
|
31
|
+
SLUG_PATTERN = r"^[a-z0-9]+(-[a-z0-9]+)*$"
|
|
32
|
+
# Semantic Versioning 2.0.0, the regex recommended on semver.org.
|
|
33
|
+
SEMVER_PATTERN = (
|
|
34
|
+
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
|
|
35
|
+
r"(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
|
|
36
|
+
r"(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_SLUG_RE = re.compile(SLUG_PATTERN)
|
|
40
|
+
_SEMVER_RE = re.compile(SEMVER_PATTERN)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --------------------------------------------------------------------------------------------------
|
|
44
|
+
# Enums
|
|
45
|
+
# --------------------------------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ChallengeType(StrEnum):
|
|
49
|
+
"""Kind of challenge."""
|
|
50
|
+
|
|
51
|
+
DEBUG = "debug"
|
|
52
|
+
FEATURE = "feature"
|
|
53
|
+
REVIEW = "review"
|
|
54
|
+
REFACTOR = "refactor"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Difficulty(StrEnum):
|
|
58
|
+
"""Intended difficulty."""
|
|
59
|
+
|
|
60
|
+
BEGINNER = "beginner"
|
|
61
|
+
INTERMEDIATE = "intermediate"
|
|
62
|
+
ADVANCED = "advanced"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AIPolicy(StrEnum):
|
|
66
|
+
"""Whether the user may use AI assistance."""
|
|
67
|
+
|
|
68
|
+
ALLOWED = "allowed"
|
|
69
|
+
FORBIDDEN = "forbidden"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Provenance(StrEnum):
|
|
73
|
+
"""How the challenge was authored."""
|
|
74
|
+
|
|
75
|
+
HANDMADE = "handmade"
|
|
76
|
+
GENERATED = "generated"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
RESERVED_CHALLENGE_TYPES = frozenset({"incident", "ai-validation", "design"})
|
|
80
|
+
RESERVED_AI_POLICIES = frozenset({"trap"})
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _reject_reserved(reserved: frozenset[str]) -> Any:
|
|
84
|
+
def check(value: Any) -> Any:
|
|
85
|
+
if isinstance(value, str) and value in reserved:
|
|
86
|
+
raise PydanticCustomError(
|
|
87
|
+
"reserved_value",
|
|
88
|
+
"'{value}' is reserved and not part of format v0.1",
|
|
89
|
+
{"value": value},
|
|
90
|
+
)
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
return BeforeValidator(check)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _check_spec_version(value: Any) -> Any:
|
|
97
|
+
if isinstance(value, float | int) and not isinstance(value, bool) and str(value) == "0.1":
|
|
98
|
+
raise PydanticCustomError(
|
|
99
|
+
"spec_version_not_string",
|
|
100
|
+
'spec_version must be the string "0.1"; quote it: spec_version: "0.1"',
|
|
101
|
+
)
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
SpecVersion = Annotated[Literal["0.1"], BeforeValidator(_check_spec_version)]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# --------------------------------------------------------------------------------------------------
|
|
109
|
+
# Scalar types
|
|
110
|
+
# --------------------------------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _check_not_blank(value: str) -> str:
|
|
114
|
+
if not value.strip():
|
|
115
|
+
raise PydanticCustomError("blank_string", "must not be blank")
|
|
116
|
+
return value
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _check_semver(value: str) -> str:
|
|
120
|
+
if not _SEMVER_RE.match(value):
|
|
121
|
+
raise PydanticCustomError(
|
|
122
|
+
"semver", "'{value}' is not a semantic version (e.g. 1.0.0)", {"value": value}
|
|
123
|
+
)
|
|
124
|
+
return value
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _check_slug(value: str) -> str:
|
|
128
|
+
if not _SLUG_RE.match(value):
|
|
129
|
+
raise PydanticCustomError(
|
|
130
|
+
"slug",
|
|
131
|
+
"'{value}' is not a kebab-case slug (lowercase letters, digits and single dashes)",
|
|
132
|
+
{"value": value},
|
|
133
|
+
)
|
|
134
|
+
return value
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def relative_path_problem(value: str, container: str = "challenge folder") -> str | None:
|
|
138
|
+
"""Return why ``value`` is not an acceptable manifest path, or ``None`` if it is.
|
|
139
|
+
|
|
140
|
+
Manifest paths are relative POSIX strings that stay inside ``container`` (the challenge folder
|
|
141
|
+
by default; the workspace for ``expected_paths``) lexically. A trailing ``/`` is allowed.
|
|
142
|
+
Symlinks are checked against the filesystem by the loader, not here.
|
|
143
|
+
"""
|
|
144
|
+
if not value or not value.strip():
|
|
145
|
+
return "path must not be empty"
|
|
146
|
+
if "\\" in value:
|
|
147
|
+
return "path must use '/' as separator"
|
|
148
|
+
if value.startswith("/") or re.match(r"^[A-Za-z]:", value):
|
|
149
|
+
return f"path must be relative to the {container}"
|
|
150
|
+
depth = 0
|
|
151
|
+
for part in value.split("/"):
|
|
152
|
+
if part in ("", "."):
|
|
153
|
+
continue
|
|
154
|
+
if part == "..":
|
|
155
|
+
depth -= 1
|
|
156
|
+
if depth < 0:
|
|
157
|
+
return f"path must stay inside the {container}"
|
|
158
|
+
else:
|
|
159
|
+
depth += 1
|
|
160
|
+
if depth == 0:
|
|
161
|
+
return f"path must point inside the {container}, not at the {container} itself"
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _check_relative_path(value: str) -> str:
|
|
166
|
+
problem = relative_path_problem(value)
|
|
167
|
+
if problem is not None:
|
|
168
|
+
raise PydanticCustomError("relative_path", problem)
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _check_workspace_path(value: str) -> str:
|
|
173
|
+
problem = relative_path_problem(value, "workspace")
|
|
174
|
+
if problem is not None:
|
|
175
|
+
raise PydanticCustomError("relative_path", problem)
|
|
176
|
+
return value
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# Custom validators carry the readable messages; WithJsonSchema keeps the exported schema precise.
|
|
180
|
+
NonEmptyStr = Annotated[
|
|
181
|
+
str,
|
|
182
|
+
AfterValidator(_check_not_blank),
|
|
183
|
+
WithJsonSchema({"type": "string", "minLength": 1}),
|
|
184
|
+
]
|
|
185
|
+
Slug = Annotated[
|
|
186
|
+
str,
|
|
187
|
+
AfterValidator(_check_slug),
|
|
188
|
+
WithJsonSchema({"type": "string", "pattern": SLUG_PATTERN}),
|
|
189
|
+
]
|
|
190
|
+
SemVer = Annotated[
|
|
191
|
+
str,
|
|
192
|
+
AfterValidator(_check_semver),
|
|
193
|
+
WithJsonSchema({"type": "string", "pattern": SEMVER_PATTERN}),
|
|
194
|
+
]
|
|
195
|
+
RelPath = Annotated[
|
|
196
|
+
str,
|
|
197
|
+
AfterValidator(_check_relative_path),
|
|
198
|
+
WithJsonSchema(
|
|
199
|
+
{
|
|
200
|
+
"type": "string",
|
|
201
|
+
"minLength": 1,
|
|
202
|
+
"description": "Relative POSIX path inside the challenge folder "
|
|
203
|
+
"(no absolute paths, must not escape the folder).",
|
|
204
|
+
}
|
|
205
|
+
),
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
WorkspacePath = Annotated[
|
|
209
|
+
str,
|
|
210
|
+
AfterValidator(_check_workspace_path),
|
|
211
|
+
WithJsonSchema(
|
|
212
|
+
{
|
|
213
|
+
"type": "string",
|
|
214
|
+
"minLength": 1,
|
|
215
|
+
"description": "Relative POSIX path inside the workspace (no absolute paths, must not "
|
|
216
|
+
"escape the workspace; a trailing '/' marks a directory). Need not exist.",
|
|
217
|
+
}
|
|
218
|
+
),
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
# The models validate the str/int types from YAML without lax coercion of, e.g., "45" -> 45.
|
|
222
|
+
StrictPositiveInt = Annotated[int, Field(strict=True, gt=0)]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# --------------------------------------------------------------------------------------------------
|
|
226
|
+
# Base model: unknown keys rejected, ``x-`` keys accepted and ignored
|
|
227
|
+
# --------------------------------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _extension_schema(schema: dict[str, Any]) -> None:
|
|
231
|
+
schema["additionalProperties"] = False
|
|
232
|
+
schema["patternProperties"] = {"^x-": {}}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class ManifestModel(BaseModel):
|
|
236
|
+
"""Shared base: rejects unknown keys except x- extensions, which are dropped."""
|
|
237
|
+
|
|
238
|
+
model_config = ConfigDict(
|
|
239
|
+
extra="forbid",
|
|
240
|
+
frozen=True,
|
|
241
|
+
json_schema_extra=_extension_schema,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
@model_validator(mode="before")
|
|
245
|
+
@classmethod
|
|
246
|
+
def _drop_extension_keys(cls, data: Any) -> Any:
|
|
247
|
+
if isinstance(data, dict):
|
|
248
|
+
return {
|
|
249
|
+
key: value
|
|
250
|
+
for key, value in data.items()
|
|
251
|
+
if not (isinstance(key, str) and key.startswith(EXTENSION_PREFIX))
|
|
252
|
+
}
|
|
253
|
+
return data
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# --------------------------------------------------------------------------------------------------
|
|
257
|
+
# challenge.yaml
|
|
258
|
+
# --------------------------------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class Environment(ManifestModel):
|
|
262
|
+
"""Runtime image and test command."""
|
|
263
|
+
|
|
264
|
+
dockerfile: RelPath
|
|
265
|
+
services: list[NonEmptyStr] = Field(default_factory=list)
|
|
266
|
+
test_command: NonEmptyStr
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class Checks(ManifestModel):
|
|
270
|
+
"""Visible checks are copied into the workspace; hidden checks never are."""
|
|
271
|
+
|
|
272
|
+
visible: RelPath | None = None
|
|
273
|
+
hidden: RelPath
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class Author(ManifestModel):
|
|
277
|
+
"""Challenge author."""
|
|
278
|
+
|
|
279
|
+
name: NonEmptyStr
|
|
280
|
+
github: NonEmptyStr | None = None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class Debrief(ManifestModel):
|
|
284
|
+
"""Optional interviewer prompts and unweighted coaching areas."""
|
|
285
|
+
|
|
286
|
+
questions: RelPath | None = None
|
|
287
|
+
focus: list[NonEmptyStr] = Field(default_factory=list)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class GeneratedBy(ManifestModel):
|
|
291
|
+
"""Model and prompt version of a generated challenge."""
|
|
292
|
+
|
|
293
|
+
model: NonEmptyStr
|
|
294
|
+
prompt_version: NonEmptyStr
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _single_error(title: str, loc: tuple[str | int, ...], message: str, value: Any) -> ValueError:
|
|
298
|
+
"""Build a ValidationError that pins ``message`` to ``loc`` (relative to the current model)."""
|
|
299
|
+
return ValidationError.from_exception_data(
|
|
300
|
+
title,
|
|
301
|
+
[InitErrorDetails(type=PydanticCustomError("manifest", message), loc=loc, input=value)],
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _challenge_schema_extra(schema: dict[str, Any]) -> None:
|
|
306
|
+
_extension_schema(schema)
|
|
307
|
+
# generated_by is required when provenance is "generated" and forbidden otherwise.
|
|
308
|
+
schema["if"] = {
|
|
309
|
+
"properties": {"provenance": {"const": Provenance.GENERATED.value}},
|
|
310
|
+
"required": ["provenance"],
|
|
311
|
+
}
|
|
312
|
+
schema["then"] = {
|
|
313
|
+
"properties": {"generated_by": {"type": "object"}},
|
|
314
|
+
"required": ["generated_by"],
|
|
315
|
+
}
|
|
316
|
+
schema["else"] = {"properties": {"generated_by": {"type": "null"}}}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class ChallengeManifest(ManifestModel):
|
|
320
|
+
"""Manifest of a challenge folder (challenge.yaml, format v0.1)."""
|
|
321
|
+
|
|
322
|
+
model_config = ConfigDict(
|
|
323
|
+
title="Patchground challenge.yaml v0.1",
|
|
324
|
+
json_schema_extra=_challenge_schema_extra,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
spec_version: SpecVersion
|
|
328
|
+
id: Slug
|
|
329
|
+
version: SemVer
|
|
330
|
+
title: NonEmptyStr
|
|
331
|
+
type: Annotated[ChallengeType, _reject_reserved(RESERVED_CHALLENGE_TYPES)]
|
|
332
|
+
summary: NonEmptyStr
|
|
333
|
+
languages: Annotated[list[NonEmptyStr], Field(min_length=1)]
|
|
334
|
+
tags: list[NonEmptyStr] = Field(default_factory=list)
|
|
335
|
+
difficulty: Difficulty
|
|
336
|
+
timebox_minutes: StrictPositiveInt
|
|
337
|
+
ai_policy: Annotated[AIPolicy, _reject_reserved(RESERVED_AI_POLICIES)]
|
|
338
|
+
environment: Environment
|
|
339
|
+
checks: Checks
|
|
340
|
+
solution: RelPath
|
|
341
|
+
expected_paths: list[WorkspacePath] = Field(default_factory=list)
|
|
342
|
+
hints: list[NonEmptyStr] = Field(default_factory=list)
|
|
343
|
+
debrief: Debrief | None = None
|
|
344
|
+
author: Author
|
|
345
|
+
content_license: NonEmptyStr = "CC-BY-4.0"
|
|
346
|
+
provenance: Provenance = Provenance.HANDMADE
|
|
347
|
+
generated_by: GeneratedBy | None = None
|
|
348
|
+
|
|
349
|
+
@model_validator(mode="after")
|
|
350
|
+
def _check_generated_by(self) -> Self:
|
|
351
|
+
if self.provenance is Provenance.GENERATED and self.generated_by is None:
|
|
352
|
+
raise _single_error(
|
|
353
|
+
type(self).__name__,
|
|
354
|
+
("provenance",),
|
|
355
|
+
"generated_by (model, prompt_version) is required when provenance is 'generated'",
|
|
356
|
+
self.provenance.value,
|
|
357
|
+
)
|
|
358
|
+
if self.provenance is not Provenance.GENERATED and self.generated_by is not None:
|
|
359
|
+
raise _single_error(
|
|
360
|
+
type(self).__name__,
|
|
361
|
+
("generated_by",),
|
|
362
|
+
"generated_by is only allowed when provenance is 'generated'",
|
|
363
|
+
None,
|
|
364
|
+
)
|
|
365
|
+
return self
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# --------------------------------------------------------------------------------------------------
|
|
369
|
+
# pack.yaml
|
|
370
|
+
# --------------------------------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class PackManifest(ManifestModel):
|
|
374
|
+
"""Manifest of a pack folder (pack.yaml, format v0.1)."""
|
|
375
|
+
|
|
376
|
+
model_config = ConfigDict(title="Patchground pack.yaml v0.1")
|
|
377
|
+
|
|
378
|
+
spec_version: SpecVersion
|
|
379
|
+
name: Slug
|
|
380
|
+
version: SemVer
|
|
381
|
+
description: NonEmptyStr
|
|
382
|
+
challenges: Annotated[list[Slug], Field(json_schema_extra={"uniqueItems": True})]
|
|
383
|
+
|
|
384
|
+
@field_validator("challenges")
|
|
385
|
+
@classmethod
|
|
386
|
+
def _unique_challenges(cls, value: list[str]) -> list[str]:
|
|
387
|
+
seen: set[str] = set()
|
|
388
|
+
for index, name in enumerate(value):
|
|
389
|
+
if name in seen:
|
|
390
|
+
raise _single_error(
|
|
391
|
+
cls.__name__, (index,), f"challenge '{name}' is listed more than once", name
|
|
392
|
+
)
|
|
393
|
+
seen.add(name)
|
|
394
|
+
return value
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Export the JSON Schemas of the v0.1 manifests. The files in ``schema/`` are generated by this.
|
|
2
|
+
|
|
3
|
+
Usage: ``python -m patchground.spec.schema <out_dir>``
|
|
4
|
+
|
|
5
|
+
The implementation lives in ``_schema`` so that running this module with ``-m`` does not import it
|
|
6
|
+
twice (the package ``__init__`` re-exports ``export_schemas``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from patchground.spec._schema import (
|
|
15
|
+
GENERATED_COMMENT,
|
|
16
|
+
JSON_SCHEMA_DIALECT,
|
|
17
|
+
SCHEMA_BASE_URL,
|
|
18
|
+
build_schema,
|
|
19
|
+
export_schemas,
|
|
20
|
+
render_schema,
|
|
21
|
+
schema_filename,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"GENERATED_COMMENT",
|
|
26
|
+
"JSON_SCHEMA_DIALECT",
|
|
27
|
+
"SCHEMA_BASE_URL",
|
|
28
|
+
"build_schema",
|
|
29
|
+
"export_schemas",
|
|
30
|
+
"main",
|
|
31
|
+
"render_schema",
|
|
32
|
+
"schema_filename",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv: list[str] | None = None) -> int:
|
|
37
|
+
"""Command-line entry point: write the schema files into ``out_dir``."""
|
|
38
|
+
parser = argparse.ArgumentParser(
|
|
39
|
+
prog="python -m patchground.spec.schema",
|
|
40
|
+
description="Export the challenge.yaml and pack.yaml JSON Schemas.",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument("out_dir", type=Path, help="directory to write the schema files into")
|
|
43
|
+
args = parser.parse_args(argv)
|
|
44
|
+
for path in export_schemas(args.out_dir):
|
|
45
|
+
print(path)
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""patchground.verify — see SPEC.md in this folder."""
|
|
2
|
+
|
|
3
|
+
from patchground.verify.errors import VerifyEnvironmentError
|
|
4
|
+
from patchground.verify.record import VERIFICATION_FILE, read_verification
|
|
5
|
+
from patchground.verify.result import (
|
|
6
|
+
RunSummary,
|
|
7
|
+
StepName,
|
|
8
|
+
StepResult,
|
|
9
|
+
StepStatus,
|
|
10
|
+
TestOutcome,
|
|
11
|
+
ValidationReport,
|
|
12
|
+
)
|
|
13
|
+
from patchground.verify.validate import DEFAULT_LIMITS, DockerSandbox, Sandbox, validate_challenge
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DEFAULT_LIMITS",
|
|
17
|
+
"VERIFICATION_FILE",
|
|
18
|
+
"DockerSandbox",
|
|
19
|
+
"RunSummary",
|
|
20
|
+
"Sandbox",
|
|
21
|
+
"StepName",
|
|
22
|
+
"StepResult",
|
|
23
|
+
"StepStatus",
|
|
24
|
+
"TestOutcome",
|
|
25
|
+
"ValidationReport",
|
|
26
|
+
"VerifyEnvironmentError",
|
|
27
|
+
"read_verification",
|
|
28
|
+
"validate_challenge",
|
|
29
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Exceptions raised by the verify module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class VerifyEnvironmentError(Exception):
|
|
7
|
+
"""The host cannot run validation (engine unreachable, git missing, engine failure).
|
|
8
|
+
|
|
9
|
+
This is not a verdict on the challenge: no step failed, validation could not proceed.
|
|
10
|
+
"""
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Pure pytest recognition shared by anti-gaming probes and regression evidence.
|
|
2
|
+
|
|
3
|
+
Nothing here executes authored code or touches a container: commands are only tokenised and test
|
|
4
|
+
modules are only parsed. Recognition is deliberately narrow (direct ``pytest`` / ``python -m
|
|
5
|
+
pytest`` invocations, conventional module names, static ``test_*`` functions and ``Test*``
|
|
6
|
+
methods) and anything else is simply not recognised.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import shlex
|
|
13
|
+
import tokenize
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
MAX_SOURCE_BYTES = 1024 * 1024
|
|
18
|
+
PYTHON_NAMES = ("python", "python3", "python3.12", "python3.13", "python3.14")
|
|
19
|
+
|
|
20
|
+
TestNode = ast.FunctionDef | ast.AsyncFunctionDef
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UnsupportedPytest(Exception):
|
|
24
|
+
"""Source this adapter cannot analyse; messages never carry source text."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def split_pytest_command(command: str) -> tuple[list[str], list[str]] | None:
|
|
28
|
+
"""Return ``(invocation words, arguments)`` for direct pytest, or ``None`` for anything else.
|
|
29
|
+
|
|
30
|
+
Shell pipelines, lists, redirections, environment prefixes and wrappers are not recognised.
|
|
31
|
+
"""
|
|
32
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
|
|
33
|
+
lexer.whitespace_split = True
|
|
34
|
+
try:
|
|
35
|
+
words = list(lexer)
|
|
36
|
+
except ValueError:
|
|
37
|
+
return None
|
|
38
|
+
if not words or any(word and all(c in "();<>|&" for c in word) for word in words):
|
|
39
|
+
return None
|
|
40
|
+
if words[0] in ("pytest", "pytest-3"):
|
|
41
|
+
return words[:1], words[1:]
|
|
42
|
+
if len(words) >= 3 and words[0] in PYTHON_NAMES and words[1:3] == ["-m", "pytest"]:
|
|
43
|
+
return words[:3], words[3:]
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def supports_pytest(command: str) -> bool:
|
|
48
|
+
"""Recognize direct pytest and python -m pytest invocations, not shell pipelines."""
|
|
49
|
+
return split_pytest_command(command) is not None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_module(path: Path) -> ast.Module:
|
|
53
|
+
try:
|
|
54
|
+
with tokenize.open(path) as stream:
|
|
55
|
+
return ast.parse(stream.read())
|
|
56
|
+
except (SyntaxError, UnicodeError, LookupError, ValueError) as exc:
|
|
57
|
+
raise UnsupportedPytest("not supported Python source") from exc
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_test_filename(name: str) -> bool:
|
|
61
|
+
return name.startswith("test_") or name.endswith("_test.py")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def iter_test_definitions(tree: ast.Module) -> Iterator[tuple[str, TestNode]]:
|
|
65
|
+
"""Yield ``(key, node)`` for static tests: ``test_x`` functions and ``TestC::test_x`` methods.
|
|
66
|
+
|
|
67
|
+
Duplicate keys are yielded as written; callers decide whether that is ambiguous.
|
|
68
|
+
"""
|
|
69
|
+
for node in tree.body:
|
|
70
|
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and node.name.startswith(
|
|
71
|
+
"test_"
|
|
72
|
+
):
|
|
73
|
+
yield node.name, node
|
|
74
|
+
elif isinstance(node, ast.ClassDef) and node.name.startswith("Test"):
|
|
75
|
+
for member in node.body:
|
|
76
|
+
if isinstance(
|
|
77
|
+
member, ast.FunctionDef | ast.AsyncFunctionDef
|
|
78
|
+
) and member.name.startswith("test_"):
|
|
79
|
+
yield f"{node.name}::{member.name}", member
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_definitions(path: Path) -> dict[str, TestNode]:
|
|
83
|
+
"""Static tests of one module, keyed ``test_x`` or ``TestC::test_x``.
|
|
84
|
+
|
|
85
|
+
Raises :class:`UnsupportedPytest` for source over 1 MiB, source that does not parse, and
|
|
86
|
+
duplicate test names (their identity would be ambiguous). Nothing is executed.
|
|
87
|
+
"""
|
|
88
|
+
if path.stat().st_size > MAX_SOURCE_BYTES:
|
|
89
|
+
raise UnsupportedPytest("Python source is too large to analyse")
|
|
90
|
+
found: dict[str, TestNode] = {}
|
|
91
|
+
for key, node in iter_test_definitions(parse_module(path)):
|
|
92
|
+
if key in found:
|
|
93
|
+
raise UnsupportedPytest("duplicate test names make test identity ambiguous")
|
|
94
|
+
found[key] = node
|
|
95
|
+
return found
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Write and read ``verification.json`` in a challenge root."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from patchground import __version__
|
|
13
|
+
from patchground.spec.loader import CHALLENGE_MANIFEST
|
|
14
|
+
from patchground.verify.result import ValidationReport
|
|
15
|
+
|
|
16
|
+
VERIFICATION_FILE = "verification.json"
|
|
17
|
+
FORMAT = "patchground-verification"
|
|
18
|
+
FORMAT_VERSION = 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def verification_document(report: ValidationReport) -> dict[str, Any]:
|
|
22
|
+
"""The ``verification.json`` content for a report whose challenge loaded."""
|
|
23
|
+
if report.challenge is None:
|
|
24
|
+
raise ValueError("a verification document needs a loaded challenge")
|
|
25
|
+
manifest = report.challenge.manifest
|
|
26
|
+
return {
|
|
27
|
+
"format": FORMAT,
|
|
28
|
+
"format_version": FORMAT_VERSION,
|
|
29
|
+
"patchground_version": __version__,
|
|
30
|
+
"challenge": {
|
|
31
|
+
"id": manifest.id,
|
|
32
|
+
"version": manifest.version,
|
|
33
|
+
"spec_version": manifest.spec_version,
|
|
34
|
+
},
|
|
35
|
+
"image_id": report.image_id,
|
|
36
|
+
"validated_at": report.validated_at,
|
|
37
|
+
"steps": [step.to_record() for step in report.steps],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def write_verification(root: Path, document: dict[str, Any]) -> Path:
|
|
42
|
+
"""Atomically write ``document`` to ``root/verification.json`` and return its path."""
|
|
43
|
+
target = root / VERIFICATION_FILE
|
|
44
|
+
text = json.dumps(document, indent=2, ensure_ascii=False) + "\n"
|
|
45
|
+
fd, tmp_name = tempfile.mkstemp(dir=root, prefix=f".{VERIFICATION_FILE}.", suffix=".tmp")
|
|
46
|
+
tmp = Path(tmp_name)
|
|
47
|
+
try:
|
|
48
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
49
|
+
fh.write(text)
|
|
50
|
+
fh.flush()
|
|
51
|
+
os.fsync(fh.fileno())
|
|
52
|
+
tmp.chmod(0o644)
|
|
53
|
+
tmp.replace(target)
|
|
54
|
+
except BaseException:
|
|
55
|
+
with contextlib.suppress(OSError):
|
|
56
|
+
tmp.unlink()
|
|
57
|
+
raise
|
|
58
|
+
return target
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def remove_verification(folder: Path) -> Path | None:
|
|
62
|
+
"""Delete a stale ``verification.json`` from ``folder``; return its path if one was removed.
|
|
63
|
+
|
|
64
|
+
A failed validation must not leave an earlier record claiming the challenge is valid. Only a
|
|
65
|
+
Patchground record (see :func:`read_verification`) in a folder holding ``challenge.yaml`` is
|
|
66
|
+
removed; any other file of that name is not ours and is left alone.
|
|
67
|
+
"""
|
|
68
|
+
folder = Path(folder)
|
|
69
|
+
target = folder / VERIFICATION_FILE
|
|
70
|
+
if not (target.is_file() or target.is_symlink()):
|
|
71
|
+
return None
|
|
72
|
+
if not (folder / CHALLENGE_MANIFEST).is_file() or read_verification(folder) is None:
|
|
73
|
+
return None
|
|
74
|
+
target.unlink()
|
|
75
|
+
return target
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_verification(folder: Path) -> dict[str, Any] | None:
|
|
79
|
+
"""Return the parsed ``verification.json`` of a challenge folder.
|
|
80
|
+
|
|
81
|
+
``None`` when the file is missing, is not valid JSON, or is not a
|
|
82
|
+
``patchground-verification`` document of a supported format version — in every such case
|
|
83
|
+
the challenge has no usable verification record and needs validating.
|
|
84
|
+
"""
|
|
85
|
+
path = Path(folder) / VERIFICATION_FILE
|
|
86
|
+
try:
|
|
87
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
88
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
89
|
+
return None
|
|
90
|
+
if not isinstance(data, dict):
|
|
91
|
+
return None
|
|
92
|
+
if data.get("format") != FORMAT or data.get("format_version") != FORMAT_VERSION:
|
|
93
|
+
return None
|
|
94
|
+
return data
|