pl-e2e-test 0.1.4__tar.gz → 0.1.5__tar.gz
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.4/src/pl_e2e_test.egg-info → pl_e2e_test-0.1.5}/PKG-INFO +26 -3
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/README.md +25 -2
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/pyproject.toml +7 -1
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5/src/pl_e2e_test.egg-info}/PKG-INFO +26 -3
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/SOURCES.txt +2 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/discovery.py +106 -3
- pl_e2e_test-0.1.5/src/prairielearn_e2e/expected_errors.py +62 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/pytest_plugin.py +51 -1
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/question_tests.py +27 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/reporting.py +82 -1
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_discovery.py +86 -0
- pl_e2e_test-0.1.5/tests/test_pytest_plugin.py +202 -0
- pl_e2e_test-0.1.5/tests/test_question_tests.py +130 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_reporting.py +114 -0
- pl_e2e_test-0.1.4/tests/test_pytest_plugin.py +0 -86
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/LICENSE +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/MANIFEST.in +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/setup.cfg +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/requires.txt +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/__init__.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/__main__.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/_git.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/backend.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/cli.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/config.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/contract.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/errors.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/fuzz.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/harness.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/html_checks.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/metadata.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/parallel.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/regression.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/repository.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/runner.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/server.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/sharding.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/__init__.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/conftest.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/stub_git.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_cli.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_config.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_contract.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_fuzz.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_git.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_harness.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_html_checks.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_metadata.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_parallel.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_publish_version.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_regression.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_repository.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_server.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_sharding.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pl-e2e-test
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Fuzz PrairieLearn question lifecycles against the real Python element runtime
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/PrairieLearn/pl-e2e-test
|
|
@@ -276,14 +276,22 @@ Use `--config path/to/settings.toml` to select another TOML file with the same
|
|
|
276
276
|
|
|
277
277
|
### Question-local configuration
|
|
278
278
|
|
|
279
|
-
Store saved concrete seeds
|
|
280
|
-
question's `info.json`:
|
|
279
|
+
Store saved concrete seeds, narrow phase skips, and expected upstream errors in
|
|
280
|
+
`.question-e2e.json` beside the question's `info.json`:
|
|
281
281
|
|
|
282
282
|
```json
|
|
283
283
|
{
|
|
284
284
|
"regression_seeds": [1843927501],
|
|
285
285
|
"skip_methods": {
|
|
286
286
|
"grade": "Temporarily blocked by an upstream issue"
|
|
287
|
+
},
|
|
288
|
+
"expected_errors": {
|
|
289
|
+
"render": [
|
|
290
|
+
{
|
|
291
|
+
"match": "^render: known upstream failure$",
|
|
292
|
+
"reason": "https://github.com/PrairieLearn/PrairieLearn/issues/12345"
|
|
293
|
+
}
|
|
294
|
+
]
|
|
287
295
|
}
|
|
288
296
|
}
|
|
289
297
|
```
|
|
@@ -292,6 +300,21 @@ Saved regression seeds run before newly generated seeds. Skips require a nonempt
|
|
|
292
300
|
reason and may name only `generate`, `prepare`, `render`, or `grade`. Manual-grading
|
|
293
301
|
questions automatically skip only the grade check.
|
|
294
302
|
|
|
303
|
+
Prefer `expected_errors` to a skip when the lifecycle can run far enough to expose a
|
|
304
|
+
known upstream defect. Each rule requires a human-readable reason or issue URL and
|
|
305
|
+
exactly one matcher: either `type`, containing a fully qualified exception type, or
|
|
306
|
+
`match`, containing a Python regular expression searched against the exception message.
|
|
307
|
+
A matching exception is reported as `XFAIL`; a different exception fails normally,
|
|
308
|
+
while seeds that do not raise an exception pass normally. A method may not be both
|
|
309
|
+
skipped and expected to fail.
|
|
310
|
+
|
|
311
|
+
Rules under one method are alternatives. At least one rule must match across the selected
|
|
312
|
+
seeds for that question and method, or an otherwise-successful run fails an audit asking
|
|
313
|
+
you to remove or update the stale expectation. Parallel runs perform this audit after
|
|
314
|
+
merging all shards. Because one upstream error can prevent later code from running,
|
|
315
|
+
obsolete individual alternatives cannot be detected while another alternative still
|
|
316
|
+
matches.
|
|
317
|
+
|
|
295
318
|
Record the concrete seed shown in a failing pytest case with:
|
|
296
319
|
|
|
297
320
|
```sh
|
|
@@ -252,14 +252,22 @@ Use `--config path/to/settings.toml` to select another TOML file with the same
|
|
|
252
252
|
|
|
253
253
|
### Question-local configuration
|
|
254
254
|
|
|
255
|
-
Store saved concrete seeds
|
|
256
|
-
question's `info.json`:
|
|
255
|
+
Store saved concrete seeds, narrow phase skips, and expected upstream errors in
|
|
256
|
+
`.question-e2e.json` beside the question's `info.json`:
|
|
257
257
|
|
|
258
258
|
```json
|
|
259
259
|
{
|
|
260
260
|
"regression_seeds": [1843927501],
|
|
261
261
|
"skip_methods": {
|
|
262
262
|
"grade": "Temporarily blocked by an upstream issue"
|
|
263
|
+
},
|
|
264
|
+
"expected_errors": {
|
|
265
|
+
"render": [
|
|
266
|
+
{
|
|
267
|
+
"match": "^render: known upstream failure$",
|
|
268
|
+
"reason": "https://github.com/PrairieLearn/PrairieLearn/issues/12345"
|
|
269
|
+
}
|
|
270
|
+
]
|
|
263
271
|
}
|
|
264
272
|
}
|
|
265
273
|
```
|
|
@@ -268,6 +276,21 @@ Saved regression seeds run before newly generated seeds. Skips require a nonempt
|
|
|
268
276
|
reason and may name only `generate`, `prepare`, `render`, or `grade`. Manual-grading
|
|
269
277
|
questions automatically skip only the grade check.
|
|
270
278
|
|
|
279
|
+
Prefer `expected_errors` to a skip when the lifecycle can run far enough to expose a
|
|
280
|
+
known upstream defect. Each rule requires a human-readable reason or issue URL and
|
|
281
|
+
exactly one matcher: either `type`, containing a fully qualified exception type, or
|
|
282
|
+
`match`, containing a Python regular expression searched against the exception message.
|
|
283
|
+
A matching exception is reported as `XFAIL`; a different exception fails normally,
|
|
284
|
+
while seeds that do not raise an exception pass normally. A method may not be both
|
|
285
|
+
skipped and expected to fail.
|
|
286
|
+
|
|
287
|
+
Rules under one method are alternatives. At least one rule must match across the selected
|
|
288
|
+
seeds for that question and method, or an otherwise-successful run fails an audit asking
|
|
289
|
+
you to remove or update the stale expectation. Parallel runs perform this audit after
|
|
290
|
+
merging all shards. Because one upstream error can prevent later code from running,
|
|
291
|
+
obsolete individual alternatives cannot be detected while another alternative still
|
|
292
|
+
matches.
|
|
293
|
+
|
|
271
294
|
Record the concrete seed shown in a failing pytest case with:
|
|
272
295
|
|
|
273
296
|
```sh
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pl-e2e-test"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.5"
|
|
8
8
|
description = "Fuzz PrairieLearn question lifecycles against the real Python element runtime"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.13"
|
|
@@ -39,6 +39,12 @@ where = ["src"]
|
|
|
39
39
|
|
|
40
40
|
[tool.pytest.ini_options]
|
|
41
41
|
testpaths = ["tests"]
|
|
42
|
+
markers = [
|
|
43
|
+
"generate: question generate-phase lifecycle checks",
|
|
44
|
+
"prepare: question prepare-phase lifecycle checks",
|
|
45
|
+
"render: question render-phase lifecycle checks",
|
|
46
|
+
"grade: question grading lifecycle checks",
|
|
47
|
+
]
|
|
42
48
|
|
|
43
49
|
[tool.pyright]
|
|
44
50
|
include = ["src/prairielearn_e2e", "tests"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pl-e2e-test
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Fuzz PrairieLearn question lifecycles against the real Python element runtime
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/PrairieLearn/pl-e2e-test
|
|
@@ -276,14 +276,22 @@ Use `--config path/to/settings.toml` to select another TOML file with the same
|
|
|
276
276
|
|
|
277
277
|
### Question-local configuration
|
|
278
278
|
|
|
279
|
-
Store saved concrete seeds
|
|
280
|
-
question's `info.json`:
|
|
279
|
+
Store saved concrete seeds, narrow phase skips, and expected upstream errors in
|
|
280
|
+
`.question-e2e.json` beside the question's `info.json`:
|
|
281
281
|
|
|
282
282
|
```json
|
|
283
283
|
{
|
|
284
284
|
"regression_seeds": [1843927501],
|
|
285
285
|
"skip_methods": {
|
|
286
286
|
"grade": "Temporarily blocked by an upstream issue"
|
|
287
|
+
},
|
|
288
|
+
"expected_errors": {
|
|
289
|
+
"render": [
|
|
290
|
+
{
|
|
291
|
+
"match": "^render: known upstream failure$",
|
|
292
|
+
"reason": "https://github.com/PrairieLearn/PrairieLearn/issues/12345"
|
|
293
|
+
}
|
|
294
|
+
]
|
|
287
295
|
}
|
|
288
296
|
}
|
|
289
297
|
```
|
|
@@ -292,6 +300,21 @@ Saved regression seeds run before newly generated seeds. Skips require a nonempt
|
|
|
292
300
|
reason and may name only `generate`, `prepare`, `render`, or `grade`. Manual-grading
|
|
293
301
|
questions automatically skip only the grade check.
|
|
294
302
|
|
|
303
|
+
Prefer `expected_errors` to a skip when the lifecycle can run far enough to expose a
|
|
304
|
+
known upstream defect. Each rule requires a human-readable reason or issue URL and
|
|
305
|
+
exactly one matcher: either `type`, containing a fully qualified exception type, or
|
|
306
|
+
`match`, containing a Python regular expression searched against the exception message.
|
|
307
|
+
A matching exception is reported as `XFAIL`; a different exception fails normally,
|
|
308
|
+
while seeds that do not raise an exception pass normally. A method may not be both
|
|
309
|
+
skipped and expected to fail.
|
|
310
|
+
|
|
311
|
+
Rules under one method are alternatives. At least one rule must match across the selected
|
|
312
|
+
seeds for that question and method, or an otherwise-successful run fails an audit asking
|
|
313
|
+
you to remove or update the stale expectation. Parallel runs perform this audit after
|
|
314
|
+
merging all shards. Because one upstream error can prevent later code from running,
|
|
315
|
+
obsolete individual alternatives cannot be detected while another alternative still
|
|
316
|
+
matches.
|
|
317
|
+
|
|
295
318
|
Record the concrete seed shown in a failing pytest case with:
|
|
296
319
|
|
|
297
320
|
```sh
|
|
@@ -17,6 +17,7 @@ src/prairielearn_e2e/config.py
|
|
|
17
17
|
src/prairielearn_e2e/contract.py
|
|
18
18
|
src/prairielearn_e2e/discovery.py
|
|
19
19
|
src/prairielearn_e2e/errors.py
|
|
20
|
+
src/prairielearn_e2e/expected_errors.py
|
|
20
21
|
src/prairielearn_e2e/fuzz.py
|
|
21
22
|
src/prairielearn_e2e/harness.py
|
|
22
23
|
src/prairielearn_e2e/html_checks.py
|
|
@@ -45,6 +46,7 @@ tests/test_metadata.py
|
|
|
45
46
|
tests/test_parallel.py
|
|
46
47
|
tests/test_publish_version.py
|
|
47
48
|
tests/test_pytest_plugin.py
|
|
49
|
+
tests/test_question_tests.py
|
|
48
50
|
tests/test_regression.py
|
|
49
51
|
tests/test_reporting.py
|
|
50
52
|
tests/test_repository.py
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import random
|
|
4
|
+
import re
|
|
4
5
|
from collections.abc import Sequence
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
+
from dataclasses import dataclass, field
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import Any, Literal
|
|
8
9
|
|
|
@@ -22,25 +23,49 @@ LIFECYCLE_METHODS: frozenset[str] = frozenset(
|
|
|
22
23
|
)
|
|
23
24
|
|
|
24
25
|
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class ExpectedError:
|
|
28
|
+
"""One acceptable exception signature for a lifecycle method."""
|
|
29
|
+
|
|
30
|
+
reason: str
|
|
31
|
+
exception_type: str | None = None
|
|
32
|
+
match: str | None = None
|
|
33
|
+
|
|
34
|
+
def matches(self, exception: Exception) -> bool:
|
|
35
|
+
if self.exception_type is not None:
|
|
36
|
+
exception_type = type(exception)
|
|
37
|
+
qualified_type = (
|
|
38
|
+
f"{exception_type.__module__}.{exception_type.__qualname__}"
|
|
39
|
+
)
|
|
40
|
+
return self.exception_type == qualified_type
|
|
41
|
+
assert self.match is not None
|
|
42
|
+
return re.search(self.match, str(exception)) is not None
|
|
43
|
+
|
|
44
|
+
|
|
25
45
|
@dataclass(frozen=True, slots=True)
|
|
26
46
|
class E2EConfig:
|
|
27
47
|
"""Question-local end-to-end test configuration."""
|
|
28
48
|
|
|
29
49
|
skip_methods: dict[str, str]
|
|
30
50
|
regression_seeds: tuple[int, ...]
|
|
51
|
+
expected_errors: dict[str, tuple[ExpectedError, ...]] = field(default_factory=dict)
|
|
31
52
|
|
|
32
53
|
|
|
33
54
|
def load_e2e_config(config_path: Path) -> E2EConfig:
|
|
34
55
|
"""Load and validate a question-local E2E configuration file."""
|
|
35
56
|
|
|
36
57
|
if not config_path.is_file():
|
|
37
|
-
return E2EConfig(skip_methods={}, regression_seeds=())
|
|
58
|
+
return E2EConfig(skip_methods={}, regression_seeds=(), expected_errors={})
|
|
38
59
|
|
|
39
60
|
config = load_json(config_path)
|
|
40
61
|
if not isinstance(config, dict):
|
|
41
62
|
raise HarnessError(f"{config_path}: top-level JSON value must be an object")
|
|
42
63
|
|
|
43
|
-
if unknown_keys := set(config) - {
|
|
64
|
+
if unknown_keys := set(config) - {
|
|
65
|
+
"expected_errors",
|
|
66
|
+
"regression_seeds",
|
|
67
|
+
"skip_methods",
|
|
68
|
+
}:
|
|
44
69
|
raise HarnessError(
|
|
45
70
|
f"{config_path}: unknown keys: {', '.join(sorted(unknown_keys))}"
|
|
46
71
|
)
|
|
@@ -62,6 +87,80 @@ def load_e2e_config(config_path: Path) -> E2EConfig:
|
|
|
62
87
|
)
|
|
63
88
|
validated_skip_methods[method] = reason.strip()
|
|
64
89
|
|
|
90
|
+
expected_errors = config.get("expected_errors", {})
|
|
91
|
+
if not isinstance(expected_errors, dict):
|
|
92
|
+
raise HarnessError(f"{config_path}: expected_errors must be an object")
|
|
93
|
+
if unknown_methods := set(expected_errors) - LIFECYCLE_METHODS:
|
|
94
|
+
raise HarnessError(
|
|
95
|
+
f"{config_path}: unknown expected-error methods: "
|
|
96
|
+
f"{', '.join(sorted(unknown_methods))}"
|
|
97
|
+
)
|
|
98
|
+
if conflicts := set(expected_errors) & set(validated_skip_methods):
|
|
99
|
+
raise HarnessError(
|
|
100
|
+
f"{config_path}: methods cannot be both skipped and expected to fail: "
|
|
101
|
+
f"{', '.join(sorted(conflicts))}"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
validated_expected_errors: dict[str, tuple[ExpectedError, ...]] = {}
|
|
105
|
+
allowed_rule_keys = {"type", "match", "reason"}
|
|
106
|
+
for method, rules in expected_errors.items():
|
|
107
|
+
context = f"{config_path}: expected_errors[{method!r}]"
|
|
108
|
+
if not isinstance(rules, list) or not rules:
|
|
109
|
+
raise HarnessError(f"{context} must be a non-empty array")
|
|
110
|
+
validated_rules: list[ExpectedError] = []
|
|
111
|
+
for index, rule in enumerate(rules):
|
|
112
|
+
rule_context = f"{context}[{index}]"
|
|
113
|
+
if not isinstance(rule, dict):
|
|
114
|
+
raise HarnessError(f"{rule_context} must be an object")
|
|
115
|
+
if unknown_rule_keys := set(rule) - allowed_rule_keys:
|
|
116
|
+
raise HarnessError(
|
|
117
|
+
f"{rule_context} has unknown keys: "
|
|
118
|
+
f"{', '.join(sorted(unknown_rule_keys))}"
|
|
119
|
+
)
|
|
120
|
+
if "reason" not in rule:
|
|
121
|
+
raise HarnessError(f"{rule_context} is missing key: reason")
|
|
122
|
+
has_type = "type" in rule
|
|
123
|
+
has_match = "match" in rule
|
|
124
|
+
if has_type == has_match:
|
|
125
|
+
raise HarnessError(
|
|
126
|
+
f"{rule_context} must contain exactly one of type or match"
|
|
127
|
+
)
|
|
128
|
+
exception_type = rule.get("type")
|
|
129
|
+
match = rule.get("match")
|
|
130
|
+
reason = rule["reason"]
|
|
131
|
+
if has_type:
|
|
132
|
+
if not isinstance(exception_type, str):
|
|
133
|
+
raise HarnessError(
|
|
134
|
+
f"{rule_context}.type must be a fully qualified exception type"
|
|
135
|
+
)
|
|
136
|
+
type_parts = exception_type.strip().split(".")
|
|
137
|
+
if len(type_parts) < 2 or not all(
|
|
138
|
+
part.isidentifier() for part in type_parts
|
|
139
|
+
):
|
|
140
|
+
raise HarnessError(
|
|
141
|
+
f"{rule_context}.type must be a fully qualified exception type"
|
|
142
|
+
)
|
|
143
|
+
exception_type = exception_type.strip()
|
|
144
|
+
else:
|
|
145
|
+
if not isinstance(match, str) or not match:
|
|
146
|
+
raise HarnessError(
|
|
147
|
+
f"{rule_context}.match must be a non-empty string"
|
|
148
|
+
)
|
|
149
|
+
try:
|
|
150
|
+
re.compile(match)
|
|
151
|
+
except re.error as exc:
|
|
152
|
+
raise HarnessError(
|
|
153
|
+
f"{rule_context}.match is not a valid regular expression: {exc}"
|
|
154
|
+
) from exc
|
|
155
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
156
|
+
raise HarnessError(f"{rule_context}.reason must be a non-empty string")
|
|
157
|
+
validated_rules.append(
|
|
158
|
+
ExpectedError(
|
|
159
|
+
reason=reason.strip(), exception_type=exception_type, match=match
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
validated_expected_errors[method] = tuple(validated_rules)
|
|
163
|
+
|
|
65
164
|
regression_seeds = config.get("regression_seeds", [])
|
|
66
165
|
if not isinstance(regression_seeds, list):
|
|
67
166
|
raise HarnessError(f"{config_path}: regression_seeds must be an array")
|
|
@@ -88,6 +187,7 @@ def load_e2e_config(config_path: Path) -> E2EConfig:
|
|
|
88
187
|
return E2EConfig(
|
|
89
188
|
skip_methods=validated_skip_methods,
|
|
90
189
|
regression_seeds=tuple(validated_regression_seeds),
|
|
190
|
+
expected_errors=validated_expected_errors,
|
|
91
191
|
)
|
|
92
192
|
|
|
93
193
|
|
|
@@ -126,6 +226,9 @@ class QuestionCase:
|
|
|
126
226
|
def e2e_skip_reason(self, method: LifecycleMethod) -> str | None:
|
|
127
227
|
return self.e2e_config().skip_methods.get(method)
|
|
128
228
|
|
|
229
|
+
def e2e_expected_errors(self, method: LifecycleMethod) -> tuple[ExpectedError, ...]:
|
|
230
|
+
return self.e2e_config().expected_errors.get(method, ())
|
|
231
|
+
|
|
129
232
|
def unvalidated_metadata(self) -> dict[str, Any]:
|
|
130
233
|
value = load_json(self.info_path)
|
|
131
234
|
return value if isinstance(value, dict) else {}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Session-level accounting for configured expected upstream errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class ExpectedErrorGroup:
|
|
10
|
+
"""A question lifecycle method whose expected errors are alternatives."""
|
|
11
|
+
|
|
12
|
+
group_id: str
|
|
13
|
+
question: str
|
|
14
|
+
method: str
|
|
15
|
+
config_path: str
|
|
16
|
+
|
|
17
|
+
def payload(self) -> dict[str, str]:
|
|
18
|
+
return {
|
|
19
|
+
"id": self.group_id,
|
|
20
|
+
"question": self.question,
|
|
21
|
+
"method": self.method,
|
|
22
|
+
"config_path": self.config_path,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class ExpectedErrorAudit:
|
|
28
|
+
"""Track expected-error groups reached and observed during one pytest run."""
|
|
29
|
+
|
|
30
|
+
required: dict[str, ExpectedErrorGroup] = field(default_factory=dict)
|
|
31
|
+
matched: set[str] = field(default_factory=set)
|
|
32
|
+
|
|
33
|
+
def require(
|
|
34
|
+
self, *, question: str, method: str, config_path: str
|
|
35
|
+
) -> ExpectedErrorGroup:
|
|
36
|
+
group_id = f"{question}::{method}"
|
|
37
|
+
group = ExpectedErrorGroup(group_id, question, method, config_path)
|
|
38
|
+
existing = self.required.setdefault(group_id, group)
|
|
39
|
+
if existing != group:
|
|
40
|
+
raise ValueError(f"conflicting expected-error group {group_id!r}")
|
|
41
|
+
return existing
|
|
42
|
+
|
|
43
|
+
def record_match(self, group: ExpectedErrorGroup) -> None:
|
|
44
|
+
if self.required.get(group.group_id) != group:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"expected-error group {group.group_id!r} was not registered"
|
|
47
|
+
)
|
|
48
|
+
self.matched.add(group.group_id)
|
|
49
|
+
|
|
50
|
+
def missing(self) -> tuple[ExpectedErrorGroup, ...]:
|
|
51
|
+
return tuple(
|
|
52
|
+
self.required[group_id]
|
|
53
|
+
for group_id in sorted(set(self.required) - self.matched)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def payload(self) -> dict[str, object]:
|
|
57
|
+
return {
|
|
58
|
+
"required": [
|
|
59
|
+
self.required[group_id].payload() for group_id in sorted(self.required)
|
|
60
|
+
],
|
|
61
|
+
"matched": sorted(self.matched),
|
|
62
|
+
}
|
|
@@ -5,8 +5,9 @@ from __future__ import annotations
|
|
|
5
5
|
import secrets
|
|
6
6
|
import shlex
|
|
7
7
|
import sys
|
|
8
|
+
import time
|
|
8
9
|
import warnings
|
|
9
|
-
from collections.abc import Hashable
|
|
10
|
+
from collections.abc import Generator, Hashable
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from typing import Any, cast
|
|
12
13
|
|
|
@@ -16,12 +17,14 @@ from .backend import PrairieLearnBackend
|
|
|
16
17
|
from .config import HarnessConfig, load_config
|
|
17
18
|
from .discovery import QuestionCase, discover_questions, variant_seeds
|
|
18
19
|
from .errors import HarnessError
|
|
20
|
+
from .expected_errors import ExpectedErrorAudit, ExpectedErrorGroup
|
|
19
21
|
from .reporting import ShardReportRecorder, serialize_pytest_report
|
|
20
22
|
from .sharding import Shard, parse_shard, selected_group_keys
|
|
21
23
|
|
|
22
24
|
_CONFIG_KEY: pytest.StashKey[HarnessConfig] = pytest.StashKey()
|
|
23
25
|
_REPORTER_KEY: pytest.StashKey[ShardReportRecorder] = pytest.StashKey()
|
|
24
26
|
_EMPTY_SHARD_KEY: pytest.StashKey[bool] = pytest.StashKey()
|
|
27
|
+
_EXPECTED_ERROR_AUDIT_KEY: pytest.StashKey[ExpectedErrorAudit] = pytest.StashKey()
|
|
25
28
|
_active_recorder: ShardReportRecorder | None = None
|
|
26
29
|
|
|
27
30
|
|
|
@@ -98,6 +101,9 @@ def _course_root(config: pytest.Config) -> Path:
|
|
|
98
101
|
def pytest_configure(config: pytest.Config) -> None:
|
|
99
102
|
global _active_recorder
|
|
100
103
|
|
|
104
|
+
expected_error_audit = ExpectedErrorAudit()
|
|
105
|
+
config.stash[_EXPECTED_ERROR_AUDIT_KEY] = expected_error_audit
|
|
106
|
+
|
|
101
107
|
for marker, description in (
|
|
102
108
|
("generate", "question generate-phase lifecycle checks"),
|
|
103
109
|
("prepare", "question prepare-phase lifecycle checks"),
|
|
@@ -140,6 +146,7 @@ def pytest_configure(config: pytest.Config) -> None:
|
|
|
140
146
|
recorder = ShardReportRecorder(
|
|
141
147
|
path=report_path, shard=cast(Shard, shard), fuzz_seed=fuzz_seed
|
|
142
148
|
)
|
|
149
|
+
recorder.expected_error_audit = expected_error_audit
|
|
143
150
|
config.stash[_REPORTER_KEY] = recorder
|
|
144
151
|
_active_recorder = recorder
|
|
145
152
|
|
|
@@ -279,6 +286,44 @@ def pytest_runtest_logreport(report: pytest.TestReport) -> None:
|
|
|
279
286
|
_active_recorder.reports.append(serialize_pytest_report(report))
|
|
280
287
|
|
|
281
288
|
|
|
289
|
+
def _missing_expected_error_report(group: ExpectedErrorGroup) -> pytest.TestReport:
|
|
290
|
+
now = time.time()
|
|
291
|
+
return pytest.TestReport(
|
|
292
|
+
nodeid=(
|
|
293
|
+
f"prairielearn_e2e::expected_error_audit[{group.question}:{group.method}]"
|
|
294
|
+
),
|
|
295
|
+
location=(group.config_path, None, f"expected error audit: {group.method}"),
|
|
296
|
+
keywords={},
|
|
297
|
+
outcome="failed",
|
|
298
|
+
longrepr=(
|
|
299
|
+
f"Expected upstream error was not observed for {group.question} during "
|
|
300
|
+
f"{group.method}; all selected cases passed. Remove or update "
|
|
301
|
+
f"expected_errors in {group.config_path}."
|
|
302
|
+
),
|
|
303
|
+
when="call",
|
|
304
|
+
duration=0.0,
|
|
305
|
+
start=now,
|
|
306
|
+
stop=now,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@pytest.hookimpl(wrapper=True)
|
|
311
|
+
def pytest_runtestloop(session: pytest.Session) -> Generator[None, object, object]:
|
|
312
|
+
result = yield
|
|
313
|
+
shard = cast(Shard | None, session.config.getoption("shard"))
|
|
314
|
+
if shard is not None or session.testsfailed:
|
|
315
|
+
return result
|
|
316
|
+
audit = session.config.stash[_EXPECTED_ERROR_AUDIT_KEY]
|
|
317
|
+
missing = audit.missing()
|
|
318
|
+
session.testscollected += len(missing)
|
|
319
|
+
for group in missing:
|
|
320
|
+
session.config.hook.pytest_runtest_logreport(
|
|
321
|
+
report=_missing_expected_error_report(group)
|
|
322
|
+
)
|
|
323
|
+
session.testsfailed += 1
|
|
324
|
+
return result
|
|
325
|
+
|
|
326
|
+
|
|
282
327
|
def pytest_collectreport(report: pytest.CollectReport) -> None:
|
|
283
328
|
if _active_recorder is not None and report.failed:
|
|
284
329
|
_active_recorder.collection_errors.append(serialize_pytest_report(report))
|
|
@@ -359,3 +404,8 @@ def prairielearn_backend(
|
|
|
359
404
|
@pytest.fixture(scope="session")
|
|
360
405
|
def harness_config(request: pytest.FixtureRequest) -> HarnessConfig:
|
|
361
406
|
return request.config.stash[_CONFIG_KEY]
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@pytest.fixture(scope="session")
|
|
410
|
+
def expected_error_audit(request: pytest.FixtureRequest) -> ExpectedErrorAudit:
|
|
411
|
+
return request.config.stash[_EXPECTED_ERROR_AUDIT_KEY]
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from collections.abc import Callable
|
|
6
|
+
from typing import cast
|
|
6
7
|
|
|
7
8
|
import pytest
|
|
8
9
|
|
|
9
10
|
from .backend import PrairieLearnBackend
|
|
10
11
|
from .discovery import LifecycleMethod, QuestionCase
|
|
11
12
|
from .errors import UnsupportedQuestion
|
|
13
|
+
from .expected_errors import ExpectedErrorAudit
|
|
12
14
|
from .harness import QuestionHarness
|
|
13
15
|
|
|
14
16
|
type LifecycleCheck = Callable[[QuestionHarness, int], object]
|
|
@@ -26,6 +28,18 @@ def _run_check(
|
|
|
26
28
|
assert question is not None
|
|
27
29
|
if reason := question.e2e_skip_reason(method):
|
|
28
30
|
pytest.skip(reason)
|
|
31
|
+
expected_errors = question.e2e_expected_errors(method)
|
|
32
|
+
audit: ExpectedErrorAudit | None = None
|
|
33
|
+
group = None
|
|
34
|
+
if expected_errors:
|
|
35
|
+
audit = cast(
|
|
36
|
+
ExpectedErrorAudit, request.getfixturevalue("expected_error_audit")
|
|
37
|
+
)
|
|
38
|
+
group = audit.require(
|
|
39
|
+
question=question.qid,
|
|
40
|
+
method=method,
|
|
41
|
+
config_path=str(question.e2e_config_path),
|
|
42
|
+
)
|
|
29
43
|
backend: PrairieLearnBackend = request.getfixturevalue("prairielearn_backend")
|
|
30
44
|
try:
|
|
31
45
|
check(
|
|
@@ -36,6 +50,19 @@ def _run_check(
|
|
|
36
50
|
)
|
|
37
51
|
except UnsupportedQuestion as exc:
|
|
38
52
|
pytest.skip(str(exc))
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
matching_rule = next(
|
|
55
|
+
(rule for rule in expected_errors if rule.matches(exc)), None
|
|
56
|
+
)
|
|
57
|
+
if matching_rule is None:
|
|
58
|
+
raise
|
|
59
|
+
assert audit is not None and group is not None
|
|
60
|
+
audit.record_match(group)
|
|
61
|
+
exception_type = type(exc)
|
|
62
|
+
qualified_type = f"{exception_type.__module__}.{exception_type.__qualname__}"
|
|
63
|
+
pytest.xfail(
|
|
64
|
+
f"expected upstream error: {matching_rule.reason} [{qualified_type}: {exc}]"
|
|
65
|
+
)
|
|
39
66
|
|
|
40
67
|
|
|
41
68
|
@pytest.mark.generate
|