pl-e2e-test 0.1.2__tar.gz → 0.1.4__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 (56) hide show
  1. {pl_e2e_test-0.1.2/src/pl_e2e_test.egg-info → pl_e2e_test-0.1.4}/PKG-INFO +89 -1
  2. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/README.md +87 -0
  3. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/pyproject.toml +2 -4
  4. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4/src/pl_e2e_test.egg-info}/PKG-INFO +89 -1
  5. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/SOURCES.txt +3 -1
  6. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/requires.txt +1 -0
  7. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/__init__.py +5 -0
  8. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/cli.py +3 -1
  9. pl_e2e_test-0.1.4/src/prairielearn_e2e/config.py +68 -0
  10. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/fuzz.py +6 -0
  11. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/harness.py +5 -2
  12. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/html_checks.py +18 -7
  13. pl_e2e_test-0.1.4/src/prairielearn_e2e/parallel.py +250 -0
  14. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/pytest_plugin.py +26 -0
  15. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/question_tests.py +6 -1
  16. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/reporting.py +1 -1
  17. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_cli.py +2 -2
  18. pl_e2e_test-0.1.4/tests/test_config.py +88 -0
  19. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_fuzz.py +7 -1
  20. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_harness.py +29 -0
  21. pl_e2e_test-0.1.4/tests/test_parallel.py +400 -0
  22. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_pytest_plugin.py +2 -0
  23. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_reporting.py +1 -1
  24. pl_e2e_test-0.1.2/src/prairielearn_e2e/config.json +0 -7
  25. pl_e2e_test-0.1.2/src/prairielearn_e2e/config.py +0 -30
  26. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/LICENSE +0 -0
  27. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/MANIFEST.in +0 -0
  28. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/setup.cfg +0 -0
  29. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
  30. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
  31. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
  32. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/__main__.py +0 -0
  33. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/_git.py +0 -0
  34. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/backend.py +0 -0
  35. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/contract.py +0 -0
  36. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/discovery.py +0 -0
  37. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/errors.py +0 -0
  38. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/metadata.py +0 -0
  39. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/regression.py +0 -0
  40. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/repository.py +0 -0
  41. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/runner.py +0 -0
  42. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/server.py +0 -0
  43. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/sharding.py +0 -0
  44. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/__init__.py +0 -0
  45. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/conftest.py +0 -0
  46. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/stub_git.py +0 -0
  47. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_contract.py +0 -0
  48. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_discovery.py +0 -0
  49. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_git.py +0 -0
  50. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_html_checks.py +0 -0
  51. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_metadata.py +0 -0
  52. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_publish_version.py +0 -0
  53. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_regression.py +0 -0
  54. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_repository.py +0 -0
  55. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_server.py +0 -0
  56. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/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.2
3
+ Version: 0.1.4
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
@@ -18,6 +18,7 @@ Requires-Dist: platformdirs>=4.5
18
18
  Requires-Dist: prairielearn
19
19
  Requires-Dist: pyright>=1.1.414
20
20
  Requires-Dist: pytest>=9.1
21
+ Requires-Dist: rich>=14
21
22
  Requires-Dist: ruff>=0.16
22
23
  Dynamic: license-file
23
24
 
@@ -34,6 +35,7 @@ The package installs the reusable `prairielearn_e2e` Python API and a single
34
35
  - `pl-e2e-test` (no command, or any pytest/harness options) runs the installed
35
36
  lifecycle pytest suite.
36
37
  - `pl-e2e-test diff` fuzzes questions selected from a Git diff.
38
+ - `pl-e2e-test parallel` runs local shards and merges their results.
37
39
  - `pl-e2e-test add-seed` records a failing concrete variant seed.
38
40
  - `pl-e2e-test reports` merges parallel shard results into terminal and JUnit reports.
39
41
  - `pl-e2e-test version` prints the installed version.
@@ -96,6 +98,68 @@ uv run pl-e2e-test \
96
98
  variant set with `--fuzz-seed N`. Without either option, deterministic seeds start at
97
99
  zero. Question metadata with `singleVariant: true` always uses seed zero.
98
100
 
