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.
- {pl_e2e_test-0.1.2/src/pl_e2e_test.egg-info → pl_e2e_test-0.1.4}/PKG-INFO +89 -1
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/README.md +87 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/pyproject.toml +2 -4
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4/src/pl_e2e_test.egg-info}/PKG-INFO +89 -1
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/SOURCES.txt +3 -1
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/requires.txt +1 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/__init__.py +5 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/cli.py +3 -1
- pl_e2e_test-0.1.4/src/prairielearn_e2e/config.py +68 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/fuzz.py +6 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/harness.py +5 -2
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/html_checks.py +18 -7
- pl_e2e_test-0.1.4/src/prairielearn_e2e/parallel.py +250 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/pytest_plugin.py +26 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/question_tests.py +6 -1
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/reporting.py +1 -1
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_cli.py +2 -2
- pl_e2e_test-0.1.4/tests/test_config.py +88 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_fuzz.py +7 -1
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_harness.py +29 -0
- pl_e2e_test-0.1.4/tests/test_parallel.py +400 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_pytest_plugin.py +2 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_reporting.py +1 -1
- pl_e2e_test-0.1.2/src/prairielearn_e2e/config.json +0 -7
- pl_e2e_test-0.1.2/src/prairielearn_e2e/config.py +0 -30
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/LICENSE +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/MANIFEST.in +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/setup.cfg +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/__main__.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/_git.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/backend.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/contract.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/discovery.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/errors.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/metadata.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/regression.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/repository.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/runner.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/server.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/src/prairielearn_e2e/sharding.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/__init__.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/conftest.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/stub_git.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_contract.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_discovery.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_git.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_html_checks.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_metadata.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_publish_version.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_regression.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_repository.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.4}/tests/test_server.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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
|
|
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(
|
|
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(
|
|
112
|
+
return _answer_names_matching_selectors(
|
|
113
|
+
html, config.element_solution_answer_selectors
|
|
114
|
+
)
|
|
111
115
|
|
|
112
116
|
|
|
113
|
-
def non_gradable_answer_names(
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
)
|