pl-e2e-test 0.1.3__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.
Files changed (59) hide show
  1. {pl_e2e_test-0.1.3/src/pl_e2e_test.egg-info → pl_e2e_test-0.1.5}/PKG-INFO +50 -3
  2. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/README.md +49 -2
  3. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/pyproject.toml +7 -4
  4. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5/src/pl_e2e_test.egg-info}/PKG-INFO +50 -3
  5. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/SOURCES.txt +3 -1
  6. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/__init__.py +3 -0
  7. pl_e2e_test-0.1.5/src/prairielearn_e2e/config.py +68 -0
  8. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/discovery.py +106 -3
  9. pl_e2e_test-0.1.5/src/prairielearn_e2e/expected_errors.py +62 -0
  10. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/fuzz.py +6 -0
  11. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/harness.py +5 -2
  12. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/html_checks.py +18 -7
  13. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/pytest_plugin.py +77 -1
  14. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/question_tests.py +33 -1
  15. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/reporting.py +83 -2
  16. pl_e2e_test-0.1.5/tests/test_config.py +88 -0
  17. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_discovery.py +86 -0
  18. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_fuzz.py +7 -1
  19. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_harness.py +29 -0
  20. pl_e2e_test-0.1.5/tests/test_pytest_plugin.py +202 -0
  21. pl_e2e_test-0.1.5/tests/test_question_tests.py +130 -0
  22. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_reporting.py +115 -1
  23. pl_e2e_test-0.1.3/src/prairielearn_e2e/config.json +0 -7
  24. pl_e2e_test-0.1.3/src/prairielearn_e2e/config.py +0 -30
  25. pl_e2e_test-0.1.3/tests/test_pytest_plugin.py +0 -84
  26. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/LICENSE +0 -0
  27. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/MANIFEST.in +0 -0
  28. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/setup.cfg +0 -0
  29. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
  30. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
  31. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/requires.txt +0 -0
  32. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
  33. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/__main__.py +0 -0
  34. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/_git.py +0 -0
  35. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/backend.py +0 -0
  36. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/cli.py +0 -0
  37. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/contract.py +0 -0
  38. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/errors.py +0 -0
  39. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/metadata.py +0 -0
  40. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/parallel.py +0 -0
  41. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/regression.py +0 -0
  42. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/repository.py +0 -0
  43. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/runner.py +0 -0
  44. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/server.py +0 -0
  45. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/sharding.py +0 -0
  46. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/__init__.py +0 -0
  47. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/conftest.py +0 -0
  48. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/stub_git.py +0 -0
  49. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_cli.py +0 -0
  50. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_contract.py +0 -0
  51. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_git.py +0 -0
  52. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_html_checks.py +0 -0
  53. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_metadata.py +0 -0
  54. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_parallel.py +0 -0
  55. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_publish_version.py +0 -0
  56. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_regression.py +0 -0
  57. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_repository.py +0 -0
  58. {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_server.py +0 -0
  59. {pl_e2e_test-0.1.3 → 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
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
@@ -255,16 +255,43 @@ element controllers are archived into the test run's temporary directory. Standa
255
255
  cache environment settings such as `XDG_CACHE_HOME` can relocate the managed cache;
256
256
  CI systems can persist that directory between runs if desired.
257
257
 
258
+ ### Harness configuration
259
+
260
+ Selector settings are read automatically from the course's `pyproject.toml`:
261
+
262
+ ```toml
263
+ [tool.pl-e2e-test]
264
+ elementSolutionSelectors = ["pl-sketch"]
265
+ nonGradableSelectors = ['pl-sketch[read-only="true"]', 'pl-sketch[read-only="1"]']
266
+ ```
267
+
268
+ These are the defaults. `elementSolutionSelectors` identifies elements that provide
269
+ solutions without a `correct_answers` entry. `nonGradableSelectors` excludes
270
+ display-only answers from grading checks. Each supplied list replaces its default;
271
+ omitted settings keep their defaults, and `[]` disables a list.
272
+
273
+ Use `--config path/to/settings.toml` to select another TOML file with the same
274
+ `[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
275
+ `parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
276
+
258
277
  ### Question-local configuration
259
278
 
260
- Store saved concrete seeds and narrow phase skips in `.question-e2e.json` beside the
261
- 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`:
262
281
 
263
282
  ```json
264
283
  {
265
284
  "regression_seeds": [1843927501],
266
285
  "skip_methods": {
267
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
+ ]
268
295
  }
269
296
  }
270
297
  ```
@@ -273,6 +300,21 @@ Saved regression seeds run before newly generated seeds. Skips require a nonempt
273
300
  reason and may name only `generate`, `prepare`, `render`, or `grade`. Manual-grading
274
301
  questions automatically skip only the grade check.
275
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
+
276
318
  Record the concrete seed shown in a failing pytest case with:
277
319
 
278
320
  ```sh
@@ -331,6 +373,11 @@ from prairielearn_e2e import PrairieLearnBackend, QuestionCase, QuestionHarness
331
373
  increasingly complete portions of the lifecycle. `run_variant` remains an alias for
332
374
  the complete grade path.
333
375
 
376
+ Pass `config=load_config(course_root=course_root)` to `QuestionHarness` to load the
377
+ course's TOML settings, or `config=load_config("settings.toml")` for an explicit file.
378
+ `load_config` and `HarnessConfig` are exported from `prairielearn_e2e`. Omitting
379
+ `config` uses the built-in defaults.
380
+
334
381
  ## Development
335
382
 
336
383
  This project uses uv 0.9 or newer:
@@ -231,16 +231,43 @@ element controllers are archived into the test run's temporary directory. Standa
231
231
  cache environment settings such as `XDG_CACHE_HOME` can relocate the managed cache;
232
232
  CI systems can persist that directory between runs if desired.
233
233
 
234
+ ### Harness configuration
235
+
236
+ Selector settings are read automatically from the course's `pyproject.toml`:
237
+
238
+ ```toml
239
+ [tool.pl-e2e-test]
240
+ elementSolutionSelectors = ["pl-sketch"]
241
+ nonGradableSelectors = ['pl-sketch[read-only="true"]', 'pl-sketch[read-only="1"]']
242
+ ```
243
+
244
+ These are the defaults. `elementSolutionSelectors` identifies elements that provide
245
+ solutions without a `correct_answers` entry. `nonGradableSelectors` excludes
246
+ display-only answers from grading checks. Each supplied list replaces its default;
247
+ omitted settings keep their defaults, and `[]` disables a list.
248
+
249
+ Use `--config path/to/settings.toml` to select another TOML file with the same
250
+ `[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
251
+ `parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
252
+
234
253
  ### Question-local configuration
235
254
 
236
- Store saved concrete seeds and narrow phase skips in `.question-e2e.json` beside the
237
- 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`:
238
257
 
239
258
  ```json
240
259
  {
241
260
  "regression_seeds": [1843927501],
242
261
  "skip_methods": {
243
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
+ ]
244
271
  }
245
272
  }