101
+ ### Local parallel runs
102
+
103
+ Run multiple shards in one machine or CI job, with six concurrent processes by default:
104
+
105
+ ```sh
106
+ uv run pl-e2e-test parallel --shard-count 6 run -- \
107
+ --question-path questions --fuzz-seeds
108
+
109
+ uv run pl-e2e-test parallel --shard-count 6 --junitxml results.xml diff -- \
110
+ --base main --fuzz-seed 42
111
+ ```
112
+
113
+ Launcher options go before the required `run` or `diff` selector; arguments after
114
+ `--` are forwarded to that command. Normal runs remain deterministic. For
115
+ `--fuzz-seeds` and diff runs, the launcher generates one shared master seed unless
116
+ `--fuzz-seed` is supplied, and prints a command for replaying the run without sharding.
117
+
118
+ Each shard runs in a separate Python process using the current environment and
119
+ working directory. Terminals show Rich progress; redirected output prints completion
120
+ lines. All shards finish before reports are merged, even when a test fails. Captured
121
+ failure output and successful stderr are shown before the combined summary.
122
+ Interrupting the launcher stops and reaps its children.
123
+
124
+ Intermediate reports are temporary. Pass the launcher's `--junitxml` option to retain
125
+ merged JUnit output. Forwarded `--shard`, `--shard-report`, `--junitxml`, and
126
+ `--junit-xml` options are rejected because the launcher manages those outputs.
127
+ Choose the course with the working directory, or `course_root` in the Python API.
128
+
129
+ The same orchestration is available to Python callers:
130
+
131
+ ```python
132
+ from prairielearn_e2e import run_parallel
133
+
134
+ status = run_parallel(
135
+ ["--question-path", "questions", "--fuzz-seeds"],
136
+ command="run",
137
+ shard_count=6,
138
+ course_root="/path/to/course",
139
+ junitxml="results.xml",
140
+ )
141
+ ```
142
+
143
+ `run_parallel` returns an integer exit status, including 130 on interruption.
144
+ Relative JUnit paths are resolved against the course root. With `junitxml=None`,
145
+ merged output is temporary. Invalid launcher settings raise `ValueError`.
146
+
147
+ Courses using `scripts/run_e2e_shards.py` can replace its invocation with
148
+ `pl-e2e-test parallel`, move `--fuzz-seed` into the forwarded arguments, and explicitly
149
+ pass `--fuzz-seeds` for full-suite runs to preserve the script's fuzzing behavior.
150
+ For example:
151
+
152
+ ```make
153
+ test-e2e-sharded:
154
+ uv run --active pl-e2e-test parallel --shard-count "$(E2E_SHARD_COUNT)" run -- \
155
+ $(E2E_TEST_ARGS) $(E2E_PRAIRIELEARN_ARG) --fuzz-seeds $(E2E_FUZZ_SEED_ARG) $(PYTEST_ARGS)
156
+
157
+ test-e2e-fuzz-sharded:
158
+ uv run --active pl-e2e-test parallel --shard-count "$(E2E_SHARD_COUNT)" diff -- \
159
+ --base "$(E2E_DIFF_BASE)" --head "$(E2E_DIFF_HEAD)" \
160
+ --seed-count "$(E2E_FUZZ_SEED_COUNT)" $(E2E_FUZZ_SEED_ARG) $(E2E_PRAIRIELEARN_ARG) $(PYTEST_ARGS)
161
+ ```
162
+
99
163
  ### CI sharding
100
164
 
101
165
  Use `--shard INDEX/COUNT` to divide the selected question variants between parallel
@@ -191,6 +255,25 @@ element controllers are archived into the test run's temporary directory. Standa
191
255
  cache environment settings such as `XDG_CACHE_HOME` can relocate the managed cache;
192
256
  CI systems can persist that directory between runs if desired.
193
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
+
194
277
  ### Question-local configuration
195
278
 
196
279
  Store saved concrete seeds and narrow phase skips in `.question-e2e.json` beside the
@@ -267,6 +350,11 @@ from prairielearn_e2e import PrairieLearnBackend, QuestionCase, QuestionHarness
267
350
  increasingly complete portions of the lifecycle. `run_variant` remains an alias for
