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,713 @@
1
+ """Load challenge and pack folders into validated objects, reporting every problem with its line.
2
+
3
+ Parsing uses ruamel.yaml round-trip mode so each value keeps its source position. All problems
4
+ (YAML, schema, filesystem) are collected in one pass and raised together as a ``ManifestError``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import stat
12
+ from collections.abc import Iterable, Sequence
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from pydantic import ValidationError
18
+ from pydantic_core import ErrorDetails
19
+ from ruamel.yaml import YAML
20
+ from ruamel.yaml.comments import CommentedMap, CommentedSeq
21
+ from ruamel.yaml.error import MarkedYAMLError, YAMLError
22
+ from ruamel.yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode
23
+
24
+ from patchground.spec.models import (
25
+ SLUG_PATTERN,
26
+ ChallengeManifest,
27
+ PackManifest,
28
+ relative_path_problem,
29
+ )
30
+
31
+ CHALLENGE_MANIFEST = "challenge.yaml"
32
+ PACK_MANIFEST = "pack.yaml"
33
+ TICKET_FILE = "TICKET.md"
34
+ WORKSPACE_DIR = "workspace"
35
+
36
+ MAX_MANIFEST_BYTES = 1024 * 1024
37
+ """Manifests are small; anything larger is rejected before parsing."""
38
+
39
+ MAX_YAML_DEPTH = 64
40
+ """Maximum nesting depth accepted by the YAML composer."""
41
+
42
+ Loc = tuple[str | int, ...]
43
+
44
+
45
+ def _normalized(path: Path) -> Path:
46
+ """Absolute, lexically normalized path (symlinks are not followed)."""
47
+ return Path(os.path.normpath(path.absolute()))
48
+
49
+
50
+ # --------------------------------------------------------------------------------------------------
51
+ # Public types
52
+ # --------------------------------------------------------------------------------------------------
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ManifestIssue:
57
+ """One problem found while loading a manifest. ``line`` is 1-based."""
58
+
59
+ file: Path
60
+ line: int | None
61
+ field: str | None
62
+ message: str
63
+
64
+ def format(self, relative_to: Path | None = None) -> str:
65
+ """Render as ``file:line: field: message``, ``file`` relative to ``relative_to``."""
66
+ shown = str(self.file)
67
+ if relative_to is not None:
68
+ file_abs = _normalized(self.file)
69
+ base_abs = _normalized(relative_to)
70
+ if file_abs.is_relative_to(base_abs):
71
+ shown = file_abs.relative_to(base_abs).as_posix()
72
+ location = shown if self.line is None else f"{shown}:{self.line}"
73
+ if self.field:
74
+ return f"{location}: {self.field}: {self.message}"
75
+ return f"{location}: {self.message}"
76
+
77
+ def __str__(self) -> str:
78
+ return self.format()
79
+
80
+
81
+ class ManifestError(Exception):
82
+ """Raised with every problem found in a challenge or pack folder."""
83
+
84
+ def __init__(self, issues: Sequence[ManifestIssue]) -> None:
85
+ self.issues: list[ManifestIssue] = list(issues)
86
+ super().__init__("\n".join(str(issue) for issue in self.issues))
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class Challenge:
91
+ """A validated challenge folder."""
92
+
93
+ root: Path
94
+ manifest_path: Path
95
+ manifest: ChallengeManifest
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class Pack:
100
+ """A validated pack folder."""
101
+
102
+ root: Path
103
+ manifest_path: Path
104
+ manifest: PackManifest
105
+
106
+
107
+ # --------------------------------------------------------------------------------------------------
108
+ # YAML parsing with positions
109
+ # --------------------------------------------------------------------------------------------------
110
+
111
+
112
+ def _make_yaml(*, allow_duplicate_keys: bool) -> YAML:
113
+ yaml = YAML(typ="rt", pure=True)
114
+ yaml.allow_duplicate_keys = allow_duplicate_keys
115
+ yaml.max_depth = MAX_YAML_DEPTH
116
+ return yaml
117
+
118
+
119
+ def _line(mark: Any) -> int | None:
120
+ line = getattr(mark, "line", None)
121
+ return line + 1 if isinstance(line, int) else None
122
+
123
+
124
+ def _duplicate_key_issues(node: Node, file: Path) -> list[ManifestIssue]:
125
+ """Find every duplicate mapping key in the composed node graph (aliases visited once)."""
126
+ issues: list[ManifestIssue] = []
127
+ seen_nodes: set[int] = set()
128
+ stack: list[Node] = [node]
129
+ while stack:
130
+ current = stack.pop()
131
+ if id(current) in seen_nodes:
132
+ continue
133
+ seen_nodes.add(id(current))
134
+ if isinstance(current, MappingNode):
135
+ first_line: dict[str, int | None] = {}
136
+ for key_node, value_node in current.value:
137
+ stack.append(value_node)
138
+ if not isinstance(key_node, ScalarNode) or key_node.value == "<<":
139
+ continue
140
+ key = str(key_node.value)
141
+ line = _line(key_node.start_mark)
142
+ if key in first_line:
143
+ where = first_line[key]
144
+ suffix = f" (first defined on line {where})" if where is not None else ""
145
+ issues.append(ManifestIssue(file, line, None, f"duplicate key '{key}'{suffix}"))
146
+ else:
147
+ first_line[key] = line
148
+ elif isinstance(current, SequenceNode):
149
+ stack.extend(current.value)
150
+ issues.sort(key=lambda issue: issue.line or 0)
151
+ return issues
152
+
153
+
154
+ def _parse_yaml(path: Path, issues: list[ManifestIssue]) -> Any:
155
+ """Parse ``path``; append problems to ``issues``. Returns the round-trip data or ``None``."""
156
+ try:
157
+ size = path.stat().st_size
158
+ except OSError as exc:
159
+ issues.append(ManifestIssue(path, None, None, f"cannot read file: {exc.strerror}"))
160
+ return None
161
+ if size > MAX_MANIFEST_BYTES:
162
+ issues.append(
163
+ ManifestIssue(path, None, None, f"file is larger than {MAX_MANIFEST_BYTES} bytes")
164
+ )
165
+ return None
166
+ try:
167
+ text = path.read_bytes().decode("utf-8")
168
+ except OSError as exc:
169
+ issues.append(ManifestIssue(path, None, None, f"cannot read file: {exc.strerror}"))
170
+ return None
171
+ except UnicodeDecodeError:
172
+ issues.append(ManifestIssue(path, None, None, "file is not valid UTF-8"))
173
+ return None
174
+
175
+ try:
176
+ node = _make_yaml(allow_duplicate_keys=False).compose(text)
177
+ if node is not None:
178
+ issues.extend(_duplicate_key_issues(node, path))
179
+ data = _make_yaml(allow_duplicate_keys=True).load(text)
180
+ except MarkedYAMLError as exc:
181
+ mark = exc.problem_mark or exc.context_mark
182
+ problem = exc.problem or exc.context or "invalid YAML"
183
+ context = f" ({exc.context})" if exc.context and exc.problem else ""
184
+ issues.append(ManifestIssue(path, _line(mark), None, f"YAML error: {problem}{context}"))
185
+ return None
186
+ except YAMLError as exc:
187
+ issues.append(ManifestIssue(path, None, None, f"YAML error: {exc}"))
188
+ return None
189
+ except RecursionError:
190
+ issues.append(ManifestIssue(path, None, None, "YAML error: document is nested too deeply"))
191
+ return None
192
+
193
+ if not isinstance(data, CommentedMap):
194
+ issues.append(
195
+ ManifestIssue(path, 1, None, f"{path.name} must contain a mapping of keys at the top")
196
+ )
197
+ return None
198
+ return data
199
+
200
+
201
+ def _to_plain(value: Any, memo: dict[int, Any] | None = None) -> Any:
202
+ """Convert ruamel round-trip containers and scalar subclasses into plain Python values.
203
+
204
+ Shared (aliased) containers are converted once, so alias expansion cannot blow up.
205
+ """
206
+ if memo is None:
207
+ memo = {}
208
+ if isinstance(value, dict | list) and id(value) in memo:
209
+ return memo[id(value)]
210
+ if isinstance(value, dict):
211
+ out_map: dict[Any, Any] = {}
212
+ memo[id(value)] = out_map
213
+ for key, item in value.items():
214
+ if isinstance(key, str): # non-string keys are reported by _non_string_key_issues
215
+ out_map[str(key)] = _to_plain(item, memo)
216
+ return out_map
217
+ if isinstance(value, list):
218
+ out_list: list[Any] = []
219
+ memo[id(value)] = out_list
220
+ out_list.extend(_to_plain(item, memo) for item in value)
221
+ return out_list
222
+ if isinstance(value, bool) or value is None:
223
+ return value
224
+ if isinstance(value, str):
225
+ return str(value)
226
+ if isinstance(value, int):
227
+ return int(value)
228
+ if isinstance(value, float):
229
+ return float(value)
230
+ return value
231
+
232
+
233
+ def _non_string_key_issues(raw: Any, file: Path) -> list[ManifestIssue]:
234
+ """Report mapping keys that are not strings (e.g. ``1:``, ``null:``, ``true:``)."""
235
+ issues: list[ManifestIssue] = []
236
+ seen: set[int] = set()
237
+ stack: list[tuple[Any, Loc]] = [(raw, ())]
238
+ while stack:
239
+ current, loc = stack.pop()
240
+ if id(current) in seen:
241
+ continue
242
+ seen.add(id(current))
243
+ if isinstance(current, CommentedMap):
244
+ for key, item in current.items():
245
+ if isinstance(key, str):
246
+ stack.append((item, (*loc, key)))
247
+ continue
248
+ position = current.lc.data.get(key)
249
+ line = position[0] + 1 if position else _container_line(current)
250
+ issues.append(
251
+ ManifestIssue(file, line, _format_loc(loc), "mapping keys must be strings")
252
+ )
253
+ elif isinstance(current, CommentedSeq):
254
+ stack.extend((item, (*loc, index)) for index, item in enumerate(current))
255
+ return issues
256
+
257
+
258
+ # --------------------------------------------------------------------------------------------------
259
+ # Locations
260
+ # --------------------------------------------------------------------------------------------------
261
+
262
+
263
+ def _container_line(container: Any) -> int | None:
264
+ lc = getattr(container, "lc", None)
265
+ line = getattr(lc, "line", None)
266
+ return line + 1 if isinstance(line, int) else None
267
+
268
+
269
+ def _line_for(raw: Any, loc: Loc) -> int | None:
270
+ """Line of the deepest part of ``loc`` present in ``raw`` (key line for maps, item for seqs)."""
271
+ line = _container_line(raw)
272
+ current = raw
273
+ for part in loc:
274
+ if (isinstance(current, CommentedMap) and isinstance(part, str) and part in current) or (
275
+ isinstance(current, CommentedSeq) and isinstance(part, int) and 0 <= part < len(current)
276
+ ):
277
+ position = current.lc.data.get(part)
278
+ if position:
279
+ line = position[0] + 1
280
+ current = current[part]
281
+ else:
282
+ # Missing key (e.g. "field required") or a pydantic-internal loc part: keep the
283
+ # parent's line and try the remaining parts against the same container.
284
+ continue
285
+ return line
286
+
287
+
288
+ def _format_loc(loc: Loc) -> str | None:
289
+ text = ""
290
+ for part in loc:
291
+ if isinstance(part, int):
292
+ text += f"[{part}]"
293
+ else:
294
+ text += f".{part}" if text else part
295
+ return text or None
296
+
297
+
298
+ def _message(error: ErrorDetails) -> str:
299
+ kind = error["type"]
300
+ if kind == "missing":
301
+ return "field required"
302
+ if kind == "extra_forbidden":
303
+ return "unknown key (only keys starting with 'x-' may be added)"
304
+ return str(error["msg"])
305
+
306
+
307
+ def _validation_issues(exc: ValidationError, raw: Any, file: Path) -> list[ManifestIssue]:
308
+ return [
309
+ ManifestIssue(
310
+ file,
311
+ _line_for(raw, tuple(error["loc"])),
312
+ _format_loc(tuple(error["loc"])),
313
+ _message(error),
314
+ )
315
+ for error in exc.errors(include_url=False, include_input=False)
316
+ ]
317
+
318
+
319
+ def _raw_get(raw: Any, loc: Loc) -> Any:
320
+ current = raw
321
+ for part in loc:
322
+ if isinstance(current, dict) and isinstance(part, str):
323
+ current = current.get(part)
324
+ elif isinstance(current, list) and isinstance(part, int) and 0 <= part < len(current):
325
+ current = current[part]
326
+ else:
327
+ return None
328
+ return current
329
+
330
+
331
+ # --------------------------------------------------------------------------------------------------
332
+ # Filesystem checks
333
+ # --------------------------------------------------------------------------------------------------
334
+
335
+
336
+ def _check_path(
337
+ *, root: Path, folder: Path, rel: str, want_dir: bool, container: str = "challenge folder"
338
+ ) -> Path | str:
339
+ """Check one referenced path: resolved path if usable, else the problem as a message."""
340
+ target = folder / rel
341
+ resolved = target.resolve()
342
+ if not resolved.is_relative_to(root):
343
+ return f"'{rel}' resolves outside the {container}"
344
+ if not target.exists():
345
+ return f"'{rel}' does not exist"
346
+ if want_dir and not target.is_dir():
347
+ return f"'{rel}' must be a directory"
348
+ if not want_dir and not target.is_file():
349
+ return f"'{rel}' must be a file"
350
+ return resolved
351
+
352
+
353
+ def _relative_entries(directory: Path) -> tuple[set[str], set[str]]:
354
+ """Relative POSIX paths of non-directory entries and of directories below ``directory``.
355
+
356
+ Symlinks are not followed; a symlink counts as a file entry.
357
+ """
358
+ files: set[str] = set()
359
+ dirs: set[str] = set()
360
+ for current, dirnames, filenames in directory.walk(follow_symlinks=False):
361
+ for name in list(dirnames):
362
+ entry = current / name
363
+ rel = entry.relative_to(directory).as_posix()
364
+ if entry.is_symlink():
365
+ files.add(rel)
366
+ dirnames.remove(name)
367
+ else:
368
+ dirs.add(rel)
369
+ for name in filenames:
370
+ files.add((current / name).relative_to(directory).as_posix())
371
+ return files, dirs
372
+
373
+
374
+ @dataclass(frozen=True)
375
+ class _MergeSource:
376
+ label: str
377
+ path: Path
378
+ line: int | None
379
+ field: str | None
380
+
381
+
382
+ def _clash_issues(sources: Iterable[_MergeSource], file: Path) -> list[ManifestIssue]:
383
+ """Paths that would collide when checks are merged into the workspace root."""
384
+ issues: list[ManifestIssue] = []
385
+ seen: list[tuple[_MergeSource, set[str], set[str]]] = []
386
+ for source in sources:
387
+ files, dirs = _relative_entries(source.path)
388
+ for earlier, earlier_files, earlier_dirs in seen:
389
+ clashes = (files & earlier_files) | (files & earlier_dirs) | (dirs & earlier_files)
390
+ for rel in sorted(clashes):
391
+ issues.append(
392
+ ManifestIssue(
393
+ file,
394
+ source.line,
395
+ source.field,
396
+ f"'{rel}' exists in both {earlier.label} and {source.label}; "
397
+ "check files are merged into the workspace root and must not collide",
398
+ )
399
+ )
400
+ seen.append((source, files, dirs))
401
+ return issues
402
+
403
+
404
+ def _relation(a: Path, b: Path) -> str | None:
405
+ """How resolved path ``a`` relates to resolved path ``b``: same, inside, contains, or None."""
406
+ if a == b:
407
+ return "is the same path as"
408
+ if a.is_relative_to(b):
409
+ return "is inside"
410
+ if b.is_relative_to(a):
411
+ return "contains"
412
+ return None
413
+
414
+
415
+ def _non_regular_entries(directory: Path) -> list[tuple[str, str]]:
416
+ """``(relative POSIX path, kind)`` of every symlink or special file below ``directory``.
417
+
418
+ Only names and kinds are reported, never contents; symlinks are not followed.
419
+ """
420
+ found: list[tuple[str, str]] = []
421
+ for current, dirnames, filenames in directory.walk(follow_symlinks=False):
422
+ dirnames.sort()
423
+ for name in sorted([*dirnames, *filenames]):
424
+ entry = current / name
425
+ try:
426
+ mode = entry.lstat().st_mode
427
+ except OSError:
428
+ continue
429
+ if stat.S_ISDIR(mode) or stat.S_ISREG(mode):
430
+ continue
431
+ if stat.S_ISLNK(mode):
432
+ kind = "a symlink"
433
+ if name in dirnames:
434
+ dirnames.remove(name)
435
+ elif stat.S_ISFIFO(mode):
436
+ kind = "a FIFO"
437
+ elif stat.S_ISSOCK(mode):
438
+ kind = "a socket"
439
+ elif stat.S_ISCHR(mode) or stat.S_ISBLK(mode):
440
+ kind = "a device file"
441
+ else:
442
+ kind = "a special file"
443
+ found.append((entry.relative_to(directory).as_posix(), kind))
444
+ return found
445
+
446
+
447
+ def _git_entries(directory: Path) -> list[str]:
448
+ """Relative POSIX paths of every ``.git`` entry (any case, file or dir) below ``directory``.
449
+
450
+ A ``.git`` directory is not descended into; symlinks are not followed.
451
+ """
452
+ found: list[str] = []
453
+ for current, dirnames, filenames in directory.walk(follow_symlinks=False):
454
+ dirnames.sort()
455
+ for name in sorted([*dirnames, *filenames]):
456
+ if name.casefold() != ".git":
457
+ continue
458
+ found.append((current / name).relative_to(directory).as_posix())
459
+ if name in dirnames:
460
+ dirnames.remove(name)
461
+ return found
462
+
463
+
464
+ @dataclass(frozen=True)
465
+ class _Ref:
466
+ """A resolved folder entry and where to report problems about it."""
467
+
468
+ label: str
469
+ path: Path
470
+ file: Path
471
+ line: int | None
472
+ field: str | None
473
+
474
+ def issue(self, message: str) -> ManifestIssue:
475
+ return ManifestIssue(self.file, self.line, self.field, message)
476
+
477
+
478
+ def _isolation_issues(refs: dict[str, _Ref]) -> list[ManifestIssue]:
479
+ """Enforce that solution and hidden checks can never reach the workspace or the image.
480
+
481
+ Everything under ``workspace/`` and ``checks.visible`` is copied into the user's workspace,
482
+ and the Dockerfile's folder is the build context sent into the image the sessions run. So
483
+ ``solution`` and ``checks.hidden`` must not overlap the workspace or visible checks, visible
484
+ checks must not overlap the workspace, and the build context must be a separate folder.
485
+ """
486
+ issues: list[ManifestIssue] = []
487
+ workspace = refs.get("workspace")
488
+ visible = refs.get("visible")
489
+ for secret_key in ("hidden", "solution"):
490
+ secret = refs.get(secret_key)
491
+ if secret is None:
492
+ continue
493
+ for exposed in (workspace, visible):
494
+ if exposed is None:
495
+ continue
496
+ relation = _relation(secret.path, exposed.path)
497
+ if relation is None:
498
+ continue
499
+ # Blame checks.visible when it covers the secret; otherwise the secret's own field.
500
+ blame_exposed = exposed.field is not None and relation != "contains"
501
+ blamed, other = (exposed, secret) if blame_exposed else (secret, exposed)
502
+ issues.append(
503
+ blamed.issue(
504
+ f"{blamed.label} {_relation(blamed.path, other.path)} {other.label}; "
505
+ f"{secret.label} must never be copied into the workspace, "
506
+ "keep them in separate folders"
507
+ )
508
+ )
509
+ if workspace is not None and visible is not None:
510
+ relation = _relation(visible.path, workspace.path)
511
+ if relation is not None:
512
+ issues.append(
513
+ visible.issue(
514
+ f"{visible.label} {relation} {workspace.label}; visible checks must be a "
515
+ "separate folder that is merged into the workspace"
516
+ )
517
+ )
518
+ dockerfile = refs.get("dockerfile")
519
+ if dockerfile is not None:
520
+ context = dockerfile.path.parent
521
+ for key in ("workspace", "hidden", "solution"):
522
+ leaked = refs.get(key)
523
+ if leaked is None:
524
+ continue
525
+ leaks = leaked.path.is_relative_to(context)
526
+ if key == "hidden": # a context inside the hidden checks ships some of them too
527
+ leaks = leaks or context.is_relative_to(leaked.path)
528
+ if leaks:
529
+ issues.append(
530
+ dockerfile.issue(
531
+ f"the Dockerfile's folder (the build context) "
532
+ f"{_relation(context, leaked.path)} {leaked.label}; the build context goes "
533
+ "into the image, so it must be a separate folder such as environment/"
534
+ )
535
+ )
536
+ return issues
537
+
538
+
539
+ def _challenge_fs_issues(folder: Path, root: Path, manifest: Path, raw: Any) -> list[ManifestIssue]:
540
+ issues: list[ManifestIssue] = []
541
+
542
+ # Required entries that are not referenced from the manifest.
543
+ ticket = _check_path(root=root, folder=folder, rel=TICKET_FILE, want_dir=False)
544
+ if isinstance(ticket, str):
545
+ issues.append(ManifestIssue(folder / TICKET_FILE, None, None, ticket))
546
+ workspace = _check_path(root=root, folder=folder, rel=WORKSPACE_DIR, want_dir=True)
547
+ if isinstance(workspace, str):
548
+ issues.append(ManifestIssue(folder / WORKSPACE_DIR, None, None, workspace))
549
+
550
+ if raw is None:
551
+ return issues
552
+
553
+ folder_name = _normalized(folder).name
554
+ challenge_id = _raw_get(raw, ("id",))
555
+ if isinstance(challenge_id, str) and challenge_id != folder_name:
556
+ issues.append(
557
+ ManifestIssue(
558
+ manifest,
559
+ _line_for(raw, ("id",)),
560
+ "id",
561
+ f"id '{challenge_id}' must match the folder name '{folder_name}'",
562
+ )
563
+ )
564
+
565
+ references: list[tuple[Loc, bool]] = [
566
+ (("environment", "dockerfile"), False),
567
+ (("checks", "visible"), True),
568
+ (("checks", "hidden"), True),
569
+ (("solution",), False),
570
+ (("debrief", "questions"), False),
571
+ ]
572
+ resolved: dict[Loc, Path] = {}
573
+ for loc, want_dir in references:
574
+ value = _raw_get(raw, loc)
575
+ # Values the model rejects (wrong type, absolute, escaping) are reported by the model.
576
+ if not isinstance(value, str) or relative_path_problem(value) is not None:
577
+ continue
578
+ target = _check_path(root=root, folder=folder, rel=value, want_dir=want_dir)
579
+ if isinstance(target, str):
580
+ issues.append(ManifestIssue(manifest, _line_for(raw, loc), _format_loc(loc), target))
581
+ else:
582
+ resolved[loc] = target
583
+
584
+ refs: dict[str, _Ref] = {}
585
+ if isinstance(workspace, Path):
586
+ refs["workspace"] = _Ref(f"{WORKSPACE_DIR}/", workspace, folder / WORKSPACE_DIR, None, None)
587
+ for key, loc in (
588
+ ("dockerfile", ("environment", "dockerfile")),
589
+ ("visible", ("checks", "visible")),
590
+ ("hidden", ("checks", "hidden")),
591
+ ("solution", ("solution",)),
592
+ ):
593
+ if loc in resolved:
594
+ field = str(_format_loc(loc))
595
+ refs[key] = _Ref(field, resolved[loc], manifest, _line_for(raw, loc), field)
596
+
597
+ isolation = _isolation_issues(refs)
598
+ issues.extend(isolation)
599
+
600
+ for key in ("workspace", "visible", "hidden"):
601
+ ref = refs.get(key)
602
+ if ref is None:
603
+ continue
604
+ for rel in _git_entries(ref.path):
605
+ shown = (ref.path / rel).relative_to(root).as_posix()
606
+ issues.append(
607
+ ref.issue(
608
+ f"'{shown}' is a git repository or git file; challenges must not ship .git"
609
+ )
610
+ )
611
+ for rel, kind in _non_regular_entries(ref.path):
612
+ shown = (ref.path / rel).relative_to(root).as_posix()
613
+ issues.append(
614
+ ref.issue(
615
+ f"'{shown}' is {kind}; {ref.label} may only contain regular files "
616
+ "and directories"
617
+ )
618
+ )
619
+
620
+ if not isolation: # overlapping folders would only repeat the same problem as clashes
621
+ sources = [
622
+ _MergeSource(ref.label, ref.path, ref.line, ref.field)
623
+ for key in ("workspace", "visible", "hidden")
624
+ if (ref := refs.get(key)) is not None
625
+ ]
626
+ issues.extend(_clash_issues(sources, manifest))
627
+ return issues
628
+
629
+
630
+ def _pack_fs_issues(folder: Path, root: Path, manifest: Path, raw: Any) -> list[ManifestIssue]:
631
+ issues: list[ManifestIssue] = []
632
+ challenges = _raw_get(raw, ("challenges",))
633
+ if not isinstance(challenges, list):
634
+ return issues
635
+ for index, name in enumerate(challenges):
636
+ if not isinstance(name, str) or not re.fullmatch(SLUG_PATTERN, name):
637
+ continue # reported by the model
638
+ loc: Loc = ("challenges", index)
639
+ target = _check_path(
640
+ root=root, folder=folder, rel=name, want_dir=True, container="pack folder"
641
+ )
642
+ if isinstance(target, str):
643
+ message = f"challenge folder {target}"
644
+ issues.append(ManifestIssue(manifest, _line_for(raw, loc), _format_loc(loc), message))
645
+ return issues
646
+
647
+
648
+ # --------------------------------------------------------------------------------------------------
649
+ # Entry points
650
+ # --------------------------------------------------------------------------------------------------
651
+
652
+
653
+ def _sorted(issues: list[ManifestIssue]) -> list[ManifestIssue]:
654
+ # Stable: issues without a line keep their discovery order after the located ones of a file.
655
+ return sorted(issues, key=lambda issue: (issue.line is None, issue.line or 0))
656
+
657
+
658
+ def load_challenge(folder: Path) -> Challenge:
659
+ """Load and validate the challenge in ``folder``.
660
+
661
+ Raises ``ManifestError`` listing every problem found.
662
+ """
663
+ folder = Path(folder)
664
+ manifest_path = folder / CHALLENGE_MANIFEST
665
+ if not manifest_path.is_file():
666
+ raise ManifestError(
667
+ [ManifestIssue(manifest_path, None, None, f"{CHALLENGE_MANIFEST} not found")]
668
+ )
669
+ root = folder.resolve()
670
+
671
+ issues: list[ManifestIssue] = []
672
+ raw = _parse_yaml(manifest_path, issues)
673
+ model: ChallengeManifest | None = None
674
+ if raw is not None:
675
+ issues.extend(_non_string_key_issues(raw, manifest_path))
676
+ try:
677
+ model = ChallengeManifest.model_validate(_to_plain(raw))
678
+ except ValidationError as exc:
679
+ issues.extend(_validation_issues(exc, raw, manifest_path))
680
+ issues.extend(_challenge_fs_issues(folder, root, manifest_path, raw))
681
+
682
+ if issues or model is None:
683
+ raise ManifestError(_sorted(issues))
684
+ return Challenge(root=root, manifest_path=root / CHALLENGE_MANIFEST, manifest=model)
685
+
686
+
687
+ def load_pack(folder: Path) -> Pack:
688
+ """Load and validate the pack in ``folder``.
689
+
690
+ Raises ``ManifestError`` listing every problem found.
691
+ """
692
+ folder = Path(folder)
693
+ manifest_path = folder / PACK_MANIFEST
694
+ if not manifest_path.is_file():
695
+ raise ManifestError(
696
+ [ManifestIssue(manifest_path, None, None, f"{PACK_MANIFEST} not found")]
697
+ )
698
+ root = folder.resolve()
699
+
700
+ issues: list[ManifestIssue] = []
701
+ raw = _parse_yaml(manifest_path, issues)
702
+ model: PackManifest | None = None
703
+ if raw is not None:
704
+ issues.extend(_non_string_key_issues(raw, manifest_path))
705
+ try:
706
+ model = PackManifest.model_validate(_to_plain(raw))
707
+ except ValidationError as exc:
708
+ issues.extend(_validation_issues(exc, raw, manifest_path))
709
+ issues.extend(_pack_fs_issues(folder, root, manifest_path, raw))
710
+
711
+ if issues or model is None:
712
+ raise ManifestError(_sorted(issues))
713
+ return Pack(root=root, manifest_path=root / PACK_MANIFEST, manifest=model)