pl-e2e-test 0.2.0__tar.gz → 0.2.2__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 (69) hide show
  1. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/MANIFEST.in +1 -0
  2. {pl_e2e_test-0.2.0/src/pl_e2e_test.egg-info → pl_e2e_test-0.2.2}/PKG-INFO +76 -26
  3. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/README.md +73 -25
  4. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/pyproject.toml +3 -1
  5. pl_e2e_test-0.2.2/scripts/demo_cli.py +219 -0
  6. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2/src/pl_e2e_test.egg-info}/PKG-INFO +76 -26
  7. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/pl_e2e_test.egg-info/SOURCES.txt +5 -0
  8. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/pl_e2e_test.egg-info/requires.txt +2 -0
  9. pl_e2e_test-0.2.2/src/prairielearn_e2e/_options.py +29 -0
  10. pl_e2e_test-0.2.2/src/prairielearn_e2e/cli.py +721 -0
  11. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/config.py +77 -5
  12. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/fuzz.py +116 -163
  13. pl_e2e_test-0.2.2/src/prairielearn_e2e/instances.py +199 -0
  14. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/parallel.py +114 -84
  15. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/pytest_plugin.py +1 -1
  16. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/regression.py +22 -31
  17. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/reporting.py +353 -32
  18. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/repository.py +72 -6
  19. pl_e2e_test-0.2.2/src/prairielearn_e2e/runner.py +82 -0
  20. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/runtime.py +41 -18
  21. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/sharding.py +4 -5
  22. pl_e2e_test-0.2.2/src/prairielearn_e2e/ui.py +158 -0
  23. pl_e2e_test-0.2.2/tests/test_cli.py +294 -0
  24. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/test_config.py +41 -1
  25. pl_e2e_test-0.2.2/tests/test_demo_cli.py +64 -0
  26. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/test_fuzz.py +54 -37
  27. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/test_instances.py +134 -53
  28. pl_e2e_test-0.2.2/tests/test_options.py +17 -0
  29. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/test_parallel.py +135 -26
  30. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/test_pytest_plugin.py +232 -1
  31. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/test_regression.py +1 -2
  32. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/test_reporting.py +25 -0
  33. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/test_repository.py +106 -1
  34. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/test_runtime.py +25 -0
  35. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/test_sharding.py +1 -2
  36. pl_e2e_test-0.2.0/src/prairielearn_e2e/cli.py +0 -89
  37. pl_e2e_test-0.2.0/src/prairielearn_e2e/instances.py +0 -161
  38. pl_e2e_test-0.2.0/src/prairielearn_e2e/runner.py +0 -48
  39. pl_e2e_test-0.2.0/tests/test_cli.py +0 -91
  40. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/LICENSE +0 -0
  41. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/setup.cfg +0 -0
  42. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
  43. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
  44. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
  45. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/__init__.py +0 -0
  46. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/__main__.py +0 -0
  47. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/_git.py +0 -0
  48. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/backend.py +0 -0
  49. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/contract.py +0 -0
  50. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/discovery.py +0 -0
  51. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/errors.py +0 -0
  52. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/expected_errors.py +0 -0
  53. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/harness.py +0 -0
  54. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/html_checks.py +0 -0
  55. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/metadata.py +0 -0
  56. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/question_tests.py +0 -0
  57. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/src/prairielearn_e2e/server.py +0 -0
  58. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/__init__.py +0 -0
  59. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/conftest.py +0 -0
  60. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/stub_git.py +0 -0
  61. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/test_contract.py +0 -0
  62. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/test_discovery.py +0 -0
  63. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/test_git.py +0 -0
  64. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/test_harness.py +0 -0
  65. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/test_html_checks.py +0 -0
  66. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/test_metadata.py +0 -0
  67. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/test_publish_version.py +0 -0
  68. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/test_question_tests.py +0 -0
  69. {pl_e2e_test-0.2.0 → pl_e2e_test-0.2.2}/tests/test_server.py +0 -0
@@ -1,2 +1,3 @@
1
1
  graft tests
2
+ include scripts/demo_cli.py
2
3
  global-exclude *.py[cod]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pl-e2e-test
3
- Version: 0.2.0
3
+ Version: 0.2.2
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
@@ -10,6 +10,7 @@ Requires-Python: >=3.13
10
10
  Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
12
  Requires-Dist: chevron==0.14.0
13
+ Requires-Dist: cyclopts<6,>=5.3
13
14
  Requires-Dist: filelock>=3.20