268
351
  the complete grade path.
269
352
 
353
+ Pass `config=load_config(course_root=course_root)` to `QuestionHarness` to load the
354
+ course's TOML settings, or `config=load_config("settings.toml")` for an explicit file.
355
+ `load_config` and `HarnessConfig` are exported from `prairielearn_e2e`. Omitting
356
+ `config` uses the built-in defaults.
357
+
270
358
  ## Development
271
359
 
272
360
  This project uses uv 0.9 or newer:
@@ -11,6 +11,7 @@ The package installs the reusable `prairielearn_e2e` Python API and a single
11
11
  - `pl-e2e-test` (no command, or any pytest/harness options) runs the installed
12
12
  lifecycle pytest suite.
13
13
  - `pl-e2e-test diff` fuzzes questions selected from a Git diff.
14
+ - `pl-e2e-test parallel` runs local shards and merges their results.
14
15
  - `pl-e2e-test add-seed` records a failing concrete variant seed.
15
16
  - `pl-e2e-test reports` merges parallel shard results into terminal and JUnit reports.
16
17
  - `pl-e2e-test version` prints the installed version.
@@ -73,6 +74,68 @@ uv run pl-e2e-test \
73
74
  variant set with `--fuzz-seed N`. Without either option, deterministic seeds start at
74
75
  zero. Question metadata with `singleVariant: true` always uses seed zero.
75
76
 
77
+ ### Local parallel runs
78
+
79
+ Run multiple shards in one machine or CI job, with six concurrent processes by default:
80
+
81
+ ```sh
82
+ uv run pl-e2e-test parallel --shard-count 6 run -- \
83
+ --question-path questions --fuzz-seeds
84
+
85
+ uv run pl-e2e-test parallel --shard-count 6 --junitxml results.xml diff -- \
86
+ --base main --fuzz-seed 42
87
+ ```
88
+
89
+ Launcher options go before the required `run` or `diff` selector; arguments after
90
+ `--` are forwarded to that command. Normal runs remain deterministic. For
91
+ `--fuzz-seeds` and diff runs, the launcher generates one shared master seed unless
92
+ `--fuzz-seed` is supplied, and prints a command for replaying the run without sharding.
93
+
94
+ Each shard runs in a separate Python process using the current environment and
95
+ working directory. Terminals show Rich progress; redirected output prints completion
96
+ lines. All shards finish before reports are merged, even when a test fails. Captured
97
+ failure output and successful stderr are shown before the combined summary.
98
+ Interrupting the launcher stops and reaps its children.
99
+
100
+ Intermediate reports are temporary. Pass the launcher's `--junitxml` option to retain
101
+ merged JUnit output. Forwarded `--shard`, `--shard-report`, `--junitxml`, and
102
+ `--junit-xml` options are rejected because the launcher manages those outputs.
103
+ Choose the course with the working directory, or `course_root` in the Python API.
104
+
105
+ The same orchestration is available to Python callers:
106
+
107
+ ```python
108
+ from prairielearn_e2e import run_parallel
109
+
110
+ status = run_parallel(
111
+ ["--question-path", "questions", "--fuzz-seeds"],
112
+ command="run",
113
+ shard_count=6,
114
+ course_root="/path/to/course",
115
+ junitxml="results.xml",
116
+ )
117
+ ```
118
+
119
+ `run_parallel` returns an integer exit status, including 130 on interruption.
120
+ Relative JUnit paths are resolved against the course root. With `junitxml=None`,
121
+ merged output is temporary. Invalid launcher settings raise `ValueError`.
122
+
123
+ Courses using `scripts/run_e2e_shards.py` can replace its invocation with
124
+ `pl-e2e-test parallel`, move `--fuzz-seed` into the forwarded arguments, and explicitly
125
+ pass `--fuzz-seeds` for full-suite runs to preserve the script's fuzzing behavior.
126
+ For example:
127
+
128
+ ```make
129
+ test-e2e-sharded:
130
+ uv run --active pl-e2e-test parallel --shard-count "$(E2E_SHARD_COUNT)" run -- \
131
+ $(E2E_TEST_ARGS) $(E2E_PRAIRIELEARN_ARG) --fuzz-seeds $(E2E_FUZZ_SEED_ARG) $(PYTEST_ARGS)
132
+
133
+ test-e2e-fuzz-sharded:
134
+ uv run --active pl-e2e-test parallel --shard-count "$(E2E_SHARD_COUNT)" diff -- \
135
+ --base "$(E2E_DIFF_BASE)" --head "$(E2E_DIFF_HEAD)" \
136
+ --seed-count "$(E2E_FUZZ_SEED_COUNT)" $(E2E_FUZZ_SEED_ARG) $(E2E_PRAIRIELEARN_ARG) $(PYTEST_ARGS)
137
+ ```
138
+
76
139
  ### CI sharding
