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,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,7 @@
1
+ {
2
+ "element_solution_answer_selectors": ["pl-sketch"],
3
+ "non_gradable_answer_selectors": [
4
+ "pl-sketch[read-only=\"true\"]",
5
+ "pl-sketch[read-only=\"1\"]"
6
+ ]
7
+ }
@@ -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."""