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.
Files changed (54) hide show
  1. {pl_e2e_test-0.1.2/src/pl_e2e_test.egg-info → pl_e2e_test-0.1.3}/PKG-INFO +65 -1
  2. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/README.md +63 -0
  3. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/pyproject.toml +2 -1
  4. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3/src/pl_e2e_test.egg-info}/PKG-INFO +65 -1
  5. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/pl_e2e_test.egg-info/SOURCES.txt +2 -0
  6. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/pl_e2e_test.egg-info/requires.txt +1 -0
  7. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/__init__.py +2 -0
  8. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/cli.py +3 -1
  9. pl_e2e_test-0.1.3/src/prairielearn_e2e/parallel.py +250 -0
  10. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_cli.py +2 -2
  11. pl_e2e_test-0.1.3/tests/test_parallel.py +400 -0
  12. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/LICENSE +0 -0
  13. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/MANIFEST.in +0 -0
  14. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/setup.cfg +0 -0
  15. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
  16. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
  17. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
  18. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/__main__.py +0 -0
  19. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/_git.py +0 -0
  20. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/backend.py +0 -0
  21. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/config.json +0 -0
  22. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/config.py +0 -0
  23. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/contract.py +0 -0
  24. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/discovery.py +0 -0
  25. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/errors.py +0 -0
  26. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/fuzz.py +0 -0
  27. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/harness.py +0 -0
  28. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/html_checks.py +0 -0
  29. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/metadata.py +0 -0
  30. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/pytest_plugin.py +0 -0
  31. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/question_tests.py +0 -0
  32. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/regression.py +0 -0
  33. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/reporting.py +0 -0
  34. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/repository.py +0 -0
  35. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/runner.py +0 -0
  36. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/server.py +0 -0
  37. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/src/prairielearn_e2e/sharding.py +0 -0
  38. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/__init__.py +0 -0
  39. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/conftest.py +0 -0
  40. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/stub_git.py +0 -0
  41. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_contract.py +0 -0
  42. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_discovery.py +0 -0
  43. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_fuzz.py +0 -0
  44. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_git.py +0 -0
  45. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_harness.py +0 -0
  46. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_html_checks.py +0 -0
  47. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_metadata.py +0 -0
  48. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_publish_version.py +0 -0
  49. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_pytest_plugin.py +0 -0
  50. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_regression.py +0 -0
  51. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_reporting.py +0 -0
  52. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_repository.py +0 -0
  53. {pl_e2e_test-0.1.2 → pl_e2e_test-0.1.3}/tests/test_server.py +0 -0
  54. {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.2
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.2"
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.2
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
@@ -7,4 +7,5 @@ platformdirs>=4.5
7
7
  prairielearn
8
8
  pyright>=1.1.414
9
9
  pytest>=9.1
10
+ rich>=14
10
11
  ruff>=0.16
@@ -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