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.
- patchground/__init__.py +10 -0
- patchground/__main__.py +3 -0
- patchground/authoring/__init__.py +5 -0
- patchground/authoring/create.py +179 -0
- patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
- patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
- patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
- patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
- patchground/automation/__init__.py +5 -0
- patchground/automation/__main__.py +131 -0
- patchground/automation/workflow.py +214 -0
- patchground/catalog/README.md +10 -0
- patchground/catalog/cache-expiry/TICKET.md +49 -0
- patchground/catalog/cache-expiry/challenge.yaml +32 -0
- patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
- patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
- patchground/catalog/cache-expiry/debrief/questions.md +4 -0
- patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
- patchground/catalog/cache-expiry/solution/reference.patch +41 -0
- patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
- patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
- patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
- patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
- patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
- patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
- patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
- patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
- patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
- patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
- patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
- patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
- patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
- patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
- patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
- patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
- patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
- patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
- patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
- patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
- patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
- patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
- patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
- patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
- patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
- patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
- patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
- patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
- patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
- patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
- patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
- patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
- patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
- patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
- patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
- patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
- patchground/catalog/pagination-last-page/TICKET.md +44 -0
- patchground/catalog/pagination-last-page/challenge.yaml +31 -0
- patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
- patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
- patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
- patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
- patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
- patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
- patchground/catalog/release-version-ordering/TICKET.md +72 -0
- patchground/catalog/release-version-ordering/challenge.yaml +31 -0
- patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
- patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
- patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
- patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
- patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
- patchground/catalog/release-version-ordering/environment/package.json +13 -0
- patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
- patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
- patchground/catalog/release-version-ordering/workspace/README.md +7 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
- patchground/cli/__init__.py +1 -0
- patchground/cli/app.py +59 -0
- patchground/cli/authoring.py +41 -0
- patchground/cli/consent.py +77 -0
- patchground/cli/discovery.py +189 -0
- patchground/cli/doctor.py +249 -0
- patchground/cli/packs.py +142 -0
- patchground/cli/practice.py +141 -0
- patchground/cli/render.py +58 -0
- patchground/cli/reports.py +53 -0
- patchground/cli/sessions.py +195 -0
- patchground/cli/validate.py +117 -0
- patchground/packs/__init__.py +23 -0
- patchground/packs/builtin.py +53 -0
- patchground/packs/discovery.py +285 -0
- patchground/packs/execution.py +420 -0
- patchground/packs/models.py +98 -0
- patchground/packs/registry.py +318 -0
- patchground/packs/source.py +230 -0
- patchground/packs/store.py +420 -0
- patchground/py.typed +0 -0
- patchground/report/__init__.py +6 -0
- patchground/report/analysis.py +188 -0
- patchground/report/models.py +59 -0
- patchground/report/regression.py +597 -0
- patchground/report/render.py +92 -0
- patchground/report/workflow.py +84 -0
- patchground/runner_assets/Dockerfile +7 -0
- patchground/runner_assets/__init__.py +16 -0
- patchground/runner_assets/package-lock.json +1054 -0
- patchground/runner_assets/package.json +13 -0
- patchground/runner_assets/vitest-runner.mjs +124 -0
- patchground/safeio/__init__.py +115 -0
- patchground/sandbox/__init__.py +39 -0
- patchground/sandbox/_engine.py +23 -0
- patchground/sandbox/build.py +202 -0
- patchground/sandbox/errors.py +23 -0
- patchground/sandbox/junit.py +76 -0
- patchground/sandbox/probe.py +118 -0
- patchground/sandbox/run.py +535 -0
- patchground/sessions/__init__.py +44 -0
- patchground/sessions/diagnostics.py +145 -0
- patchground/sessions/files.py +113 -0
- patchground/sessions/git.py +70 -0
- patchground/sessions/history.py +386 -0
- patchground/sessions/history_ignore.py +279 -0
- patchground/sessions/models.py +126 -0
- patchground/sessions/practice.py +202 -0
- patchground/sessions/reset.py +121 -0
- patchground/sessions/store.py +203 -0
- patchground/sessions/submission.py +344 -0
- patchground/sessions/workflow.py +297 -0
- patchground/spec/__init__.py +35 -0
- patchground/spec/_schema.py +53 -0
- patchground/spec/loader.py +713 -0
- patchground/spec/models.py +394 -0
- patchground/spec/schema.py +50 -0
- patchground/verify/__init__.py +29 -0
- patchground/verify/errors.py +10 -0
- patchground/verify/pytest_adapter.py +95 -0
- patchground/verify/record.py +94 -0
- patchground/verify/reference.py +158 -0
- patchground/verify/result.py +199 -0
- patchground/verify/runners.py +47 -0
- patchground/verify/tampering.py +106 -0
- patchground/verify/validate.py +686 -0
- patchground/verify/vitest_adapter.py +252 -0
- patchground-0.1.0.dist-info/METADATA +258 -0
- patchground-0.1.0.dist-info/RECORD +194 -0
- patchground-0.1.0.dist-info/WHEEL +4 -0
- patchground-0.1.0.dist-info/entry_points.txt +3 -0
- 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)
|
patchground/cli/packs.py
ADDED
|
@@ -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)
|