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.
@@ -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