14
15
  Requires-Dist: jsonschema>=4.26
15
16
  Requires-Dist: lxml>=6.1
@@ -20,6 +21,7 @@ Requires-Dist: pyright>=1.1.414
20
21
  Requires-Dist: pytest>=9.1
21
22
  Requires-Dist: rich>=14
22
23
  Requires-Dist: ruff>=0.16
24
+ Requires-Dist: tomlkit>=0.15.2
23
25
  Dynamic: license-file
24
26
 
25
27
  # pl-e2e-test
@@ -32,16 +34,18 @@ instead of copying the harness and its support scripts.
32
34
  The package installs the reusable `prairielearn_e2e` Python API and a single
33
35
  `pl-e2e-test` command:
34
36
 
35
- - `pl-e2e-test` (no command, or any pytest/harness options) runs the installed
36
- lifecycle pytest suite.
37
+ - `pl-e2e-test run` runs the installed lifecycle pytest suite.
37
38
  - `pl-e2e-test diff` fuzzes questions selected from a Git diff.
38
- - `pl-e2e-test parallel` runs local shards and merges their results.
39
+ - `pl-e2e-test parallel run` and `parallel diff` run local shards and merge them.
39
40
  - `pl-e2e-test add-seed` records a failing concrete variant seed.
40
41
  - `pl-e2e-test reports` merges parallel shard results into terminal and JUnit reports.
41
42
  - `pl-e2e-test instances` shows configured, installed, cached, and local PrairieLearn
42
43
  revisions.
43
- - `pl-e2e-test version` prints the installed version.
44
- - `pl-e2e-test help [COMMAND]` prints top-level help, or one command's help.
44
+ - `pl-e2e-test version [REVISION]` reads or updates the configured PrairieLearn revision.
45
+ - `pl-e2e-test cache clean` clears the managed PrairieLearn instance cache.
46
+ - `pl-e2e-test --version` prints the installed CLI version.
47
+ - `pl-e2e-test help [COMMAND...]` prints top-level or nested command help. A bare
48
+ invocation also prints top-level help.
45
49
 
46
50
  ## Requirements and installation
47
51
 
@@ -83,20 +87,37 @@ Pass a question, a directory of questions, or an `info.json` file. Repeat
83
87
  `--question-path` to select multiple roots:
84
88
 
85
89
  ```sh
86
- uv run pl-e2e-test \
90
+ uv run pl-e2e-test run \
87
91
  --question-path questions \
88
92
  --seed-count 3
89
93
  ```
90
94
 
91
95
  The installed suite exposes separate `generate`, `prepare`, `render`, and `grade`
92
- pytest markers. Any remaining arguments are passed to pytest:
96
+ pytest markers. Put pytest selection and execution arguments after `--`:
93
97
 
94
98
  ```sh
95
- uv run pl-e2e-test \
99
+ uv run pl-e2e-test -vv run \
96
100
  --question-path questions/chapter/example \
97
- --fuzz-seeds -m render -x -vv
101
+ --fuzz-seeds -- -m render -x
98
102
  ```
99
103
 
104
+ The native reporter owns progress, capture, tracebacks, summaries, and color. Use
105
+ repeatable global `-q` or `-v` flags and `--color auto|always|never`; pytest
106
+ presentation flags such as `-q`, `-v`, `--tb`, `-r`, `--color`, and `-s` are rejected
107
+ after `--`. `NO_COLOR`, `FORCE_COLOR`, and `CLICOLOR_FORCE` are also honored.
108
+
109
+ To preview the terminal UX without a PrairieLearn course or network access, run:
110
+
111
+ ```sh
112
+ uv run python scripts/demo_cli.py
113
+ uv run python scripts/demo_cli.py --color always mixed verbose cli-error
114
+ uv run python scripts/demo_cli.py --list
115
+ ```
116
+
117
+ The demo covers help and CLI errors plus passing, failing, skipped, xfailed/xpassed,
118
+ warning, collection-error, empty-selection, quiet, verbose, and aggregate shard progress
119
+ output.
120
+
100
121
  `--fuzz-seeds` creates and reports a random 64-bit master seed. Reproduce the same
101
122
  variant set with `--fuzz-seed N`. Without either option, deterministic seeds start at
102
123
  zero. Question metadata with `singleVariant: true` always uses seed zero.