77
140
 
78
141
  Use `--shard INDEX/COUNT` to divide the selected question variants between parallel
@@ -168,6 +231,25 @@ element controllers are archived into the test run's temporary directory. Standa
168
231
  cache environment settings such as `XDG_CACHE_HOME` can relocate the managed cache;
169
232
  CI systems can persist that directory between runs if desired.
170
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
+
171
253
  ### Question-local configuration
172
254
 
173
255
  Store saved concrete seeds and narrow phase skips in `.question-e2e.json` beside the
@@ -244,6 +326,11 @@ from prairielearn_e2e import PrairieLearnBackend, QuestionCase, QuestionHarness
244
326
  increasingly complete portions of the lifecycle. `run_variant` remains an alias for
245
327
  the complete grade path.
246
328
 
329
+ Pass `config=load_config(course_root=course_root)` to `QuestionHarness` to load the
330
+ course's TOML settings, or `config=load_config("settings.toml")` for an explicit file.
331
+ `load_config` and `HarnessConfig` are exported from `prairielearn_e2e`. Omitting
332
+ `config` uses the built-in defaults.
333
+
247
334
  ## Development
248
335
 
249
336
  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.2"
7
+ version = "0.1.4"
8
8
  description = "Fuzz PrairieLearn question lifecycles against the real Python element runtime"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -19,6 +19,7 @@ dependencies = [
19
19
  "prairielearn",
20
20
  "pyright>=1.1.414",
21
21
  "pytest>=9.1",
22
+ "rich>=14",
22
23
  "ruff>=0.16",
23
24
  ]
24
25
 
@@ -36,9 +37,6 @@ pl-e2e-test = "prairielearn_e2e.cli:main"
36
37
  [tool.setuptools.packages.find]
37
38
  where = ["src"]
38
39
 
39
- [tool.setuptools.package-data]
40
- prairielearn_e2e = ["config.json"]
41
-
42
40
  [tool.pytest.ini_options]
43
41
  testpaths = ["tests"]
44
42
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pl-e2e-test
3
- Version: 0.1.2
3
+ Version: 0.1.4
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
@@ -18,6 +18,7 @@ Requires-Dist: platformdirs>=4.5
18
18
  Requires-Dist: prairielearn
19
19
  Requires-Dist: pyright>=1.1.414
20
20
  Requires-Dist: pytest>=9.1
21
+ Requires-Dist: rich>=14
21
22
  Requires-Dist: ruff>=0.16
22
23
  Dynamic: license-file
23
24
 
@@ -34,6 +35,7 @@ The package installs the reusable `prairielearn_e2e` Python API and a single
34
35
  - `pl-e2e-test` (no command, or any pytest/harness options) runs the installed
35
36
  lifecycle pytest suite.
36
37
  - `pl-e2e-test diff` fuzzes questions selected from a Git diff.
38
+ - `pl-e2e-test parallel` runs local shards and merges their results.
37
39
  - `pl-e2e-test add-seed` records a failing concrete variant seed.
38
40
  - `pl-e2e-test reports` merges parallel shard results into terminal and JUnit reports.
39
41
  - `pl-e2e-test version` prints the installed version.
@@ -96,6 +98,68 @@ uv run pl-e2e-test \
96
98
  variant set with `--fuzz-seed N`. Without either option, deterministic seeds start at
97
99
  zero. Question metadata with `singleVariant: true` always uses seed zero.
