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,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
+ }