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,195 @@
1
+ """Thin local-session commands; only structured results, never sandbox output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from typing import Annotated, Any
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.text import Text
12
+ from typer.core import TyperCommand
13
+
14
+ from patchground.cli.consent import emit_required, with_consent
15
+ from patchground.cli.render import (
16
+ EXIT_ERROR,
17
+ EXIT_FAILED,
18
+ EXIT_INTERRUPTED,
19
+ EXIT_OK,
20
+ emit,
21
+ emit_error,
22
+ )
23
+ from patchground.packs.models import ConsentRequired
24
+ from patchground.sessions import (
25
+ CheckResult,
26
+ ResetResult,
27
+ SessionError,
28
+ SessionStatus,
29
+ StartedSession,
30
+ check_session,
31
+ reset_session,
32
+ session_status,
33
+ start_session,
34
+ )
35
+ from patchground.sessions.models import SnapshotStatus
36
+
37
+ Result = StartedSession | CheckResult | SessionStatus | ResetResult
38
+ JsonOption = Annotated[bool, typer.Option("--json", help="Print one JSON document.")]
39
+ SessionOption = Annotated[Path | None, typer.Option("--session", help="Select a session path.")]
40
+
41
+
42
+ class SessionCommand(TyperCommand):
43
+ """Keep argument-parsing failures inside the session JSON output contract."""
44
+
45
+ def parse_args(self, ctx: Any, args: list[str]) -> list[str]:
46
+ json_mode = "--json" in args
47
+ try:
48
+ return super().parse_args(ctx, args)
49
+ except typer.Exit:
50
+ raise
51
+ except Exception as exc:
52
+ if not json_mode:
53
+ raise
54
+ emit_error("environment", "invalid session command arguments", True)
55
+ raise typer.Exit(EXIT_ERROR) from exc
56
+
57
+
58
+ def _human(console: Console, result: Result) -> None:
59
+ if isinstance(result, StartedSession):
60
+ console.print(Text(f"started {result.challenge_id}"))
61
+ if result.pack is not None:
62
+ console.print(Text(f"unofficial pack: {result.pack.name} @ {result.pack.commit}"))
63
+ console.print(Text(str(result.workspace)))
64
+ if result.history is not None:
65
+ _history(console, result.history)
66
+ console.print(Text(result.ticket))
67
+ console.print(Text("Next steps:", style="bold"))
68
+ console.print(Text(f" cd {result.workspace}"))
69
+ console.print(Text(" pgnd check run the checks, including hidden ones"))
70
+ console.print(Text(" pgnd hint reveal the next authored hint"))
71
+ console.print(Text(" pgnd submit finish with a verified-outcomes report"))
72
+ console.print(Text("Outside the workspace, add --session " + str(result.session) + "."))
73
+ elif isinstance(result, ResetResult):
74
+ console.print(Text(f"reset {result.challenge_id}; timer and history preserved"))
75
+ elif isinstance(result, SessionStatus):
76
+ console.print(Text(f"session {result.challenge_id}: {result.state}"))
77
+ if result.timer:
78
+ console.print(
79
+ Text(f"elapsed {int(result.elapsed_seconds)}s / {result.timebox_minutes} minutes")
80
+ )
81
+ else:
82
+ console.print(Text("timer off"))
83
+ console.print(Text(f"last check: {result.last_outcome or 'not run'}"))
84
+ if result.last_check is not None:
85
+ _human(console, result.last_check)
86
+ else:
87
+ console.print(Text(f"check: {result.outcome.value}"))
88
+ for test in result.tests:
89
+ console.print(Text(f"{test.status.value} {test.id}"))
90
+ console.print(Text(" ".join(f"{key}: {value}" for key, value in result.counts.items())))
91
+ if result.limit is not None:
92
+ console.print(Text(f"limit exceeded: {result.limit}"))
93
+ if not result.passed:
94
+ console.print(
95
+ Text(
96
+ "Test output is not shown. To see visible-test details, run your test command "
97
+ "with: pgnd run -- <command>",
98
+ style="dim",
99
+ )
100
+ )
101
+ if result.history is not None:
102
+ _history(console, result.history)
103
+
104
+
105
+ def _history(console: Console, history: SnapshotStatus) -> None:
106
+ console.print(Text(f"history: {history.state}"))
107
+ if history.detail:
108
+ console.print(
109
+ Text(f"{history.detail}; optional checkpoints only, checks are unaffected", style="dim")
110
+ )
111
+
112
+
113
+ def _invoke(call: Callable[[], Result], json_mode: bool) -> None:
114
+ try:
115
+ result = call()
116
+ except ConsentRequired as exc:
117
+ emit_required(exc, json_mode)
118
+ raise typer.Exit(EXIT_ERROR) from exc
119
+ except SessionError as exc:
120
+ emit_error("environment", str(exc), json_mode)
121
+ raise typer.Exit(EXIT_ERROR) from exc
122
+ except KeyboardInterrupt as exc:
123
+ emit_error("interrupted", "interrupted", json_mode)
124
+ raise typer.Exit(EXIT_INTERRUPTED) from exc
125
+ except Exception as exc:
126
+ # Exception text can itself contain untrusted logs or hidden source.
127
+ emit_error("internal", "session operation failed unexpectedly", json_mode)
128
+ raise typer.Exit(EXIT_ERROR) from exc
129
+ emit(result.model_dump(mode="json"), lambda console: _human(console, result), json_mode)
130
+ raise typer.Exit(
131
+ EXIT_FAILED if isinstance(result, CheckResult) and not result.passed else EXIT_OK
132
+ )
133
+
134
+
135
+ def start(
136
+ path: Annotated[
137
+ str, typer.Argument(help="Local folder, installed ID or pack-name:challenge-id.")
138
+ ],
139
+ yes: Annotated[
140
+ bool, typer.Option("--yes", help="Approve the exact selected pack revision.")
141
+ ] = False,
142
+ no_timer: Annotated[bool, typer.Option("--no-timer")] = False,
143
+ no_history: Annotated[
144
+ bool, typer.Option("--no-history", help="Disable checkpoint history.")
145
+ ] = False,
146
+ json_mode: JsonOption = False,
147
+ ) -> None:
148
+ """Start a challenge: a built-in ID, a local folder or an approved installed pack ID."""
149
+
150
+ def operation(approved: bool, identity: str | None) -> StartedSession:
151
+ return start_session(
152
+ path,
153
+ timer=not no_timer,
154
+ history=not no_history,
155
+ yes=approved,
156
+ expected_identity=identity,
157
+ )
158
+
159
+ _invoke(lambda: with_consent(operation, yes=yes, json_mode=json_mode), json_mode)
160
+
161
+
162
+ def check(session: SessionOption = None, json_mode: JsonOption = False) -> None:
163
+ """Run checks on a temporary copy of the selected workspace."""
164
+ _invoke(lambda: check_session(session), json_mode)
165
+
166
+
167
+ def reset(
168
+ session: SessionOption = None,
169
+ yes: Annotated[bool, typer.Option("--yes", help="Confirm destructive reset.")] = False,
170
+ json_mode: JsonOption = False,
171
+ ) -> None:
172
+ """Restore starting files, preserving Git and credential-pattern entries."""
173
+ if not yes:
174
+ if json_mode:
175
+ emit_error("environment", "reset requires --yes in JSON mode", True)
176
+ raise typer.Exit(EXIT_ERROR)
177
+ try:
178
+ confirmed = typer.confirm(
179
+ "Discard workspace edits and ordinary added files? "
180
+ "Git, credential-pattern files, timer and history are preserved. "
181
+ "Filesystem errors may leave a partial reset",
182
+ default=False,
183
+ )
184
+ except (KeyboardInterrupt, EOFError, typer.Abort) as exc:
185
+ emit_error("interrupted", "reset cancelled", False)
186
+ raise typer.Exit(EXIT_INTERRUPTED) from exc
187
+ if not confirmed:
188
+ emit_error("interrupted", "reset cancelled", False)
189
+ raise typer.Exit(EXIT_INTERRUPTED)
190
+ _invoke(lambda: reset_session(session), json_mode)
191
+
192
+
193
+ def status(session: SessionOption = None, json_mode: JsonOption = False) -> None:
194
+ """Show the selected session without requiring Docker."""
195
+ _invoke(lambda: session_status(session), json_mode)
@@ -0,0 +1,117 @@
1
+ """`pgnd validate`: run challenge verification and render the per-step report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.text import Text
10
+
11
+ from patchground.cli.consent import emit_required, with_consent
12
+ from patchground.cli.render import (
13
+ EXIT_ERROR,
14
+ EXIT_FAILED,
15
+ EXIT_INTERRUPTED,
16
+ EXIT_OK,
17
+ emit,
18
+ emit_error,
19
+ progress,
20
+ )
21
+ from patchground.packs.builtin import is_builtin_path
22
+ from patchground.packs.execution import resolve_challenge
23
+ from patchground.packs.models import ConsentRequired
24
+ from patchground.verify import (
25
+ StepStatus,
26
+ ValidationReport,
27
+ VerifyEnvironmentError,
28
+ validate_challenge,
29
+ )
30
+
31
+ _MARKS = {
32
+ StepStatus.PASSED: ("✓", "green"),
33
+ StepStatus.FAILED: ("✗", "red"),
34
+ StepStatus.NOT_RUN: ("-", "dim"),
35
+ }
36
+
37
+
38
+ def validate(
39
+ path: Annotated[
40
+ str, typer.Argument(help="Local folder, installed ID or pack-name:challenge-id.")
41
+ ],
42
+ json_mode: Annotated[
43
+ bool, typer.Option("--json", help="Print one JSON document on stdout.")
44
+ ] = False,
45
+ yes: Annotated[
46
+ bool, typer.Option("--yes", help="Approve the exact selected pack revision.")
47
+ ] = False,
48
+ no_write: Annotated[
49
+ bool, typer.Option("--no-write", help="Do not write verification.json on success.")
50
+ ] = False,
51
+ ) -> None:
52
+ """Verify outcomes, determinism and supported editable-test tampering resistance."""
53
+ # The bundled catalog is part of the installed distribution and is never written to.
54
+ write = not no_write and not _is_builtin(path)
55
+ try:
56
+ with progress(json_mode) as notify:
57
+ report = with_consent(
58
+ lambda approved, identity: validate_challenge(
59
+ path,
60
+ write=write,
61
+ progress=notify,
62
+ yes=approved,
63
+ expected_identity=identity,
64
+ ),
65
+ yes=yes,
66
+ json_mode=json_mode,
67
+ )
68
+ except ConsentRequired as exc:
69
+ emit_required(exc, json_mode)
70
+ raise typer.Exit(EXIT_ERROR) from exc
71
+ except VerifyEnvironmentError as exc:
72
+ emit_error("environment", str(exc), json_mode)
73
+ raise typer.Exit(EXIT_ERROR) from exc
74
+ except KeyboardInterrupt as exc:
75
+ emit_error("interrupted", "interrupted", json_mode)
76
+ raise typer.Exit(EXIT_INTERRUPTED) from exc
77
+ except Exception as exc: # last resort: never a traceback, never confused with "invalid"
78
+ emit_error("internal", "validation failed unexpectedly", json_mode)
79
+ raise typer.Exit(EXIT_ERROR) from exc
80
+ emit(report.to_dict(), lambda console: _human(console, report), json_mode)
81
+ raise typer.Exit(EXIT_OK if report.valid else EXIT_FAILED)
82
+
83
+
84
+ def _is_builtin(reference: str) -> bool:
85
+ try:
86
+ return is_builtin_path(resolve_challenge(reference).path)
87
+ except Exception: # resolution problems are reported by validation itself
88
+ return False
89
+
90
+
91
+ def _human(console: Console, report: ValidationReport) -> None:
92
+ width = max(len(step.name.value) for step in report.steps)
93
+ for step in report.steps:
94
+ mark, style = _MARKS[step.status]
95
+ line = Text()
96
+ line.append(f"{mark} ", style=style)
97
+ line.append(step.name.value.ljust(width), style="bold")
98
+ line.append(f" {step.detail}")
99
+ console.print(line)
100
+ for issue_line in step.issue_lines:
101
+ console.print(Text(f" {issue_line}"))
102
+ for note in step.notes:
103
+ console.print(Text(f" {note}", style="dim"))
104
+ if step.log_tail:
105
+ for log_line in step.log_tail.splitlines():
106
+ console.print(Text(f" | {log_line}", style="dim"))
107
+ if report.verification_file is not None:
108
+ console.print(Text(f"wrote {report.verification_file}", style="dim"))
109
+ if report.removed_verification_file is not None:
110
+ console.print(Text(f"removed stale {report.removed_verification_file}", style="dim"))
111
+ failed = report.failed_step
112
+ if report.valid:
113
+ console.print(Text("valid", style="bold green"))
114
+ elif failed is not None:
115
+ console.print(Text(f"invalid: failed at step {failed.value}", style="bold red"))
116
+ else: # pragma: no cover - every report has a passed or failed step
117
+ console.print(Text("invalid", style="bold red"))
@@ -0,0 +1,23 @@
1
+ """patchground.packs — see SPEC.md in this folder."""
2
+
3
+ from patchground.packs.discovery import (
4
+ ChallengeDetails,
5
+ ChallengeSummary,
6
+ DiscoveryError,
7
+ DiscoveryFilters,
8
+ DiscoveryResult,
9
+ InvalidChallenge,
10
+ discover_challenges,
11
+ show_challenge,
12
+ )
13
+
14
+ __all__ = [
15
+ "ChallengeDetails",
16
+ "ChallengeSummary",
17
+ "DiscoveryError",
18
+ "DiscoveryFilters",
19
+ "DiscoveryResult",
20
+ "InvalidChallenge",
21
+ "discover_challenges",
22
+ "show_challenge",
23
+ ]
@@ -0,0 +1,53 @@
1
+ """Curated challenges shipped inside the CLI distribution.
2
+
3
+ They are first-party content, installed together with the code that runs them, so they keep local
4
+ behaviour: no pack registry, no consent prompt. A bare ID falls back to them only after local
5
+ folders and installed packs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+
13
+ import patchground
14
+ from patchground.spec.models import SLUG_PATTERN
15
+
16
+ _CATALOG = "catalog"
17
+
18
+
19
+ def builtin_root() -> Path | None:
20
+ """The bundled catalog of a wheel, or ``examples/`` when running from a source checkout."""
21
+ package = Path(patchground.__file__).resolve().parent
22
+ candidates = [package / _CATALOG]
23
+ if package.parent.name == "src":
24
+ candidates.append(package.parent.parent / "examples")
25
+ for candidate in candidates:
26
+ if candidate.is_dir() and not candidate.is_symlink():
27
+ return candidate
28
+ return None
29
+
30
+
31
+ def builtin_challenge(reference: str) -> Path | None:
32
+ """The bundled folder for a bare challenge ID, if one exists."""
33
+ if not re.fullmatch(SLUG_PATTERN, reference):
34
+ return None
35
+ root = builtin_root()
36
+ if root is None:
37
+ return None
38
+ folder = root / reference
39
+ if folder.is_symlink() or not (folder / "challenge.yaml").is_file():
40
+ return None
41
+ return folder
42
+
43
+
44
+ def is_builtin_path(path: Path) -> bool:
45
+ """Whether ``path`` lies inside the bundled catalog (never written to)."""
46
+ root = builtin_root()
47
+ if root is None:
48
+ return False
49
+ try:
50
+ path.resolve().relative_to(root.resolve())
51
+ except (OSError, ValueError):
52
+ return False
53
+ return True
@@ -0,0 +1,285 @@
1
+ """Local challenge discovery for ``list`` and ``show``: read-only metadata, never execution.
2
+
3
+ Discovery looks at a directory the user names (default: the current one) and its *immediate*
4
+ children only. A child is a candidate when it contains a ``challenge.yaml`` entry. Each candidate is
5
+ validated with :func:`patchground.spec.load_challenge`; valid ones become summaries and invalid ones
6
+ are reported with their problems instead of being hidden. Symlinked children are reported but never
7
+ followed. Nothing is built, run, installed, remembered, or granted trust here.
8
+
9
+ The output never contains the reference solution, hidden checks, hints text, or the test command:
10
+ only manifest metadata and, for ``show``, the ticket the user would see anyway.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import stat
17
+ from collections.abc import Iterable
18
+ from pathlib import Path
19
+
20
+ from pydantic import BaseModel, ConfigDict
21
+
22
+ from patchground.spec import (
23
+ AIPolicy,
24
+ ChallengeManifest,
25
+ ChallengeType,
26
+ Difficulty,
27
+ ManifestError,
28
+ load_challenge,
29
+ )
30
+
31
+ CHALLENGE_MANIFEST = "challenge.yaml"
32
+ TICKET_FILE = "TICKET.md"
33
+ MAX_TICKET_BYTES = 256 * 1024
34
+ """Tickets are short prose; anything larger is reported instead of read."""
35
+
36
+
37
+ class DiscoveryError(Exception):
38
+ """The requested directory or path cannot be examined at all (an environment problem)."""
39
+
40
+
41
+ class _Result(BaseModel):
42
+ model_config = ConfigDict(frozen=True)
43
+
44
+
45
+ class DiscoveryFilters(_Result):
46
+ """Optional ``list`` filters; all given filters must match (AND). Text matches ignore case."""
47
+
48
+ type: ChallengeType | None = None
49
+ lang: str | None = None
50
+ difficulty: Difficulty | None = None
51
+ tag: str | None = None
52
+
53
+ def matches(self, manifest: ChallengeManifest) -> bool:
54
+ if self.type is not None and manifest.type is not self.type:
55
+ return False
56
+ if self.difficulty is not None and manifest.difficulty is not self.difficulty:
57
+ return False
58
+ if self.lang is not None and not _contains(manifest.languages, self.lang):
59
+ return False
60
+ return self.tag is None or _contains(manifest.tags, self.tag)
61
+
62
+
63
+ class ChallengeSummary(_Result):
64
+ """Public metadata of one valid challenge."""
65
+
66
+ id: str
67
+ path: str
68
+ title: str
69
+ version: str
70
+ type: ChallengeType
71
+ summary: str
72
+ languages: tuple[str, ...]
73
+ tags: tuple[str, ...]
74
+ difficulty: Difficulty
75
+ timebox_minutes: int
76
+ ai_policy: AIPolicy
77
+
78
+
79
+ class InvalidChallenge(_Result):
80
+ """A candidate folder that could not be loaded; ``issues`` are file:line problems."""
81
+
82
+ name: str
83
+ path: str
84
+ issues: tuple[str, ...]
85
+
86
+
87
+ class DiscoveryResult(_Result):
88
+ """Result of ``list``: valid matches plus every invalid candidate (filters never hide them)."""
89
+
90
+ directory: str
91
+ filters: DiscoveryFilters
92
+ challenges: tuple[ChallengeSummary, ...]
93
+ invalid: tuple[InvalidChallenge, ...]
94
+
95
+
96
+ class ChallengeDetails(_Result):
97
+ """Result of ``show``: metadata and ticket when valid, problems otherwise."""
98
+
99
+ path: str
100
+ valid: bool
101
+ challenge: ChallengeSummary | None = None
102
+ author: str | None = None
103
+ content_license: str | None = None
104
+ provenance: str | None = None
105
+ hint_count: int | None = None
106
+ services: tuple[str, ...] = ()
107
+ ticket: str | None = None
108
+ issues: tuple[str, ...] = ()
109
+
110
+
111
+ def _contains(values: Iterable[str], wanted: str) -> bool:
112
+ folded = wanted.strip().casefold()
113
+ return any(value.casefold() == folded for value in values)
114
+
115
+
116
+ def _summary(manifest: ChallengeManifest, shown: str) -> ChallengeSummary:
117
+ return ChallengeSummary(
118
+ id=manifest.id,
119
+ path=shown,
120
+ title=manifest.title,
121
+ version=manifest.version,
122
+ type=manifest.type,
123
+ summary=manifest.summary,
124
+ languages=tuple(manifest.languages),
125
+ tags=tuple(manifest.tags),
126
+ difficulty=manifest.difficulty,
127
+ timebox_minutes=manifest.timebox_minutes,
128
+ ai_policy=manifest.ai_policy,
129
+ )
130
+
131
+
132
+ def _regular_file_problem(folder: Path, name: str) -> str | None:
133
+ """Why ``folder/name`` is not a plain regular file (symlinks are not followed)."""
134
+ try:
135
+ mode = (folder / name).lstat().st_mode
136
+ except FileNotFoundError:
137
+ return f"{name}: not found"
138
+ except OSError:
139
+ return f"{name}: cannot be read"
140
+ if stat.S_ISLNK(mode):
141
+ return f"{name}: is a symlink; challenge files must be regular files"
142
+ if not stat.S_ISREG(mode):
143
+ return f"{name}: must be a regular file"
144
+ return None
145
+
146
+
147
+ def _load(folder: Path) -> tuple[ChallengeManifest | None, tuple[str, ...]]:
148
+ """Validate ``folder`` without executing anything. Returns the manifest or its problems."""
149
+ for name in (CHALLENGE_MANIFEST, TICKET_FILE):
150
+ problem = _regular_file_problem(folder, name)
151
+ if problem is not None:
152
+ return None, (problem,)
153
+ try:
154
+ challenge = load_challenge(folder)
155
+ except ManifestError as exc:
156
+ return None, tuple(
157
+ f"{issue.file.name}:{issue.line or 1}: invalid challenge manifest or layout"
158
+ for issue in exc.issues
159
+ )
160
+ except OSError:
161
+ return None, ("challenge folder cannot be read",)
162
+ return challenge.manifest, ()
163
+
164
+
165
+ def _read_ticket(folder: Path) -> tuple[str | None, str | None]:
166
+ """Read ``TICKET.md`` without following a symlink; returns ``(text, problem)``."""
167
+ path = folder / TICKET_FILE
168
+ flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
169
+ try:
170
+ fd = os.open(path, flags)
171
+ except OSError:
172
+ return None, f"{TICKET_FILE}: cannot be read safely"
173
+ try:
174
+ info = os.fstat(fd)
175
+ if not stat.S_ISREG(info.st_mode):
176
+ return None, f"{TICKET_FILE}: must be a regular file"
177
+ if info.st_size > MAX_TICKET_BYTES:
178
+ return None, f"{TICKET_FILE}: larger than {MAX_TICKET_BYTES} bytes"
179
+ with os.fdopen(fd, "rb") as handle:
180
+ fd = -1
181
+ data = handle.read(MAX_TICKET_BYTES + 1)
182
+ except OSError:
183
+ return None, f"{TICKET_FILE}: cannot be read"
184
+ finally:
185
+ if fd >= 0:
186
+ os.close(fd)
187
+ if len(data) > MAX_TICKET_BYTES:
188
+ return None, f"{TICKET_FILE}: larger than {MAX_TICKET_BYTES} bytes"
189
+ try:
190
+ return data.decode("utf-8"), None
191
+ except UnicodeDecodeError:
192
+ return None, f"{TICKET_FILE}: not valid UTF-8"
193
+
194
+
195
+ def _require_directory(path: Path, what: str) -> None:
196
+ try:
197
+ is_dir = path.is_dir()
198
+ except OSError:
199
+ is_dir = False
200
+ if not is_dir:
201
+ raise DiscoveryError(f"{what} '{path}' does not exist or is not a directory")
202
+
203
+
204
+ def discover_challenges(
205
+ directory: Path | None = None, filters: DiscoveryFilters | None = None
206
+ ) -> DiscoveryResult:
207
+ """List challenge folders directly inside ``directory`` (default: the current directory).
208
+
209
+ Only immediate children are examined; nothing is followed recursively. A symlinked child is
210
+ reported as invalid when it points at a folder, and never read through. Raises
211
+ ``DiscoveryError`` when ``directory`` itself is missing, not a directory, or unreadable.
212
+ """
213
+ base = Path(directory) if directory is not None else Path()
214
+ active = filters or DiscoveryFilters()
215
+ _require_directory(base, "directory")
216
+ try:
217
+ entries = sorted(os.scandir(base), key=lambda entry: entry.name)
218
+ except OSError as exc:
219
+ raise DiscoveryError(f"directory '{base}' cannot be read") from exc
220
+
221
+ challenges: list[ChallengeSummary] = []
222
+ invalid: list[InvalidChallenge] = []
223
+ for entry in entries:
224
+ child = base / entry.name
225
+ shown = str(child)
226
+ try:
227
+ if entry.is_symlink():
228
+ if entry.is_dir(follow_symlinks=True):
229
+ invalid.append(
230
+ InvalidChallenge(
231
+ name=entry.name,
232
+ path=shown,
233
+ issues=("is a symlink; discovery does not follow symlinked folders",),
234
+ )
235
+ )
236
+ continue
237
+ if not entry.is_dir(follow_symlinks=False):
238
+ continue
239
+ os.lstat(child / CHALLENGE_MANIFEST)
240
+ except FileNotFoundError:
241
+ continue
242
+ except OSError:
243
+ invalid.append(
244
+ InvalidChallenge(name=entry.name, path=shown, issues=("folder cannot be read",))
245
+ )
246
+ continue
247
+ manifest, issues = _load(child)
248
+ if manifest is None:
249
+ invalid.append(InvalidChallenge(name=entry.name, path=shown, issues=issues))
250
+ elif active.matches(manifest):
251
+ challenges.append(_summary(manifest, shown))
252
+ return DiscoveryResult(
253
+ directory=str(base),
254
+ filters=active,
255
+ challenges=tuple(challenges),
256
+ invalid=tuple(invalid),
257
+ )
258
+
259
+
260
+ def show_challenge(path: Path) -> ChallengeDetails:
261
+ """Describe one local challenge folder: safe metadata and its ticket, never secrets.
262
+
263
+ Invalid folders yield ``valid=False`` with their problems. Raises ``DiscoveryError`` when
264
+ ``path`` is missing or not a directory.
265
+ """
266
+ folder = Path(path)
267
+ _require_directory(folder, "challenge folder")
268
+ shown = str(folder)
269
+ manifest, issues = _load(folder)
270
+ if manifest is None:
271
+ return ChallengeDetails(path=shown, valid=False, issues=issues)
272
+ ticket, problem = _read_ticket(folder)
273
+ if problem is not None:
274
+ return ChallengeDetails(path=shown, valid=False, issues=(problem,))
275
+ return ChallengeDetails(
276
+ path=shown,
277
+ valid=True,
278
+ challenge=_summary(manifest, shown),
279
+ author=manifest.author.name,
280
+ content_license=manifest.content_license,
281
+ provenance=manifest.provenance.value,
282
+ hint_count=len(manifest.hints),
283
+ services=tuple(manifest.environment.services),
284
+ ticket=ticket,
285
+ )