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,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
from prairielearn.internal import question_phases
|
|
11
|
+
from prairielearn.internal.question_phases import ElementInfo, RenderContext
|
|
12
|
+
|
|
13
|
+
from .contract import Phase, snapshot, validate_mutations, validate_serializable
|
|
14
|
+
from .errors import HarnessError
|
|
15
|
+
from .metadata import load_json
|
|
16
|
+
from .repository import (
|
|
17
|
+
PrairieLearnRepository,
|
|
18
|
+
installed_prairielearn_source,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_elements(root: Path, element_type: str) -> dict[str, ElementInfo]:
|
|
23
|
+
result: dict[str, ElementInfo] = {}
|
|
24
|
+
if not root.is_dir():
|
|
25
|
+
return result
|
|
26
|
+
for directory in sorted(root.iterdir()):
|
|
27
|
+
info_path = directory / "info.json"
|
|
28
|
+
if not directory.is_dir() or not info_path.is_file():
|
|
29
|
+
continue
|
|
30
|
+
raw = load_json(info_path)
|
|
31
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("controller"), str):
|
|
32
|
+
raise HarnessError(f"{info_path}: element info must declare a controller")
|
|
33
|
+
info = cast(
|
|
34
|
+
ElementInfo,
|
|
35
|
+
{
|
|
36
|
+
"name": directory.name,
|
|
37
|
+
"controller": raw["controller"],
|
|
38
|
+
"type": element_type,
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
result[directory.name] = info
|
|
42
|
+
if element_type == "core":
|
|
43
|
+
result[directory.name.replace("-", "_")] = info
|
|
44
|
+
for alias in raw.get("additionalNames", []):
|
|
45
|
+
if not isinstance(alias, str):
|
|
46
|
+
raise HarnessError(
|
|
47
|
+
f"{info_path}: additionalNames must contain strings"
|
|
48
|
+
)
|
|
49
|
+
result[alias] = info
|
|
50
|
+
result[alias.replace("-", "_")] = info
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _load_extensions(course_root: Path) -> dict[str, dict[str, dict[Any, Any]]]:
|
|
55
|
+
result: dict[str, dict[str, dict[Any, Any]]] = {}
|
|
56
|
+
root = course_root / "elementExtensions"
|
|
57
|
+
if not root.is_dir():
|
|
58
|
+
return result
|
|
59
|
+
for info_path in sorted(root.glob("*/*/info.json")):
|
|
60
|
+
raw = load_json(info_path)
|
|
61
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("controller"), str):
|
|
62
|
+
raise HarnessError(f"{info_path}: extension info must declare a controller")
|
|
63
|
+
element_name = info_path.parent.parent.name
|
|
64
|
+
extension_name = info_path.parent.name
|
|
65
|
+
result.setdefault(element_name, {})[extension_name] = {
|
|
66
|
+
"name": extension_name,
|
|
67
|
+
"directory": str(info_path.parent),
|
|
68
|
+
**raw,
|
|
69
|
+
}
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PrairieLearnBackend:
|
|
74
|
+
"""Real PrairieLearn Python element phases backed by a pinned clone archive."""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
course_root: Path,
|
|
80
|
+
core_elements_path: Path,
|
|
81
|
+
commit: str,
|
|
82
|
+
) -> None:
|
|
83
|
+
self.course_root = course_root.resolve()
|
|
84
|
+
self.core_elements_path = core_elements_path.resolve()
|
|
85
|
+
self.commit = commit
|
|
86
|
+
core = _load_elements(self.core_elements_path, "core")
|
|
87
|
+
course = _load_elements(self.course_root / "elements", "course")
|
|
88
|
+
self.elements = {**core, **course}
|
|
89
|
+
self.element_extensions = _load_extensions(self.course_root)
|
|
90
|
+
question_phases.CORE_ELEMENTS_PATH = self.core_elements_path
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_clone(
|
|
94
|
+
cls,
|
|
95
|
+
clone_path: str | Path,
|
|
96
|
+
course_root: str | Path,
|
|
97
|
+
materialize_root: str | Path,
|
|
98
|
+
) -> PrairieLearnBackend:
|
|
99
|
+
root = Path(course_root).resolve()
|
|
100
|
+
destination = Path(materialize_root).resolve()
|
|
101
|
+
source = installed_prairielearn_source(root)
|
|
102
|
+
elements = PrairieLearnRepository(source).materialize_clone(
|
|
103
|
+
clone_path, destination
|
|
104
|
+
)
|
|
105
|
+
return cls(course_root=root, core_elements_path=elements, commit=source.commit)
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_environment(
|
|
109
|
+
cls,
|
|
110
|
+
course_root: str | Path,
|
|
111
|
+
materialize_root: str | Path,
|
|
112
|
+
prairielearn_path: str | Path | None = None,
|
|
113
|
+
) -> PrairieLearnBackend:
|
|
114
|
+
"""Build a backend from an override repository or the managed cache."""
|
|
115
|
+
|
|
116
|
+
root = Path(course_root).resolve()
|
|
117
|
+
destination = Path(materialize_root).resolve()
|
|
118
|
+
source = installed_prairielearn_source(root)
|
|
119
|
+
elements = PrairieLearnRepository(source).materialize(
|
|
120
|
+
destination, prairielearn_path
|
|
121
|
+
)
|
|
122
|
+
return cls(course_root=root, core_elements_path=elements, commit=source.commit)
|
|
123
|
+
|
|
124
|
+
def context(self, html: str) -> RenderContext:
|
|
125
|
+
return {
|
|
126
|
+
"html": html,
|
|
127
|
+
"elements": self.elements,
|
|
128
|
+
"element_extensions": self.element_extensions,
|
|
129
|
+
"course_path": str(self.course_root),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
def process(
|
|
133
|
+
self, phase: Phase, html: str, data: dict[str, Any]
|
|
134
|
+
) -> tuple[str | None, set[str]]:
|
|
135
|
+
before = snapshot(data, phase)
|
|
136
|
+
old_cwd = Path.cwd()
|
|
137
|
+
old_path = sys.path.copy()
|
|
138
|
+
stdout = io.StringIO()
|
|
139
|
+
stderr = io.StringIO()
|
|
140
|
+
try:
|
|
141
|
+
with redirect_stdout(stdout), redirect_stderr(stderr):
|
|
142
|
+
result, processed = question_phases.process(
|
|
143
|
+
phase, data, self.context(html)
|
|
144
|
+
)
|
|
145
|
+
finally:
|
|
146
|
+
os.chdir(old_cwd)
|
|
147
|
+
sys.path[:] = old_path
|
|
148
|
+
output = stdout.getvalue() + stderr.getvalue()
|
|
149
|
+
if output:
|
|
150
|
+
raise HarnessError(
|
|
151
|
+
f"{phase}: PrairieLearn element emitted unexpected console output: {output!r}"
|
|
152
|
+
)
|
|
153
|
+
validate_mutations(before, data, phase)
|
|
154
|
+
validate_serializable(data, phase)
|
|
155
|
+
return result, processed
|
|
156
|
+
|
|
157
|
+
def unresolved_elements(self, html: str) -> set[str]:
|
|
158
|
+
from lxml import html as lxml_html
|
|
159
|
+
|
|
160
|
+
fragments = lxml_html.fragments_fromstring(html)
|
|
161
|
+
registered = set(self.elements)
|
|
162
|
+
return {
|
|
163
|
+
node.tag
|
|
164
|
+
for fragment in fragments
|
|
165
|
+
if not isinstance(fragment, str)
|
|
166
|
+
for node in fragment.iter()
|
|
167
|
+
if isinstance(node.tag, str) and node.tag in registered
|
|
168
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Load declarative configuration for the question harness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
_CONFIG_PATH = Path(__file__).with_name("config.json")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _load_config() -> dict[str, Any]:
|
|
13
|
+
with _CONFIG_PATH.open(encoding="utf-8") as config_file:
|
|
14
|
+
config = json.load(config_file)
|
|
15
|
+
if not isinstance(config, dict):
|
|
16
|
+
raise TypeError("e2e-tests config must be a JSON object")
|
|
17
|
+
return config
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_CONFIG = _load_config()
|
|
21
|
+
ELEMENT_SOLUTION_ANSWER_SELECTORS: tuple[str, ...] = tuple(
|
|
22
|
+
selector
|
|
23
|
+
for selector in _CONFIG.get("element_solution_answer_selectors", [])
|
|
24
|
+
if isinstance(selector, str) and selector.strip()
|
|
25
|
+
)
|
|
26
|
+
NON_GRADABLE_ANSWER_SELECTORS: tuple[str, ...] = tuple(
|
|
27
|
+
selector
|
|
28
|
+
for selector in _CONFIG.get("non_gradable_answer_selectors", [])
|
|
29
|
+
if isinstance(selector, str) and selector.strip()
|
|
30
|
+
)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from prairielearn.internal.zygote_utils import assert_all_integers_within_limits
|
|
10
|
+
|
|
11
|
+
from .errors import HarnessError
|
|
12
|
+
|
|
13
|
+
type Phase = Literal["generate", "prepare", "render", "parse", "grade", "test", "file"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class Field:
|
|
18
|
+
kind: Literal["dict", "integer", "number", "string", "boolean"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
BASE_FIELDS = {
|
|
22
|
+
"params": Field("dict"),
|
|
23
|
+
"correct_answers": Field("dict"),
|
|
24
|
+
"variant_seed": Field("integer"),
|
|
25
|
+
"options": Field("dict"),
|
|
26
|
+
"preferences": Field("dict"),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
PHASE_FIELDS: dict[Phase, dict[str, Field]] = {
|
|
30
|
+
"generate": BASE_FIELDS,
|
|
31
|
+
"prepare": {**BASE_FIELDS, "answers_names": Field("dict")},
|
|
32
|
+
"render": {
|
|
33
|
+
**BASE_FIELDS,
|
|
34
|
+
"submitted_answers": Field("dict"),
|
|
35
|
+
"format_errors": Field("dict"),
|
|
36
|
+
"raw_submitted_answers": Field("dict"),
|
|
37
|
+
"partial_scores": Field("dict"),
|
|
38
|
+
"score": Field("number"),
|
|
39
|
+
"feedback": Field("dict"),
|
|
40
|
+
"editable": Field("boolean"),
|
|
41
|
+
"manual_grading": Field("boolean"),
|
|
42
|
+
"ai_grading": Field("boolean"),
|
|
43
|
+
"panel": Field("string"),
|
|
44
|
+
"correct_answer_shown": Field("boolean"),
|
|
45
|
+
"num_valid_submissions": Field("integer"),
|
|
46
|
+
},
|
|
47
|
+
"parse": {
|
|
48
|
+
**BASE_FIELDS,
|
|
49
|
+
"submitted_answers": Field("dict"),
|
|
50
|
+
"format_errors": Field("dict"),
|
|
51
|
+
"raw_submitted_answers": Field("dict"),
|
|
52
|
+
"feedback": Field("dict"),
|
|
53
|
+
"gradable": Field("boolean"),
|
|
54
|
+
},
|
|
55
|
+
"grade": {
|
|
56
|
+
**BASE_FIELDS,
|
|
57
|
+
"submitted_answers": Field("dict"),
|
|
58
|
+
"format_errors": Field("dict"),
|
|
59
|
+
"raw_submitted_answers": Field("dict"),
|
|
60
|
+
"partial_scores": Field("dict"),
|
|
61
|
+
"score": Field("number"),
|
|
62
|
+
"feedback": Field("dict"),
|
|
63
|
+
"gradable": Field("boolean"),
|
|
64
|
+
},
|
|
65
|
+
"test": {
|
|
66
|
+
**BASE_FIELDS,
|
|
67
|
+
"format_errors": Field("dict"),
|
|
68
|
+
"raw_submitted_answers": Field("dict"),
|
|
69
|
+
"partial_scores": Field("dict"),
|
|
70
|
+
"score": Field("number"),
|
|
71
|
+
"feedback": Field("dict"),
|
|
72
|
+
"gradable": Field("boolean"),
|
|
73
|
+
"test_type": Field("string"),
|
|
74
|
+
},
|
|
75
|
+
"file": {
|
|
76
|
+
**BASE_FIELDS,
|
|
77
|
+
"submitted_answers": Field("dict"),
|
|
78
|
+
"format_errors": Field("dict"),
|
|
79
|
+
"raw_submitted_answers": Field("dict"),
|
|
80
|
+
"partial_scores": Field("dict"),
|
|
81
|
+
"score": Field("number"),
|
|
82
|
+
"feedback": Field("dict"),
|
|
83
|
+
"num_valid_submissions": Field("integer"),
|
|
84
|
+
"filename": Field("string"),
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
EDITABLE_FIELDS: dict[Phase, frozenset[str]] = {
|
|
89
|
+
"generate": frozenset({"params", "correct_answers"}),
|
|
90
|
+
"prepare": frozenset({"params", "correct_answers", "answers_names"}),
|
|
91
|
+
"render": frozenset(),
|
|
92
|
+
"parse": frozenset(
|
|
93
|
+
{"params", "correct_answers", "submitted_answers", "format_errors", "feedback"}
|
|
94
|
+
),
|
|
95
|
+
"grade": frozenset(
|
|
96
|
+
{
|
|
97
|
+
"params",
|
|
98
|
+
"correct_answers",
|
|
99
|
+
"submitted_answers",
|
|
100
|
+
"format_errors",
|
|
101
|
+
"partial_scores",
|
|
102
|
+
"score",
|
|
103
|
+
"feedback",
|
|
104
|
+
}
|
|
105
|
+
),
|
|
106
|
+
"test": frozenset(
|
|
107
|
+
{
|
|
108
|
+
"raw_submitted_answers",
|
|
109
|
+
"format_errors",
|
|
110
|
+
"partial_scores",
|
|
111
|
+
"score",
|
|
112
|
+
"feedback",
|
|
113
|
+
}
|
|
114
|
+
),
|
|
115
|
+
"file": frozenset(),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _valid_type(value: Any, kind: str) -> bool:
|
|
120
|
+
if kind == "dict":
|
|
121
|
+
return isinstance(value, dict)
|
|
122
|
+
if kind == "integer":
|
|
123
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
124
|
+
if kind == "number":
|
|
125
|
+
return (
|
|
126
|
+
isinstance(value, (int, float))
|
|
127
|
+
and not isinstance(value, bool)
|
|
128
|
+
and math.isfinite(value)
|
|
129
|
+
)
|
|
130
|
+
if kind == "string":
|
|
131
|
+
return isinstance(value, str)
|
|
132
|
+
if kind == "boolean":
|
|
133
|
+
return isinstance(value, bool)
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def validate_shape(data: dict[str, Any], phase: Phase) -> None:
|
|
138
|
+
fields = PHASE_FIELDS[phase]
|
|
139
|
+
actual = set(data)
|
|
140
|
+
expected = set(fields)
|
|
141
|
+
if missing := expected - actual:
|
|
142
|
+
raise HarnessError(
|
|
143
|
+
f"{phase}: data is missing fields: {', '.join(sorted(missing))}"
|
|
144
|
+
)
|
|
145
|
+
if extra := actual - expected:
|
|
146
|
+
raise HarnessError(
|
|
147
|
+
f"{phase}: data has extra fields: {', '.join(sorted(extra))}"
|
|
148
|
+
)
|
|
149
|
+
for name, field in fields.items():
|
|
150
|
+
if not _valid_type(data[name], field.kind):
|
|
151
|
+
raise HarnessError(
|
|
152
|
+
f"{phase}: data[{name!r}] must have type {field.kind}, "
|
|
153
|
+
f"got {type(data[name]).__name__}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def validate_mutations(
|
|
158
|
+
before: dict[str, Any], after: dict[str, Any], phase: Phase
|
|
159
|
+
) -> None:
|
|
160
|
+
validate_shape(after, phase)
|
|
161
|
+
editable = EDITABLE_FIELDS[phase]
|
|
162
|
+
for name in PHASE_FIELDS[phase]:
|
|
163
|
+
if name not in editable and before[name] != after[name]:
|
|
164
|
+
raise HarnessError(f"{phase}: data[{name!r}] was illegally modified")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def validate_serializable(data: dict[str, Any], phase: Phase) -> None:
|
|
168
|
+
def check_json(value: Any, path: str) -> None:
|
|
169
|
+
if value is None or isinstance(value, (str, bool, int)):
|
|
170
|
+
return
|
|
171
|
+
if isinstance(value, float):
|
|
172
|
+
if not math.isfinite(value):
|
|
173
|
+
raise TypeError(f"{path} contains NaN or infinity")
|
|
174
|
+
return
|
|
175
|
+
if isinstance(value, list):
|
|
176
|
+
for index, item in enumerate(value):
|
|
177
|
+
check_json(item, f"{path}[{index}]")
|
|
178
|
+
return
|
|
179
|
+
if isinstance(value, dict):
|
|
180
|
+
for key, item in value.items():
|
|
181
|
+
if not isinstance(key, str):
|
|
182
|
+
raise TypeError(f"{path} has a non-string object key")
|
|
183
|
+
check_json(item, f"{path}[{key!r}]")
|
|
184
|
+
return
|
|
185
|
+
raise TypeError(f"{path} contains {type(value).__name__}, which is not JSON")
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
check_json(data, "data")
|
|
189
|
+
assert_all_integers_within_limits(data)
|
|
190
|
+
json.dumps(data, allow_nan=False)
|
|
191
|
+
except (TypeError, ValueError) as exc:
|
|
192
|
+
raise HarnessError(
|
|
193
|
+
f"{phase}: data is not PrairieLearn JSON serializable: {exc}"
|
|
194
|
+
) from exc
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def snapshot(data: dict[str, Any], phase: Phase) -> dict[str, Any]:
|
|
198
|
+
validate_shape(data, phase)
|
|
199
|
+
return copy.deepcopy(data)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from .errors import HarnessError
|
|
10
|
+
from .metadata import load_json
|
|
11
|
+
|
|
12
|
+
type LifecycleMethod = Literal["generate", "prepare", "render", "grade"]
|
|
13
|
+
|
|
14
|
+
E2E_CONFIG_FILENAME = ".question-e2e.json"
|
|
15
|
+
LIFECYCLE_METHODS: frozenset[str] = frozenset(
|
|
16
|
+
{
|
|
17
|
+
"generate",
|
|
18
|
+
"prepare",
|
|
19
|
+
"render",
|
|
20
|
+
"grade",
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class E2EConfig:
|
|
27
|
+
"""Question-local end-to-end test configuration."""
|
|
28
|
+
|
|
29
|
+
skip_methods: dict[str, str]
|
|
30
|
+
regression_seeds: tuple[int, ...]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_e2e_config(config_path: Path) -> E2EConfig:
|
|
34
|
+
"""Load and validate a question-local E2E configuration file."""
|
|
35
|
+
|
|
36
|
+
if not config_path.is_file():
|
|
37
|
+
return E2EConfig(skip_methods={}, regression_seeds=())
|
|
38
|
+
|
|
39
|
+
config = load_json(config_path)
|
|
40
|
+
if not isinstance(config, dict):
|
|
41
|
+
raise HarnessError(f"{config_path}: top-level JSON value must be an object")
|
|
42
|
+
|
|
43
|
+
if unknown_keys := set(config) - {"regression_seeds", "skip_methods"}:
|
|
44
|
+
raise HarnessError(
|
|
45
|
+
f"{config_path}: unknown keys: {', '.join(sorted(unknown_keys))}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
skip_methods = config.get("skip_methods", {})
|
|
49
|
+
if not isinstance(skip_methods, dict):
|
|
50
|
+
raise HarnessError(f"{config_path}: skip_methods must be an object")
|
|
51
|
+
|
|
52
|
+
if unknown_methods := set(skip_methods) - LIFECYCLE_METHODS:
|
|
53
|
+
raise HarnessError(
|
|
54
|
+
f"{config_path}: unknown skip methods: {', '.join(sorted(unknown_methods))}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
validated_skip_methods: dict[str, str] = {}
|
|
58
|
+
for method, reason in skip_methods.items():
|
|
59
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
60
|
+
raise HarnessError(
|
|
61
|
+
f"{config_path}: skip reason for {method!r} must be a non-empty string"
|
|
62
|
+
)
|
|
63
|
+
validated_skip_methods[method] = reason.strip()
|
|
64
|
+
|
|
65
|
+
regression_seeds = config.get("regression_seeds", [])
|
|
66
|
+
if not isinstance(regression_seeds, list):
|
|
67
|
+
raise HarnessError(f"{config_path}: regression_seeds must be an array")
|
|
68
|
+
|
|
69
|
+
validated_regression_seeds: list[int] = []
|
|
70
|
+
seen_regression_seeds: set[int] = set()
|
|
71
|
+
for index, seed in enumerate(regression_seeds):
|
|
72
|
+
if isinstance(seed, bool) or not isinstance(seed, int):
|
|
73
|
+
raise HarnessError(
|
|
74
|
+
f"{config_path}: regression_seeds[{index}] must be an integer"
|
|
75
|
+
)
|
|
76
|
+
if not 0 <= seed < 2**32:
|
|
77
|
+
raise HarnessError(
|
|
78
|
+
f"{config_path}: regression_seeds[{index}] must be between "
|
|
79
|
+
f"0 and {2**32 - 1}"
|
|
80
|
+
)
|
|
81
|
+
if seed in seen_regression_seeds:
|
|
82
|
+
raise HarnessError(
|
|
83
|
+
f"{config_path}: regression_seeds contains duplicate seed {seed}"
|
|
84
|
+
)
|
|
85
|
+
validated_regression_seeds.append(seed)
|
|
86
|
+
seen_regression_seeds.add(seed)
|
|
87
|
+
|
|
88
|
+
return E2EConfig(
|
|
89
|
+
skip_methods=validated_skip_methods,
|
|
90
|
+
regression_seeds=tuple(validated_regression_seeds),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_e2e_skip_methods(config_path: Path) -> dict[str, str]:
|
|
95
|
+
"""Load method-specific E2E skips from a question-local config file."""
|
|
96
|
+
|
|
97
|
+
return load_e2e_config(config_path).skip_methods
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class QuestionCase:
|
|
102
|
+
"""A question directory discovered from an ``info.json`` file."""
|
|
103
|
+
|
|
104
|
+
directory: Path
|
|
105
|
+
course_root: Path
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def info_path(self) -> Path:
|
|
109
|
+
return self.directory / "info.json"
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def qid(self) -> str:
|
|
113
|
+
questions_root = self.course_root / "questions"
|
|
114
|
+
try:
|
|
115
|
+
return self.directory.relative_to(questions_root).as_posix()
|
|
116
|
+
except ValueError:
|
|
117
|
+
return self.directory.relative_to(self.course_root).as_posix()
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def e2e_config_path(self) -> Path:
|
|
121
|
+
return self.directory / E2E_CONFIG_FILENAME
|
|
122
|
+
|
|
123
|
+
def e2e_config(self) -> E2EConfig:
|
|
124
|
+
return load_e2e_config(self.e2e_config_path)
|
|
125
|
+
|
|
126
|
+
def e2e_skip_reason(self, method: LifecycleMethod) -> str | None:
|
|
127
|
+
return self.e2e_config().skip_methods.get(method)
|
|
128
|
+
|
|
129
|
+
def unvalidated_metadata(self) -> dict[str, Any]:
|
|
130
|
+
value = load_json(self.info_path)
|
|
131
|
+
return value if isinstance(value, dict) else {}
|
|
132
|
+
|
|
133
|
+
def is_manual_grading(self) -> bool:
|
|
134
|
+
"""Whether this question contains instructor-only grading content."""
|
|
135
|
+
|
|
136
|
+
metadata = self.unvalidated_metadata()
|
|
137
|
+
declared = metadata.get("gradingMethod") == "Manual"
|
|
138
|
+
question_path = self.directory / "question.html"
|
|
139
|
+
if not question_path.is_file():
|
|
140
|
+
return declared
|
|
141
|
+
has_manual_content = (
|
|
142
|
+
"<pl-manual-grading-only"
|
|
143
|
+
in question_path.read_text(encoding="utf-8").lower()
|
|
144
|
+
)
|
|
145
|
+
if has_manual_content and not declared:
|
|
146
|
+
raise HarnessError(
|
|
147
|
+
f"{self.info_path}: question uses pl-manual-grading-only "
|
|
148
|
+
'but info.json does not declare gradingMethod: "Manual"'
|
|
149
|
+
)
|
|
150
|
+
return declared or has_manual_content
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def variant_seeds(
|
|
154
|
+
metadata: dict[str, Any],
|
|
155
|
+
seed_count: int,
|
|
156
|
+
fuzz_seed: int | None = None,
|
|
157
|
+
*,
|
|
158
|
+
regression_seeds: Sequence[int] = (),
|
|
159
|
+
) -> tuple[int, ...]:
|
|
160
|
+
"""Return saved regressions followed by generated variant seeds."""
|
|
161
|
+
if seed_count < 1:
|
|
162
|
+
raise ValueError("seed count must be at least 1")
|
|
163
|
+
if seed_count > 2**32:
|
|
164
|
+
raise ValueError("fuzz seed count cannot exceed 2**32")
|
|
165
|
+
if metadata.get("singleVariant") is True:
|
|
166
|
+
if any(seed != 0 for seed in regression_seeds):
|
|
167
|
+
raise ValueError("singleVariant questions may only use regression seed 0")
|
|
168
|
+
generated_seeds = (0,)
|
|
169
|
+
elif fuzz_seed is None:
|
|
170
|
+
generated_seeds = tuple(range(seed_count))
|
|
171
|
+
else:
|
|
172
|
+
generated_seeds = tuple(
|
|
173
|
+
random.Random(fuzz_seed).sample(range(2**32), seed_count)
|
|
174
|
+
)
|
|
175
|
+
return tuple(dict.fromkeys((*regression_seeds, *generated_seeds)))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _info_paths(input_path: Path) -> list[Path]:
|
|
179
|
+
if input_path.is_file():
|
|
180
|
+
if input_path.name != "info.json":
|
|
181
|
+
raise ValueError(f"{input_path}: expected an info.json file")
|
|
182
|
+
return [input_path]
|
|
183
|
+
if not input_path.is_dir():
|
|
184
|
+
raise ValueError(f"{input_path}: path does not exist")
|
|
185
|
+
|
|
186
|
+
paths = list(input_path.rglob("info.json"))
|
|
187
|
+
direct = input_path / "info.json"
|
|
188
|
+
if direct.is_file() and direct not in paths:
|
|
189
|
+
paths.append(direct)
|
|
190
|
+
if not paths:
|
|
191
|
+
raise ValueError(f"{input_path}: no info.json files found")
|
|
192
|
+
return paths
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def discover_questions(
|
|
196
|
+
paths: Sequence[str | Path],
|
|
197
|
+
course_root: str | Path,
|
|
198
|
+
) -> list[QuestionCase]:
|
|
199
|
+
"""Discover question directories beneath explicit files or directories."""
|
|
200
|
+
|
|
201
|
+
root = Path(course_root).resolve()
|
|
202
|
+
info_paths: set[Path] = set()
|
|
203
|
+
for raw_path in paths:
|
|
204
|
+
path = Path(raw_path)
|
|
205
|
+
if not path.is_absolute():
|
|
206
|
+
path = root / path
|
|
207
|
+
info_paths.update(candidate.resolve() for candidate in _info_paths(path))
|
|
208
|
+
|
|
209
|
+
return [
|
|
210
|
+
QuestionCase(info_path.parent, root)
|
|
211
|
+
for info_path in sorted(info_paths, key=lambda item: item.as_posix())
|
|
212
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Errors raised by the PrairieLearn end-to-end harness."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class HarnessError(AssertionError):
|
|
5
|
+
"""A selected question violated the PrairieLearn lifecycle contract."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UnsupportedQuestion(HarnessError):
|
|
9
|
+
"""A selected question uses a format that this harness does not execute."""
|