246
273
  ```
@@ -249,6 +276,21 @@ Saved regression seeds run before newly generated seeds. Skips require a nonempt
249
276
  reason and may name only `generate`, `prepare`, `render`, or `grade`. Manual-grading
250
277
  questions automatically skip only the grade check.
251
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
+
252
294
  Record the concrete seed shown in a failing pytest case with:
253
295
 
254
296
  ```sh
@@ -307,6 +349,11 @@ from prairielearn_e2e import PrairieLearnBackend, QuestionCase, QuestionHarness
307
349
  increasingly complete portions of the lifecycle. `run_variant` remains an alias for
308
350
  the complete grade path.
309
351
 
352
+ Pass `config=load_config(course_root=course_root)` to `QuestionHarness` to load the
353
+ course's TOML settings, or `config=load_config("settings.toml")` for an explicit file.
354
+ `load_config` and `HarnessConfig` are exported from `prairielearn_e2e`. Omitting
355
+ `config` uses the built-in defaults.
356
+
310
357
  ## Development
311
358
 
312
359
  This project uses uv 0.9 or newer:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pl-e2e-test"
7
- version = "0.1.3"
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"
@@ -37,11 +37,14 @@ pl-e2e-test = "prairielearn_e2e.cli:main"
37
37
  [tool.setuptools.packages.find]
38
38
  where = ["src"]
39
39
 
40
- [tool.setuptools.package-data]
41
- prairielearn_e2e = ["config.json"]
42
-
43
40
  [tool.pytest.ini_options]
44
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
+ ]
45
48
 