98
100
 
101
+ ### Local parallel runs
102
+
103
+ Run multiple shards in one machine or CI job, with six concurrent processes by default:
104
+
105
+ ```sh
106
+ uv run pl-e2e-test parallel --shard-count 6 run -- \
107
+ --question-path questions --fuzz-seeds
108
+
109
+ uv run pl-e2e-test parallel --shard-count 6 --junitxml results.xml diff -- \
110
+ --base main --fuzz-seed 42
111
+ ```
112
+
113
+ Launcher options go before the required `run` or `diff` selector; arguments after
114
+ `--` are forwarded to that command. Normal runs remain deterministic. For
115
+ `--fuzz-seeds` and diff runs, the launcher generates one shared master seed unless
116
+ `--fuzz-seed` is supplied, and prints a command for replaying the run without sharding.
117
+
118
+ Each shard runs in a separate Python process using the current environment and
119
+ working directory. Terminals show Rich progress; redirected output prints completion
120
+ lines. All shards finish before reports are merged, even when a test fails. Captured
121
+ failure output and successful stderr are shown before the combined summary.
122
+ Interrupting the launcher stops and reaps its children.
123
+
124
+ Intermediate reports are temporary. Pass the launcher's `--junitxml` option to retain
125
+ merged JUnit output. Forwarded `--shard`, `--shard-report`, `--junitxml`, and
126
+ `--junit-xml` options are rejected because the launcher manages those outputs.
127
+ Choose the course with the working directory, or `course_root` in the Python API.
128
+
129
+ The same orchestration is available to Python callers:
130
+
131
+ ```python
132
+ from prairielearn_e2e import run_parallel
133
+
134
+ status = run_parallel(
135
+ ["--question-path", "questions", "--fuzz-seeds"],
136
+ command="run",
137
+ shard_count=6,
138
+ course_root="/path/to/course",
139
+ junitxml="results.xml",
140
+ )
141
+ ```
142
+
143
+ `run_parallel` returns an integer exit status, including 130 on interruption.
144
+ Relative JUnit paths are resolved against the course root. With `junitxml=None`,
145
+ merged output is temporary. Invalid launcher settings raise `ValueError`.
146
+
147
+ Courses using `scripts/run_e2e_shards.py` can replace its invocation with
148
+ `pl-e2e-test parallel`, move `--fuzz-seed` into the forwarded arguments, and explicitly
149
+ pass `--fuzz-seeds` for full-suite runs to preserve the script's fuzzing behavior.
150
+ For example:
151
+
152
+ ```make
153
+ test-e2e-sharded:
154
+ uv run --active pl-e2e-test parallel --shard-count "$(E2E_SHARD_COUNT)" run -- \
155
+ $(E2E_TEST_ARGS) $(E2E_PRAIRIELEARN_ARG) --fuzz-seeds $(E2E_FUZZ_SEED_ARG) $(PYTEST_ARGS)
156
+
157
+ test-e2e-fuzz-sharded:
158
+ uv run --active pl-e2e-test parallel --shard-count "$(E2E_SHARD_COUNT)" diff -- \
159
+ --base "$(E2E_DIFF_BASE)" --head "$(E2E_DIFF_HEAD)" \
160
+ --seed-count "$(E2E_FUZZ_SEED_COUNT)" $(E2E_FUZZ_SEED_ARG) $(E2E_PRAIRIELEARN_ARG) $(PYTEST_ARGS)
161
+ ```
162
+
99
163
  ### CI sharding
100
164
 
101
165
  Use `--shard INDEX/COUNT` to divide the selected question variants between parallel
@@ -191,6 +255,25 @@ element controllers are archived into the test run's temporary directory. Standa
191
255
  cache environment settings such as `XDG_CACHE_HOME` can relocate the managed cache;
192
256
  CI systems can persist that directory between runs if desired.
193
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
+
194
277
  ### Question-local configuration
195
278
 
196
279
  Store saved concrete seeds and narrow phase skips in `.question-e2e.json` beside the
@@ -267,6 +350,11 @@ from prairielearn_e2e import PrairieLearnBackend, QuestionCase, QuestionHarness
267
350
  increasingly complete portions of the lifecycle. `run_variant` remains an alias for
