patchground 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. patchground/__init__.py +10 -0
  2. patchground/__main__.py +3 -0
  3. patchground/authoring/__init__.py +5 -0
  4. patchground/authoring/create.py +179 -0
  5. patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
  6. patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
  7. patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  8. patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
  9. patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
  10. patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
  11. patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
  12. patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
  13. patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
  14. patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
  15. patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  16. patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
  17. patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
  18. patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
  19. patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
  20. patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
  21. patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
  22. patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
  23. patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  24. patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
  25. patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
  26. patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
  27. patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
  28. patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
  29. patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
  30. patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  31. patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
  32. patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
  33. patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
  34. patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
  35. patchground/automation/__init__.py +5 -0
  36. patchground/automation/__main__.py +131 -0
  37. patchground/automation/workflow.py +214 -0
  38. patchground/catalog/README.md +10 -0
  39. patchground/catalog/cache-expiry/TICKET.md +49 -0
  40. patchground/catalog/cache-expiry/challenge.yaml +32 -0
  41. patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
  42. patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
  43. patchground/catalog/cache-expiry/debrief/questions.md +4 -0
  44. patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
  45. patchground/catalog/cache-expiry/solution/reference.patch +41 -0
  46. patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
  47. patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
  48. patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
  49. patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
  50. patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
  51. patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
  52. patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
  53. patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
  54. patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
  55. patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
  56. patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
  57. patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
  58. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
  59. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
  60. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
  61. patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
  62. patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
  63. patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
  64. patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
  65. patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
  66. patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
  67. patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
  68. patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
  69. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
  70. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
  71. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
  72. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
  73. patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
  74. patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
  75. patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
  76. patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
  77. patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
  78. patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
  79. patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
  80. patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
  81. patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
  82. patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
  83. patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
  84. patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
  85. patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
  86. patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
  87. patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
  88. patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
  89. patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
  90. patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
  91. patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
  92. patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
  93. patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
  94. patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
  95. patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
  96. patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
  97. patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
  98. patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
  99. patchground/catalog/pagination-last-page/TICKET.md +44 -0
  100. patchground/catalog/pagination-last-page/challenge.yaml +31 -0
  101. patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
  102. patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
  103. patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
  104. patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
  105. patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
  106. patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
  107. patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
  108. patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
  109. patchground/catalog/release-version-ordering/TICKET.md +72 -0
  110. patchground/catalog/release-version-ordering/challenge.yaml +31 -0
  111. patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
  112. patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
  113. patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
  114. patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
  115. patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
  116. patchground/catalog/release-version-ordering/environment/package.json +13 -0
  117. patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
  118. patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
  119. patchground/catalog/release-version-ordering/workspace/README.md +7 -0
  120. patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
  121. patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
  122. patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
  123. patchground/cli/__init__.py +1 -0
  124. patchground/cli/app.py +59 -0
  125. patchground/cli/authoring.py +41 -0
  126. patchground/cli/consent.py +77 -0
  127. patchground/cli/discovery.py +189 -0
  128. patchground/cli/doctor.py +249 -0
  129. patchground/cli/packs.py +142 -0
  130. patchground/cli/practice.py +141 -0
  131. patchground/cli/render.py +58 -0
  132. patchground/cli/reports.py +53 -0
  133. patchground/cli/sessions.py +195 -0
  134. patchground/cli/validate.py +117 -0
  135. patchground/packs/__init__.py +23 -0
  136. patchground/packs/builtin.py +53 -0
  137. patchground/packs/discovery.py +285 -0
  138. patchground/packs/execution.py +420 -0
  139. patchground/packs/models.py +98 -0
  140. patchground/packs/registry.py +318 -0
  141. patchground/packs/source.py +230 -0
  142. patchground/packs/store.py +420 -0
  143. patchground/py.typed +0 -0
  144. patchground/report/__init__.py +6 -0
  145. patchground/report/analysis.py +188 -0
  146. patchground/report/models.py +59 -0
  147. patchground/report/regression.py +597 -0
  148. patchground/report/render.py +92 -0
  149. patchground/report/workflow.py +84 -0
  150. patchground/runner_assets/Dockerfile +7 -0
  151. patchground/runner_assets/__init__.py +16 -0
  152. patchground/runner_assets/package-lock.json +1054 -0
  153. patchground/runner_assets/package.json +13 -0
  154. patchground/runner_assets/vitest-runner.mjs +124 -0
  155. patchground/safeio/__init__.py +115 -0
  156. patchground/sandbox/__init__.py +39 -0
  157. patchground/sandbox/_engine.py +23 -0
  158. patchground/sandbox/build.py +202 -0
  159. patchground/sandbox/errors.py +23 -0
  160. patchground/sandbox/junit.py +76 -0
  161. patchground/sandbox/probe.py +118 -0
  162. patchground/sandbox/run.py +535 -0
  163. patchground/sessions/__init__.py +44 -0
  164. patchground/sessions/diagnostics.py +145 -0
  165. patchground/sessions/files.py +113 -0
  166. patchground/sessions/git.py +70 -0
  167. patchground/sessions/history.py +386 -0
  168. patchground/sessions/history_ignore.py +279 -0
  169. patchground/sessions/models.py +126 -0
  170. patchground/sessions/practice.py +202 -0
  171. patchground/sessions/reset.py +121 -0
  172. patchground/sessions/store.py +203 -0
  173. patchground/sessions/submission.py +344 -0
  174. patchground/sessions/workflow.py +297 -0
  175. patchground/spec/__init__.py +35 -0
  176. patchground/spec/_schema.py +53 -0
  177. patchground/spec/loader.py +713 -0
  178. patchground/spec/models.py +394 -0
  179. patchground/spec/schema.py +50 -0
  180. patchground/verify/__init__.py +29 -0
  181. patchground/verify/errors.py +10 -0
  182. patchground/verify/pytest_adapter.py +95 -0
  183. patchground/verify/record.py +94 -0
  184. patchground/verify/reference.py +158 -0
  185. patchground/verify/result.py +199 -0
  186. patchground/verify/runners.py +47 -0
  187. patchground/verify/tampering.py +106 -0
  188. patchground/verify/validate.py +686 -0
  189. patchground/verify/vitest_adapter.py +252 -0
  190. patchground-0.1.0.dist-info/METADATA +258 -0
  191. patchground-0.1.0.dist-info/RECORD +194 -0
  192. patchground-0.1.0.dist-info/WHEEL +4 -0
  193. patchground-0.1.0.dist-info/entry_points.txt +3 -0
  194. patchground-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,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