46
49
  [tool.pyright]
47
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
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
@@ -255,16 +255,43 @@ element controllers are archived into the test run's temporary directory. Standa
255
255
  cache environment settings such as `XDG_CACHE_HOME` can relocate the managed cache;
256
256
  CI systems can persist that directory between runs if desired.
257
257
 
258
+ ### Harness configuration
259
+
260
+ Selector settings are read automatically from the course's `pyproject.toml`:
261
+
262
+ ```toml
263
+ [tool.pl-e2e-test]
264
+ elementSolutionSelectors = ["pl-sketch"]
265
+ nonGradableSelectors = ['pl-sketch[read-only="true"]', 'pl-sketch[read-only="1"]']
266
+ ```
267
+
268
+ These are the defaults. `elementSolutionSelectors` identifies elements that provide
269
+ solutions without a `correct_answers` entry. `nonGradableSelectors` excludes
270
+ display-only answers from grading checks. Each supplied list replaces its default;
271
+ omitted settings keep their defaults, and `[]` disables a list.
272
+
273
+ Use `--config path/to/settings.toml` to select another TOML file with the same
274
+ `[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
275
+ `parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
276
+
258
277
  ### Question-local configuration
259
278
 
260
- Store saved concrete seeds and narrow phase skips in `.question-e2e.json` beside the
261
- 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`:
262
281
 
263
282
  ```json
264
283
  {
265
284
  "regression_seeds": [1843927501],
266
285
  "skip_methods": {
267
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
+ ]
268
295
  }
269
296
  }
270
297
  ```
@@ -273,6 +300,21 @@ Saved regression seeds run before newly generated seeds. Skips require a nonempt
273
300
  reason and may name only `generate`, `prepare`, `render`, or `grade`. Manual-grading
274
301
  questions automatically skip only the grade check.
275
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
+
276
318
  Record the concrete seed shown in a failing pytest case with:
277
319
 
278
320
  ```sh
@@ -331,6 +373,11 @@ from prairielearn_e2e import PrairieLearnBackend, QuestionCase, QuestionHarness
331
373
  increasingly complete portions of the lifecycle. `run_variant` remains an alias for
332
374
  the complete grade path.
333
375
 
376
+ Pass `config=load_config(course_root=course_root)` to `QuestionHarness` to load the
377
+ course's TOML settings, or `config=load_config("settings.toml")` for an explicit file.
378
+ `load_config` and `HarnessConfig` are exported from `prairielearn_e2e`. Omitting
379
+ `config` uses the built-in defaults.
380
+
334
381
  ## Development
335
382
 
336
383
  This project uses uv 0.9 or newer:
@@ -13,11 +13,11 @@ src/prairielearn_e2e/__main__.py
13
13
  src/prairielearn_e2e/_git.py
14
14
  src/prairielearn_e2e/backend.py
15
15
  src/prairielearn_e2e/cli.py
16
- src/prairielearn_e2e/config.json
17
16
  src/prairielearn_e2e/config.py
18
17
  src/prairielearn_e2e/contract.py
19
18
  src/prairielearn_e2e/discovery.py
20
19
  src/prairielearn_e2e/errors.py
20
+ src/prairielearn_e2e/expected_errors.py
21
21
  src/prairielearn_e2e/fuzz.py
22
22
  src/prairielearn_e2e/harness.py
23
23
  src/prairielearn_e2e/html_checks.py
@@ -35,6 +35,7 @@ tests/__init__.py
35
35
  tests/conftest.py
36
36
  tests/stub_git.py
37
37
  tests/test_cli.py
38
+ tests/test_config.py
38
39
  tests/test_contract.py
39
40
  tests/test_discovery.py
40
41
  tests/test_fuzz.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
@@ -3,6 +3,7 @@
3
3
  from importlib.metadata import version
4
4
 
5
5
  from .backend import PrairieLearnBackend
6
+ from .config import HarnessConfig, load_config
6
7
  from .discovery import QuestionCase, discover_questions
7
8
  from .harness import LifecycleResult, QuestionHarness
8
9
  from .parallel import run_parallel
@@ -10,11 +11,13 @@ from .parallel import run_parallel
10
11
  __version__ = version("pl-e2e-test")
11
12
 