@@ -106,22 +127,23 @@ zero. Question metadata with `singleVariant: true` always uses seed zero.
106
127
  Run multiple shards in one machine or CI job, with six concurrent processes by default:
107
128
 
108
129
  ```sh
109
- uv run pl-e2e-test parallel --shard-count 6 run -- \
130
+ uv run pl-e2e-test parallel run --shard-count 6 \
110
131
  --question-path questions --fuzz-seeds
111
132
 
112
- uv run pl-e2e-test parallel --shard-count 6 --junitxml results.xml diff -- \
133
+ uv run pl-e2e-test parallel diff --shard-count 6 --junitxml results.xml \
113
134
  --base main --fuzz-seed 42
114
135
  ```
115
136
 
116
- Launcher options go before the required `run` or `diff` selector; arguments after
117
- `--` are forwarded to that command. Normal runs remain deterministic. For
137
+ Native options go after `parallel run` or `parallel diff` and before an optional `--`;
138
+ only pytest arguments follow that boundary. Normal runs remain deterministic. For
118
139
  `--fuzz-seeds` and diff runs, the launcher generates one shared master seed unless
119
140
  `--fuzz-seed` is supplied, and prints a command for replaying the run without sharding.
120
141
 
121
142
  Each shard runs in a separate Python process using the current environment and
122
- working directory. Terminals show Rich progress; redirected output prints completion
123
- lines. All shards finish before reports are merged, even when a test fails. Captured
124
- failure output and successful stderr are shown before the combined summary.
143
+ working directory. Terminals show aggregate progress; redirected output uses stable
144
+ start and summary lines. All shards finish before reports are merged, even when a test
145
+ fails. Raw child output is shown only when a launch or internal failure cannot produce
146
+ a usable report.
125
147
  Interrupting the launcher stops and reaps its children.
126
148
 
127
149
  Intermediate reports are temporary. Pass the launcher's `--junitxml` option to retain
@@ -148,19 +170,19 @@ Relative JUnit paths are resolved against the course root. With `junitxml=None`,
148
170
  merged output is temporary. Invalid launcher settings raise `ValueError`.
149
171
 
150
172
  Courses using `scripts/run_e2e_shards.py` can replace its invocation with
151
- `pl-e2e-test parallel`, move `--fuzz-seed` into the forwarded arguments, and explicitly
173
+ `pl-e2e-test parallel run`, move `--fuzz-seed` into the native arguments, and explicitly
152
174
  pass `--fuzz-seeds` for full-suite runs to preserve the script's fuzzing behavior.
153
175
  For example:
154
176
 
155
177
  ```make
156
178
  test-e2e-sharded:
157
- uv run --active pl-e2e-test parallel --shard-count "$(E2E_SHARD_COUNT)" run -- \
158
- $(E2E_TEST_ARGS) $(E2E_PRAIRIELEARN_ARG) --fuzz-seeds $(E2E_FUZZ_SEED_ARG) $(PYTEST_ARGS)
179
+ uv run --active pl-e2e-test parallel run --shard-count "$(E2E_SHARD_COUNT)" \
180
+ $(E2E_TEST_ARGS) $(E2E_PRAIRIELEARN_ARG) --fuzz-seeds $(E2E_FUZZ_SEED_ARG) -- $(PYTEST_ARGS)
159
181
 
160
182
  test-e2e-fuzz-sharded:
161
- uv run --active pl-e2e-test parallel --shard-count "$(E2E_SHARD_COUNT)" diff -- \
183
+ uv run --active pl-e2e-test parallel diff --shard-count "$(E2E_SHARD_COUNT)" \
162
184
  --base "$(E2E_DIFF_BASE)" --head "$(E2E_DIFF_HEAD)" \
163
- --seed-count "$(E2E_FUZZ_SEED_COUNT)" $(E2E_FUZZ_SEED_ARG) $(E2E_PRAIRIELEARN_ARG) $(PYTEST_ARGS)
185
+ --seed-count "$(E2E_FUZZ_SEED_COUNT)" $(E2E_FUZZ_SEED_ARG) $(E2E_PRAIRIELEARN_ARG) -- $(PYTEST_ARGS)
164
186
  ```
165
187
 
166
188
  ### CI sharding
@@ -202,7 +224,7 @@ jobs:
202
224
  - run: uv python install && uv sync --locked
203
225
  - name: Run shard
204
226
  run: |
205
- uv run pl-e2e-test \
227
+ uv run pl-e2e-test run \
206
228
  --question-path questions \
