pl-e2e-test 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.
- pl_e2e_test-0.1.0.dist-info/METADATA +322 -0
- pl_e2e_test-0.1.0.dist-info/RECORD +27 -0
- pl_e2e_test-0.1.0.dist-info/WHEEL +5 -0
- pl_e2e_test-0.1.0.dist-info/entry_points.txt +5 -0
- pl_e2e_test-0.1.0.dist-info/licenses/LICENSE +21 -0
- pl_e2e_test-0.1.0.dist-info/top_level.txt +1 -0
- prairielearn_e2e/__init__.py +18 -0
- prairielearn_e2e/__main__.py +3 -0
- prairielearn_e2e/_git.py +167 -0
- prairielearn_e2e/backend.py +168 -0
- prairielearn_e2e/config.json +7 -0
- prairielearn_e2e/config.py +30 -0
- prairielearn_e2e/contract.py +199 -0
- prairielearn_e2e/discovery.py +212 -0
- prairielearn_e2e/errors.py +9 -0
- prairielearn_e2e/fuzz.py +293 -0
- prairielearn_e2e/harness.py +457 -0
- prairielearn_e2e/html_checks.py +133 -0
- prairielearn_e2e/metadata.py +56 -0
- prairielearn_e2e/pytest_plugin.py +335 -0
- prairielearn_e2e/question_tests.py +68 -0
- prairielearn_e2e/regression.py +128 -0
- prairielearn_e2e/reporting.py +675 -0
- prairielearn_e2e/repository.py +298 -0
- prairielearn_e2e/runner.py +28 -0
- prairielearn_e2e/server.py +128 -0
- prairielearn_e2e/sharding.py +46 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Pytest integration for installed PrairieLearn question lifecycle tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
import shlex
|
|
7
|
+
import sys
|
|
8
|
+
import warnings
|
|
9
|
+
from collections.abc import Hashable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, cast
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from .backend import PrairieLearnBackend
|
|
16
|
+
from .discovery import QuestionCase, discover_questions, variant_seeds
|
|
17
|
+
from .errors import HarnessError
|
|
18
|
+
from .reporting import ShardReportRecorder, serialize_pytest_report
|
|
19
|
+
from .sharding import Shard, parse_shard, selected_group_keys
|
|
20
|
+
|
|
21
|
+
_REPORTER_KEY: pytest.StashKey[ShardReportRecorder] = pytest.StashKey()
|
|
22
|
+
_EMPTY_SHARD_KEY: pytest.StashKey[bool] = pytest.StashKey()
|
|
23
|
+
_active_recorder: ShardReportRecorder | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
27
|
+
group = parser.getgroup("prairielearn-e2e")
|
|
28
|
+
group.addoption(
|
|
29
|
+
"--course-root",
|
|
30
|
+
metavar="PATH",
|
|
31
|
+
help="PrairieLearn course root (default: current working directory)",
|
|
32
|
+
)
|
|
33
|
+
group.addoption(
|
|
34
|
+
"--question-path",
|
|
35
|
+
action="append",
|
|
36
|
+
default=[],
|
|
37
|
+
metavar="PATH",
|
|
38
|
+
help="info.json, question directory, or parent directory (repeatable)",
|
|
39
|
+
)
|
|
40
|
+
group.addoption(
|
|
41
|
+
"--prairielearn-path",
|
|
42
|
+
metavar="PATH",
|
|
43
|
+
help=(
|
|
44
|
+
"PrairieLearn Git repository containing the installed pinned revision "
|
|
45
|
+
"(default: managed user cache)"
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
group.addoption(
|
|
49
|
+
"--seed-count",
|
|
50
|
+
type=int,
|
|
51
|
+
default=3,
|
|
52
|
+
metavar="N",
|
|
53
|
+
help="number of variant seeds to test (default: 3)",
|
|
54
|
+
)
|
|
55
|
+
group.addoption(
|
|
56
|
+
"--fuzz-seed",
|
|
57
|
+
type=int,
|
|
58
|
+
metavar="N",
|
|
59
|
+
help="master seed used to generate reproducible pseudo-random variant seeds",
|
|
60
|
+
)
|
|
61
|
+
group.addoption(
|
|
62
|
+
"--fuzz-seeds",
|
|
63
|
+
action="store_true",
|
|
64
|
+
help="generate a master seed when --fuzz-seed is not provided",
|
|
65
|
+
)
|
|
66
|
+
group.addoption(
|
|
67
|
+
"--seed-replay-command",
|
|
68
|
+
default="pl-e2e-test",
|
|
69
|
+
metavar="COMMAND",
|
|
70
|
+
help="command shown in fuzz-seed replay instructions",
|
|
71
|
+
)
|
|
72
|
+
group.addoption(
|
|
73
|
+
"--shard",
|
|
74
|
+
type=parse_shard,
|
|
75
|
+
metavar="INDEX/COUNT",
|
|
76
|
+
help="run one one-based shard of the selected question variants",
|
|
77
|
+
)
|
|
78
|
+
group.addoption(
|
|
79
|
+
"--shard-report",
|
|
80
|
+
metavar="PATH",
|
|
81
|
+
help="write a mergeable JSON result blob for this shard",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _course_root(config: pytest.Config) -> Path:
|
|
86
|
+
configured = cast(str | None, config.getoption("course_root"))
|
|
87
|
+
return Path(configured or Path.cwd()).resolve()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
91
|
+
global _active_recorder
|
|
92
|
+
|
|
93
|
+
for marker, description in (
|
|
94
|
+
("generate", "question generate-phase lifecycle checks"),
|
|
95
|
+
("prepare", "question prepare-phase lifecycle checks"),
|
|
96
|
+
("render", "question render-phase lifecycle checks"),
|
|
97
|
+
("grade", "question grading lifecycle checks"),
|
|
98
|
+
):
|
|
99
|
+
config.addinivalue_line("markers", f"{marker}: {description}")
|
|
100
|
+
|
|
101
|
+
fuzz_seeds = cast(bool, config.getoption("fuzz_seeds"))
|
|
102
|
+
fuzz_seed = cast(int | None, config.getoption("fuzz_seed"))
|
|
103
|
+
shard = cast(Shard | None, config.getoption("shard"))
|
|
104
|
+
shard_report = cast(str | None, config.getoption("shard_report"))
|
|
105
|
+
if shard_report is not None and shard is None:
|
|
106
|
+
raise pytest.UsageError("--shard-report requires --shard")
|
|
107
|
+
if shard is not None and fuzz_seeds and fuzz_seed is None:
|
|
108
|
+
raise pytest.UsageError(
|
|
109
|
+
"sharded fuzz runs require an explicit shared --fuzz-seed"
|
|
110
|
+
)
|
|
111
|
+
if fuzz_seeds and fuzz_seed is None:
|
|
112
|
+
config.option.fuzz_seed = secrets.randbits(64)
|
|
113
|
+
fuzz_seed = cast(int, config.option.fuzz_seed)
|
|
114
|
+
if shard_report is not None:
|
|
115
|
+
report_path = Path(shard_report)
|
|
116
|
+
try:
|
|
117
|
+
report_path.unlink(missing_ok=True)
|
|
118
|
+
except OSError as exc:
|
|
119
|
+
raise pytest.UsageError(
|
|
120
|
+
f"unable to replace shard report {report_path}: {exc}"
|
|
121
|
+
) from exc
|
|
122
|
+
recorder = ShardReportRecorder(
|
|
123
|
+
path=report_path, shard=cast(Shard, shard), fuzz_seed=fuzz_seed
|
|
124
|
+
)
|
|
125
|
+
config.stash[_REPORTER_KEY] = recorder
|
|
126
|
+
_active_recorder = recorder
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def pytest_report_header(config: pytest.Config) -> str | None:
|
|
130
|
+
paths = cast(list[str], config.getoption("question_path"))
|
|
131
|
+
fuzz_seeds = cast(bool, config.getoption("fuzz_seeds"))
|
|
132
|
+
fuzz_seed = cast(int | None, config.getoption("fuzz_seed"))
|
|
133
|
+
replay_command = cast(str, config.getoption("seed_replay_command"))
|
|
134
|
+
prairielearn_path = cast(str | None, config.getoption("prairielearn_path"))
|
|
135
|
+
shard = cast(Shard | None, config.getoption("shard"))
|
|
136
|
+
if not paths and shard is None:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
lines: list[str] = []
|
|
140
|
+
if shard is not None:
|
|
141
|
+
lines.append(f"E2E shard: {shard}")
|
|
142
|
+
if paths:
|
|
143
|
+
seed_count = cast(int, config.getoption("seed_count"))
|
|
144
|
+
try:
|
|
145
|
+
seeds = variant_seeds({}, seed_count, fuzz_seed)
|
|
146
|
+
except ValueError:
|
|
147
|
+
return "\n".join(lines) or None
|
|
148
|
+
seed_list = f"E2E variant seeds: {', '.join(str(seed) for seed in seeds)}"
|
|
149
|
+
if fuzz_seed is None:
|
|
150
|
+
lines.append(seed_list)
|
|
151
|
+
elif fuzz_seeds:
|
|
152
|
+
replay = f"{replay_command} --fuzz-seed {fuzz_seed}"
|
|
153
|
+
if prairielearn_path is not None:
|
|
154
|
+
replay += f" --prairielearn-path {shlex.quote(prairielearn_path)}"
|
|
155
|
+
if shard is not None:
|
|
156
|
+
replay += f" --shard {shard}"
|
|
157
|
+
lines.extend(
|
|
158
|
+
(
|
|
159
|
+
f"E2E fuzz seed: {fuzz_seed}",
|
|
160
|
+
seed_list,
|
|
161
|
+
f"Replay with: {replay}",
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
return "\n".join(lines) or None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _cases(config: pytest.Config) -> list[tuple[QuestionCase | None, int]]:
|
|
168
|
+
paths = cast(list[str], config.getoption("question_path"))
|
|
169
|
+
if not paths:
|
|
170
|
+
return [(None, 0)]
|
|
171
|
+
seed_count = cast(int, config.getoption("seed_count"))
|
|
172
|
+
fuzz_seed = cast(int | None, config.getoption("fuzz_seed"))
|
|
173
|
+
if seed_count < 1:
|
|
174
|
+
raise pytest.UsageError("--seed-count must be at least 1")
|
|
175
|
+
try:
|
|
176
|
+
questions = discover_questions(paths, _course_root(config))
|
|
177
|
+
except (HarnessError, ValueError) as exc:
|
|
178
|
+
raise pytest.UsageError(str(exc)) from exc
|
|
179
|
+
|
|
180
|
+
result: list[tuple[QuestionCase | None, int]] = []
|
|
181
|
+
for question in questions:
|
|
182
|
+
try:
|
|
183
|
+
metadata: dict[str, Any] = question.unvalidated_metadata()
|
|
184
|
+
except HarnessError:
|
|
185
|
+
metadata = {}
|
|
186
|
+
try:
|
|
187
|
+
e2e_config = question.e2e_config()
|
|
188
|
+
seeds = variant_seeds(
|
|
189
|
+
metadata,
|
|
190
|
+
seed_count,
|
|
191
|
+
fuzz_seed,
|
|
192
|
+
regression_seeds=e2e_config.regression_seeds,
|
|
193
|
+
)
|
|
194
|
+
except HarnessError as exc:
|
|
195
|
+
raise pytest.UsageError(str(exc)) from exc
|
|
196
|
+
except ValueError as exc:
|
|
197
|
+
raise pytest.UsageError(f"{question.qid}: {exc}") from exc
|
|
198
|
+
result.extend((question, seed) for seed in seeds)
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
|
|
203
|
+
if "question_variant" not in metafunc.fixturenames:
|
|
204
|
+
return
|
|
205
|
+
cases = _cases(metafunc.config)
|
|
206
|
+
ids = [
|
|
207
|
+
"no-question-paths" if question is None else f"{question.qid}[seed={seed}]"
|
|
208
|
+
for question, seed in cases
|
|
209
|
+
]
|
|
210
|
+
metafunc.parametrize("question_variant", cases, ids=ids)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _shard_group(item: pytest.Item) -> Hashable:
|
|
214
|
+
callspec = getattr(item, "callspec", None)
|
|
215
|
+
if callspec is None:
|
|
216
|
+
return ("item", item.nodeid)
|
|
217
|
+
question_variant = callspec.params.get("question_variant")
|
|
218
|
+
if not (
|
|
219
|
+
isinstance(question_variant, tuple)
|
|
220
|
+
and len(question_variant) == 2
|
|
221
|
+
and isinstance(question_variant[1], int)
|
|
222
|
+
):
|
|
223
|
+
return ("item", item.nodeid)
|
|
224
|
+
question, seed = question_variant
|
|
225
|
+
qid = None if question is None else question.qid
|
|
226
|
+
return ("question-variant", qid, seed)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@pytest.hookimpl(trylast=True)
|
|
230
|
+
def pytest_collection_modifyitems(
|
|
231
|
+
config: pytest.Config, items: list[pytest.Item]
|
|
232
|
+
) -> None:
|
|
233
|
+
shard = cast(Shard | None, config.getoption("shard"))
|
|
234
|
+
if shard is None:
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
full_items = list(items)
|
|
238
|
+
keys = [_shard_group(item) for item in full_items]
|
|
239
|
+
selected_keys = selected_group_keys(keys, shard)
|
|
240
|
+
selected = [
|
|
241
|
+
item for item, key in zip(full_items, keys, strict=True) if key in selected_keys
|
|
242
|
+
]
|
|
243
|
+
deselected = [item for item in full_items if item not in selected]
|
|
244
|
+
items[:] = selected
|
|
245
|
+
if deselected:
|
|
246
|
+
config.hook.pytest_deselected(items=deselected)
|
|
247
|
+
config.stash[_EMPTY_SHARD_KEY] = bool(full_items) and not selected
|
|
248
|
+
|
|
249
|
+
recorder = config.stash.get(_REPORTER_KEY, None)
|
|
250
|
+
if recorder is not None:
|
|
251
|
+
recorder.set_collection(
|
|
252
|
+
[item.nodeid for item in full_items], [item.nodeid for item in selected]
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def pytest_runtest_logreport(report: pytest.TestReport) -> None:
|
|
257
|
+
if _active_recorder is not None:
|
|
258
|
+
_active_recorder.reports.append(serialize_pytest_report(report))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def pytest_collectreport(report: pytest.CollectReport) -> None:
|
|
262
|
+
if _active_recorder is not None and report.failed:
|
|
263
|
+
_active_recorder.collection_errors.append(serialize_pytest_report(report))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def pytest_warning_recorded(
|
|
267
|
+
warning_message: warnings.WarningMessage,
|
|
268
|
+
when: str,
|
|
269
|
+
nodeid: str,
|
|
270
|
+
location: tuple[str, int, str] | None,
|
|
271
|
+
) -> None:
|
|
272
|
+
if _active_recorder is not None:
|
|
273
|
+
message = warning_message.message
|
|
274
|
+
_active_recorder.warnings.append(
|
|
275
|
+
{
|
|
276
|
+
"message": str(message),
|
|
277
|
+
"category": type(message).__name__,
|
|
278
|
+
"when": when,
|
|
279
|
+
"nodeid": nodeid,
|
|
280
|
+
"location": list(location) if location is not None else None,
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def pytest_internalerror(excrepr: object, excinfo: object) -> None:
|
|
286
|
+
del excinfo
|
|
287
|
+
if _active_recorder is not None:
|
|
288
|
+
_active_recorder.internal_errors.append(str(excrepr))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def pytest_keyboard_interrupt(excinfo: object) -> None:
|
|
292
|
+
if _active_recorder is not None:
|
|
293
|
+
_active_recorder.interrupted.append(str(excinfo))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@pytest.hookimpl(trylast=True)
|
|
297
|
+
def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
|
|
298
|
+
if (
|
|
299
|
+
session.config.stash.get(_EMPTY_SHARD_KEY, False)
|
|
300
|
+
and exitstatus == pytest.ExitCode.NO_TESTS_COLLECTED
|
|
301
|
+
):
|
|
302
|
+
session.exitstatus = pytest.ExitCode.OK
|
|
303
|
+
recorder = session.config.stash.get(_REPORTER_KEY, None)
|
|
304
|
+
if recorder is None:
|
|
305
|
+
return
|
|
306
|
+
if not recorder.fingerprint:
|
|
307
|
+
recorder.set_collection((), ())
|
|
308
|
+
try:
|
|
309
|
+
recorder.write(int(session.exitstatus))
|
|
310
|
+
except OSError as exc:
|
|
311
|
+
session.exitstatus = pytest.ExitCode.INTERNAL_ERROR
|
|
312
|
+
print(
|
|
313
|
+
f"ERROR: unable to write shard report {recorder.path}: {exc}",
|
|
314
|
+
file=sys.stderr,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def pytest_unconfigure(config: pytest.Config) -> None:
|
|
319
|
+
global _active_recorder
|
|
320
|
+
|
|
321
|
+
recorder = config.stash.get(_REPORTER_KEY, None)
|
|
322
|
+
if recorder is _active_recorder:
|
|
323
|
+
_active_recorder = None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@pytest.fixture(scope="session")
|
|
327
|
+
def prairielearn_backend(
|
|
328
|
+
request: pytest.FixtureRequest, tmp_path_factory: pytest.TempPathFactory
|
|
329
|
+
) -> PrairieLearnBackend:
|
|
330
|
+
clone = cast(str | None, request.config.getoption("prairielearn_path"))
|
|
331
|
+
return PrairieLearnBackend.from_environment(
|
|
332
|
+
_course_root(request.config),
|
|
333
|
+
tmp_path_factory.mktemp("prairielearn-core"),
|
|
334
|
+
clone,
|
|
335
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Installed pytest cases for the PrairieLearn question lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from .backend import PrairieLearnBackend
|
|
10
|
+
from .discovery import LifecycleMethod, QuestionCase
|
|
11
|
+
from .errors import UnsupportedQuestion
|
|
12
|
+
from .harness import QuestionHarness
|
|
13
|
+
|
|
14
|
+
type LifecycleCheck = Callable[[QuestionHarness, int], object]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _run_check(
|
|
18
|
+
question_variant: tuple[QuestionCase | None, int],
|
|
19
|
+
request: pytest.FixtureRequest,
|
|
20
|
+
method: LifecycleMethod,
|
|
21
|
+
check: LifecycleCheck,
|
|
22
|
+
) -> None:
|
|
23
|
+
question, seed = question_variant
|
|
24
|
+
if question is None:
|
|
25
|
+
pytest.skip("pass one or more --question-path values to select content")
|
|
26
|
+
assert question is not None
|
|
27
|
+
if reason := question.e2e_skip_reason(method):
|
|
28
|
+
pytest.skip(reason)
|
|
29
|
+
backend: PrairieLearnBackend = request.getfixturevalue("prairielearn_backend")
|
|
30
|
+
try:
|
|
31
|
+
check(QuestionHarness(question, backend), seed)
|
|
32
|
+
except UnsupportedQuestion as exc:
|
|
33
|
+
pytest.skip(str(exc))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.generate
|
|
37
|
+
def test_question_generate(
|
|
38
|
+
question_variant: tuple[QuestionCase | None, int],
|
|
39
|
+
request: pytest.FixtureRequest,
|
|
40
|
+
) -> None:
|
|
41
|
+
_run_check(question_variant, request, "generate", QuestionHarness.run_generate)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.prepare
|
|
45
|
+
def test_question_prepare(
|
|
46
|
+
question_variant: tuple[QuestionCase | None, int],
|
|
47
|
+
request: pytest.FixtureRequest,
|
|
48
|
+
) -> None:
|
|
49
|
+
_run_check(question_variant, request, "prepare", QuestionHarness.run_prepare)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.render
|
|
53
|
+
def test_question_render(
|
|
54
|
+
question_variant: tuple[QuestionCase | None, int],
|
|
55
|
+
request: pytest.FixtureRequest,
|
|
56
|
+
) -> None:
|
|
57
|
+
_run_check(question_variant, request, "render", QuestionHarness.run_render)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.grade
|
|
61
|
+
def test_question_grade(
|
|
62
|
+
question_variant: tuple[QuestionCase | None, int],
|
|
63
|
+
request: pytest.FixtureRequest,
|
|
64
|
+
) -> None:
|
|
65
|
+
question, _ = question_variant
|
|
66
|
+
if question is not None and question.is_manual_grading():
|
|
67
|
+
pytest.skip("question is configured for manual grading")
|
|
68
|
+
_run_check(question_variant, request, "grade", QuestionHarness.run_grade)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Record a concrete variant seed in a question's regression corpus."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _git_root(start: Path) -> Path:
|
|
16
|
+
result = subprocess.run(
|
|
17
|
+
["git", "-C", os.fspath(start), "rev-parse", "--show-toplevel"],
|
|
18
|
+
check=True,
|
|
19
|
+
capture_output=True,
|
|
20
|
+
text=True,
|
|
21
|
+
)
|
|
22
|
+
return Path(result.stdout.strip()).resolve()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _variant_seed(value: str) -> int:
|
|
26
|
+
if (
|
|
27
|
+
not value.isascii()
|
|
28
|
+
or not value.isdecimal()
|
|
29
|
+
or (len(value) > 1 and value.startswith("0"))
|
|
30
|
+
):
|
|
31
|
+
raise argparse.ArgumentTypeError("must be an integer between 0 and 4294967295")
|
|
32
|
+
seed = int(value)
|
|
33
|
+
if seed >= 2**32:
|
|
34
|
+
raise argparse.ArgumentTypeError("must be an integer between 0 and 4294967295")
|
|
35
|
+
return seed
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def add_regression_seed(course_root: Path, question_path: Path, seed: int) -> bool:
|
|
39
|
+
"""Add *seed* atomically, returning whether the config changed."""
|
|
40
|
+
|
|
41
|
+
root = course_root.resolve()
|
|
42
|
+
question = question_path if question_path.is_absolute() else root / question_path
|
|
43
|
+
question = question.resolve()
|
|
44
|
+
questions_root = (root / "questions").resolve()
|
|
45
|
+
try:
|
|
46
|
+
question.relative_to(questions_root)
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"QUESTION_PATH must be a directory beneath questions/: {question_path}"
|
|
50
|
+
) from exc
|
|
51
|
+
if not question.is_dir():
|
|
52
|
+
raise ValueError(f"Question directory does not exist: {question_path}")
|
|
53
|
+
if not (question / "info.json").is_file():
|
|
54
|
+
raise ValueError(f"Question directory has no info.json: {question_path}")
|
|
55
|
+
|
|
56
|
+
config_path = question / ".question-e2e.json"
|
|
57
|
+
if config_path.is_file():
|
|
58
|
+
try:
|
|
59
|
+
config: Any = json.loads(config_path.read_text(encoding="utf-8"))
|
|
60
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
61
|
+
raise ValueError(f"Invalid E2E config {config_path}: {exc}") from exc
|
|
62
|
+
if not isinstance(config, dict):
|
|
63
|
+
raise ValueError(f"E2E config must contain a JSON object: {config_path}")
|
|
64
|
+
else:
|
|
65
|
+
config = {}
|
|
66
|
+
|
|
67
|
+
raw_seeds = config.get("regression_seeds", [])
|
|
68
|
+
if not isinstance(raw_seeds, list) or any(
|
|
69
|
+
isinstance(item, bool) or not isinstance(item, int) for item in raw_seeds
|
|
70
|
+
):
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"regression_seeds must be an array of integers: {config_path}"
|
|
73
|
+
)
|
|
74
|
+
if seed in raw_seeds:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
config["regression_seeds"] = sorted({*raw_seeds, seed})
|
|
78
|
+
temporary_path: Path | None = None
|
|
79
|
+
try:
|
|
80
|
+
with tempfile.NamedTemporaryFile(
|
|
81
|
+
"w",
|
|
82
|
+
encoding="utf-8",
|
|
83
|
+
dir=question,
|
|
84
|
+
prefix=".question-e2e.json.tmp.",
|
|
85
|
+
delete=False,
|
|
86
|
+
) as temporary:
|
|
87
|
+
json.dump(config, temporary, indent=2)
|
|
88
|
+
temporary.write("\n")
|
|
89
|
+
temporary_path = Path(temporary.name)
|
|
90
|
+
temporary_path.chmod(0o644)
|
|
91
|
+
temporary_path.replace(config_path)
|
|
92
|
+
finally:
|
|
93
|
+
if temporary_path is not None:
|
|
94
|
+
temporary_path.unlink(missing_ok=True)
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
99
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
100
|
+
parser.add_argument("question_path", type=Path)
|
|
101
|
+
parser.add_argument("variant_seed", type=_variant_seed)
|
|
102
|
+
parser.add_argument("--course-root", type=Path)
|
|
103
|
+
args = parser.parse_args(argv)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
course_root = (
|
|
107
|
+
args.course_root.resolve()
|
|
108
|
+
if args.course_root is not None
|
|
109
|
+
else _git_root(Path.cwd())
|
|
110
|
+
)
|
|
111
|
+
changed = add_regression_seed(
|
|
112
|
+
course_root, args.question_path, args.variant_seed
|
|
113
|
+
)
|
|
114
|
+
except (OSError, subprocess.CalledProcessError, ValueError) as exc:
|
|
115
|
+
parser.error(str(exc))
|
|
116
|
+
|
|
117
|
+
config_path = (
|
|
118
|
+
args.question_path / ".question-e2e.json"
|
|
119
|
+
if not args.question_path.is_absolute()
|
|
120
|
+
else args.question_path.resolve() / ".question-e2e.json"
|
|
121
|
+
)
|
|
122
|
+
if changed:
|
|
123
|
+
print(f"Added regression seed {args.variant_seed} to {config_path}")
|
|
124
|
+
else:
|
|
125
|
+
print(
|
|
126
|
+
f"Regression seed {args.variant_seed} is already recorded in {config_path}"
|
|
127
|
+
)
|
|
128
|
+
return 0
|