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,457 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import io
|
|
5
|
+
import random
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
import chevron
|
|
12
|
+
import lxml.html
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from .backend import PrairieLearnBackend
|
|
16
|
+
from .contract import PHASE_FIELDS, Phase
|
|
17
|
+
from .discovery import QuestionCase
|
|
18
|
+
from .errors import HarnessError, UnsupportedQuestion
|
|
19
|
+
from .html_checks import non_gradable_answer_names, validate_declared_answers
|
|
20
|
+
from .metadata import default_preferences, validate_question_metadata
|
|
21
|
+
from .server import ServerModule
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class LifecycleResult:
|
|
26
|
+
seed: int
|
|
27
|
+
answer_names: tuple[str, ...]
|
|
28
|
+
raw_submitted_answers: dict[str, Any]
|
|
29
|
+
submitted_answers: dict[str, Any]
|
|
30
|
+
partial_scores: dict[str, Any]
|
|
31
|
+
score: float
|
|
32
|
+
question_html: str
|
|
33
|
+
answer_html: str | None
|
|
34
|
+
submission_html: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
type LifecycleCheck = Literal["generate", "prepare", "render", "grade"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _aggregate_score(data: dict[str, Any], partial_credit: bool) -> None:
|
|
41
|
+
parts = data["partial_scores"]
|
|
42
|
+
if partial_credit:
|
|
43
|
+
total_weight = 0.0
|
|
44
|
+
weighted_score = 0.0
|
|
45
|
+
for name, value in parts.items():
|
|
46
|
+
if not isinstance(value, dict):
|
|
47
|
+
raise HarnessError(f"partial_scores[{name!r}] must be an object")
|
|
48
|
+
score = value.get("score")
|
|
49
|
+
weight = value.get("weight", 1)
|
|
50
|
+
if not isinstance(score, (int, float)) or isinstance(score, bool):
|
|
51
|
+
raise HarnessError(f"partial_scores[{name!r}].score must be numeric")
|
|
52
|
+
if not isinstance(weight, (int, float)) or isinstance(weight, bool):
|
|
53
|
+
raise HarnessError(f"partial_scores[{name!r}].weight must be numeric")
|
|
54
|
+
total_weight += float(weight)
|
|
55
|
+
weighted_score += float(weight) * float(score)
|
|
56
|
+
data["score"] = weighted_score / (total_weight or 1)
|
|
57
|
+
else:
|
|
58
|
+
data["score"] = float(
|
|
59
|
+
bool(parts)
|
|
60
|
+
and all(
|
|
61
|
+
isinstance(part, dict)
|
|
62
|
+
and isinstance(part.get("score"), (int, float))
|
|
63
|
+
and not isinstance(part.get("score"), bool)
|
|
64
|
+
and float(part["score"]) >= 1
|
|
65
|
+
for part in parts.values()
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _assert_full_credit(data: dict[str, Any], answer_names: tuple[str, ...]) -> None:
|
|
71
|
+
if data["format_errors"]:
|
|
72
|
+
raise HarnessError(
|
|
73
|
+
f"correct submission has format errors: {data['format_errors']!r}"
|
|
74
|
+
)
|
|
75
|
+
if not data["gradable"]:
|
|
76
|
+
raise HarnessError("correct submission was marked ungradable")
|
|
77
|
+
if float(data["score"]) != 1:
|
|
78
|
+
raise HarnessError(
|
|
79
|
+
f"correct submission received score {data['score']!r}, expected 1"
|
|
80
|
+
)
|
|
81
|
+
unparsed = [
|
|
82
|
+
name
|
|
83
|
+
for name in answer_names
|
|
84
|
+
if name not in data["submitted_answers"]
|
|
85
|
+
or data["submitted_answers"][name] is None
|
|
86
|
+
]
|
|
87
|
+
if unparsed:
|
|
88
|
+
raise HarnessError(
|
|
89
|
+
"declared answers did not parse successfully: " + ", ".join(unparsed)
|
|
90
|
+
)
|
|
91
|
+
missing = [name for name in answer_names if name not in data["partial_scores"]]
|
|
92
|
+
if missing:
|
|
93
|
+
raise HarnessError(
|
|
94
|
+
"declared answers have no partial score: " + ", ".join(missing)
|
|
95
|
+
)
|
|
96
|
+
not_full = [
|
|
97
|
+
name
|
|
98
|
+
for name in answer_names
|
|
99
|
+
if not isinstance(data["partial_scores"][name], dict)
|
|
100
|
+
or data["partial_scores"][name].get("score") != 1
|
|
101
|
+
]
|
|
102
|
+
if not_full:
|
|
103
|
+
raise HarnessError(
|
|
104
|
+
"declared answers did not receive full credit: " + ", ".join(not_full)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class QuestionHarness:
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
question: QuestionCase | str | Path,
|
|
112
|
+
backend: PrairieLearnBackend,
|
|
113
|
+
*,
|
|
114
|
+
course_root: str | Path | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
if isinstance(question, QuestionCase):
|
|
117
|
+
self.question = question
|
|
118
|
+
else:
|
|
119
|
+
root = Path(course_root or backend.course_root).resolve()
|
|
120
|
+
directory = Path(question)
|
|
121
|
+
if not directory.is_absolute():
|
|
122
|
+
directory = root / directory
|
|
123
|
+
if directory.name == "info.json":
|
|
124
|
+
directory = directory.parent
|
|
125
|
+
self.question = QuestionCase(directory.resolve(), root)
|
|
126
|
+
self.backend = backend
|
|
127
|
+
|
|
128
|
+
def _options(self) -> dict[str, Any]:
|
|
129
|
+
directory = self.question.directory
|
|
130
|
+
root = self.question.course_root
|
|
131
|
+
return {
|
|
132
|
+
"question_path": str(directory),
|
|
133
|
+
"client_files_question_path": str(directory / "clientFilesQuestion"),
|
|
134
|
+
"client_files_course_path": str(root / "clientFilesCourse"),
|
|
135
|
+
"server_files_course_path": str(root / "serverFilesCourse"),
|
|
136
|
+
"course_extensions_path": str(root / "elementExtensions"),
|
|
137
|
+
"client_files_question_url": "/clientFilesQuestion",
|
|
138
|
+
"client_files_course_url": "/clientFilesCourse",
|
|
139
|
+
"client_files_question_dynamic_url": "/generatedFilesQuestion",
|
|
140
|
+
"course_element_files_url": "/courseElements",
|
|
141
|
+
"course_element_extension_files_url": "/courseElementExtensions",
|
|
142
|
+
"submission_files_url": "/submissionFiles",
|
|
143
|
+
"variant_id": 1,
|
|
144
|
+
"external_image_capture_url": "/externalImageCapture",
|
|
145
|
+
"base_url": "/pl",
|
|
146
|
+
"workspace_url": None,
|
|
147
|
+
"user": None,
|
|
148
|
+
"group": None,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
def _base(self, seed: int, preferences: dict[str, Any]) -> dict[str, Any]:
|
|
152
|
+
return {
|
|
153
|
+
"params": {},
|
|
154
|
+
"correct_answers": {},
|
|
155
|
+
"variant_seed": seed,
|
|
156
|
+
"options": self._options(),
|
|
157
|
+
"preferences": preferences,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _phase_data(
|
|
162
|
+
phase: Phase,
|
|
163
|
+
base: dict[str, Any],
|
|
164
|
+
**overrides: Any,
|
|
165
|
+
) -> dict[str, Any]:
|
|
166
|
+
fields = PHASE_FIELDS[phase]
|
|
167
|
+
defaults: dict[str, Any] = {
|
|
168
|
+
"answers_names": {},
|
|
169
|
+
"submitted_answers": {},
|
|
170
|
+
"format_errors": {},
|
|
171
|
+
"raw_submitted_answers": {},
|
|
172
|
+
"partial_scores": {},
|
|
173
|
+
"score": 0.0,
|
|
174
|
+
"feedback": {},
|
|
175
|
+
"gradable": True,
|
|
176
|
+
"editable": True,
|
|
177
|
+
"manual_grading": False,
|
|
178
|
+
"ai_grading": False,
|
|
179
|
+
"panel": "question",
|
|
180
|
+
"correct_answer_shown": False,
|
|
181
|
+
"num_valid_submissions": 0,
|
|
182
|
+
"test_type": "correct",
|
|
183
|
+
"filename": "__prairielearn_e2e_sentinel__",
|
|
184
|
+
}
|
|
185
|
+
result: dict[str, Any] = {}
|
|
186
|
+
for name in fields:
|
|
187
|
+
if name in overrides:
|
|
188
|
+
value = overrides[name]
|
|
189
|
+
elif name in base:
|
|
190
|
+
value = base[name]
|
|
191
|
+
else:
|
|
192
|
+
value = defaults[name]
|
|
193
|
+
result[name] = copy.deepcopy(value)
|
|
194
|
+
return result
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def _template(source: str, data: dict[str, Any]) -> str:
|
|
198
|
+
try:
|
|
199
|
+
return chevron.render(source, data)
|
|
200
|
+
except Exception as exc:
|
|
201
|
+
raise HarnessError(
|
|
202
|
+
f"question.html Mustache rendering failed: {exc}"
|
|
203
|
+
) from exc
|
|
204
|
+
|
|
205
|
+
def _process(
|
|
206
|
+
self,
|
|
207
|
+
phase: Phase,
|
|
208
|
+
source: str,
|
|
209
|
+
data: dict[str, Any],
|
|
210
|
+
server: ServerModule,
|
|
211
|
+
) -> tuple[str | None, set[str]]:
|
|
212
|
+
html = self._template(source, data)
|
|
213
|
+
result, processed = self.backend.process(phase, html, data)
|
|
214
|
+
if phase == "render":
|
|
215
|
+
assert isinstance(result, str)
|
|
216
|
+
result = server.invoke("render", data, result)
|
|
217
|
+
elif phase == "file":
|
|
218
|
+
server_result = server.invoke("file", data)
|
|
219
|
+
if self._file_has_content(result) and self._file_has_content(server_result):
|
|
220
|
+
raise HarnessError(
|
|
221
|
+
"file: server.py attempted to overwrite nonempty element file data"
|
|
222
|
+
)
|
|
223
|
+
else:
|
|
224
|
+
server.invoke(phase, data)
|
|
225
|
+
return result, processed
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _file_has_content(value: Any) -> bool:
|
|
229
|
+
if value is None:
|
|
230
|
+
return False
|
|
231
|
+
if isinstance(value, (str, bytes, bytearray, memoryview)):
|
|
232
|
+
return bool(value)
|
|
233
|
+
if isinstance(value, io.IOBase):
|
|
234
|
+
position = value.tell()
|
|
235
|
+
value.seek(0)
|
|
236
|
+
content = value.read()
|
|
237
|
+
value.seek(position)
|
|
238
|
+
return bool(content)
|
|
239
|
+
return True
|
|
240
|
+
|
|
241
|
+
def _render(
|
|
242
|
+
self,
|
|
243
|
+
source: str,
|
|
244
|
+
base: dict[str, Any],
|
|
245
|
+
server: ServerModule,
|
|
246
|
+
*,
|
|
247
|
+
panel: str,
|
|
248
|
+
show_correct: bool,
|
|
249
|
+
submission: dict[str, Any] | None = None,
|
|
250
|
+
) -> str:
|
|
251
|
+
state = self._phase_data(
|
|
252
|
+
"render",
|
|
253
|
+
base,
|
|
254
|
+
**(submission or {}),
|
|
255
|
+
panel=panel,
|
|
256
|
+
correct_answer_shown=show_correct,
|
|
257
|
+
editable=panel == "question",
|
|
258
|
+
num_valid_submissions=1 if submission else 0,
|
|
259
|
+
)
|
|
260
|
+
rendered, _ = self._process("render", source, state, server)
|
|
261
|
+
assert isinstance(rendered, str)
|
|
262
|
+
unresolved = self.backend.unresolved_elements(rendered)
|
|
263
|
+
if unresolved:
|
|
264
|
+
raise HarnessError(
|
|
265
|
+
f"{panel} panel contains unresolved registered elements: "
|
|
266
|
+
+ ", ".join(sorted(unresolved))
|
|
267
|
+
)
|
|
268
|
+
return rendered
|
|
269
|
+
|
|
270
|
+
@staticmethod
|
|
271
|
+
def _dynamic_filenames(html: str) -> tuple[str, ...]:
|
|
272
|
+
fragments = lxml.html.fragments_fromstring(html)
|
|
273
|
+
result: set[str] = set()
|
|
274
|
+
for fragment in fragments:
|
|
275
|
+
if isinstance(fragment, str):
|
|
276
|
+
continue
|
|
277
|
+
for element in fragment.xpath('//*[@type="dynamic"][@file-name]'):
|
|
278
|
+
filename = element.get("file-name")
|
|
279
|
+
if filename:
|
|
280
|
+
result.add(filename)
|
|
281
|
+
for element in fragment.xpath("//*[@src] | //*[@href]"):
|
|
282
|
+
value = element.get("src") or element.get("href") or ""
|
|
283
|
+
marker = "/generatedFilesQuestion/"
|
|
284
|
+
if marker in value:
|
|
285
|
+
result.add(value.split(marker, 1)[1].split("?", 1)[0])
|
|
286
|
+
return tuple(sorted(result))
|
|
287
|
+
|
|
288
|
+
def _run(self, seed: int, through: LifecycleCheck) -> LifecycleResult | None:
|
|
289
|
+
metadata = validate_question_metadata(
|
|
290
|
+
self.question.info_path, self.question.course_root
|
|
291
|
+
)
|
|
292
|
+
if metadata.get("type") != "v3":
|
|
293
|
+
raise UnsupportedQuestion(
|
|
294
|
+
f"{self.question.qid}: only v3 questions are supported"
|
|
295
|
+
)
|
|
296
|
+
question_html = self.question.directory / "question.html"
|
|
297
|
+
if not question_html.is_file():
|
|
298
|
+
raise HarnessError(f"{question_html}: v3 questions require question.html")
|
|
299
|
+
source = question_html.read_text(encoding="utf-8")
|
|
300
|
+
if re.search(r"<\s*markdown(?:\s|>)", source, flags=re.IGNORECASE):
|
|
301
|
+
raise HarnessError(
|
|
302
|
+
f"{question_html}: <markdown> needs PrairieLearn's TypeScript preprocessor"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
python_state = random.getstate()
|
|
306
|
+
numpy_state = np.random.get_state()
|
|
307
|
+
try:
|
|
308
|
+
random.seed(seed)
|
|
309
|
+
np.random.seed(seed)
|
|
310
|
+
server = ServerModule(
|
|
311
|
+
self.question.directory / "server.py", self.question.course_root
|
|
312
|
+
)
|
|
313
|
+
preferences = default_preferences(metadata)
|
|
314
|
+
base = self._base(seed, preferences)
|
|
315
|
+
|
|
316
|
+
generate = self._phase_data("generate", base)
|
|
317
|
+
server.invoke("generate", generate)
|
|
318
|
+
base.update(
|
|
319
|
+
params=generate["params"], correct_answers=generate["correct_answers"]
|
|
320
|
+
)
|
|
321
|
+
if through == "generate":
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
prepare = self._phase_data("prepare", base)
|
|
325
|
+
prepared_html = self._template(source, prepare)
|
|
326
|
+
self.backend.process("prepare", prepared_html, prepare)
|
|
327
|
+
server.invoke("prepare", prepare)
|
|
328
|
+
base.update(
|
|
329
|
+
params=prepare["params"], correct_answers=prepare["correct_answers"]
|
|
330
|
+
)
|
|
331
|
+
if through == "prepare":
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
show_correct = metadata.get("showCorrectAnswer", True)
|
|
335
|
+
answer_names = validate_declared_answers(
|
|
336
|
+
prepared_html, base["correct_answers"], show_correct
|
|
337
|
+
)
|
|
338
|
+
tested_answer_names = tuple(
|
|
339
|
+
name
|
|
340
|
+
for name in answer_names
|
|
341
|
+
if name not in non_gradable_answer_names(prepared_html)
|
|
342
|
+
)
|
|
343
|
+
rendered_question = self._render(
|
|
344
|
+
source, base, server, panel="question", show_correct=show_correct
|
|
345
|
+
)
|
|
346
|
+
rendered_answer = (
|
|
347
|
+
self._render(
|
|
348
|
+
source, base, server, panel="answer", show_correct=show_correct
|
|
349
|
+
)
|
|
350
|
+
if show_correct
|
|
351
|
+
else None
|
|
352
|
+
)
|
|
353
|
+
if through == "render":
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
test = self._phase_data("test", base, test_type="correct")
|
|
357
|
+
self._process("test", source, test, server)
|
|
358
|
+
_aggregate_score(test, metadata.get("partialCredit", True))
|
|
359
|
+
|
|
360
|
+
parse = self._phase_data(
|
|
361
|
+
"parse",
|
|
362
|
+
base,
|
|
363
|
+
raw_submitted_answers=test["raw_submitted_answers"],
|
|
364
|
+
submitted_answers=test["raw_submitted_answers"],
|
|
365
|
+
format_errors=test["format_errors"],
|
|
366
|
+
feedback=test["feedback"],
|
|
367
|
+
gradable=test["gradable"],
|
|
368
|
+
)
|
|
369
|
+
self._process("parse", source, parse, server)
|
|
370
|
+
if parse["format_errors"]:
|
|
371
|
+
parse["gradable"] = False
|
|
372
|
+
|
|
373
|
+
grade = self._phase_data(
|
|
374
|
+
"grade",
|
|
375
|
+
base,
|
|
376
|
+
params=parse["params"],
|
|
377
|
+
correct_answers=parse["correct_answers"],
|
|
378
|
+
submitted_answers=parse["submitted_answers"],
|
|
379
|
+
raw_submitted_answers=parse["raw_submitted_answers"],
|
|
380
|
+
format_errors=parse["format_errors"],
|
|
381
|
+
feedback=parse["feedback"],
|
|
382
|
+
gradable=parse["gradable"],
|
|
383
|
+
partial_scores=test["partial_scores"],
|
|
384
|
+
score=test["score"],
|
|
385
|
+
)
|
|
386
|
+
self._process("grade", source, grade, server)
|
|
387
|
+
if grade["format_errors"]:
|
|
388
|
+
grade["gradable"] = False
|
|
389
|
+
_aggregate_score(grade, metadata.get("partialCredit", True))
|
|
390
|
+
_assert_full_credit(grade, tested_answer_names)
|
|
391
|
+
|
|
392
|
+
graded_base = {
|
|
393
|
+
**base,
|
|
394
|
+
"params": grade["params"],
|
|
395
|
+
"correct_answers": grade["correct_answers"],
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
submission = {
|
|
399
|
+
name: grade[name]
|
|
400
|
+
for name in (
|
|
401
|
+
"submitted_answers",
|
|
402
|
+
"format_errors",
|
|
403
|
+
"raw_submitted_answers",
|
|
404
|
+
"partial_scores",
|
|
405
|
+
"score",
|
|
406
|
+
"feedback",
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
rendered_submission = self._render(
|
|
410
|
+
source,
|
|
411
|
+
graded_base,
|
|
412
|
+
server,
|
|
413
|
+
panel="submission",
|
|
414
|
+
show_correct=show_correct,
|
|
415
|
+
submission=submission,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
prepared_render_html = self._template(source, graded_base)
|
|
419
|
+
filenames = self._dynamic_filenames(prepared_render_html)
|
|
420
|
+
for filename in filenames or ("__prairielearn_e2e_sentinel__",):
|
|
421
|
+
file_data = self._phase_data(
|
|
422
|
+
"file", graded_base, **submission, filename=filename
|
|
423
|
+
)
|
|
424
|
+
self._process("file", source, file_data, server)
|
|
425
|
+
|
|
426
|
+
return LifecycleResult(
|
|
427
|
+
seed=seed,
|
|
428
|
+
answer_names=answer_names,
|
|
429
|
+
raw_submitted_answers=copy.deepcopy(grade["raw_submitted_answers"]),
|
|
430
|
+
submitted_answers=copy.deepcopy(grade["submitted_answers"]),
|
|
431
|
+
partial_scores=copy.deepcopy(grade["partial_scores"]),
|
|
432
|
+
score=float(grade["score"]),
|
|
433
|
+
question_html=rendered_question,
|
|
434
|
+
answer_html=rendered_answer,
|
|
435
|
+
submission_html=rendered_submission,
|
|
436
|
+
)
|
|
437
|
+
finally:
|
|
438
|
+
random.setstate(python_state)
|
|
439
|
+
np.random.set_state(numpy_state)
|
|
440
|
+
|
|
441
|
+
def run_generate(self, seed: int) -> None:
|
|
442
|
+
self._run(seed, "generate")
|
|
443
|
+
|
|
444
|
+
def run_prepare(self, seed: int) -> None:
|
|
445
|
+
self._run(seed, "prepare")
|
|
446
|
+
|
|
447
|
+
def run_render(self, seed: int) -> None:
|
|
448
|
+
self._run(seed, "render")
|
|
449
|
+
|
|
450
|
+
def run_grade(self, seed: int) -> LifecycleResult:
|
|
451
|
+
result = self._run(seed, "grade")
|
|
452
|
+
assert result is not None
|
|
453
|
+
return result
|
|
454
|
+
|
|
455
|
+
def run_variant(self, seed: int) -> LifecycleResult:
|
|
456
|
+
"""Run the complete lifecycle (compatibility alias for ``run_grade``)."""
|
|
457
|
+
return self.run_grade(seed)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import lxml.html
|
|
8
|
+
|
|
9
|
+
from .config import ELEMENT_SOLUTION_ANSWER_SELECTORS, NON_GRADABLE_ANSWER_SELECTORS
|
|
10
|
+
from .errors import HarnessError
|
|
11
|
+
|
|
12
|
+
_SIMPLE_CSS_SELECTOR = re.compile(
|
|
13
|
+
r"^(?P<tag>[A-Za-z][\w-]*)(?:\[(?P<attribute>[\w-]+)=(?:\"(?P<double>[^\"]*)\"|'(?P<single>[^']*)'|(?P<bare>[^\]]+))\])?$"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _select_elements(fragment: Any, selector: str) -> list[Any]:
|
|
18
|
+
"""Apply configured CSS selectors, with a small dependency-free fallback."""
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
return list(fragment.cssselect(selector))
|
|
22
|
+
except ImportError:
|
|
23
|
+
match = _SIMPLE_CSS_SELECTOR.fullmatch(selector.strip())
|
|
24
|
+
if match is None:
|
|
25
|
+
raise HarnessError(
|
|
26
|
+
"CSS selector support requires the cssselect package for: " + selector
|
|
27
|
+
) from None
|
|
28
|
+
expected_tag = match.group("tag").lower()
|
|
29
|
+
expected_attribute = match.group("attribute")
|
|
30
|
+
expected_value = None
|
|
31
|
+
if expected_attribute is not None:
|
|
32
|
+
expected_value = next(
|
|
33
|
+
value
|
|
34
|
+
for value in (
|
|
35
|
+
match.group("double"),
|
|
36
|
+
match.group("single"),
|
|
37
|
+
match.group("bare"),
|
|
38
|
+
)
|
|
39
|
+
if value is not None
|
|
40
|
+
)
|
|
41
|
+
candidates = [fragment, *fragment.iter()]
|
|
42
|
+
return [
|
|
43
|
+
element
|
|
44
|
+
for element in candidates
|
|
45
|
+
if element.tag.lower() == expected_tag
|
|
46
|
+
and (
|
|
47
|
+
expected_attribute is None
|
|
48
|
+
or element.get(expected_attribute) == expected_value
|
|
49
|
+
)
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def declared_answer_names(html: str) -> tuple[str, ...]:
|
|
54
|
+
fragments = lxml.html.fragments_fromstring(html)
|
|
55
|
+
names: list[str] = []
|
|
56
|
+
for fragment in fragments:
|
|
57
|
+
if isinstance(fragment, str):
|
|
58
|
+
continue
|
|
59
|
+
for element in fragment.xpath(".//*[@answers-name] | self::*[@answers-name]"):
|
|
60
|
+
name = element.get("answers-name")
|
|
61
|
+
if name is None or not name.strip():
|
|
62
|
+
raise HarnessError("question.html contains an empty answers-name")
|
|
63
|
+
names.append(name)
|
|
64
|
+
duplicates = sorted(name for name, count in Counter(names).items() if count > 1)
|
|
65
|
+
if duplicates:
|
|
66
|
+
raise HarnessError(
|
|
67
|
+
"question.html declares duplicate answers-name values: "
|
|
68
|
+
+ ", ".join(duplicates)
|
|
69
|
+
)
|
|
70
|
+
return tuple(names)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def require_correct_answers(
|
|
74
|
+
answer_names: tuple[str, ...], correct_answers: dict[str, Any]
|
|
75
|
+
) -> None:
|
|
76
|
+
missing = [
|
|
77
|
+
name
|
|
78
|
+
for name in answer_names
|
|
79
|
+
if name not in correct_answers or correct_answers[name] is None
|
|
80
|
+
]
|
|
81
|
+
if missing:
|
|
82
|
+
raise HarnessError(
|
|
83
|
+
"showCorrectAnswer requires non-null correct answers for: "
|
|
84
|
+
+ ", ".join(missing)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _answer_names_matching_selectors(
|
|
89
|
+
html: str, selectors: tuple[str, ...]
|
|
90
|
+
) -> frozenset[str]:
|
|
91
|
+
fragments = lxml.html.fragments_fromstring(html)
|
|
92
|
+
names: set[str] = set()
|
|
93
|
+
for fragment in fragments:
|
|
94
|
+
if isinstance(fragment, str):
|
|
95
|
+
continue
|
|
96
|
+
for selector in selectors:
|
|
97
|
+
for element in _select_elements(fragment, selector):
|
|
98
|
+
if name := element.get("answers-name"):
|
|
99
|
+
names.add(name)
|
|
100
|
+
return frozenset(names)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def element_solution_answer_names(html: str) -> frozenset[str]:
|
|
104
|
+
"""Return answer names whose solution is owned by the element.
|
|
105
|
+
|
|
106
|
+
These elements do not need an entry in ``correct_answers``, but they can
|
|
107
|
+
still be graded and must remain part of the parse/grade lifecycle checks.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
return _answer_names_matching_selectors(html, ELEMENT_SOLUTION_ANSWER_SELECTORS)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def non_gradable_answer_names(html: str) -> frozenset[str]:
|
|
114
|
+
"""Return answer names intentionally excluded from server-side grading.
|
|
115
|
+
|
|
116
|
+
Configured display-only elements still carry an ``answers-name`` for
|
|
117
|
+
client-side identity, but have no submitted answer, correct answer, or
|
|
118
|
+
partial score.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
return _answer_names_matching_selectors(html, NON_GRADABLE_ANSWER_SELECTORS)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def validate_declared_answers(
|
|
125
|
+
html: str, correct_answers: dict[str, Any], show_correct_answer: bool
|
|
126
|
+
) -> tuple[str, ...]:
|
|
127
|
+
names = declared_answer_names(html)
|
|
128
|
+
if show_correct_answer:
|
|
129
|
+
ignored = non_gradable_answer_names(html) | element_solution_answer_names(html)
|
|
130
|
+
require_correct_answers(
|
|
131
|
+
tuple(name for name in names if name not in ignored), correct_answers
|
|
132
|
+
)
|
|
133
|
+
return names
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from jsonschema import FormatChecker
|
|
8
|
+
from jsonschema.validators import validator_for
|
|
9
|
+
|
|
10
|
+
from .errors import HarnessError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _reject_constant(value: str) -> None:
|
|
14
|
+
raise ValueError(f"{value} is not a valid JSON value")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_json(path: Path) -> Any:
|
|
18
|
+
try:
|
|
19
|
+
return json.loads(
|
|
20
|
+
path.read_text(encoding="utf-8"),
|
|
21
|
+
parse_constant=_reject_constant,
|
|
22
|
+
)
|
|
23
|
+
except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc:
|
|
24
|
+
raise HarnessError(f"{path}: invalid JSON: {exc}") from exc
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def validate_question_metadata(info_path: Path, course_root: Path) -> dict[str, Any]:
|
|
28
|
+
metadata = load_json(info_path)
|
|
29
|
+
if not isinstance(metadata, dict):
|
|
30
|
+
raise HarnessError(f"{info_path}: top-level JSON value must be an object")
|
|
31
|
+
|
|
32
|
+
schema_path = course_root / ".prairielearn" / "schemas" / "infoQuestion.json"
|
|
33
|
+
schema = load_json(schema_path)
|
|
34
|
+
validator_class = validator_for(schema)
|
|
35
|
+
validator_class.check_schema(schema)
|
|
36
|
+
errors = sorted(
|
|
37
|
+
validator_class(schema, format_checker=FormatChecker()).iter_errors(metadata),
|
|
38
|
+
key=lambda error: [str(part) for part in error.absolute_path],
|
|
39
|
+
)
|
|
40
|
+
if errors:
|
|
41
|
+
details = "; ".join(error.message for error in errors)
|
|
42
|
+
raise HarnessError(f"{info_path}: metadata schema validation failed: {details}")
|
|
43
|
+
return metadata
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def default_preferences(
|
|
47
|
+
metadata: dict[str, Any],
|
|
48
|
+
) -> dict[str, str | int | float | bool]:
|
|
49
|
+
preferences = metadata.get("preferences", {})
|
|
50
|
+
if not isinstance(preferences, dict):
|
|
51
|
+
return {}
|
|
52
|
+
return {
|
|
53
|
+
name: definition["default"]
|
|
54
|
+
for name, definition in preferences.items()
|
|
55
|
+
if isinstance(definition, dict) and "default" in definition
|
|
56
|
+
}
|