207
229
  --seed-count 10 \
208
230
  --fuzz-seed "${{ github.run_id }}" \
@@ -272,6 +294,34 @@ Add `--refresh` to resolve the configured moving selector and verify remote acce
272
294
  The table distinguishes fully hydrated runtime revisions from legacy element-only cache
273
295
  entries. Standard cache environment settings such as `XDG_CACHE_HOME` can relocate the
274
296
  managed cache; CI systems can persist that directory between runs if desired.
297
+ Cache use refreshes each repository or hydrated runtime's last-use timestamp. Whenever
298
+ the managed cache is used, unlocked items that have been idle for more than 30 days are
299
+ removed automatically across all configured PrairieLearn sources.
300
+
301
+ Read the effective configured selector without resolving it or contacting the network:
302
+
303
+ ```sh
304
+ uv run pl-e2e-test version
305
+ ```
306
+
307
+ Set `tool.pl-e2e-test.prairielearnVersion` in an existing `pyproject.toml` to a full
308
+ commit or verified PrairieLearn release tag with:
309
+
310
+ ```sh
311
+ uv run pl-e2e-test version 3.2.0
312
+ ```
313
+
314
+ Release tags are verified against the configured PrairieLearn Git source before the
315
+ file is changed. Use `--config PATH` to update another existing TOML file. Comments and
316
+ unrelated formatting are preserved, and a missing file is not created.
317
+
318
+ Remove all managed bare repositories and hydrated runtime revisions with:
319
+
320
+ ```sh
321
+ uv run pl-e2e-test cache clean
322
+ ```
323
+
324
+ The next test run fetches and hydrates its selected PrairieLearn revision again.
275
325
 
276
326
  ### Harness configuration
277
327
 
@@ -296,8 +346,8 @@ answers from grading checks. Each supplied list replaces its default; omitted se
296
346
  keep their defaults, and `[]` disables a list.
297
347
 
298
348
  Use `--config path/to/settings.toml` to select another TOML file with the same
299
- `[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
300
- `parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
349
+ `[tool.pl-e2e-test]` table. This works with `run`, `diff`, `parallel run`, and
350
+ `parallel diff`. Explicit paths are relative to the working directory.
301
351
 
302
352
  ### Lifecycle JSON boundary
303
353
 
