pl-e2e-test 0.1.2__tar.gz → 0.1.3__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.3}/PKG-INFO +65 -1
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/README.md +63 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/pyproject.toml +2 -1
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3/src/pl_e2e_test.egg-info}/PKG-INFO +65 -1
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/pl_e2e_test.egg-info/SOURCES.txt +2 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/pl_e2e_test.egg-info/requires.txt +1 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/__init__.py +2 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/cli.py +3 -1
- pl_e2e_test-0.1.3/src/prairielearn_e2e/parallel.py +250 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_cli.py +2 -2
- pl_e2e_test-0.1.3/tests/test_parallel.py +400 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/LICENSE +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/MANIFEST.in +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/setup.cfg +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/__main__.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/_git.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/backend.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/config.json +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/config.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/contract.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/discovery.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/errors.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/fuzz.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/harness.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/html_checks.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/metadata.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/pytest_plugin.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/question_tests.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/regression.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/reporting.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/repository.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/runner.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/server.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/sharding.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/__init__.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/conftest.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/stub_git.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_contract.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_discovery.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_fuzz.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_git.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_harness.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_html_checks.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_metadata.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_publish_version.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_pytest_plugin.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_regression.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_reporting.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_repository.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_server.py +0 -0
- {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/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.3
|
|
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
|
|
@@ -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
|
|
@@ -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.3"
|
|
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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pl-e2e-test
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
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
|
|
@@ -22,6 +22,7 @@ src/prairielearn_e2e/fuzz.py
|
|
|
22
22
|
src/prairielearn_e2e/harness.py
|
|
23
23
|
src/prairielearn_e2e/html_checks.py
|
|
24
24
|
src/prairielearn_e2e/metadata.py
|
|
25
|
+
src/prairielearn_e2e/parallel.py
|
|
25
26
|
src/prairielearn_e2e/pytest_plugin.py
|
|
26
27
|
src/prairielearn_e2e/question_tests.py
|
|
27
28
|
src/prairielearn_e2e/regression.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
|
|
@@ -5,6 +5,7 @@ from importlib.metadata import version
|
|
|
5
5
|
from .backend import PrairieLearnBackend
|
|
6
6
|
from .discovery import QuestionCase, discover_questions
|
|
7
7
|
from .harness import LifecycleResult, QuestionHarness
|
|
8
|
+
from .parallel import run_parallel
|
|
8
9
|
|
|
9
10
|
__version__ = version("pl-e2e-test")
|
|
10
11
|
|
|
@@ -15,4 +16,5 @@ __all__ = (
|
|
|
15
16
|
"QuestionHarness",
|
|
16
17
|
"__version__",
|
|
17
18
|
"discover_questions",
|
|
19
|
+
"run_parallel",
|
|
18
20
|
)
|
|
@@ -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,250 @@
|
|
|
1
|
+
"""Run local shards in isolated processes and merge their results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import shlex
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from contextlib import ExitStack
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from tempfile import TemporaryDirectory
|
|
17
|
+
from typing import IO
|
|
18
|
+
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
|
|
23
|
+
from .reporting import ReportError, merge_reports
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class _Child:
|
|
28
|
+
index: int
|
|
29
|
+
report: Path
|
|
30
|
+
stdout: IO[str]
|
|
31
|
+
stderr: IO[str]
|
|
32
|
+
process: subprocess.Popen[str] | None = None
|
|
33
|
+
returncode: int | None = None
|
|
34
|
+
error: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _arguments(arguments: Sequence[str], command: str) -> tuple[list[str], int | None]:
|
|
38
|
+
arguments = list(arguments)
|
|
39
|
+
for argument in arguments:
|
|
40
|
+
option = argument.split("=", 1)[0]
|
|
41
|
+
if option in {"--shard", "--shard-report", "--junitxml", "--junit-xml"}:
|
|
42
|
+
raise ValueError(f"{option} is managed by the parallel launcher")
|
|
43
|
+
if option == "--course-root":
|
|
44
|
+
raise ValueError(
|
|
45
|
+
"use course_root or the working directory to select a course"
|
|
46
|
+
)
|
|
47
|
+
parser = argparse.ArgumentParser(
|
|
48
|
+
add_help=False, allow_abbrev=False, exit_on_error=False
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument("--fuzz-seed", type=int)
|
|
51
|
+
parser.add_argument("--fuzz-seeds", action="store_true")
|
|
52
|
+
# Parse only the seed options; preserve all forwarded tokens and their order.
|
|
53
|
+
try:
|
|
54
|
+
args, _ = parser.parse_known_args(arguments)
|
|
55
|
+
except argparse.ArgumentError as exc:
|
|
56
|
+
raise ValueError(str(exc)) from exc
|
|
57
|
+
seed = args.fuzz_seed
|
|
58
|
+
if seed is None and (command == "diff" or args.fuzz_seeds):
|
|
59
|
+
seed = secrets.randbits(64)
|
|
60
|
+
arguments.extend(("--fuzz-seed", str(seed)))
|
|
61
|
+
return arguments, seed
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _stop(children: Sequence[_Child]) -> None:
|
|
65
|
+
running = [child.process for child in children if child.process is not None]
|
|
66
|
+
for process in running:
|
|
67
|
+
if process.poll() is None:
|
|
68
|
+
process.terminate()
|
|
69
|
+
deadline = time.monotonic() + 3
|
|
70
|
+
for process in running:
|
|
71
|
+
try:
|
|
72
|
+
process.wait(timeout=max(0, deadline - time.monotonic()))
|
|
73
|
+
except subprocess.TimeoutExpired:
|
|
74
|
+
process.kill()
|
|
75
|
+
process.wait()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def run_parallel(
|
|
79
|
+
arguments: Sequence[str],
|
|
80
|
+
*,
|
|
81
|
+
command: str = "run",
|
|
82
|
+
shard_count: int = 6,
|
|
83
|
+
course_root: str | Path | None = None,
|
|
84
|
+
junitxml: str | Path | None = None,
|
|
85
|
+
) -> int:
|
|
86
|
+
"""Run and merge local shards; return a CLI status (130 on interruption).
|
|
87
|
+
|
|
88
|
+
Invalid launcher options raise ValueError. Relative JUnit paths are resolved
|
|
89
|
+
against course_root. Child output is captured on disk until all shards finish.
|
|
90
|
+
"""
|
|
91
|
+
if command not in {"run", "diff"}:
|
|
92
|
+
raise ValueError("command must be run or diff")
|
|
93
|
+
if (
|
|
94
|
+
isinstance(shard_count, bool)
|
|
95
|
+
or not isinstance(shard_count, int)
|
|
96
|
+
or shard_count < 1
|
|
97
|
+
):
|
|
98
|
+
raise ValueError("shard_count must be a positive integer")
|
|
99
|
+
forwarded, seed = _arguments(arguments, command)
|
|
100
|
+
root = Path(course_root or Path.cwd()).resolve()
|
|
101
|
+
console = Console()
|
|
102
|
+
invocation = [sys.executable, "-m", "prairielearn_e2e"]
|
|
103
|
+
if command == "diff":
|
|
104
|
+
invocation.append("diff")
|
|
105
|
+
invocation.extend(forwarded)
|
|
106
|
+
if seed is not None:
|
|
107
|
+
console.print(f"E2E fuzz seed: {seed}", markup=False)
|
|
108
|
+
console.print(
|
|
109
|
+
f"Replay without sharding: {shlex.join(invocation)}",
|
|
110
|
+
markup=False,
|
|
111
|
+
soft_wrap=True,
|
|
112
|
+
)
|
|
113
|
+
console.print(f"Running {shard_count} shards in parallel", markup=False)
|
|
114
|
+
|
|
115
|
+
with TemporaryDirectory(prefix="pl-e2e-shards-") as temporary, ExitStack() as stack:
|
|
116
|
+
directory = Path(temporary)
|
|
117
|
+
children: list[_Child] = []
|
|
118
|
+
progress = Progress(
|
|
119
|
+
SpinnerColumn(),
|
|
120
|
+
TextColumn("{task.description}"),
|
|
121
|
+
TimeElapsedColumn(),
|
|
122
|
+
console=console,
|
|
123
|
+
disable=not console.is_terminal,
|
|
124
|
+
)
|
|
125
|
+
with progress:
|
|
126
|
+
try:
|
|
127
|
+
tasks = {}
|
|
128
|
+
for index in range(1, shard_count + 1):
|
|
129
|
+
child = _Child(
|
|
130
|
+
index,
|
|
131
|
+
directory / f"shard-{index}.pl-e2e-report.json",
|
|
132
|
+
stack.enter_context(
|
|
133
|
+
(directory / f"{index}.stdout").open(
|
|
134
|
+
"w+", encoding="utf-8", errors="replace"
|
|
135
|
+
)
|
|
136
|
+
),
|
|
137
|
+
stack.enter_context(
|
|
138
|
+
(directory / f"{index}.stderr").open(
|
|
139
|
+
"w+", encoding="utf-8", errors="replace"
|
|
140
|
+
)
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
children.append(child)
|
|
144
|
+
tasks[index] = progress.add_task(
|
|
145
|
+
f"Shard {index}/{shard_count}: running", total=None
|
|
146
|
+
)
|
|
147
|
+
try:
|
|
148
|
+
child.process = subprocess.Popen(
|
|
149
|
+
[
|
|
150
|
+
*invocation,
|
|
151
|
+
"--shard",
|
|
152
|
+
f"{index}/{shard_count}",
|
|
153
|
+
"--shard-report",
|
|
154
|
+
os.fspath(child.report),
|
|
155
|
+
],
|
|
156
|
+
cwd=root,
|
|
157
|
+
stdout=child.stdout,
|
|
158
|
+
stderr=child.stderr,
|
|
159
|
+
text=True,
|
|
160
|
+
)
|
|
161
|
+
except OSError as exc:
|
|
162
|
+
child.returncode, child.error = 127, str(exc)
|
|
163
|
+
pending = list(children)
|
|
164
|
+
while pending:
|
|
165
|
+
for child in pending[:]:
|
|
166
|
+
if child.process is not None:
|
|
167
|
+
child.returncode = child.process.poll()
|
|
168
|
+
if child.returncode is None:
|
|
169
|
+
continue
|
|
170
|
+
status = (
|
|
171
|
+
"passed"
|
|
172
|
+
if child.returncode == 0
|
|
173
|
+
else f"failed ({child.returncode})"
|
|
174
|
+
)
|
|
175
|
+
label = f"Shard {child.index}/{shard_count}: {status}"
|
|
176
|
+
progress.update(
|
|
177
|
+
tasks[child.index], description=label, total=1, completed=1
|
|
178
|
+
)
|
|
179
|
+
if not console.is_terminal:
|
|
180
|
+
console.print(label, markup=False)
|
|
181
|
+
pending.remove(child)
|
|
182
|
+
if pending:
|
|
183
|
+
time.sleep(0.05)
|
|
184
|
+
except KeyboardInterrupt:
|
|
185
|
+
console.print("Interrupted; stopping shards.", markup=False)
|
|
186
|
+
return 130
|
|
187
|
+
finally:
|
|
188
|
+
_stop(children)
|
|
189
|
+
|
|
190
|
+
for child in children:
|
|
191
|
+
failed = child.returncode != 0 or not child.report.is_file()
|
|
192
|
+
child.stdout.seek(0)
|
|
193
|
+
child.stderr.seek(0)
|
|
194
|
+
stderr = child.stderr.read()
|
|
195
|
+
if failed or stderr:
|
|
196
|
+
console.rule(f"Shard {child.index}: {'failed' if failed else 'notes'}")
|
|
197
|
+
if child.error:
|
|
198
|
+
console.print(child.error, markup=False)
|
|
199
|
+
if failed:
|
|
200
|
+
console.print(Text.from_ansi(child.stdout.read().rstrip()))
|
|
201
|
+
if stderr:
|
|
202
|
+
console.print(Text.from_ansi(stderr.rstrip()))
|
|
203
|
+
output = (
|
|
204
|
+
directory / "results.xml" if junitxml is None else root / Path(junitxml)
|
|
205
|
+
)
|
|
206
|
+
try:
|
|
207
|
+
status = merge_reports([child.report for child in children], output)
|
|
208
|
+
except (OSError, ReportError) as exc:
|
|
209
|
+
console.print(f"Unable to merge shard reports: {exc}", markup=False)
|
|
210
|
+
return 2
|
|
211
|
+
if status:
|
|
212
|
+
return status
|
|
213
|
+
return next(
|
|
214
|
+
(
|
|
215
|
+
child.returncode if child.returncode > 0 else 128 - child.returncode
|
|
216
|
+
for child in children
|
|
217
|
+
if child.returncode
|
|
218
|
+
),
|
|
219
|
+
0,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
224
|
+
parser = argparse.ArgumentParser(
|
|
225
|
+
prog="pl-e2e-test parallel", description=__doc__, allow_abbrev=False
|
|
226
|
+
)
|
|
227
|
+
parser.add_argument(
|
|
228
|
+
"--shard-count",
|
|
229
|
+
type=int,
|
|
230
|
+
default=6,
|
|
231
|
+
help="number of concurrent shards (default: 6)",
|
|
232
|
+
)
|
|
233
|
+
parser.add_argument(
|
|
234
|
+
"--junitxml", type=Path, help="retain merged JUnit output at this path"
|
|
235
|
+
)
|
|
236
|
+
parser.add_argument("command", choices=("run", "diff"))
|
|
237
|
+
parser.add_argument(
|
|
238
|
+
"arguments", nargs=argparse.REMAINDER, help="arguments to forward after --"
|
|
239
|
+
)
|
|
240
|
+
args = parser.parse_args(argv)
|
|
241
|
+
arguments = args.arguments[1:] if args.arguments[:1] == ["--"] else args.arguments
|
|
242
|
+
try:
|
|
243
|
+
return run_parallel(
|
|
244
|
+
arguments,
|
|
245
|
+
command=args.command,
|
|
246
|
+
shard_count=args.shard_count,
|
|
247
|
+
junitxml=args.junitxml,
|
|
248
|
+
)
|
|
249
|
+
except ValueError as exc:
|
|
250
|
+
parser.error(str(exc))
|
|
@@ -16,7 +16,7 @@ def _stub(calls: list[Sequence[str]], result: int = 0):
|
|
|
16
16
|
return _main
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
@pytest.mark.parametrize("command", ["diff", "add-seed", "reports"])
|
|
19
|
+
@pytest.mark.parametrize("command", ["diff", "add-seed", "reports", "parallel"])
|
|
20
20
|
def test_dispatches_known_commands_to_their_module(
|
|
21
21
|
monkeypatch: pytest.MonkeyPatch, command: str
|
|
22
22
|
) -> None:
|
|
@@ -60,7 +60,7 @@ def test_help_command_with_no_argument_prints_top_level_help(
|
|
|
60
60
|
assert cli.main(["help"]) == 0
|
|
61
61
|
out = capsys.readouterr().out
|
|
62
62
|
assert "Commands:" in out
|
|
63
|
-
for command in ("diff", "add-seed", "reports", "version", "help"):
|
|
63
|
+
for command in ("diff", "add-seed", "reports", "parallel", "version", "help"):
|
|
64
64
|
assert command in out
|
|
65
65
|
|
|
66
66
|
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
|
|
17
|
+
from prairielearn_e2e import parallel, run_parallel
|
|
18
|
+
from prairielearn_e2e.reporting import write_empty_shard_report
|
|
19
|
+
from prairielearn_e2e.sharding import parse_shard
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.parametrize(
|
|
23
|
+
("command", "arguments", "expected"),
|
|
24
|
+
[
|
|
25
|
+
("run", [], None),
|
|
26
|
+
("run", ["--fuzz-seeds"], 123),
|
|
27
|
+
("diff", [], 123),
|
|
28
|
+
("run", ["--fuzz-seed=42"], 42),
|
|
29
|
+
("diff", ["--fuzz-seed", "0"], 0),
|
|
30
|
+
],
|
|
31
|
+
)
|
|
32
|
+
def test_seed_policy(
|
|
33
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
34
|
+
command: str,
|
|
35
|
+
arguments: list[str],
|
|
36
|
+
expected: int | None,
|
|
37
|
+
) -> None:
|
|
38
|
+
monkeypatch.setattr(parallel.secrets, "randbits", lambda _: 123)
|
|
39
|
+
forwarded, seed = parallel._arguments(arguments, command)
|
|
40
|
+
assert seed == expected
|
|
41
|
+
assert forwarded[: len(arguments)] == arguments
|
|
42
|
+
if expected == 123:
|
|
43
|
+
assert forwarded[-2:] == ["--fuzz-seed", "123"]
|
|
44
|
+
else:
|
|
45
|
+
assert forwarded == arguments
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize(
|
|
49
|
+
"argument",
|
|
50
|
+
[
|
|
51
|
+
"--shard",
|
|
52
|
+
"--shard=1/2",
|
|
53
|
+
"--shard-report=x",
|
|
54
|
+
"--junitxml=x",
|
|
55
|
+
"--junit-xml",
|
|
56
|
+
"--course-root=x",
|
|
57
|
+
],
|
|
58
|
+
)
|
|
59
|
+
def test_rejects_owned_options(argument: str) -> None:
|
|
60
|
+
with pytest.raises(ValueError):
|
|
61
|
+
run_parallel([argument])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.mark.parametrize("count", [0, -1, True])
|
|
65
|
+
def test_invalid_count(count: int) -> None:
|
|
66
|
+
with pytest.raises(ValueError, match="positive integer"):
|
|
67
|
+
run_parallel([], shard_count=count)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class FinishedProcess:
|
|
71
|
+
def __init__(self, status: int):
|
|
72
|
+
self.status = status
|
|
73
|
+
|
|
74
|
+
def poll(self) -> int:
|
|
75
|
+
return self.status
|
|
76
|
+
|
|
77
|
+
def wait(self, timeout: float | None = None) -> int:
|
|
78
|
+
return self.status
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.parametrize("terminal", [False, True])
|
|
82
|
+
@pytest.mark.parametrize("failure", ["none", "missing", "corrupt", "launch", "exit"])
|
|
83
|
+
def test_orchestration_and_diagnostics(
|
|
84
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, terminal: bool, failure: str
|
|
85
|
+
) -> None:
|
|
86
|
+
output = io.StringIO()
|
|
87
|
+
monkeypatch.setattr(
|
|
88
|
+
parallel,
|
|
89
|
+
"Console",
|
|
90
|
+
lambda: Console(file=output, force_terminal=terminal, width=120),
|
|
91
|
+
)
|
|
92
|
+
commands: list[list[str]] = []
|
|
93
|
+
reports: list[Path] = []
|
|
94
|
+
|
|
95
|
+
def popen(command: list[str], **kwargs: Any) -> FinishedProcess:
|
|
96
|
+
commands.append(command)
|
|
97
|
+
assert kwargs["cwd"] == tmp_path
|
|
98
|
+
assert command[:3] == [sys.executable, "-m", "prairielearn_e2e"]
|
|
99
|
+
assert command[3:8] == [
|
|
100
|
+
"--question-path",
|
|
101
|
+
"questions with spaces",
|
|
102
|
+
"-m",
|
|
103
|
+
"render",
|
|
104
|
+
"--fuzz-seeds",
|
|
105
|
+
]
|
|
106
|
+
shard = parse_shard(command[command.index("--shard") + 1])
|
|
107
|
+
report = Path(command[command.index("--shard-report") + 1])
|
|
108
|
+
reports.append(report)
|
|
109
|
+
kwargs["stdout"].write(
|
|
110
|
+
"hidden success output" if shard.index != 1 else "failure details"
|
|
111
|
+
)
|
|
112
|
+
kwargs["stdout"].flush()
|
|
113
|
+
kwargs["stderr"].write("a warning\n")
|
|
114
|
+
kwargs["stderr"].flush()
|
|
115
|
+
if shard.index == 1 and failure == "launch":
|
|
116
|
+
raise OSError("cannot start child")
|
|
117
|
+
if shard.index != 1 or failure not in {"missing", "corrupt"}:
|
|
118
|
+
write_empty_shard_report(report, shard, fuzz_seed=42)
|
|
119
|
+
elif failure == "corrupt":
|
|
120
|
+
report.write_text("invalid json")
|
|
121
|
+
return FinishedProcess(7 if failure == "exit" and shard.index == 1 else 0)
|
|
122
|
+
|
|
123
|
+
monkeypatch.setattr(parallel.subprocess, "Popen", popen)
|
|
124
|
+
result = run_parallel(
|
|
125
|
+
[
|
|
126
|
+
"--question-path",
|
|
127
|
+
"questions with spaces",
|
|
128
|
+
"-m",
|
|
129
|
+
"render",
|
|
130
|
+
"--fuzz-seeds",
|
|
131
|
+
"--fuzz-seed=42",
|
|
132
|
+
],
|
|
133
|
+
shard_count=3,
|
|
134
|
+
course_root=tmp_path,
|
|
135
|
+
junitxml="combined.xml",
|
|
136
|
+
)
|
|
137
|
+
assert (
|
|
138
|
+
result
|
|
139
|
+
== {"none": 0, "missing": 2, "corrupt": 2, "launch": 2, "exit": 7}[failure]
|
|
140
|
+
)
|
|
141
|
+
assert len(commands) == 3
|
|
142
|
+
assert all(not path.exists() for path in reports)
|
|
143
|
+
assert "a warning" in output.getvalue()
|
|
144
|
+
assert "hidden success output" not in output.getvalue()
|
|
145
|
+
assert "Shard 3/3: passed" in output.getvalue()
|
|
146
|
+
if failure in {"none", "exit"}:
|
|
147
|
+
assert (tmp_path / "combined.xml").is_file()
|
|
148
|
+
if failure == "launch":
|
|
149
|
+
assert "cannot start child" in output.getvalue()
|
|
150
|
+
if failure == "exit":
|
|
151
|
+
assert "failure details" in output.getvalue()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_cli_forwards_arguments(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
155
|
+
calls: list[tuple[Sequence[str], dict[str, Any]]] = []
|
|
156
|
+
|
|
157
|
+
def run(arguments: Sequence[str], **kwargs: Any) -> int:
|
|
158
|
+
calls.append((arguments, kwargs))
|
|
159
|
+
return 3
|
|
160
|
+
|
|
161
|
+
monkeypatch.setattr(parallel, "run_parallel", run)
|
|
162
|
+
assert (
|
|
163
|
+
parallel.main(
|
|
164
|
+
[
|
|
165
|
+
"--shard-count",
|
|
166
|
+
"2",
|
|
167
|
+
"--junitxml",
|
|
168
|
+
"out.xml",
|
|
169
|
+
"diff",
|
|
170
|
+
"--",
|
|
171
|
+
"--base",
|
|
172
|
+
"main",
|
|
173
|
+
"--fuzz-seed",
|
|
174
|
+
"4",
|
|
175
|
+
]
|
|
176
|
+
)
|
|
177
|
+
== 3
|
|
178
|
+
)
|
|
179
|
+
assert calls == [
|
|
180
|
+
(
|
|
181
|
+
["--base", "main", "--fuzz-seed", "4"],
|
|
182
|
+
{"command": "diff", "shard_count": 2, "junitxml": Path("out.xml")},
|
|
183
|
+
)
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_real_processes_overlap_and_cleanup_on_interrupt(
|
|
188
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
189
|
+
) -> None:
|
|
190
|
+
original_popen = subprocess.Popen
|
|
191
|
+
processes: list[subprocess.Popen[str]] = []
|
|
192
|
+
reports: list[Path] = []
|
|
193
|
+
# Each child must observe both processes alive before it finishes. Serial
|
|
194
|
+
# execution times out and fails, without relying on a timing speedup assertion.
|
|
195
|
+
script = """
|
|
196
|
+
import pathlib, sys, time
|
|
197
|
+
root = pathlib.Path(sys.argv[1])
|
|
198
|
+
(root / sys.argv[2]).touch()
|
|
199
|
+
deadline = time.monotonic() + 10
|
|
200
|
+
while len(list(root.glob('ready-*'))) != 2:
|
|
201
|
+
if time.monotonic() > deadline:
|
|
202
|
+
raise SystemExit(9)
|
|
203
|
+
time.sleep(.01)
|
|
204
|
+
time.sleep(30)
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
def popen(command: list[str], **kwargs: Any) -> subprocess.Popen[str]:
|
|
208
|
+
reports.append(Path(command[command.index("--shard-report") + 1]))
|
|
209
|
+
process = original_popen(
|
|
210
|
+
[sys.executable, "-c", script, str(tmp_path), f"ready-{len(processes)}"],
|
|
211
|
+
**kwargs,
|
|
212
|
+
)
|
|
213
|
+
processes.append(process)
|
|
214
|
+
return process
|
|
215
|
+
|
|
216
|
+
original_sleep = parallel.time.sleep
|
|
217
|
+
|
|
218
|
+
interrupted = False
|
|
219
|
+
|
|
220
|
+
def interrupt_when_ready(seconds: float) -> None:
|
|
221
|
+
nonlocal interrupted
|
|
222
|
+
if not interrupted and len(list(tmp_path.glob("ready-*"))) == 2:
|
|
223
|
+
interrupted = True
|
|
224
|
+
assert all(process.poll() is None for process in processes)
|
|
225
|
+
raise KeyboardInterrupt
|
|
226
|
+
original_sleep(seconds)
|
|
227
|
+
|
|
228
|
+
monkeypatch.setattr(parallel.subprocess, "Popen", popen)
|
|
229
|
+
monkeypatch.setattr(parallel.time, "sleep", interrupt_when_ready)
|
|
230
|
+
assert run_parallel([], shard_count=2, course_root=tmp_path) == 130
|
|
231
|
+
assert len(processes) == 2
|
|
232
|
+
assert all(process.poll() is not None for process in processes)
|
|
233
|
+
assert all(not path.parent.exists() for path in reports)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_real_shards_match_serial_replay(
|
|
237
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
238
|
+
) -> None:
|
|
239
|
+
monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1")
|
|
240
|
+
# Skip each phase before backend initialization: this exercises real discovery,
|
|
241
|
+
# seed generation, collection, execution and merging without network access.
|
|
242
|
+
question = tmp_path / "questions" / "example"
|
|
243
|
+
question.mkdir(parents=True)
|
|
244
|
+
(question / "info.json").write_text(json.dumps({"uuid": "test", "type": "v3"}))
|
|
245
|
+
(question / ".question-e2e.json").write_text(
|
|
246
|
+
json.dumps(
|
|
247
|
+
{
|
|
248
|
+
"skip_methods": {
|
|
249
|
+
phase: "fixture"
|
|
250
|
+
for phase in ("generate", "prepare", "render", "grade")
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
arguments = [
|
|
256
|
+
"--question-path",
|
|
257
|
+
"questions",
|
|
258
|
+
"--seed-count",
|
|
259
|
+
"2",
|
|
260
|
+
"--fuzz-seeds",
|
|
261
|
+
"--fuzz-seed",
|
|
262
|
+
"42",
|
|
263
|
+
]
|
|
264
|
+
assert (
|
|
265
|
+
run_parallel(
|
|
266
|
+
arguments, shard_count=3, course_root=tmp_path, junitxml="parallel.xml"
|
|
267
|
+
)
|
|
268
|
+
== 0
|
|
269
|
+
)
|
|
270
|
+
output = capsys.readouterr().out
|
|
271
|
+
replay = next(
|
|
272
|
+
line.removeprefix("Replay without sharding: ")
|
|
273
|
+
for line in output.splitlines()
|
|
274
|
+
if line.startswith("Replay without sharding: ")
|
|
275
|
+
)
|
|
276
|
+
assert shlex.split(replay) == [sys.executable, "-m", "prairielearn_e2e", *arguments]
|
|
277
|
+
result = subprocess.run(
|
|
278
|
+
[
|
|
279
|
+
sys.executable,
|
|
280
|
+
"-m",
|
|
281
|
+
"prairielearn_e2e",
|
|
282
|
+
*arguments,
|
|
283
|
+
"--junitxml",
|
|
284
|
+
str(tmp_path / "serial.xml"),
|
|
285
|
+
],
|
|
286
|
+
cwd=tmp_path,
|
|
287
|
+
capture_output=True,
|
|
288
|
+
text=True,
|
|
289
|
+
check=False,
|
|
290
|
+
)
|
|
291
|
+
assert result.returncode == 0, result.stdout + result.stderr
|
|
292
|
+
|
|
293
|
+
def cases(path: Path) -> list[str]:
|
|
294
|
+
return sorted(
|
|
295
|
+
case.attrib["name"].split("::")[-1]
|
|
296
|
+
for case in ET.parse(path).iter("testcase")
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
assert cases(tmp_path / "parallel.xml") == cases(tmp_path / "serial.xml")
|
|
300
|
+
assert len(cases(tmp_path / "parallel.xml")) == 8
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_real_no_change_diff(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
304
|
+
monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1")
|
|
305
|
+
for arguments in [
|
|
306
|
+
["init", "-q"],
|
|
307
|
+
[
|
|
308
|
+
"-c",
|
|
309
|
+
"user.name=Test",
|
|
310
|
+
"-c",
|
|
311
|
+
"user.email=test@example.com",
|
|
312
|
+
"commit",
|
|
313
|
+
"--allow-empty",
|
|
314
|
+
"-qm",
|
|
315
|
+
"initial",
|
|
316
|
+
],
|
|
317
|
+
]:
|
|
318
|
+
subprocess.run(
|
|
319
|
+
["git", *arguments],
|
|
320
|
+
cwd=tmp_path,
|
|
321
|
+
check=True,
|
|
322
|
+
env={**os.environ, "GIT_CONFIG_NOSYSTEM": "1"},
|
|
323
|
+
)
|
|
324
|
+
assert (
|
|
325
|
+
run_parallel(
|
|
326
|
+
["--base", "HEAD"],
|
|
327
|
+
command="diff",
|
|
328
|
+
shard_count=2,
|
|
329
|
+
course_root=tmp_path,
|
|
330
|
+
junitxml="diff.xml",
|
|
331
|
+
)
|
|
332
|
+
== 0
|
|
333
|
+
)
|
|
334
|
+
assert list(ET.parse(tmp_path / "diff.xml").iter("testcase")) == []
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_default_six_shards_and_merge_failure_precedence(
|
|
338
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
339
|
+
) -> None:
|
|
340
|
+
commands: list[list[str]] = []
|
|
341
|
+
outputs: list[Path] = []
|
|
342
|
+
|
|
343
|
+
def popen(command: list[str], **kwargs: Any) -> FinishedProcess:
|
|
344
|
+
commands.append(command)
|
|
345
|
+
report = Path(command[command.index("--shard-report") + 1])
|
|
346
|
+
write_empty_shard_report(
|
|
347
|
+
report, parse_shard(command[command.index("--shard") + 1])
|
|
348
|
+
)
|
|
349
|
+
return FinishedProcess(7)
|
|
350
|
+
|
|
351
|
+
def merge(inputs: Sequence[Path], output: Path) -> int:
|
|
352
|
+
assert len(commands) == len(inputs) == 6
|
|
353
|
+
assert all(path.is_file() for path in inputs)
|
|
354
|
+
outputs.append(output)
|
|
355
|
+
output.write_text("merged")
|
|
356
|
+
return 1
|
|
357
|
+
|
|
358
|
+
monkeypatch.setattr(parallel.subprocess, "Popen", popen)
|
|
359
|
+
monkeypatch.setattr(parallel, "merge_reports", merge)
|
|
360
|
+
assert run_parallel([], course_root=tmp_path) == 1
|
|
361
|
+
assert all(not path.exists() for path in outputs)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def test_force_kills_child_that_ignores_termination() -> None:
|
|
365
|
+
class StubbornProcess:
|
|
366
|
+
terminated = False
|
|
367
|
+
killed = False
|
|
368
|
+
reaped = False
|
|
369
|
+
|
|
370
|
+
def poll(self) -> None:
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
def terminate(self) -> None:
|
|
374
|
+
self.terminated = True
|
|
375
|
+
|
|
376
|
+
def kill(self) -> None:
|
|
377
|
+
self.killed = True
|
|
378
|
+
|
|
379
|
+
def wait(self, timeout: float | None = None) -> int:
|
|
380
|
+
if not self.killed:
|
|
381
|
+
raise subprocess.TimeoutExpired("child", timeout or 0)
|
|
382
|
+
self.reaped = True
|
|
383
|
+
return -9
|
|
384
|
+
|
|
385
|
+
process = StubbornProcess()
|
|
386
|
+
child = parallel._Child(
|
|
387
|
+
1,
|
|
388
|
+
Path("unused"),
|
|
389
|
+
io.StringIO(),
|
|
390
|
+
io.StringIO(),
|
|
391
|
+
process=cast(subprocess.Popen[str], process),
|
|
392
|
+
)
|
|
393
|
+
parallel._stop([child])
|
|
394
|
+
assert process.terminated and process.killed and process.reaped
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@pytest.mark.parametrize("arguments", [["--fuzz-seed"], ["--fuzz-seed=bad"]])
|
|
398
|
+
def test_invalid_seed_is_api_value_error(arguments: list[str]) -> None:
|
|
399
|
+
with pytest.raises(ValueError):
|
|
400
|
+
run_parallel(arguments)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|