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,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
|
+
)
|