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,420 @@
1
+ """Reference resolution, installed discovery and consent-gated execution preparation.
2
+
3
+ Local paths stay untouched. Anything that resolves into managed storage — by id or by path — is a
4
+ pack version and is gated: its bytes are captured into a private temporary copy, verified against
5
+ the registered digest, reviewed from that very copy, and only then handed out.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import tempfile
13
+ from collections.abc import Iterable, Iterator
14
+ from contextlib import contextmanager
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from patchground.packs.builtin import builtin_challenge
19
+ from patchground.packs.discovery import (
20
+ ChallengeDetails,
21
+ ChallengeSummary,
22
+ DiscoveryFilters,
23
+ DiscoveryResult,
24
+ InvalidChallenge,
25
+ discover_challenges,
26
+ show_challenge,
27
+ )
28
+ from patchground.packs.models import (
29
+ ConsentRequired,
30
+ ExecutionRecipe,
31
+ PackError,
32
+ PackIdentity,
33
+ PackReview,
34
+ PreparedChallenge,
35
+ ResolvedChallenge,
36
+ )
37
+ from patchground.packs.store import (
38
+ Entry,
39
+ Version,
40
+ cache_path,
41
+ cache_problem,
42
+ capture_tree,
43
+ identity_of,
44
+ load_state,
45
+ locked,
46
+ packs_dir,
47
+ read_limited,
48
+ remove_tree,
49
+ save_state,
50
+ state_root,
51
+ )
52
+ from patchground.safeio import safe_path
53
+ from patchground.spec import ManifestError, PackManifest, load_challenge, load_pack
54
+ from patchground.spec.models import SLUG_PATTERN
55
+
56
+ MAX_RECIPE_BYTES = 64 * 1024
57
+ MAX_REVIEW_BYTES = 1024 * 1024
58
+ """Whole-pack recipe review bound; exceeding it fails the operation, never truncates."""
59
+ """Anything longer cannot be shown in full, so such a pack is refused rather than half-reviewed."""
60
+ _SLUG = SLUG_PATTERN.removeprefix("^").removesuffix("$")
61
+ _QUALIFIED = re.compile(rf"(?P<pack>{_SLUG}):(?P<id>{_SLUG})")
62
+ _CACHE_NAME = re.compile(r"(.+)@([0-9a-f]{40}|[0-9a-f]{64})")
63
+
64
+
65
+ class InstalledChallengeSummary(ChallengeSummary):
66
+ pack: PackIdentity
67
+ reference: str
68
+ """Unambiguous ``pack:challenge`` form accepted by ``resolve_challenge``."""
69
+
70
+
71
+ class InstalledDiscoveryResult(DiscoveryResult):
72
+ challenges: tuple[InstalledChallengeSummary, ...]
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class Inspection:
77
+ manifest: PackManifest
78
+ recipes: tuple[ExecutionRecipe, ...]
79
+ summaries: tuple[ChallengeSummary, ...]
80
+
81
+
82
+ def _describe(exc: ManifestError, folder: Path, prefix: str = "") -> str:
83
+ parts = []
84
+ for issue in exc.issues[:5]:
85
+ try:
86
+ shown = os.path.relpath(issue.file, folder)
87
+ except ValueError:
88
+ shown = issue.file.name
89
+ parts.append(f"{prefix}{shown}:{issue.line or 1}: invalid manifest or layout")
90
+ return "; ".join(parts) or "invalid pack"
91
+
92
+
93
+ def read_recipes(pack_root: Path, challenges: Iterable[str]) -> tuple[ExecutionRecipe, ...]:
94
+ """Complete build/run recipes of each indexed challenge, read from ``pack_root``."""
95
+ recipes = []
96
+ reviewed = 0
97
+ for name in challenges:
98
+ folder = pack_root / name
99
+ try:
100
+ manifest = load_challenge(folder).manifest
101
+ except ManifestError as exc:
102
+ raise PackError(_describe(exc, folder, f"{name}/")) from None
103
+ env = manifest.environment
104
+ try:
105
+ dockerfile = read_limited(folder / env.dockerfile, MAX_RECIPE_BYTES).decode()
106
+ except (UnicodeDecodeError, OSError) as exc:
107
+ raise PackError(f"{name}: Dockerfile cannot be reviewed in full") from exc
108
+ if len(env.test_command.encode()) > MAX_RECIPE_BYTES:
109
+ raise PackError(f"{name}: test command cannot be reviewed in full")
110
+ reviewed += len(dockerfile) + len(env.test_command) + sum(map(len, env.services))
111
+ if reviewed > MAX_REVIEW_BYTES:
112
+ raise PackError("pack recipes are too large to review in full")
113
+ recipes.append(
114
+ ExecutionRecipe(
115
+ challenge_id=name,
116
+ dockerfile_path=env.dockerfile,
117
+ dockerfile=dockerfile,
118
+ services=tuple(env.services),
119
+ command=env.test_command,
120
+ )
121
+ )
122
+ return tuple(recipes)
123
+
124
+
125
+ def inspect_pack(folder: Path) -> Inspection:
126
+ """Structurally validate a captured pack and every indexed challenge; nothing is executed."""
127
+ try:
128
+ pack = load_pack(folder)
129
+ except ManifestError as exc:
130
+ raise PackError(_describe(exc, folder)) from None
131
+ names = tuple(pack.manifest.challenges)
132
+ recipes = read_recipes(folder, names)
133
+ for name in names:
134
+ if load_challenge(folder / name).manifest.id != name:
135
+ raise PackError(f"{name}: challenge id must equal its folder name")
136
+ found = {c.id: c for c in discover_challenges(folder).challenges}
137
+ missing = [name for name in names if name not in found]
138
+ if missing:
139
+ raise PackError(f"{missing[0]}: challenge folder is not a valid challenge")
140
+ return Inspection(pack.manifest, recipes, tuple(found[name] for name in names))
141
+
142
+
143
+ def make_review(
144
+ identity: PackIdentity,
145
+ recipes: tuple[ExecutionRecipe, ...],
146
+ previous: Version | None = None,
147
+ folders: dict[str, str] | None = None,
148
+ ) -> PackReview:
149
+ if previous is None or folders is None:
150
+ return PackReview(pack=identity, recipes=recipes)
151
+ old, new = previous.folders, folders
152
+ return PackReview(
153
+ pack=identity,
154
+ recipes=recipes,
155
+ previous_commit=previous.commit,
156
+ added=tuple(sorted(set(new) - set(old))),
157
+ removed=tuple(sorted(set(old) - set(new))),
158
+ changed=tuple(sorted(n for n in set(old) & set(new) if old[n] != new[n])),
159
+ )
160
+
161
+
162
+ # --------------------------------------------------------------------------------------------
163
+ # Resolution
164
+ # --------------------------------------------------------------------------------------------
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class _Managed:
169
+ name: str
170
+ entry: Entry
171
+ version: Version
172
+ challenge: str
173
+
174
+
175
+ def _managed(name: str, entry: Entry, commit: str, challenge: str) -> _Managed:
176
+ version = entry.versions.get(commit)
177
+ if version is None or challenge not in version.folders:
178
+ raise PackError("path is not a registered installed challenge")
179
+ return _Managed(name, entry, version, challenge)
180
+
181
+
182
+ def _installed(packs: dict[str, Entry], name: str | None, challenge: str) -> _Managed:
183
+ if name is not None:
184
+ entry = packs.get(name)
185
+ if entry is None:
186
+ raise PackError(f"pack '{name}' is not installed")
187
+ return _managed(name, entry, entry.active, challenge)
188
+ found = [(n, e) for n, e in sorted(packs.items()) if challenge in e.versions[e.active].folders]
189
+ if not found:
190
+ raise _NotInstalled
191
+ if len(found) > 1:
192
+ options = ", ".join(f"{n}:{challenge}" for n, _ in found)
193
+ raise PackError(f"challenge '{challenge}' is ambiguous; use one of: {options}")
194
+ n, e = found[0]
195
+ return _managed(n, e, e.active, challenge)
196
+
197
+
198
+ class _NotInstalled(Exception):
199
+ pass
200
+
201
+
202
+ def _managed_rel(candidate: Path, root: Path) -> tuple[str, ...] | None:
203
+ """Parts below the pack cache when ``candidate`` lies in it, even if the cache is absent.
204
+
205
+ The lexical test needs no existing directory (a missing cache must not turn a managed-looking
206
+ path into a trusted local one); the identity test catches links and case-insensitive aliases.
207
+ """
208
+ base = packs_dir(root)
209
+ real = Path(os.path.realpath(candidate))
210
+ if real.is_relative_to(base):
211
+ return real.relative_to(base).parts
212
+ if base.is_dir():
213
+ for ancestor in (real, *real.parents):
214
+ try:
215
+ if ancestor.exists() and ancestor.samefile(base):
216
+ return real.parts[len(ancestor.parts) :]
217
+ except OSError:
218
+ continue
219
+ return None
220
+
221
+
222
+ def _by_path(candidate: Path, root: Path) -> _Managed | None:
223
+ rel = _managed_rel(candidate, root)
224
+ if rel is None:
225
+ return None
226
+ match = _CACHE_NAME.fullmatch(rel[0]) if rel else None
227
+ if match is None or len(rel) != 2:
228
+ raise PackError("path inside managed pack storage is not an installed challenge")
229
+ entry = load_state(root).packs.get(match.group(1))
230
+ if entry is None:
231
+ raise PackError("path inside managed pack storage is not registered")
232
+ return _managed(match.group(1), entry, match.group(2), rel[1])
233
+
234
+
235
+ def _locate(reference: str | Path, root: Path) -> Path | _Managed:
236
+ if isinstance(reference, str):
237
+ qualified = _QUALIFIED.fullmatch(reference)
238
+ if qualified:
239
+ packs = load_state(root).packs
240
+ return _installed(dict(packs), qualified["pack"], qualified["id"])
241
+ candidate = Path(reference)
242
+ # An existing local folder keeps its old meaning; ids are only for names not on disk.
243
+ if not (candidate.exists() or candidate.is_symlink()) and re.fullmatch(
244
+ SLUG_PATTERN, reference
245
+ ):
246
+ try:
247
+ return _installed(dict(load_state(root).packs), None, reference)
248
+ except _NotInstalled:
249
+ bundled = builtin_challenge(reference)
250
+ if bundled is not None:
251
+ return bundled
252
+ else:
253
+ candidate = reference
254
+ return _by_path(candidate, root) or candidate
255
+
256
+
257
+ def _pack_dir(root: Path, target: _Managed) -> Path:
258
+ path = cache_path(root, target.name, target.version.commit)
259
+ try:
260
+ safe_path(path, directory=True, error=PackError, label="installed pack")
261
+ except OSError as exc:
262
+ raise PackError("installed pack content is missing") from exc
263
+ return path
264
+
265
+
266
+ def resolve_challenge(reference: str | Path, *, root: Path | None = None) -> ResolvedChallenge:
267
+ """Resolve an id, ``pack:id`` or path. Resolution alone never grants consent."""
268
+ base = state_root(root)
269
+ target = _locate(reference, base)
270
+ if isinstance(target, Path):
271
+ return ResolvedChallenge(path=target)
272
+ return ResolvedChallenge(
273
+ path=_pack_dir(base, target) / target.challenge,
274
+ pack=identity_of(target.name, target.entry, target.version),
275
+ )
276
+
277
+
278
+ def discover_installed(
279
+ filters: DiscoveryFilters | None = None, *, root: Path | None = None
280
+ ) -> InstalledDiscoveryResult:
281
+ """Challenges of active pack versions from validated catalog metadata. Cache bytes are only
282
+ hashed to flag tampered or missing packs; nothing is built, executed or initialized."""
283
+ base = state_root(root)
284
+ active = filters or DiscoveryFilters()
285
+ found: list[InstalledChallengeSummary] = []
286
+ invalid: list[InvalidChallenge] = []
287
+ for name, entry in sorted(load_state(base).packs.items()):
288
+ version = entry.versions[entry.active]
289
+ pack = identity_of(name, entry, version)
290
+ folder = cache_path(base, name, version.commit)
291
+ problem = cache_problem(base, name, version)
292
+ if problem is not None:
293
+ invalid.append(InvalidChallenge(name=name, path=str(folder), issues=(problem,)))
294
+ continue
295
+ for summary in version.challenges:
296
+ if _matches(active, summary):
297
+ found.append(
298
+ InstalledChallengeSummary(
299
+ **summary.model_copy(
300
+ update={"path": str(folder / summary.id)}
301
+ ).model_dump(),
302
+ pack=pack,
303
+ reference=f"{name}:{summary.id}",
304
+ )
305
+ )
306
+ return InstalledDiscoveryResult(
307
+ directory=str(packs_dir(base)),
308
+ filters=active,
309
+ challenges=tuple(found),
310
+ invalid=tuple(invalid),
311
+ )
312
+
313
+
314
+ def _matches(filters: DiscoveryFilters, summary: ChallengeSummary) -> bool:
315
+ def has(values: Iterable[str], wanted: str) -> bool:
316
+ return any(v.casefold() == wanted.strip().casefold() for v in values)
317
+
318
+ return (
319
+ (filters.type is None or summary.type is filters.type)
320
+ and (filters.difficulty is None or summary.difficulty is filters.difficulty)
321
+ and (filters.lang is None or has(summary.languages, filters.lang))
322
+ and (filters.tag is None or has(summary.tags, filters.tag))
323
+ )
324
+
325
+
326
+ # --------------------------------------------------------------------------------------------
327
+ # Preparation
328
+ # --------------------------------------------------------------------------------------------
329
+
330
+
331
+ def _grant(root: Path, target: _Managed) -> None:
332
+ with locked(root):
333
+ state = load_state(root)
334
+ entry = state.packs.get(target.name)
335
+ current = entry.versions.get(target.version.commit) if entry else None
336
+ if entry is None or current is None or current.identity != target.version.identity:
337
+ raise PackError("pack changed while consent was being recorded")
338
+ versions = {
339
+ **entry.versions,
340
+ current.commit: current.model_copy(update={"consented": True}),
341
+ }
342
+ packs = {**state.packs, target.name: entry.model_copy(update={"versions": versions})}
343
+ save_state(root, state.model_copy(update={"packs": packs}))
344
+
345
+
346
+ @contextmanager
347
+ def _verified_copy(root: Path, target: _Managed) -> Iterator[tuple[Path, Path]]:
348
+ """Capture an installed pack into a private temporary copy and verify it against the catalog.
349
+
350
+ Yields ``(copy, canonical_cache_dir)``; the single place where cache bytes are trusted.
351
+ """
352
+ source = _pack_dir(root, target)
353
+ temp = Path(tempfile.mkdtemp(prefix="patchground-pack-")).resolve()
354
+ try:
355
+ copy = temp / "pack"
356
+ copy.mkdir(mode=0o700)
357
+ if capture_tree(source, copy, seal=False).digest != target.version.digest:
358
+ raise PackError("installed pack content failed integrity verification")
359
+ yield copy, source
360
+ finally:
361
+ remove_tree(temp)
362
+
363
+
364
+ def show_reference(reference: str | Path, *, root: Path | None = None) -> ChallengeDetails:
365
+ """Describe a challenge for ``show``. Installed content is read from a verified temporary
366
+ copy, needs no consent (nothing is built or run) and is reported under canonical paths."""
367
+ base = state_root(root)
368
+ target = _locate(reference, base)
369
+ if isinstance(target, Path):
370
+ return show_challenge(target)
371
+ with _verified_copy(base, target) as (copy, source):
372
+ details = show_challenge(copy / target.challenge)
373
+ shown = str(source / target.challenge)
374
+ challenge = (
375
+ details.challenge.model_copy(update={"path": shown}) if details.challenge else None
376
+ )
377
+ return details.model_copy(update={"path": shown, "challenge": challenge})
378
+
379
+
380
+ @contextmanager
381
+ def prepare_execution(
382
+ reference: str | Path,
383
+ *,
384
+ yes: bool = False,
385
+ expected_identity: str | None = None,
386
+ root: Path | None = None,
387
+ ) -> Iterator[PreparedChallenge]:
388
+ """Gate and prepare a challenge for build/run.
389
+
390
+ Local paths pass through unchanged. Installed content is captured into a private temporary
391
+ copy, verified against the registered digest, and reviewed from that copy. Without consent for
392
+ this exact identity ``ConsentRequired`` is raised before anything is built; ``yes`` records
393
+ consent. ``expected_identity`` fails closed if the version differs from what was reviewed.
394
+ """
395
+ base = state_root(root)
396
+ target = _locate(reference, base)
397
+ if isinstance(target, Path):
398
+ if expected_identity is not None:
399
+ raise PackError("expected identity does not apply to a local challenge")
400
+ yield PreparedChallenge(path=target, source_path=target)
401
+ return
402
+
403
+ version = target.version
404
+ pack = identity_of(target.name, target.entry, version)
405
+ with _verified_copy(base, target) as (copy, source):
406
+ if expected_identity is not None and expected_identity != version.identity:
407
+ raise PackError("pack identity differs from the reviewed version")
408
+ if not version.consented:
409
+ review = make_review(pack, read_recipes(copy, version.folders))
410
+ if not yes:
411
+ raise ConsentRequired(review)
412
+ _grant(base, target)
413
+ # The original local source must also be excluded when selecting a Git executable.
414
+ origin = (Path(pack.source),) if pack.source_kind == "local" else ()
415
+ yield PreparedChallenge(
416
+ path=copy / target.challenge,
417
+ source_path=source / target.challenge,
418
+ pack=pack,
419
+ input_roots=(copy, source, *origin),
420
+ )
@@ -0,0 +1,98 @@
1
+ """Typed pack identity, review and preparation contracts; no execution or storage."""
2
+
3
+ from pathlib import Path
4
+ from typing import Literal
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ from patchground.spec.models import SemVer, Slug
9
+
10
+
11
+ class PackError(Exception):
12
+ """An operation cannot safely resolve or manage a pack."""
13
+
14
+
15
+ class Model(BaseModel):
16
+ model_config = ConfigDict(extra="forbid", frozen=True)
17
+
18
+
19
+ class SourceSnapshot(Model):
20
+ path: Path
21
+ source: str
22
+ source_kind: Literal["https", "local"]
23
+ commit: str = Field(pattern=r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$")
24
+
25
+
26
+ class PackIdentity(Model):
27
+ name: Slug
28
+ version: SemVer
29
+ source: str
30
+ source_kind: Literal["https", "local"]
31
+ commit: str = Field(pattern=r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$")
32
+ digest: str = Field(pattern=r"^[0-9a-f]{64}$")
33
+ identity: str = Field(pattern=r"^[0-9a-f]{64}$")
34
+ trust: Literal["unofficial"] = "unofficial"
35
+
36
+
37
+ class PackInfo(Model):
38
+ pack: PackIdentity
39
+ path: Path
40
+ ref: str
41
+ consented: bool
42
+ active: bool = True
43
+
44
+
45
+ class PackProblem(Model):
46
+ name: str
47
+ reason: str
48
+
49
+
50
+ class PackListing(Model):
51
+ packs: tuple[PackInfo, ...]
52
+ invalid: tuple[PackProblem, ...] = ()
53
+
54
+
55
+ class ExecutionRecipe(Model):
56
+ challenge_id: Slug
57
+ dockerfile_path: str
58
+ dockerfile: str
59
+ services: tuple[str, ...]
60
+ command: str
61
+
62
+
63
+ class PackReview(Model):
64
+ pack: PackIdentity
65
+ recipes: tuple[ExecutionRecipe, ...]
66
+ previous_commit: str | None = None
67
+ added: tuple[str, ...] = ()
68
+ removed: tuple[str, ...] = ()
69
+ changed: tuple[str, ...] = ()
70
+
71
+
72
+ class ConsentRequired(PackError):
73
+ def __init__(self, review: PackReview) -> None:
74
+ super().__init__("explicit consent is required for this pack revision")
75
+ self.review = review
76
+
77
+
78
+ class PackUpdateResult(Model):
79
+ current: PackInfo
80
+ previous_commit: str
81
+ changed: bool
82
+
83
+
84
+ class PackRemoval(Model):
85
+ name: str
86
+ removed_versions: int
87
+
88
+
89
+ class ResolvedChallenge(Model):
90
+ path: Path
91
+ pack: PackIdentity | None = None
92
+
93
+
94
+ class PreparedChallenge(Model):
95
+ path: Path
96
+ source_path: Path
97
+ pack: PackIdentity | None = None
98
+ input_roots: tuple[Path, ...] = ()