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,249 @@
1
+ """`pgnd doctor`: diagnose engine, Git and session-disk readiness with exact fixes.
2
+
3
+ Probe only: never builds, pulls or runs a challenge, and never prints raw engine errors.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from enum import StrEnum
11
+ from typing import Annotated, Any
12
+
13
+ import typer
14
+ from rich.console import Console
15
+ from rich.text import Text
16
+
17
+ from patchground.cli.render import EXIT_ERROR, EXIT_INTERRUPTED, EXIT_OK, emit, emit_error
18
+ from patchground.sandbox import EngineProbe, EngineProblem, probe_engine
19
+ from patchground.sessions.diagnostics import (
20
+ DiskProbe,
21
+ DiskProblem,
22
+ GitProbe,
23
+ GitProblem,
24
+ probe_disk,
25
+ probe_git,
26
+ )
27
+
28
+
29
+ class CheckStatus(StrEnum):
30
+ OK = "ok"
31
+ FAILED = "failed"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class DoctorCheck:
36
+ name: str # "engine" | "git" | "disk"
37
+ status: CheckStatus
38
+ detail: str
39
+ problem: str | None = None
40
+ fix: str | None = None
41
+
42
+ def to_dict(self) -> dict[str, Any]:
43
+ return {
44
+ "name": self.name,
45
+ "status": self.status.value,
46
+ "detail": self.detail,
47
+ "problem": self.problem,
48
+ "fix": self.fix,
49
+ }
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class DoctorReport:
54
+ checks: tuple[DoctorCheck, ...]
55
+
56
+ @property
57
+ def healthy(self) -> bool:
58
+ return all(check.status is CheckStatus.OK for check in self.checks)
59
+
60
+ def to_dict(self) -> dict[str, Any]:
61
+ return {"healthy": self.healthy, "checks": [check.to_dict() for check in self.checks]}
62
+
63
+
64
+ def _platform() -> str:
65
+ if sys.platform == "darwin":
66
+ return "macos"
67
+ if sys.platform.startswith("win"):
68
+ return "windows"
69
+ return "linux"
70
+
71
+
72
+ _WINDOWS = (
73
+ "Patchground runs on Windows through WSL2: install Docker Desktop with the WSL2 backend, "
74
+ "then run pgnd inside your WSL2 distribution"
75
+ )
76
+ _ENGINE_FIXES: dict[EngineProblem, dict[str, str]] = {
77
+ EngineProblem.NOT_RUNNING: {
78
+ "macos": "Install and start Docker Desktop (or run `podman machine start` for Podman)",
79
+ "linux": "Install Docker Engine and start it: `sudo systemctl start docker` "
80
+ "(for Podman: `systemctl --user start podman.socket` and set DOCKER_HOST to its socket)",
81
+ "windows": _WINDOWS,
82
+ },
83
+ EngineProblem.PERMISSION: {
84
+ "macos": "Restart Docker Desktop; if it persists, check the socket's owner in "
85
+ "Docker Desktop's settings",
86
+ "linux": "Add yourself to the docker group: `sudo usermod -aG docker $USER`, "
87
+ "then log out and back in",
88
+ "windows": _WINDOWS,
89
+ },
90
+ EngineProblem.TIMEOUT: {
91
+ "macos": "The engine is not answering: restart Docker Desktop and retry",
92
+ "linux": "The engine is not answering: `sudo systemctl restart docker` and retry",
93
+ "windows": _WINDOWS,
94
+ },
95
+ EngineProblem.MISCONFIGURED: {
96
+ "macos": "Check DOCKER_HOST and `docker context ls`; unset DOCKER_HOST to use the default",
97
+ "linux": "Check DOCKER_HOST and `docker context ls`; unset DOCKER_HOST to use the default",
98
+ "windows": _WINDOWS,
99
+ },
100
+ EngineProblem.UNHEALTHY: {
101
+ "macos": "The engine returned an error: restart Docker Desktop and retry",
102
+ "linux": "The engine returned an error: `sudo systemctl restart docker` and retry",
103
+ "windows": _WINDOWS,
104
+ },
105
+ }
106
+ _GIT_FIXES: dict[str, str] = {
107
+ "macos": "Install Git: `xcode-select --install` (or `brew install git`)",
108
+ "linux": "Install Git with your package manager, e.g. `sudo apt install git`",
109
+ "windows": "Install Git inside your WSL2 distribution, e.g. `sudo apt install git`",
110
+ }
111
+
112
+
113
+ def _gib(value: int) -> str:
114
+ return f"{value / 1024**3:.1f} GiB"
115
+
116
+
117
+ def engine_check(probe: EngineProbe, platform: str) -> DoctorCheck:
118
+ if probe.reachable:
119
+ version = f" {probe.version}" if probe.version else ""
120
+ api = f" (API {probe.api_version})" if probe.api_version else ""
121
+ return DoctorCheck("engine", CheckStatus.OK, f"{probe.engine or 'Docker'}{version}{api}")
122
+ problem = probe.problem or EngineProblem.UNHEALTHY
123
+ details = {
124
+ EngineProblem.NOT_RUNNING: "container engine not reachable (not installed or not running)",
125
+ EngineProblem.PERMISSION: "permission denied on the container engine socket",
126
+ EngineProblem.TIMEOUT: "container engine did not answer in time",
127
+ EngineProblem.MISCONFIGURED: "container engine connection is misconfigured",
128
+ EngineProblem.UNHEALTHY: "container engine answered with an error",
129
+ }
130
+ return DoctorCheck(
131
+ "engine",
132
+ CheckStatus.FAILED,
133
+ details[problem],
134
+ problem.value,
135
+ _ENGINE_FIXES[problem][platform],
136
+ )
137
+
138
+
139
+ def git_check(probe: GitProbe, platform: str) -> DoctorCheck:
140
+ if probe.available:
141
+ return DoctorCheck("git", CheckStatus.OK, f"git {probe.version}")
142
+ problem = probe.problem or GitProblem.UNUSABLE
143
+ if problem is GitProblem.MISSING:
144
+ return DoctorCheck(
145
+ "git", CheckStatus.FAILED, "git not found on PATH", problem.value, _GIT_FIXES[platform]
146
+ )
147
+ return DoctorCheck(
148
+ "git",
149
+ CheckStatus.FAILED,
150
+ "git was found but `git --version` failed",
151
+ problem.value,
152
+ "Reinstall Git, then check that `git --version` works in a new terminal",
153
+ )
154
+
155
+
156
+ def disk_check(probe: DiskProbe) -> DoctorCheck:
157
+ minimum = _gib(probe.minimum_bytes)
158
+ if probe.ok and probe.free_bytes is not None:
159
+ return DoctorCheck(
160
+ "disk",
161
+ CheckStatus.OK,
162
+ f"{_gib(probe.free_bytes)} free for sessions at {probe.location} "
163
+ f"(baseline {minimum}; images may need more)",
164
+ )
165
+ problem = probe.problem or DiskProblem.UNREADABLE
166
+ if problem is DiskProblem.LOW_SPACE and probe.free_bytes is not None:
167
+ return DoctorCheck(
168
+ "disk",
169
+ CheckStatus.FAILED,
170
+ f"only {_gib(probe.free_bytes)} free at {probe.location}; baseline is {minimum}",
171
+ problem.value,
172
+ f"Free at least {minimum} on the disk holding {probe.location}; "
173
+ "review files before deleting them",
174
+ )
175
+ if problem is DiskProblem.INVALID_SETTING:
176
+ return DoctorCheck(
177
+ "disk",
178
+ CheckStatus.FAILED,
179
+ f"{probe.location} is set but is not an absolute path",
180
+ problem.value,
181
+ f"Set {probe.location} to an absolute directory path, or unset it to use "
182
+ "~/patchground/sessions",
183
+ )
184
+ if problem is DiskProblem.NOT_WRITABLE:
185
+ return DoctorCheck(
186
+ "disk",
187
+ CheckStatus.FAILED,
188
+ f"cannot create sessions at {probe.location}",
189
+ problem.value,
190
+ f"Make {probe.location} (or its nearest existing parent) a writable directory",
191
+ )
192
+ return DoctorCheck(
193
+ "disk",
194
+ CheckStatus.FAILED,
195
+ f"cannot inspect the disk at {probe.location}",
196
+ problem.value,
197
+ f"Check that {probe.location} or a parent directory exists and is readable",
198
+ )
199
+
200
+
201
+ def run_doctor() -> DoctorReport:
202
+ """Assemble every check; each probe reports its own failure instead of raising."""
203
+ platform = _platform()
204
+ return DoctorReport(
205
+ (
206
+ engine_check(probe_engine(), platform),
207
+ git_check(probe_git(), platform),
208
+ disk_check(probe_disk()),
209
+ )
210
+ )
211
+
212
+
213
+ _MARKS = {CheckStatus.OK: ("✓", "green"), CheckStatus.FAILED: ("✗", "red")}
214
+
215
+
216
+ def _human(console: Console, report: DoctorReport) -> None:
217
+ width = max(len(check.name) for check in report.checks)
218
+ for check in report.checks:
219
+ mark, style = _MARKS[check.status]
220
+ line = Text()
221
+ line.append(f"{mark} ", style=style)
222
+ line.append(check.name.ljust(width), style="bold")
223
+ line.append(f" {check.detail}")
224
+ console.print(line)
225
+ if check.fix:
226
+ console.print(Text(f" fix: {check.fix}"))
227
+ if report.healthy:
228
+ console.print(Text("ready", style="bold green"))
229
+ else:
230
+ failed = sum(check.status is CheckStatus.FAILED for check in report.checks)
231
+ console.print(Text(f"not ready: {failed} check(s) failed", style="bold red"))
232
+
233
+
234
+ def doctor(
235
+ json_mode: Annotated[
236
+ bool, typer.Option("--json", help="Print one JSON document on stdout.")
237
+ ] = False,
238
+ ) -> None:
239
+ """Check the container engine, Git and disk space, and print the exact fix for each failure."""
240
+ try:
241
+ report = run_doctor()
242
+ except KeyboardInterrupt as exc:
243
+ emit_error("interrupted", "interrupted", json_mode)
244
+ raise typer.Exit(EXIT_INTERRUPTED) from exc
245
+ except Exception as exc: # never a traceback or raw message; still one JSON document
246
+ emit_error("internal", f"doctor failed unexpectedly ({type(exc).__name__})", json_mode)
247
+ raise typer.Exit(EXIT_ERROR) from exc
248
+ emit(report.to_dict(), lambda console: _human(console, report), json_mode)
249
+ raise typer.Exit(EXIT_OK if report.healthy else EXIT_ERROR)
@@ -0,0 +1,142 @@
1
+ """Pinned-pack management; all source/consent/storage decisions belong to packs."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.text import Text
9
+
10
+ from patchground.cli.consent import emit_required, literal, with_consent
11
+ from patchground.cli.render import EXIT_ERROR, EXIT_FAILED, EXIT_INTERRUPTED, emit, emit_error
12
+ from patchground.cli.sessions import JsonOption, SessionCommand
13
+ from patchground.packs.models import (
14
+ ConsentRequired,
15
+ PackError,
16
+ PackInfo,
17
+ PackListing,
18
+ PackRemoval,
19
+ PackUpdateResult,
20
+ )
21
+ from patchground.packs.registry import add_pack, list_packs, remove_pack, update_pack
22
+
23
+ app = typer.Typer(
24
+ no_args_is_help=True, help="Manage pinned challenge packs without automatic execution."
25
+ )
26
+ YesOption = Annotated[bool, typer.Option("--yes", help="Explicitly approve this operation.")]
27
+ Result = PackInfo | PackListing | PackRemoval | PackUpdateResult
28
+
29
+
30
+ def _info(console: Console, info: PackInfo) -> None:
31
+ pack = info.pack
32
+ console.print(
33
+ Text(
34
+ f"{pack.name} {pack.version} @ {pack.commit} "
35
+ f"{'active' if info.active else 'cached'} unofficial "
36
+ f"{'consented' if info.consented else 'consent required before execution'}"
37
+ )
38
+ )
39
+ console.print(Text(f"source: {literal(pack.source)}"))
40
+
41
+
42
+ def _human(console: Console, result: Result) -> None:
43
+ if isinstance(result, PackInfo):
44
+ _info(console, result)
45
+ elif isinstance(result, PackListing):
46
+ if not result.packs and not result.invalid:
47
+ console.print(Text("No packs installed."))
48
+ for info in result.packs:
49
+ _info(console, info)
50
+ for problem in result.invalid:
51
+ console.print(Text(f"invalid: {literal(problem.name)}: {literal(problem.reason)}"))
52
+ elif isinstance(result, PackRemoval):
53
+ console.print(
54
+ Text(
55
+ f"removed {result.name}: {result.removed_versions} cached versions; "
56
+ "sessions preserved"
57
+ )
58
+ )
59
+ else:
60
+ console.print(Text("updated" if result.changed else "unchanged"))
61
+ _info(console, result.current)
62
+
63
+
64
+ def _invoke(call: Callable[[], Result], json_mode: bool) -> None:
65
+ try:
66
+ result = call()
67
+ except ConsentRequired as exc:
68
+ emit_required(exc, json_mode)
69
+ raise typer.Exit(EXIT_ERROR) from exc
70
+ except PackError as exc:
71
+ emit_error("environment", str(exc), json_mode)
72
+ raise typer.Exit(EXIT_ERROR) from exc
73
+ except (KeyboardInterrupt, EOFError, typer.Abort) as exc:
74
+ emit_error("interrupted", "pack operation interrupted; inspect current state", json_mode)
75
+ raise typer.Exit(EXIT_INTERRUPTED) from exc
76
+ except Exception as exc:
77
+ emit_error("internal", "pack operation failed unexpectedly", json_mode)
78
+ raise typer.Exit(EXIT_ERROR) from exc
79
+ emit(result.model_dump(mode="json"), lambda console: _human(console, result), json_mode)
80
+ if isinstance(result, PackListing) and result.invalid:
81
+ raise typer.Exit(EXIT_FAILED)
82
+
83
+
84
+ @app.command("add", cls=SessionCommand)
85
+ def add(
86
+ source: Annotated[str, typer.Argument(help="Public HTTPS URL or local Git repository.")],
87
+ ref: Annotated[str, typer.Option("--ref", help="Branch, tag or commit to pin.")] = "HEAD",
88
+ json_mode: JsonOption = False,
89
+ ) -> None:
90
+ """Install committed files; never build or run the pack."""
91
+ _invoke(lambda: add_pack(source, ref=ref), json_mode)
92
+
93
+
94
+ @app.command("list", cls=SessionCommand)
95
+ def list_installed_packs(json_mode: JsonOption = False) -> None:
96
+ """Show installed revisions and their consent state without execution."""
97
+ _invoke(list_packs, json_mode)
98
+
99
+
100
+ @app.command("update", cls=SessionCommand)
101
+ def update(
102
+ name: str,
103
+ ref: Annotated[str | None, typer.Option("--ref", help="Replace the tracked revision.")] = None,
104
+ yes: YesOption = False,
105
+ json_mode: JsonOption = False,
106
+ ) -> None:
107
+ """Review changes before activating a new immutable revision."""
108
+ _invoke(
109
+ lambda: with_consent(
110
+ lambda approved, identity: update_pack(
111
+ name, ref=ref, yes=approved, expected_identity=identity
112
+ ),
113
+ yes=yes,
114
+ json_mode=json_mode,
115
+ ),
116
+ json_mode,
117
+ )
118
+
119
+
120
+ @app.command("remove", cls=SessionCommand)
121
+ def remove(name: str, yes: YesOption = False, json_mode: JsonOption = False) -> None:
122
+ """Remove cached revisions and future-start consent, never existing sessions."""
123
+
124
+ def operation() -> PackRemoval:
125
+ if yes:
126
+ return remove_pack(name, yes=True)
127
+ if json_mode:
128
+ raise PackError("pack remove requires --yes in JSON mode")
129
+ info = next(
130
+ (item for item in list_packs().packs if item.pack.name == name and item.active), None
131
+ )
132
+ if info is None:
133
+ raise PackError("pack is not installed")
134
+ if not typer.confirm(
135
+ f"Remove {info.pack.name}, its cached revisions and future-start consent? "
136
+ "Existing sessions are preserved",
137
+ default=False,
138
+ ):
139
+ raise KeyboardInterrupt
140
+ return remove_pack(name, yes=True, expected_identity=info.pack.identity)
141
+
142
+ _invoke(operation, json_mode)
@@ -0,0 +1,141 @@
1
+ """Practice commands: bounded diagnostics, authored hints and read-only local browsing."""
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.render import EXIT_ERROR, EXIT_FAILED, EXIT_INTERRUPTED, emit, emit_error
15
+ from patchground.cli.sessions import JsonOption, SessionOption
16
+ from patchground.sessions import SessionError
17
+ from patchground.sessions.history import history_for_session
18
+ from patchground.sessions.models import HistoryResult
19
+ from patchground.sessions.practice import (
20
+ CommandRunResult,
21
+ HintResult,
22
+ SessionsList,
23
+ list_sessions,
24
+ reveal_hint,
25
+ run_session_command,
26
+ )
27
+
28
+ Result = HintResult | SessionsList | CommandRunResult | HistoryResult
29
+
30
+
31
+ class RunCommand(TyperCommand):
32
+ """Keep flags after -- literal and never echo command arguments in parse errors."""
33
+
34
+ def parse_args(self, ctx: Any, args: list[str]) -> list[str]:
35
+ options = args[: args.index("--")] if "--" in args else args
36
+ try:
37
+ if "--" not in args and "--help" not in options:
38
+ raise ValueError("missing separator")
39
+ return super().parse_args(ctx, args)
40
+ except typer.Exit:
41
+ raise
42
+ except Exception as exc:
43
+ emit_error(
44
+ "environment", "use run [options] -- <command> [arguments]", "--json" in options
45
+ )
46
+ raise typer.Exit(EXIT_ERROR) from exc
47
+
48
+
49
+ def _display_path(path: Path) -> str:
50
+ try:
51
+ return "~/" + path.relative_to(Path.home()).as_posix()
52
+ except ValueError:
53
+ return str(path)
54
+
55
+
56
+ def _human(console: Console, result: Result) -> None:
57
+ if isinstance(result, HintResult):
58
+ if result.exhausted and result.total == 0:
59
+ console.print(Text("This challenge has no authored hints."))
60
+ elif result.exhausted:
61
+ console.print(Text(f"No more authored hints (all {result.total} revealed)."))
62
+ else:
63
+ console.print(Text(f"Hint {result.index}: {result.hint}"))
64
+ console.print(Text(f"{result.remaining} remaining"))
65
+ elif isinstance(result, SessionsList):
66
+ if result.root is not None:
67
+ console.print(
68
+ Text(
69
+ f"Every attempt in {_display_path(result.root)} "
70
+ "(set PATCHGROUND_SESSIONS_DIR to keep a separate set)",
71
+ style="dim",
72
+ )
73
+ )
74
+ if not result.sessions:
75
+ console.print(Text("No sessions found."))
76
+ for item in result.sessions:
77
+ console.print(
78
+ Text(
79
+ f"{item.challenge_id} {item.state} {item.last_outcome or 'not checked'} "
80
+ f"{_display_path(item.session)}"
81
+ )
82
+ )
83
+ for invalid in result.invalid:
84
+ console.print(Text(f"invalid: {_display_path(invalid.session)}"))
85
+ elif isinstance(result, CommandRunResult):
86
+ console.print(Text("Command output tail (up to 64 KiB; container edits are discarded):"))
87
+ console.print(Text(result.output_tail))
88
+ console.print(Text(f"exit: {result.exit_code}; limit: {result.limit or 'none'}"))
89
+ else:
90
+ console.print(Text(f"history: {result.availability}"))
91
+ for snapshot in result.snapshots:
92
+ console.print(
93
+ Text(
94
+ f"{snapshot.ts.isoformat()} {snapshot.kind} {snapshot.commit[:12]} "
95
+ f"{snapshot.check_outcome or ''}"
96
+ )
97
+ )
98
+
99
+
100
+ def _invoke(call: Callable[[], Result], json_mode: bool) -> None:
101
+ try:
102
+ result = call()
103
+ except SessionError as exc:
104
+ emit_error("environment", str(exc), json_mode)
105
+ raise typer.Exit(EXIT_ERROR) from exc
106
+ except KeyboardInterrupt as exc:
107
+ emit_error("interrupted", "practice operation interrupted", json_mode)
108
+ raise typer.Exit(EXIT_INTERRUPTED) from exc
109
+ except Exception as exc:
110
+ emit_error("internal", "practice operation failed unexpectedly", json_mode)
111
+ raise typer.Exit(EXIT_ERROR) from exc
112
+ emit(result.model_dump(mode="json"), lambda console: _human(console, result), json_mode)
113
+ failed = (isinstance(result, SessionsList) and bool(result.invalid)) or (
114
+ isinstance(result, CommandRunResult) and (result.exit_code != 0 or result.limit is not None)
115
+ )
116
+ if failed:
117
+ raise typer.Exit(EXIT_FAILED)
118
+
119
+
120
+ def hint(session: SessionOption = None, json_mode: JsonOption = False) -> None:
121
+ """Reveal the next authored hint without running code."""
122
+ _invoke(lambda: reveal_hint(session), json_mode)
123
+
124
+
125
+ def sessions(json_mode: JsonOption = False) -> None:
126
+ """List local attempts without selecting one or running Docker."""
127
+ _invoke(list_sessions, json_mode)
128
+
129
+
130
+ def history(session: SessionOption = None, json_mode: JsonOption = False) -> None:
131
+ """Read privacy-filtered private checkpoint history; never restore files."""
132
+ _invoke(lambda: history_for_session(session), json_mode)
133
+
134
+
135
+ def run(
136
+ argv: Annotated[list[str], typer.Argument(help="Container command arguments after --.")],
137
+ session: SessionOption = None,
138
+ json_mode: JsonOption = False,
139
+ ) -> None:
140
+ """Run a diagnostic command in a disposable container, without hidden checks."""
141
+ _invoke(lambda: run_session_command(argv, session), json_mode)
@@ -0,0 +1,58 @@
1
+ """Shared output: every command renders one result either as Rich text or as one JSON document."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from collections.abc import Callable, Iterator
8
+ from contextlib import contextmanager
9
+ from typing import Any
10
+
11
+ from rich.console import Console
12
+ from rich.text import Text
13
+
14
+ EXIT_OK = 0
15
+ EXIT_FAILED = 1
16
+ EXIT_ERROR = 2
17
+ EXIT_INTERRUPTED = 130
18
+
19
+
20
+ def stdout_console() -> Console:
21
+ return Console(soft_wrap=True, highlight=False)
22
+
23
+
24
+ def stderr_console() -> Console:
25
+ return Console(stderr=True, soft_wrap=True, highlight=False)
26
+
27
+
28
+ def emit(
29
+ result_for_json: dict[str, Any], human: Callable[[Console], None], json_mode: bool
30
+ ) -> None:
31
+ """Write ``result_for_json`` as the single JSON document on stdout, or call ``human``."""
32
+ if json_mode:
33
+ sys.stdout.write(json.dumps(result_for_json, indent=2, ensure_ascii=True) + "\n")
34
+ sys.stdout.flush()
35
+ else:
36
+ human(stdout_console())
37
+
38
+
39
+ def emit_error(kind: str, message: str, json_mode: bool) -> None:
40
+ """Report an error that stopped the command (JSON on stdout, or text on stderr)."""
41
+ if json_mode:
42
+ emit({"error": {"kind": kind, "message": message}}, lambda _console: None, True)
43
+ else:
44
+ stderr_console().print(Text(f"error: {message}", style="bold red"))
45
+
46
+
47
+ @contextmanager
48
+ def progress(json_mode: bool) -> Iterator[Callable[[str], None]]:
49
+ """Yield a callback that shows progress on stderr in human mode (and does nothing in JSON)."""
50
+ if json_mode:
51
+ yield lambda _message: None
52
+ return
53
+ console = stderr_console()
54
+ if not console.is_terminal:
55
+ yield lambda message: console.print(Text(f"{message}…", style="dim"))
56
+ return
57
+ with console.status("starting…") as status:
58
+ yield lambda message: status.update(f"{message}…")
@@ -0,0 +1,53 @@
1
+ """Submit and read immutable local outcomes, never AI judgments."""
2
+
3
+ from collections.abc import Callable
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.text import Text
8
+
9
+ from patchground.cli.render import EXIT_ERROR, EXIT_FAILED, EXIT_INTERRUPTED, emit, emit_error
10
+ from patchground.cli.sessions import JsonOption, SessionOption
11
+ from patchground.report.models import VerifiedReport
12
+ from patchground.report.render import render_markdown
13
+ from patchground.report.workflow import saved_report, submit_session
14
+ from patchground.sessions import SessionError
15
+
16
+
17
+ def _invoke(call: Callable[[], VerifiedReport], json_mode: bool, *, submitting: bool) -> None:
18
+ try:
19
+ result = call()
20
+ except SessionError as exc:
21
+ emit_error("environment", str(exc), json_mode)
22
+ raise typer.Exit(EXIT_ERROR) from exc
23
+ except KeyboardInterrupt as exc:
24
+ message = (
25
+ "submission interrupted; retry submit to inspect its state"
26
+ if submitting
27
+ else "report read interrupted"
28
+ )
29
+ emit_error("interrupted", message, json_mode)
30
+ raise typer.Exit(EXIT_INTERRUPTED) from exc
31
+ except Exception as exc:
32
+ emit_error("internal", "report operation failed unexpectedly", json_mode)
33
+ raise typer.Exit(EXIT_ERROR) from exc
34
+
35
+ def human(console: Console) -> None:
36
+ console.print(Text(render_markdown(result, terminal=True)))
37
+ if submitting:
38
+ console.print(Text("Attempt complete. Read this report again with: pgnd report"))
39
+ console.print(Text("Try another challenge: pgnd list --builtin"))
40
+
41
+ emit(result.model_dump(mode="json"), human, json_mode)
42
+ if submitting and not result.checks.passed:
43
+ raise typer.Exit(EXIT_FAILED)
44
+
45
+
46
+ def submit(session: SessionOption = None, json_mode: JsonOption = False) -> None:
47
+ """Finalize this attempt with fresh checks and an immutable verified-outcomes report."""
48
+ _invoke(lambda: submit_session(session), json_mode, submitting=True)
49
+
50
+
51
+ def report(session: SessionOption = None, json_mode: JsonOption = False) -> None:
52
+ """Read the saved final report without Docker or rerunning checks."""
53
+ _invoke(lambda: saved_report(session), json_mode, submitting=False)