12
13
  __all__ = (
14
+ "HarnessConfig",
13
15
  "LifecycleResult",
14
16
  "PrairieLearnBackend",
15
17
  "QuestionCase",
16
18
  "QuestionHarness",
17
19
  "__version__",
18
20
  "discover_questions",
21
+ "load_config",
19
22
  "run_parallel",
20
23
  )
@@ -0,0 +1,68 @@
1
+ """Load harness settings from the course's pyproject.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class HarnessConfig:
12
+ element_solution_answer_selectors: tuple[str, ...] = ("pl-sketch",)
13
+ non_gradable_answer_selectors: tuple[str, ...] = (
14
+ 'pl-sketch[read-only="true"]',
15
+ 'pl-sketch[read-only="1"]',
16
+ )
17
+
18
+
19
+ DEFAULT_CONFIG = HarnessConfig()
20
+
21
+
22
+ def load_config(
23
+ path: str | Path | None = None, *, course_root: str | Path | None = None
24
+ ) -> HarnessConfig:
25
+ """Read [tool.pl-e2e-test], preserving defaults for omitted settings.
26
+
27
+ Without an explicit path, look for pyproject.toml in the course root.
28
+ A missing implicit file is allowed; a missing explicit file is an error.
29
+ """
30
+ config_path = (
31
+ Path(path)
32
+ if path is not None
33
+ else Path(course_root or Path.cwd()) / "pyproject.toml"
34
+ )
35
+ try:
36
+ with config_path.open("rb") as config_file:
37
+ document = tomllib.load(config_file)
38
+ except FileNotFoundError:
39
+ if path is not None:
40
+ raise
41
+ return DEFAULT_CONFIG
42
+ tool = document.get("tool", {})
43
+ if not isinstance(tool, dict):
44
+ raise TypeError("tool must be a table")
45
+ config = tool.get("pl-e2e-test", {})
46
+ if not isinstance(config, dict):
47
+ raise TypeError("tool.pl-e2e-test must be a table")
48
+
49
+ def selectors(key: str, default: tuple[str, ...]) -> tuple[str, ...]:
50
+ if key not in config:
51
+ return default
52
+ values = config[key]
53
+ if not isinstance(values, list) or any(
54
+ not isinstance(value, str) or not value.strip() for value in values
55
+ ):
56
+ raise TypeError(
57
+ f"tool.pl-e2e-test.{key} must be an array of non-empty strings"
58
+ )
59
+ return tuple(values)
60
+
61
+ return HarnessConfig(
62
+ element_solution_answer_selectors=selectors(
63
+ "elementSolutionSelectors", DEFAULT_CONFIG.element_solution_answer_selectors
64
+ ),
65
+ non_gradable_answer_selectors=selectors(
66
+ "nonGradableSelectors", DEFAULT_CONFIG.non_gradable_answer_selectors
67
+ ),
68
+ )
@@ -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) - {"regression_seeds", "skip_methods"}:
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
+ }
@@ -197,6 +197,9 @@ def positive_integer(value: str) -> int:
197
197
  def main(argv: Sequence[str] | None = None) -> int:
198
198
  parser = argparse.ArgumentParser(prog="pl-e2e-test diff", description=__doc__)
199
199
  parser.add_argument("--course-root", type=Path)
200
+ parser.add_argument(
201
+ "--config", type=Path, help="TOML config (default: COURSE_ROOT/pyproject.toml)"
202
+ )
200
203
  parser.add_argument("--base", default="main", help="base Git ref (default: main)")
201
204
  parser.add_argument("--head", default="HEAD", help="head Git ref (default: HEAD)")
202
205
  parser.add_argument("--seed-count", type=positive_integer, default=10)
@@ -276,6 +279,9 @@ def main(argv: Sequence[str] | None = None) -> int:
276
279
  print(f"Variant seeds: {', '.join(str(seed) for seed in seeds)}")
277
280
  replay_arguments = ["pl-e2e-test", "diff", "--fuzz-seed", str(fuzz_seed)]
278
281
  test_arguments: list[str] = []
282
+ if args.config is not None:
283
+ replay_arguments.extend(("--config", os.fspath(args.config)))
284
+ test_arguments.extend(("--config", os.fspath(args.config)))
279
285
  if args.prairielearn_path is not None:
280
286
  replay_arguments.extend(
281
287
  ("--prairielearn-path", os.fspath(args.prairielearn_path))