@@ -451,7 +501,7 @@ diffignore = [
451
501
 
452
502
  Rules use Git's native ignore syntax and are evaluated in order, so later rules may
453
503
  negate earlier ones with `!`. A missing `pyproject.toml`, tool table, or `diffignore`
454
- entry means that no paths are ignored. Additional arguments are forwarded to pytest.
504
+ entry means that no paths are ignored. Additional pytest arguments must follow `--`.
455
505
 
456
506
  To migrate from `.question-e2e.diffignore`, copy its non-comment rules into the
457
507
  `diffignore` array. The former file and `--e2ediffignore` option are no longer used.
@@ -8,16 +8,18 @@ instead of copying the harness and its support scripts.
8
8
  The package installs the reusable `prairielearn_e2e` Python API and a single
9
9
  `pl-e2e-test` command:
10
10
 
11
- - `pl-e2e-test` (no command, or any pytest/harness options) runs the installed
12
- lifecycle pytest suite.
11
+ - `pl-e2e-test run` runs the installed lifecycle pytest suite.
13
12
  - `pl-e2e-test diff` fuzzes questions selected from a Git diff.
14
- - `pl-e2e-test parallel` runs local shards and merges their results.
13
+ - `pl-e2e-test parallel run` and `parallel diff` run local shards and merge them.
15
14
  - `pl-e2e-test add-seed` records a failing concrete variant seed.
16
15
  - `pl-e2e-test reports` merges parallel shard results into terminal and JUnit reports.
17
16
  - `pl-e2e-test instances` shows configured, installed, cached, and local PrairieLearn
18
17
  revisions.
19
- - `pl-e2e-test version` prints the installed version.
20
- - `pl-e2e-test help [COMMAND]` prints top-level help, or one command's help.
18
+ - `pl-e2e-test version [REVISION]` reads or updates the configured PrairieLearn revision.
19
+ - `pl-e2e-test cache clean` clears the managed PrairieLearn instance cache.
20
+ - `pl-e2e-test --version` prints the installed CLI version.
21
+ - `pl-e2e-test help [COMMAND...]` prints top-level or nested command help. A bare
22
+ invocation also prints top-level help.
21
23
 
22
24
  ## Requirements and installation
23
25
 
@@ -59,20 +61,37 @@ Pass a question, a directory of questions, or an `info.json` file. Repeat
59
61
  `--question-path` to select multiple roots:
60
62
 
61
63
  ```sh
62
- uv run pl-e2e-test \
64
+ uv run pl-e2e-test run \
63
65
  --question-path questions \
64
66
  --seed-count 3
65
67
  ```
66
68
 
67
69
  The installed suite exposes separate `generate`, `prepare`, `render`, and `grade`
68
- pytest markers. Any remaining arguments are passed to pytest:
70
+ pytest markers. Put pytest selection and execution arguments after `--`:
69
71
 
70
72
  ```sh
71
- uv run pl-e2e-test \
73
+ uv run pl-e2e-test -vv run \
72
74
  --question-path questions/chapter/example \
73
- --fuzz-seeds -m render -x -vv
75
+ --fuzz-seeds -- -m render -x
74
76
  ```
75
77
 
78
+ The native reporter owns progress, capture, tracebacks, summaries, and color. Use
79
+ repeatable global `-q` or `-v` flags and `--color auto|always|never`; pytest
80
+ presentation flags such as `-q`, `-v`, `--tb`, `-r`, `--color`, and `-s` are rejected
81
+ after `--`. `NO_COLOR`, `FORCE_COLOR`, and `CLICOLOR_FORCE` are also honored.
82
+
83
+ To preview the terminal UX without a PrairieLearn course or network access, run:
84
+
85
+ ```sh
86
+ uv run python scripts/demo_cli.py
87
+ uv run python scripts/demo_cli.py --color always mixed verbose cli-error
88
+ uv run python scripts/demo_cli.py --list
89
+ ```
90
+
91
+ The demo covers help and CLI errors plus passing, failing, skipped, xfailed/xpassed,
92
+ warning, collection-error, empty-selection, quiet, verbose, and aggregate shard progress
93
+ output.
94
+
76
95
  `--fuzz-seeds` creates and reports a random 64-bit master seed. Reproduce the same
77
96
  variant set with `--fuzz-seed N`. Without either option, deterministic seeds start at
78
97
  zero. Question metadata with `singleVariant: true` always uses seed zero.
@@ -82,22 +101,23 @@ zero. Question metadata with `singleVariant: true` always uses seed zero.
82
101
  Run multiple shards in one machine or CI job, with six concurrent processes by default:
83
102
 
84
103
  ```sh
85
- uv run pl-e2e-test parallel --shard-count 6 run -- \
104
+ uv run pl-e2e-test parallel run --shard-count 6 \
86
105
  --question-path questions --fuzz-seeds
87
106
 
88
- uv run pl-e2e-test parallel --shard-count 6 --junitxml results.xml diff -- \
107
+ uv run pl-e2e-test parallel diff --shard-count 6 --junitxml results.xml \
89
108
  --base main --fuzz-seed 42
90
109
  ```
91
110
 
92
- Launcher options go before the required `run` or `diff` selector; arguments after
93
- `--` are forwarded to that command. Normal runs remain deterministic. For
111
+ Native options go after `parallel run` or `parallel diff` and before an optional `--`;
112
+ only pytest arguments follow that boundary. Normal runs remain deterministic. For
94
113
  `--fuzz-seeds` and diff runs, the launcher generates one shared master seed unless
95
114
  `--fuzz-seed` is supplied, and prints a command for replaying the run without sharding.
96
115
 
97
116
  Each shard runs in a separate Python process using the current environment and
98
- working directory. Terminals show Rich progress; redirected output prints completion
99
- lines. All shards finish before reports are merged, even when a test fails. Captured
100
- failure output and successful stderr are shown before the combined summary.
117
+ working directory. Terminals show aggregate progress; redirected output uses stable
118
+ start and summary lines. All shards finish before reports are merged, even when a test
119
+ fails. Raw child output is shown only when a launch or internal failure cannot produce
120
+ a usable report.
101
121
  Interrupting the launcher stops and reaps its children.
102
122
 
103
123
  Intermediate reports are temporary. Pass the launcher's `--junitxml` option to retain
@@ -124,19 +144,19 @@ Relative JUnit paths are resolved against the course root. With `junitxml=None`,
124
144
  merged output is temporary. Invalid launcher settings raise `ValueError`.
125
145
 
126
146
  Courses using `scripts/run_e2e_shards.py` can replace its invocation with
127
- `pl-e2e-test parallel`, move `--fuzz-seed` into the forwarded arguments, and explicitly
147
+ `pl-e2e-test parallel run`, move `--fuzz-seed` into the native arguments, and explicitly
128
148
  pass `--fuzz-seeds` for full-suite runs to preserve the script's fuzzing behavior.
129
149
  For example:
130
150
 
131
151
  ```make
132
152
  test-e2e-sharded:
133
- uv run --active pl-e2e-test parallel --shard-count "$(E2E_SHARD_COUNT)" run -- \
134
- $(E2E_TEST_ARGS) $(E2E_PRAIRIELEARN_ARG) --fuzz-seeds $(E2E_FUZZ_SEED_ARG) $(PYTEST_ARGS)
153
+ uv run --active pl-e2e-test parallel run --shard-count "$(E2E_SHARD_COUNT)" \
154
+ $(E2E_TEST_ARGS) $(E2E_PRAIRIELEARN_ARG) --fuzz-seeds $(E2E_FUZZ_SEED_ARG) -- $(PYTEST_ARGS)
135
155
 
136
156
  test-e2e-fuzz-sharded:
137
- uv run --active pl-e2e-test parallel --shard-count "$(E2E_SHARD_COUNT)" diff -- \
157
+ uv run --active pl-e2e-test parallel diff --shard-count "$(E2E_SHARD_COUNT)" \
138
158
  --base "$(E2E_DIFF_BASE)" --head "$(E2E_DIFF_HEAD)" \
139
- --seed-count "$(E2E_FUZZ_SEED_COUNT)" $(E2E_FUZZ_SEED_ARG) $(E2E_PRAIRIELEARN_ARG) $(PYTEST_ARGS)
159
+ --seed-count "$(E2E_FUZZ_SEED_COUNT)" $(E2E_FUZZ_SEED_ARG) $(E2E_PRAIRIELEARN_ARG) -- $(PYTEST_ARGS)
140
160
  ```
141
161
 
142
162
  ### CI sharding
@@ -178,7 +198,7 @@ jobs:
178
198
  - run: uv python install && uv sync --locked
179
199
  - name: Run shard
180
200
  run: |
181
- uv run pl-e2e-test \
201
+ uv run pl-e2e-test run \
182
202
  --question-path questions \
183
203
  --seed-count 10 \
184
204
  --fuzz-seed "${{ github.run_id }}" \
@@ -248,6 +268,34 @@ Add `--refresh` to resolve the configured moving selector and verify remote acce
248
268
  The table distinguishes fully hydrated runtime revisions from legacy element-only cache
249
269
  entries. Standard cache environment settings such as `XDG_CACHE_HOME` can relocate the
250
270
  managed cache; CI systems can persist that directory between runs if desired.
271
+ Cache use refreshes each repository or hydrated runtime's last-use timestamp. Whenever
272
+ the managed cache is used, unlocked items that have been idle for more than 30 days are
273
+ removed automatically across all configured PrairieLearn sources.
274
+
275
+ Read the effective configured selector without resolving it or contacting the network:
276
+
277
+ ```sh
278
+ uv run pl-e2e-test version
279
+ ```
280
+
281
+ Set `tool.pl-e2e-test.prairielearnVersion` in an existing `pyproject.toml` to a full
282
+ commit or verified PrairieLearn release tag with:
283
+
284
+ ```sh
285
+ uv run pl-e2e-test version 3.2.0
286
+ ```
287
+
288
+ Release tags are verified against the configured PrairieLearn Git source before the
289
+ file is changed. Use `--config PATH` to update another existing TOML file. Comments and
290
+ unrelated formatting are preserved, and a missing file is not created.
291
+
292
+ Remove all managed bare repositories and hydrated runtime revisions with:
293
+
294
+ ```sh
295
+ uv run pl-e2e-test cache clean
296
+ ```
297
+
298
+ The next test run fetches and hydrates its selected PrairieLearn revision again.
251
299
 
252
300
  ### Harness configuration
253
301
 
@@ -272,8 +320,8 @@ answers from grading checks. Each supplied list replaces its default; omitted se
272
320
  keep their defaults, and `[]` disables a list.
273
321
 
274
322
  Use `--config path/to/settings.toml` to select another TOML file with the same
275
- `[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
276
- `parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
323
+ `[tool.pl-e2e-test]` table. This works with `run`, `diff`, `parallel run`, and
324
+ `parallel diff`. Explicit paths are relative to the working directory.
277
325
 
278
326
  ### Lifecycle JSON boundary
279
327
 
@@ -427,7 +475,7 @@ diffignore = [
427
475
 
428
476
  Rules use Git's native ignore syntax and are evaluated in order, so later rules may
429
477
  negate earlier ones with `!`. A missing `pyproject.toml`, tool table, or `diffignore`
430
- entry means that no paths are ignored. Additional arguments are forwarded to pytest.
478
+ entry means that no paths are ignored. Additional pytest arguments must follow `--`.
431
479
 
432
480
  To migrate from `.question-e2e.diffignore`, copy its non-comment rules into the
433
481
  `diffignore` array. The former file and `--e2ediffignore` option are no longer used.
@@ -4,13 +4,14 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pl-e2e-test"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Fuzz PrairieLearn question lifecycles against the real Python element runtime"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
11
11
  license = "MIT"
12
12
  dependencies = [
13
13
  "chevron==0.14.0",
14
+ "cyclopts>=5.3,<6",
14
15
  "filelock>=3.20",
15
16
  "jsonschema>=4.26",
16
17
  "lxml>=6.1",
@@ -21,6 +22,7 @@ dependencies = [
21
22
  "pytest>=9.1",
22
23
  "rich>=14",
23
24
  "ruff>=0.16",
25
+ "tomlkit>=0.15.2",
24
26
  ]
25
27
 
26
28
  [dependency-groups]
@@ -0,0 +1,219 @@
1
+ """Show the pl-e2e-test terminal UX without a course or network access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ import textwrap
9
+ from collections.abc import Callable, Sequence
10
+ from itertools import count
11
+ from pathlib import Path
12
+ from typing import cast
13
+
14
+ import pytest
15
+
16
+ from prairielearn_e2e import cli
17
+ from prairielearn_e2e.parallel import _shard_progress
18
+ from prairielearn_e2e.reporting import NativeReporter
19
+ from prairielearn_e2e.ui import CliContext, ColorChoice, make_console
20
+
21
+ type Demo = Callable[[ColorChoice], int]
22
+ _CASE_IDS = count()
23
+
24
+
25
+ def _pytest_demo(
26
+ color: ColorChoice,
27
+ source: str,
28
+ *,
29
+ quiet: int = 0,
30
+ verbose: int = 0,
31
+ pytest_args: Sequence[str] = (),
32
+ ) -> int:
33
+ with tempfile.TemporaryDirectory(prefix="pl-e2e-terminal-demo-") as temporary:
34
+ root = Path(temporary)
35
+ test_file = root / f"test_demo_{next(_CASE_IDS)}.py"
36
+ test_file.write_text(textwrap.dedent(source), encoding="utf-8")
37
+ context = CliContext(color=color, quiet=quiet, verbose=verbose)
38
+ reporter = NativeReporter(context)
39
+ return int(
40
+ pytest.main(
41
+ [
42
+ os.fspath(test_file),
43
+ "--rootdir",
44
+ os.fspath(root),
45
+ *pytest_args,
46
+ ],
47
+ plugins=[reporter],
48
+ )
49
+ )
50
+
51
+
52
+ def _help(color: ColorChoice) -> int:
53
+ return cli.main(["--color", color, "--help"])
54
+
55
+
56
+ def _cli_error(color: ColorChoice) -> int:
57
+ return cli.main(["--color", color, "rn"])
58
+
59
+
60
+ def _passing(color: ColorChoice) -> int:
61
+ return _pytest_demo(
62
+ color,
63
+ """
64
+ def test_passes():
65
+ print("captured output from a passing test")
66
+ """,
67
+ )
68
+
69
+
70
+ def _mixed(color: ColorChoice) -> int:
71
+ return _pytest_demo(
72
+ color,
73
+ """
74
+ import warnings
75
+ import pytest
76
+
77
+ def test_passes():
78
+ print("passing capture is hidden at default verbosity")
79
+
80
+ def test_fails():
81
+ print("captured stdout from the failing test")
82
+ assert {"actual": 1} == {"expected": 2}
83
+
84
+ @pytest.mark.skip(reason="optional service is unavailable")
85
+ def test_skips():
86
+ pass
87
+
88
+ @pytest.mark.xfail(reason="known upstream issue")
89
+ def test_expected_failure():
90
+ assert False
91
+
92
+ @pytest.mark.xfail(reason="the upstream issue may be fixed")
93
+ def test_unexpected_pass():
94
+ pass
95
+
96
+ def test_warns():
97
+ warnings.warn("example lifecycle warning", UserWarning)
98
+ """,
99
+ )
100
+
101
+
102
+ def _collection_error(color: ColorChoice) -> int:
103
+ return _pytest_demo(
104
+ color,
105
+ """
106
+ def test_broken(:
107
+ pass
108
+ """,
109
+ )
110
+
111
+
112
+ def _empty(color: ColorChoice) -> int:
113
+ return _pytest_demo(
114
+ color,
115
+ """
116
+ def test_passes():
117
+ pass
118
+ """,
119
+ pytest_args=("-k", "does-not-exist"),
120
+ )
121
+
122
+
123
+ def _quiet(color: ColorChoice) -> int:
124
+ return _pytest_demo(
125
+ color,
126
+ """
127
+ def test_passes_silently():
128
+ pass
129
+ """,
130
+ quiet=2,
131
+ )
132
+
133
+
134
+ def _verbose(color: ColorChoice) -> int:
135
+ return _pytest_demo(
136
+ color,
137
+ """
138
+ def test_verbose_capture():
139
+ print("captured output shown by -vv")
140
+ """,
141
+ verbose=2,
142
+ )
143
+
144
+
145
+ def _shards(color: ColorChoice) -> int:
146
+ console = make_console(color=color)
147
+ outcomes = (True, True, False, True)
148
+ with _shard_progress(console, len(outcomes), disable=False) as complete:
149
+ for index, succeeded in enumerate(outcomes, start=1):
150
+ complete(index, succeeded)
151
+ return 1
152
+
153
+
154
+ DEMOS: dict[str, Demo] = {
155
+ "help": _help,
156
+ "cli-error": _cli_error,
157
+ "passing": _passing,
158
+ "mixed": _mixed,
159
+ "collection-error": _collection_error,
160
+ "empty": _empty,
161
+ "quiet": _quiet,
162
+ "verbose": _verbose,
163
+ "shards": _shards,
164
+ }
165
+
166
+
167
+ def _arguments(argv: Sequence[str]) -> tuple[ColorChoice, list[str], bool]:
168
+ color: ColorChoice = "auto"
169
+ selected: list[str] = []
170
+ list_only = False
171
+ index = 0
172
+ while index < len(argv):
173
+ token = argv[index]
174
+ if token == "--list":
175
+ list_only = True
176
+ elif token.startswith("--color="):
177
+ value = token.split("=", 1)[1]
178
+ if value not in {"auto", "always", "never"}:
179
+ raise ValueError("--color must be auto, always, or never")
180
+ color = cast(ColorChoice, value)
181
+ elif token == "--color":
182
+ index += 1
183
+ if index >= len(argv) or argv[index] not in {"auto", "always", "never"}:
184
+ raise ValueError("--color must be followed by auto, always, or never")
185
+ color = cast(ColorChoice, argv[index])
186
+ elif token.startswith("-"):
187
+ raise ValueError(f"unknown option: {token}")
188
+ else:
189
+ selected.append(token)
190
+ index += 1
191
+ return color, selected, list_only
192
+
193
+
194
+ def main(argv: Sequence[str] | None = None) -> int:
195
+ try:
196
+ color, selected, list_only = _arguments(sys.argv[1:] if argv is None else argv)
197
+ except ValueError as exc:
198
+ print(f"error: {exc}", file=sys.stderr)
199
+ return 2
200
+ if list_only:
201
+ print("\n".join(DEMOS))
202
+ return 0
203
+ unknown = [name for name in selected if name not in DEMOS]
204
+ if unknown:
205
+ print(f"error: unknown demo {unknown[0]!r}", file=sys.stderr)
206
+ print(f"available demos: {', '.join(DEMOS)}", file=sys.stderr)
207
+ return 2
208
+
209
+ console = make_console(color=color)
210
+ for name in selected or list(DEMOS):
211
+ console.rule(f"demo: {name}", style="bold cyan")
212
+ status = DEMOS[name](color)
213
+ console.print(f"[dim]exit status: {status}[/dim]")
214
+ console.print()
215
+ return 0
216
+
217
+
218
+ if __name__ == "__main__":
219
+ raise SystemExit(main())