268
351
  the complete grade path.
269
352
 
353
+ Pass `config=load_config(course_root=course_root)` to `QuestionHarness` to load the
354
+ course's TOML settings, or `config=load_config("settings.toml")` for an explicit file.
355
+ `load_config` and `HarnessConfig` are exported from `prairielearn_e2e`. Omitting
356
+ `config` uses the built-in defaults.
357
+
270
358
  ## Development
271
359
 
272
360
  This project uses uv 0.9 or newer:
@@ -13,7 +13,6 @@ 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
@@ -22,6 +21,7 @@ src/prairielearn_e2e/fuzz.py
22
21
  src/prairielearn_e2e/harness.py
23
22
  src/prairielearn_e2e/html_checks.py
24
23
  src/prairielearn_e2e/metadata.py
24
+ src/prairielearn_e2e/parallel.py
25
25
  src/prairielearn_e2e/pytest_plugin.py
26
26
  src/prairielearn_e2e/question_tests.py
27
27
  src/prairielearn_e2e/regression.py
@@ -34,6 +34,7 @@ tests/__init__.py
34
34
  tests/conftest.py
35
35
  tests/stub_git.py
36
36
  tests/test_cli.py
37
+ tests/test_config.py
37
38
  tests/test_contract.py
38
39
  tests/test_discovery.py
39
40
  tests/test_fuzz.py
@@ -41,6 +42,7 @@ tests/test_git.py
41
42
  tests/test_harness.py
42
43
  tests/test_html_checks.py
43
44
  tests/test_metadata.py
45
+ tests/test_parallel.py
44
46
  tests/test_publish_version.py
45
47
  tests/test_pytest_plugin.py
46
48
  tests/test_regression.py
@@ -7,4 +7,5 @@ platformdirs>=4.5
7
7
  prairielearn
8
8
  pyright>=1.1.414
9
9
  pytest>=9.1
10
+ rich>=14
10
11
  ruff>=0.16
@@ -3,16 +3,21 @@
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
9
+ from .parallel import run_parallel
8
10
 
9
11
  __version__ = version("pl-e2e-test")
10
12
 
11
13
  __all__ = (
14
+ "HarnessConfig",
12
15
  "LifecycleResult",
13
16
  "PrairieLearnBackend",
14
17
  "QuestionCase",
15
18
  "QuestionHarness",
16
19
  "__version__",
17
20
  "discover_questions",
21
+ "load_config",
22
+ "run_parallel",
18
23
  )
@@ -6,10 +6,11 @@ import sys
6
6
  from collections.abc import Sequence
7
7
  from importlib.metadata import version
8
8
 
9
- from . import fuzz, regression, reporting, runner
9
+ from . import fuzz, parallel, regression, reporting, runner
10
10
 
11
11
  _SUBCOMMANDS = {
12
12
  "diff": fuzz.main,
13
+ "parallel": parallel.main,
13
14
  "add-seed": regression.main,
14
15
  "reports": reporting.main,
15
16
  }
@@ -22,6 +23,7 @@ suite; any arguments (pytest options, --question-path, --seed-count, ...)
22
23
  are passed straight through to pytest.
23
24
 
24
25
  Commands:
26
+ parallel run local shards and merge their results
25
27
  diff fuzz questions changed between two Git refs
26
28
  add-seed record a concrete regression seed for a question
27
29
  reports merge parallel shard reports into one terminal/JUnit report
@@ -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
+ )
@@ -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))
@@ -13,6 +13,7 @@ import lxml.html
13
13
  import numpy as np
14
14
 
15
15
  from .backend import PrairieLearnBackend
16
+ from .config import DEFAULT_CONFIG, HarnessConfig
16
17
  from .contract import PHASE_FIELDS, Phase
17
18
  from .discovery import QuestionCase
18
19
  from .errors import HarnessError, UnsupportedQuestion
@@ -112,6 +113,7 @@ class QuestionHarness:
112
113
  backend: PrairieLearnBackend,
113
114
  *,
114
115
  course_root: str | Path | None = None,
