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,713 @@
|
|
|
1
|
+
"""Load challenge and pack folders into validated objects, reporting every problem with its line.
|
|
2
|
+
|
|
3
|
+
Parsing uses ruamel.yaml round-trip mode so each value keeps its source position. All problems
|
|
4
|
+
(YAML, schema, filesystem) are collected in one pass and raised together as a ``ManifestError``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import stat
|
|
12
|
+
from collections.abc import Iterable, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from pydantic import ValidationError
|
|
18
|
+
from pydantic_core import ErrorDetails
|
|
19
|
+
from ruamel.yaml import YAML
|
|
20
|
+
from ruamel.yaml.comments import CommentedMap, CommentedSeq
|
|
21
|
+
from ruamel.yaml.error import MarkedYAMLError, YAMLError
|
|
22
|
+
from ruamel.yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode
|
|
23
|
+
|
|
24
|
+
from patchground.spec.models import (
|
|
25
|
+
SLUG_PATTERN,
|
|
26
|
+
ChallengeManifest,
|
|
27
|
+
PackManifest,
|
|
28
|
+
relative_path_problem,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
CHALLENGE_MANIFEST = "challenge.yaml"
|
|
32
|
+
PACK_MANIFEST = "pack.yaml"
|
|
33
|
+
TICKET_FILE = "TICKET.md"
|
|
34
|
+
WORKSPACE_DIR = "workspace"
|
|
35
|
+
|
|
36
|
+
MAX_MANIFEST_BYTES = 1024 * 1024
|
|
37
|
+
"""Manifests are small; anything larger is rejected before parsing."""
|
|
38
|
+
|
|
39
|
+
MAX_YAML_DEPTH = 64
|
|
40
|
+
"""Maximum nesting depth accepted by the YAML composer."""
|
|
41
|
+
|
|
42
|
+
Loc = tuple[str | int, ...]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _normalized(path: Path) -> Path:
|
|
46
|
+
"""Absolute, lexically normalized path (symlinks are not followed)."""
|
|
47
|
+
return Path(os.path.normpath(path.absolute()))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --------------------------------------------------------------------------------------------------
|
|
51
|
+
# Public types
|
|
52
|
+
# --------------------------------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class ManifestIssue:
|
|
57
|
+
"""One problem found while loading a manifest. ``line`` is 1-based."""
|
|
58
|
+
|
|
59
|
+
file: Path
|
|
60
|
+
line: int | None
|
|
61
|
+
field: str | None
|
|
62
|
+
message: str
|
|
63
|
+
|
|
64
|
+
def format(self, relative_to: Path | None = None) -> str:
|
|
65
|
+
"""Render as ``file:line: field: message``, ``file`` relative to ``relative_to``."""
|
|
66
|
+
shown = str(self.file)
|
|
67
|
+
if relative_to is not None:
|
|
68
|
+
file_abs = _normalized(self.file)
|
|
69
|
+
base_abs = _normalized(relative_to)
|
|
70
|
+
if file_abs.is_relative_to(base_abs):
|
|
71
|
+
shown = file_abs.relative_to(base_abs).as_posix()
|
|
72
|
+
location = shown if self.line is None else f"{shown}:{self.line}"
|
|
73
|
+
if self.field:
|
|
74
|
+
return f"{location}: {self.field}: {self.message}"
|
|
75
|
+
return f"{location}: {self.message}"
|
|
76
|
+
|
|
77
|
+
def __str__(self) -> str:
|
|
78
|
+
return self.format()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ManifestError(Exception):
|
|
82
|
+
"""Raised with every problem found in a challenge or pack folder."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, issues: Sequence[ManifestIssue]) -> None:
|
|
85
|
+
self.issues: list[ManifestIssue] = list(issues)
|
|
86
|
+
super().__init__("\n".join(str(issue) for issue in self.issues))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Challenge:
|
|
91
|
+
"""A validated challenge folder."""
|
|
92
|
+
|
|
93
|
+
root: Path
|
|
94
|
+
manifest_path: Path
|
|
95
|
+
manifest: ChallengeManifest
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class Pack:
|
|
100
|
+
"""A validated pack folder."""
|
|
101
|
+
|
|
102
|
+
root: Path
|
|
103
|
+
manifest_path: Path
|
|
104
|
+
manifest: PackManifest
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --------------------------------------------------------------------------------------------------
|
|
108
|
+
# YAML parsing with positions
|
|
109
|
+
# --------------------------------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _make_yaml(*, allow_duplicate_keys: bool) -> YAML:
|
|
113
|
+
yaml = YAML(typ="rt", pure=True)
|
|
114
|
+
yaml.allow_duplicate_keys = allow_duplicate_keys
|
|
115
|
+
yaml.max_depth = MAX_YAML_DEPTH
|
|
116
|
+
return yaml
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _line(mark: Any) -> int | None:
|
|
120
|
+
line = getattr(mark, "line", None)
|
|
121
|
+
return line + 1 if isinstance(line, int) else None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _duplicate_key_issues(node: Node, file: Path) -> list[ManifestIssue]:
|
|
125
|
+
"""Find every duplicate mapping key in the composed node graph (aliases visited once)."""
|
|
126
|
+
issues: list[ManifestIssue] = []
|
|
127
|
+
seen_nodes: set[int] = set()
|
|
128
|
+
stack: list[Node] = [node]
|
|
129
|
+
while stack:
|
|
130
|
+
current = stack.pop()
|
|
131
|
+
if id(current) in seen_nodes:
|
|
132
|
+
continue
|
|
133
|
+
seen_nodes.add(id(current))
|
|
134
|
+
if isinstance(current, MappingNode):
|
|
135
|
+
first_line: dict[str, int | None] = {}
|
|
136
|
+
for key_node, value_node in current.value:
|
|
137
|
+
stack.append(value_node)
|
|
138
|
+
if not isinstance(key_node, ScalarNode) or key_node.value == "<<":
|
|
139
|
+
continue
|
|
140
|
+
key = str(key_node.value)
|
|
141
|
+
line = _line(key_node.start_mark)
|
|
142
|
+
if key in first_line:
|
|
143
|
+
where = first_line[key]
|
|
144
|
+
suffix = f" (first defined on line {where})" if where is not None else ""
|
|
145
|
+
issues.append(ManifestIssue(file, line, None, f"duplicate key '{key}'{suffix}"))
|
|
146
|
+
else:
|
|
147
|
+
first_line[key] = line
|
|
148
|
+
elif isinstance(current, SequenceNode):
|
|
149
|
+
stack.extend(current.value)
|
|
150
|
+
issues.sort(key=lambda issue: issue.line or 0)
|
|
151
|
+
return issues
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _parse_yaml(path: Path, issues: list[ManifestIssue]) -> Any:
|
|
155
|
+
"""Parse ``path``; append problems to ``issues``. Returns the round-trip data or ``None``."""
|
|
156
|
+
try:
|
|
157
|
+
size = path.stat().st_size
|
|
158
|
+
except OSError as exc:
|
|
159
|
+
issues.append(ManifestIssue(path, None, None, f"cannot read file: {exc.strerror}"))
|
|
160
|
+
return None
|
|
161
|
+
if size > MAX_MANIFEST_BYTES:
|
|
162
|
+
issues.append(
|
|
163
|
+
ManifestIssue(path, None, None, f"file is larger than {MAX_MANIFEST_BYTES} bytes")
|
|
164
|
+
)
|
|
165
|
+
return None
|
|
166
|
+
try:
|
|
167
|
+
text = path.read_bytes().decode("utf-8")
|
|
168
|
+
except OSError as exc:
|
|
169
|
+
issues.append(ManifestIssue(path, None, None, f"cannot read file: {exc.strerror}"))
|
|
170
|
+
return None
|
|
171
|
+
except UnicodeDecodeError:
|
|
172
|
+
issues.append(ManifestIssue(path, None, None, "file is not valid UTF-8"))
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
node = _make_yaml(allow_duplicate_keys=False).compose(text)
|
|
177
|
+
if node is not None:
|
|
178
|
+
issues.extend(_duplicate_key_issues(node, path))
|
|
179
|
+
data = _make_yaml(allow_duplicate_keys=True).load(text)
|
|
180
|
+
except MarkedYAMLError as exc:
|
|
181
|
+
mark = exc.problem_mark or exc.context_mark
|
|
182
|
+
problem = exc.problem or exc.context or "invalid YAML"
|
|
183
|
+
context = f" ({exc.context})" if exc.context and exc.problem else ""
|
|
184
|
+
issues.append(ManifestIssue(path, _line(mark), None, f"YAML error: {problem}{context}"))
|
|
185
|
+
return None
|
|
186
|
+
except YAMLError as exc:
|
|
187
|
+
issues.append(ManifestIssue(path, None, None, f"YAML error: {exc}"))
|
|
188
|
+
return None
|
|
189
|
+
except RecursionError:
|
|
190
|
+
issues.append(ManifestIssue(path, None, None, "YAML error: document is nested too deeply"))
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
if not isinstance(data, CommentedMap):
|
|
194
|
+
issues.append(
|
|
195
|
+
ManifestIssue(path, 1, None, f"{path.name} must contain a mapping of keys at the top")
|
|
196
|
+
)
|
|
197
|
+
return None
|
|
198
|
+
return data
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _to_plain(value: Any, memo: dict[int, Any] | None = None) -> Any:
|
|
202
|
+
"""Convert ruamel round-trip containers and scalar subclasses into plain Python values.
|
|
203
|
+
|
|
204
|
+
Shared (aliased) containers are converted once, so alias expansion cannot blow up.
|
|
205
|
+
"""
|
|
206
|
+
if memo is None:
|
|
207
|
+
memo = {}
|
|
208
|
+
if isinstance(value, dict | list) and id(value) in memo:
|
|
209
|
+
return memo[id(value)]
|
|
210
|
+
if isinstance(value, dict):
|
|
211
|
+
out_map: dict[Any, Any] = {}
|
|
212
|
+
memo[id(value)] = out_map
|
|
213
|
+
for key, item in value.items():
|
|
214
|
+
if isinstance(key, str): # non-string keys are reported by _non_string_key_issues
|
|
215
|
+
out_map[str(key)] = _to_plain(item, memo)
|
|
216
|
+
return out_map
|
|
217
|
+
if isinstance(value, list):
|
|
218
|
+
out_list: list[Any] = []
|
|
219
|
+
memo[id(value)] = out_list
|
|
220
|
+
out_list.extend(_to_plain(item, memo) for item in value)
|
|
221
|
+
return out_list
|
|
222
|
+
if isinstance(value, bool) or value is None:
|
|
223
|
+
return value
|
|
224
|
+
if isinstance(value, str):
|
|
225
|
+
return str(value)
|
|
226
|
+
if isinstance(value, int):
|
|
227
|
+
return int(value)
|
|
228
|
+
if isinstance(value, float):
|
|
229
|
+
return float(value)
|
|
230
|
+
return value
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _non_string_key_issues(raw: Any, file: Path) -> list[ManifestIssue]:
|
|
234
|
+
"""Report mapping keys that are not strings (e.g. ``1:``, ``null:``, ``true:``)."""
|
|
235
|
+
issues: list[ManifestIssue] = []
|
|
236
|
+
seen: set[int] = set()
|
|
237
|
+
stack: list[tuple[Any, Loc]] = [(raw, ())]
|
|
238
|
+
while stack:
|
|
239
|
+
current, loc = stack.pop()
|
|
240
|
+
if id(current) in seen:
|
|
241
|
+
continue
|
|
242
|
+
seen.add(id(current))
|
|
243
|
+
if isinstance(current, CommentedMap):
|
|
244
|
+
for key, item in current.items():
|
|
245
|
+
if isinstance(key, str):
|
|
246
|
+
stack.append((item, (*loc, key)))
|
|
247
|
+
continue
|
|
248
|
+
position = current.lc.data.get(key)
|
|
249
|
+
line = position[0] + 1 if position else _container_line(current)
|
|
250
|
+
issues.append(
|
|
251
|
+
ManifestIssue(file, line, _format_loc(loc), "mapping keys must be strings")
|
|
252
|
+
)
|
|
253
|
+
elif isinstance(current, CommentedSeq):
|
|
254
|
+
stack.extend((item, (*loc, index)) for index, item in enumerate(current))
|
|
255
|
+
return issues
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# --------------------------------------------------------------------------------------------------
|
|
259
|
+
# Locations
|
|
260
|
+
# --------------------------------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _container_line(container: Any) -> int | None:
|
|
264
|
+
lc = getattr(container, "lc", None)
|
|
265
|
+
line = getattr(lc, "line", None)
|
|
266
|
+
return line + 1 if isinstance(line, int) else None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _line_for(raw: Any, loc: Loc) -> int | None:
|
|
270
|
+
"""Line of the deepest part of ``loc`` present in ``raw`` (key line for maps, item for seqs)."""
|
|
271
|
+
line = _container_line(raw)
|
|
272
|
+
current = raw
|
|
273
|
+
for part in loc:
|
|
274
|
+
if (isinstance(current, CommentedMap) and isinstance(part, str) and part in current) or (
|
|
275
|
+
isinstance(current, CommentedSeq) and isinstance(part, int) and 0 <= part < len(current)
|
|
276
|
+
):
|
|
277
|
+
position = current.lc.data.get(part)
|
|
278
|
+
if position:
|
|
279
|
+
line = position[0] + 1
|
|
280
|
+
current = current[part]
|
|
281
|
+
else:
|
|
282
|
+
# Missing key (e.g. "field required") or a pydantic-internal loc part: keep the
|
|
283
|
+
# parent's line and try the remaining parts against the same container.
|
|
284
|
+
continue
|
|
285
|
+
return line
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _format_loc(loc: Loc) -> str | None:
|
|
289
|
+
text = ""
|
|
290
|
+
for part in loc:
|
|
291
|
+
if isinstance(part, int):
|
|
292
|
+
text += f"[{part}]"
|
|
293
|
+
else:
|
|
294
|
+
text += f".{part}" if text else part
|
|
295
|
+
return text or None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _message(error: ErrorDetails) -> str:
|
|
299
|
+
kind = error["type"]
|
|
300
|
+
if kind == "missing":
|
|
301
|
+
return "field required"
|
|
302
|
+
if kind == "extra_forbidden":
|
|
303
|
+
return "unknown key (only keys starting with 'x-' may be added)"
|
|
304
|
+
return str(error["msg"])
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _validation_issues(exc: ValidationError, raw: Any, file: Path) -> list[ManifestIssue]:
|
|
308
|
+
return [
|
|
309
|
+
ManifestIssue(
|
|
310
|
+
file,
|
|
311
|
+
_line_for(raw, tuple(error["loc"])),
|
|
312
|
+
_format_loc(tuple(error["loc"])),
|
|
313
|
+
_message(error),
|
|
314
|
+
)
|
|
315
|
+
for error in exc.errors(include_url=False, include_input=False)
|
|
316
|
+
]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _raw_get(raw: Any, loc: Loc) -> Any:
|
|
320
|
+
current = raw
|
|
321
|
+
for part in loc:
|
|
322
|
+
if isinstance(current, dict) and isinstance(part, str):
|
|
323
|
+
current = current.get(part)
|
|
324
|
+
elif isinstance(current, list) and isinstance(part, int) and 0 <= part < len(current):
|
|
325
|
+
current = current[part]
|
|
326
|
+
else:
|
|
327
|
+
return None
|
|
328
|
+
return current
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# --------------------------------------------------------------------------------------------------
|
|
332
|
+
# Filesystem checks
|
|
333
|
+
# --------------------------------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _check_path(
|
|
337
|
+
*, root: Path, folder: Path, rel: str, want_dir: bool, container: str = "challenge folder"
|
|
338
|
+
) -> Path | str:
|
|
339
|
+
"""Check one referenced path: resolved path if usable, else the problem as a message."""
|
|
340
|
+
target = folder / rel
|
|
341
|
+
resolved = target.resolve()
|
|
342
|
+
if not resolved.is_relative_to(root):
|
|
343
|
+
return f"'{rel}' resolves outside the {container}"
|
|
344
|
+
if not target.exists():
|
|
345
|
+
return f"'{rel}' does not exist"
|
|
346
|
+
if want_dir and not target.is_dir():
|
|
347
|
+
return f"'{rel}' must be a directory"
|
|
348
|
+
if not want_dir and not target.is_file():
|
|
349
|
+
return f"'{rel}' must be a file"
|
|
350
|
+
return resolved
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _relative_entries(directory: Path) -> tuple[set[str], set[str]]:
|
|
354
|
+
"""Relative POSIX paths of non-directory entries and of directories below ``directory``.
|
|
355
|
+
|
|
356
|
+
Symlinks are not followed; a symlink counts as a file entry.
|
|
357
|
+
"""
|
|
358
|
+
files: set[str] = set()
|
|
359
|
+
dirs: set[str] = set()
|
|
360
|
+
for current, dirnames, filenames in directory.walk(follow_symlinks=False):
|
|
361
|
+
for name in list(dirnames):
|
|
362
|
+
entry = current / name
|
|
363
|
+
rel = entry.relative_to(directory).as_posix()
|
|
364
|
+
if entry.is_symlink():
|
|
365
|
+
files.add(rel)
|
|
366
|
+
dirnames.remove(name)
|
|
367
|
+
else:
|
|
368
|
+
dirs.add(rel)
|
|
369
|
+
for name in filenames:
|
|
370
|
+
files.add((current / name).relative_to(directory).as_posix())
|
|
371
|
+
return files, dirs
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@dataclass(frozen=True)
|
|
375
|
+
class _MergeSource:
|
|
376
|
+
label: str
|
|
377
|
+
path: Path
|
|
378
|
+
line: int | None
|
|
379
|
+
field: str | None
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _clash_issues(sources: Iterable[_MergeSource], file: Path) -> list[ManifestIssue]:
|
|
383
|
+
"""Paths that would collide when checks are merged into the workspace root."""
|
|
384
|
+
issues: list[ManifestIssue] = []
|
|
385
|
+
seen: list[tuple[_MergeSource, set[str], set[str]]] = []
|
|
386
|
+
for source in sources:
|
|
387
|
+
files, dirs = _relative_entries(source.path)
|
|
388
|
+
for earlier, earlier_files, earlier_dirs in seen:
|
|
389
|
+
clashes = (files & earlier_files) | (files & earlier_dirs) | (dirs & earlier_files)
|
|
390
|
+
for rel in sorted(clashes):
|
|
391
|
+
issues.append(
|
|
392
|
+
ManifestIssue(
|
|
393
|
+
file,
|
|
394
|
+
source.line,
|
|
395
|
+
source.field,
|
|
396
|
+
f"'{rel}' exists in both {earlier.label} and {source.label}; "
|
|
397
|
+
"check files are merged into the workspace root and must not collide",
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
seen.append((source, files, dirs))
|
|
401
|
+
return issues
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _relation(a: Path, b: Path) -> str | None:
|
|
405
|
+
"""How resolved path ``a`` relates to resolved path ``b``: same, inside, contains, or None."""
|
|
406
|
+
if a == b:
|
|
407
|
+
return "is the same path as"
|
|
408
|
+
if a.is_relative_to(b):
|
|
409
|
+
return "is inside"
|
|
410
|
+
if b.is_relative_to(a):
|
|
411
|
+
return "contains"
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _non_regular_entries(directory: Path) -> list[tuple[str, str]]:
|
|
416
|
+
"""``(relative POSIX path, kind)`` of every symlink or special file below ``directory``.
|
|
417
|
+
|
|
418
|
+
Only names and kinds are reported, never contents; symlinks are not followed.
|
|
419
|
+
"""
|
|
420
|
+
found: list[tuple[str, str]] = []
|
|
421
|
+
for current, dirnames, filenames in directory.walk(follow_symlinks=False):
|
|
422
|
+
dirnames.sort()
|
|
423
|
+
for name in sorted([*dirnames, *filenames]):
|
|
424
|
+
entry = current / name
|
|
425
|
+
try:
|
|
426
|
+
mode = entry.lstat().st_mode
|
|
427
|
+
except OSError:
|
|
428
|
+
continue
|
|
429
|
+
if stat.S_ISDIR(mode) or stat.S_ISREG(mode):
|
|
430
|
+
continue
|
|
431
|
+
if stat.S_ISLNK(mode):
|
|
432
|
+
kind = "a symlink"
|
|
433
|
+
if name in dirnames:
|
|
434
|
+
dirnames.remove(name)
|
|
435
|
+
elif stat.S_ISFIFO(mode):
|
|
436
|
+
kind = "a FIFO"
|
|
437
|
+
elif stat.S_ISSOCK(mode):
|
|
438
|
+
kind = "a socket"
|
|
439
|
+
elif stat.S_ISCHR(mode) or stat.S_ISBLK(mode):
|
|
440
|
+
kind = "a device file"
|
|
441
|
+
else:
|
|
442
|
+
kind = "a special file"
|
|
443
|
+
found.append((entry.relative_to(directory).as_posix(), kind))
|
|
444
|
+
return found
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _git_entries(directory: Path) -> list[str]:
|
|
448
|
+
"""Relative POSIX paths of every ``.git`` entry (any case, file or dir) below ``directory``.
|
|
449
|
+
|
|
450
|
+
A ``.git`` directory is not descended into; symlinks are not followed.
|
|
451
|
+
"""
|
|
452
|
+
found: list[str] = []
|
|
453
|
+
for current, dirnames, filenames in directory.walk(follow_symlinks=False):
|
|
454
|
+
dirnames.sort()
|
|
455
|
+
for name in sorted([*dirnames, *filenames]):
|
|
456
|
+
if name.casefold() != ".git":
|
|
457
|
+
continue
|
|
458
|
+
found.append((current / name).relative_to(directory).as_posix())
|
|
459
|
+
if name in dirnames:
|
|
460
|
+
dirnames.remove(name)
|
|
461
|
+
return found
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
@dataclass(frozen=True)
|
|
465
|
+
class _Ref:
|
|
466
|
+
"""A resolved folder entry and where to report problems about it."""
|
|
467
|
+
|
|
468
|
+
label: str
|
|
469
|
+
path: Path
|
|
470
|
+
file: Path
|
|
471
|
+
line: int | None
|
|
472
|
+
field: str | None
|
|
473
|
+
|
|
474
|
+
def issue(self, message: str) -> ManifestIssue:
|
|
475
|
+
return ManifestIssue(self.file, self.line, self.field, message)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _isolation_issues(refs: dict[str, _Ref]) -> list[ManifestIssue]:
|
|
479
|
+
"""Enforce that solution and hidden checks can never reach the workspace or the image.
|
|
480
|
+
|
|
481
|
+
Everything under ``workspace/`` and ``checks.visible`` is copied into the user's workspace,
|
|
482
|
+
and the Dockerfile's folder is the build context sent into the image the sessions run. So
|
|
483
|
+
``solution`` and ``checks.hidden`` must not overlap the workspace or visible checks, visible
|
|
484
|
+
checks must not overlap the workspace, and the build context must be a separate folder.
|
|
485
|
+
"""
|
|
486
|
+
issues: list[ManifestIssue] = []
|
|
487
|
+
workspace = refs.get("workspace")
|
|
488
|
+
visible = refs.get("visible")
|
|
489
|
+
for secret_key in ("hidden", "solution"):
|
|
490
|
+
secret = refs.get(secret_key)
|
|
491
|
+
if secret is None:
|
|
492
|
+
continue
|
|
493
|
+
for exposed in (workspace, visible):
|
|
494
|
+
if exposed is None:
|
|
495
|
+
continue
|
|
496
|
+
relation = _relation(secret.path, exposed.path)
|
|
497
|
+
if relation is None:
|
|
498
|
+
continue
|
|
499
|
+
# Blame checks.visible when it covers the secret; otherwise the secret's own field.
|
|
500
|
+
blame_exposed = exposed.field is not None and relation != "contains"
|
|
501
|
+
blamed, other = (exposed, secret) if blame_exposed else (secret, exposed)
|
|
502
|
+
issues.append(
|
|
503
|
+
blamed.issue(
|
|
504
|
+
f"{blamed.label} {_relation(blamed.path, other.path)} {other.label}; "
|
|
505
|
+
f"{secret.label} must never be copied into the workspace, "
|
|
506
|
+
"keep them in separate folders"
|
|
507
|
+
)
|
|
508
|
+
)
|
|
509
|
+
if workspace is not None and visible is not None:
|
|
510
|
+
relation = _relation(visible.path, workspace.path)
|
|
511
|
+
if relation is not None:
|
|
512
|
+
issues.append(
|
|
513
|
+
visible.issue(
|
|
514
|
+
f"{visible.label} {relation} {workspace.label}; visible checks must be a "
|
|
515
|
+
"separate folder that is merged into the workspace"
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
dockerfile = refs.get("dockerfile")
|
|
519
|
+
if dockerfile is not None:
|
|
520
|
+
context = dockerfile.path.parent
|
|
521
|
+
for key in ("workspace", "hidden", "solution"):
|
|
522
|
+
leaked = refs.get(key)
|
|
523
|
+
if leaked is None:
|
|
524
|
+
continue
|
|
525
|
+
leaks = leaked.path.is_relative_to(context)
|
|
526
|
+
if key == "hidden": # a context inside the hidden checks ships some of them too
|
|
527
|
+
leaks = leaks or context.is_relative_to(leaked.path)
|
|
528
|
+
if leaks:
|
|
529
|
+
issues.append(
|
|
530
|
+
dockerfile.issue(
|
|
531
|
+
f"the Dockerfile's folder (the build context) "
|
|
532
|
+
f"{_relation(context, leaked.path)} {leaked.label}; the build context goes "
|
|
533
|
+
"into the image, so it must be a separate folder such as environment/"
|
|
534
|
+
)
|
|
535
|
+
)
|
|
536
|
+
return issues
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _challenge_fs_issues(folder: Path, root: Path, manifest: Path, raw: Any) -> list[ManifestIssue]:
|
|
540
|
+
issues: list[ManifestIssue] = []
|
|
541
|
+
|
|
542
|
+
# Required entries that are not referenced from the manifest.
|
|
543
|
+
ticket = _check_path(root=root, folder=folder, rel=TICKET_FILE, want_dir=False)
|
|
544
|
+
if isinstance(ticket, str):
|
|
545
|
+
issues.append(ManifestIssue(folder / TICKET_FILE, None, None, ticket))
|
|
546
|
+
workspace = _check_path(root=root, folder=folder, rel=WORKSPACE_DIR, want_dir=True)
|
|
547
|
+
if isinstance(workspace, str):
|
|
548
|
+
issues.append(ManifestIssue(folder / WORKSPACE_DIR, None, None, workspace))
|
|
549
|
+
|
|
550
|
+
if raw is None:
|
|
551
|
+
return issues
|
|
552
|
+
|
|
553
|
+
folder_name = _normalized(folder).name
|
|
554
|
+
challenge_id = _raw_get(raw, ("id",))
|
|
555
|
+
if isinstance(challenge_id, str) and challenge_id != folder_name:
|
|
556
|
+
issues.append(
|
|
557
|
+
ManifestIssue(
|
|
558
|
+
manifest,
|
|
559
|
+
_line_for(raw, ("id",)),
|
|
560
|
+
"id",
|
|
561
|
+
f"id '{challenge_id}' must match the folder name '{folder_name}'",
|
|
562
|
+
)
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
references: list[tuple[Loc, bool]] = [
|
|
566
|
+
(("environment", "dockerfile"), False),
|
|
567
|
+
(("checks", "visible"), True),
|
|
568
|
+
(("checks", "hidden"), True),
|
|
569
|
+
(("solution",), False),
|
|
570
|
+
(("debrief", "questions"), False),
|
|
571
|
+
]
|
|
572
|
+
resolved: dict[Loc, Path] = {}
|
|
573
|
+
for loc, want_dir in references:
|
|
574
|
+
value = _raw_get(raw, loc)
|
|
575
|
+
# Values the model rejects (wrong type, absolute, escaping) are reported by the model.
|
|
576
|
+
if not isinstance(value, str) or relative_path_problem(value) is not None:
|
|
577
|
+
continue
|
|
578
|
+
target = _check_path(root=root, folder=folder, rel=value, want_dir=want_dir)
|
|
579
|
+
if isinstance(target, str):
|
|
580
|
+
issues.append(ManifestIssue(manifest, _line_for(raw, loc), _format_loc(loc), target))
|
|
581
|
+
else:
|
|
582
|
+
resolved[loc] = target
|
|
583
|
+
|
|
584
|
+
refs: dict[str, _Ref] = {}
|
|
585
|
+
if isinstance(workspace, Path):
|
|
586
|
+
refs["workspace"] = _Ref(f"{WORKSPACE_DIR}/", workspace, folder / WORKSPACE_DIR, None, None)
|
|
587
|
+
for key, loc in (
|
|
588
|
+
("dockerfile", ("environment", "dockerfile")),
|
|
589
|
+
("visible", ("checks", "visible")),
|
|
590
|
+
("hidden", ("checks", "hidden")),
|
|
591
|
+
("solution", ("solution",)),
|
|
592
|
+
):
|
|
593
|
+
if loc in resolved:
|
|
594
|
+
field = str(_format_loc(loc))
|
|
595
|
+
refs[key] = _Ref(field, resolved[loc], manifest, _line_for(raw, loc), field)
|
|
596
|
+
|
|
597
|
+
isolation = _isolation_issues(refs)
|
|
598
|
+
issues.extend(isolation)
|
|
599
|
+
|
|
600
|
+
for key in ("workspace", "visible", "hidden"):
|
|
601
|
+
ref = refs.get(key)
|
|
602
|
+
if ref is None:
|
|
603
|
+
continue
|
|
604
|
+
for rel in _git_entries(ref.path):
|
|
605
|
+
shown = (ref.path / rel).relative_to(root).as_posix()
|
|
606
|
+
issues.append(
|
|
607
|
+
ref.issue(
|
|
608
|
+
f"'{shown}' is a git repository or git file; challenges must not ship .git"
|
|
609
|
+
)
|
|
610
|
+
)
|
|
611
|
+
for rel, kind in _non_regular_entries(ref.path):
|
|
612
|
+
shown = (ref.path / rel).relative_to(root).as_posix()
|
|
613
|
+
issues.append(
|
|
614
|
+
ref.issue(
|
|
615
|
+
f"'{shown}' is {kind}; {ref.label} may only contain regular files "
|
|
616
|
+
"and directories"
|
|
617
|
+
)
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
if not isolation: # overlapping folders would only repeat the same problem as clashes
|
|
621
|
+
sources = [
|
|
622
|
+
_MergeSource(ref.label, ref.path, ref.line, ref.field)
|
|
623
|
+
for key in ("workspace", "visible", "hidden")
|
|
624
|
+
if (ref := refs.get(key)) is not None
|
|
625
|
+
]
|
|
626
|
+
issues.extend(_clash_issues(sources, manifest))
|
|
627
|
+
return issues
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _pack_fs_issues(folder: Path, root: Path, manifest: Path, raw: Any) -> list[ManifestIssue]:
|
|
631
|
+
issues: list[ManifestIssue] = []
|
|
632
|
+
challenges = _raw_get(raw, ("challenges",))
|
|
633
|
+
if not isinstance(challenges, list):
|
|
634
|
+
return issues
|
|
635
|
+
for index, name in enumerate(challenges):
|
|
636
|
+
if not isinstance(name, str) or not re.fullmatch(SLUG_PATTERN, name):
|
|
637
|
+
continue # reported by the model
|
|
638
|
+
loc: Loc = ("challenges", index)
|
|
639
|
+
target = _check_path(
|
|
640
|
+
root=root, folder=folder, rel=name, want_dir=True, container="pack folder"
|
|
641
|
+
)
|
|
642
|
+
if isinstance(target, str):
|
|
643
|
+
message = f"challenge folder {target}"
|
|
644
|
+
issues.append(ManifestIssue(manifest, _line_for(raw, loc), _format_loc(loc), message))
|
|
645
|
+
return issues
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
# --------------------------------------------------------------------------------------------------
|
|
649
|
+
# Entry points
|
|
650
|
+
# --------------------------------------------------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _sorted(issues: list[ManifestIssue]) -> list[ManifestIssue]:
|
|
654
|
+
# Stable: issues without a line keep their discovery order after the located ones of a file.
|
|
655
|
+
return sorted(issues, key=lambda issue: (issue.line is None, issue.line or 0))
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def load_challenge(folder: Path) -> Challenge:
|
|
659
|
+
"""Load and validate the challenge in ``folder``.
|
|
660
|
+
|
|
661
|
+
Raises ``ManifestError`` listing every problem found.
|
|
662
|
+
"""
|
|
663
|
+
folder = Path(folder)
|
|
664
|
+
manifest_path = folder / CHALLENGE_MANIFEST
|
|
665
|
+
if not manifest_path.is_file():
|
|
666
|
+
raise ManifestError(
|
|
667
|
+
[ManifestIssue(manifest_path, None, None, f"{CHALLENGE_MANIFEST} not found")]
|
|
668
|
+
)
|
|
669
|
+
root = folder.resolve()
|
|
670
|
+
|
|
671
|
+
issues: list[ManifestIssue] = []
|
|
672
|
+
raw = _parse_yaml(manifest_path, issues)
|
|
673
|
+
model: ChallengeManifest | None = None
|
|
674
|
+
if raw is not None:
|
|
675
|
+
issues.extend(_non_string_key_issues(raw, manifest_path))
|
|
676
|
+
try:
|
|
677
|
+
model = ChallengeManifest.model_validate(_to_plain(raw))
|
|
678
|
+
except ValidationError as exc:
|
|
679
|
+
issues.extend(_validation_issues(exc, raw, manifest_path))
|
|
680
|
+
issues.extend(_challenge_fs_issues(folder, root, manifest_path, raw))
|
|
681
|
+
|
|
682
|
+
if issues or model is None:
|
|
683
|
+
raise ManifestError(_sorted(issues))
|
|
684
|
+
return Challenge(root=root, manifest_path=root / CHALLENGE_MANIFEST, manifest=model)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def load_pack(folder: Path) -> Pack:
|
|
688
|
+
"""Load and validate the pack in ``folder``.
|
|
689
|
+
|
|
690
|
+
Raises ``ManifestError`` listing every problem found.
|
|
691
|
+
"""
|
|
692
|
+
folder = Path(folder)
|
|
693
|
+
manifest_path = folder / PACK_MANIFEST
|
|
694
|
+
if not manifest_path.is_file():
|
|
695
|
+
raise ManifestError(
|
|
696
|
+
[ManifestIssue(manifest_path, None, None, f"{PACK_MANIFEST} not found")]
|
|
697
|
+
)
|
|
698
|
+
root = folder.resolve()
|
|
699
|
+
|
|
700
|
+
issues: list[ManifestIssue] = []
|
|
701
|
+
raw = _parse_yaml(manifest_path, issues)
|
|
702
|
+
model: PackManifest | None = None
|
|
703
|
+
if raw is not None:
|
|
704
|
+
issues.extend(_non_string_key_issues(raw, manifest_path))
|
|
705
|
+
try:
|
|
706
|
+
model = PackManifest.model_validate(_to_plain(raw))
|
|
707
|
+
except ValidationError as exc:
|
|
708
|
+
issues.extend(_validation_issues(exc, raw, manifest_path))
|
|
709
|
+
issues.extend(_pack_fs_issues(folder, root, manifest_path, raw))
|
|
710
|
+
|
|
711
|
+
if issues or model is None:
|
|
712
|
+
raise ManifestError(_sorted(issues))
|
|
713
|
+
return Pack(root=root, manifest_path=root / PACK_MANIFEST, manifest=model)
|