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