116
+ config: HarnessConfig = DEFAULT_CONFIG,
115
117
  ) -> None:
116
118
  if isinstance(question, QuestionCase):
117
119
  self.question = question
@@ -124,6 +126,7 @@ class QuestionHarness:
124
126
  directory = directory.parent
125
127
  self.question = QuestionCase(directory.resolve(), root)
126
128
  self.backend = backend
129
+ self.config = config
127
130
 
128
131
  def _options(self) -> dict[str, Any]:
129
132
  directory = self.question.directory
@@ -333,12 +336,12 @@ class QuestionHarness:
333
336
 
334
337
  show_correct = metadata.get("showCorrectAnswer", True)
335
338
  answer_names = validate_declared_answers(
336
- prepared_html, base["correct_answers"], show_correct
339
+ prepared_html, base["correct_answers"], show_correct, self.config
337
340
  )
338
341
  tested_answer_names = tuple(
339
342
  name
340
343
  for name in answer_names
341
- if name not in non_gradable_answer_names(prepared_html)
344
+ if name not in non_gradable_answer_names(prepared_html, self.config)
342
345
  )
343
346
  rendered_question = self._render(
344
347
  source, base, server, panel="question", show_correct=show_correct
@@ -6,7 +6,7 @@ from typing import Any
6
6
 
7
7
  import lxml.html
8
8
 
9
- from .config import ELEMENT_SOLUTION_ANSWER_SELECTORS, NON_GRADABLE_ANSWER_SELECTORS
9
+ from .config import DEFAULT_CONFIG, HarnessConfig
10
10
  from .errors import HarnessError
11
11
 
12
12
  _SIMPLE_CSS_SELECTOR = re.compile(
@@ -100,17 +100,23 @@ def _answer_names_matching_selectors(
100
100
  return frozenset(names)
101
101
 
102
102
 
103
- def element_solution_answer_names(html: str) -> frozenset[str]:
103
+ def element_solution_answer_names(
104
+ html: str, config: HarnessConfig = DEFAULT_CONFIG
105
+ ) -> frozenset[str]:
104
106
  """Return answer names whose solution is owned by the element.
105
107
 
106
108
  These elements do not need an entry in ``correct_answers``, but they can
107
109
  still be graded and must remain part of the parse/grade lifecycle checks.
108
110
  """
109
111
 
110
- return _answer_names_matching_selectors(html, ELEMENT_SOLUTION_ANSWER_SELECTORS)
112
+ return _answer_names_matching_selectors(
113
+ html, config.element_solution_answer_selectors
114
+ )
111
115
 
112
116
 
113
- def non_gradable_answer_names(html: str) -> frozenset[str]:
117
+ def non_gradable_answer_names(
118
+ html: str, config: HarnessConfig = DEFAULT_CONFIG
119
+ ) -> frozenset[str]:
114
120
  """Return answer names intentionally excluded from server-side grading.
115
121
 
116
122
  Configured display-only elements still carry an ``answers-name`` for
@@ -118,15 +124,20 @@ def non_gradable_answer_names(html: str) -> frozenset[str]:
118
124
  partial score.
119
125
  """
120
126
 
121
- return _answer_names_matching_selectors(html, NON_GRADABLE_ANSWER_SELECTORS)
127
+ return _answer_names_matching_selectors(html, config.non_gradable_answer_selectors)
122
128
 
123
129
 
124
130
  def validate_declared_answers(
125
- html: str, correct_answers: dict[str, Any], show_correct_answer: bool
131
+ html: str,
132
+ correct_answers: dict[str, Any],
133
+ show_correct_answer: bool,
134
+ config: HarnessConfig = DEFAULT_CONFIG,
126
135
  ) -> tuple[str, ...]:
127
136
  names = declared_answer_names(html)
128
137
  if show_correct_answer:
129
- ignored = non_gradable_answer_names(html) | element_solution_answer_names(html)
138
+ ignored = non_gradable_answer_names(
139
+ html, config
140
+ ) | element_solution_answer_names(html, config)
130
141
  require_correct_answers(
131
142
  tuple(name for name in names if name not in ignored), correct_answers
132
143
  )