pytest-zygote 0.1.0__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.
- pytest_zygote-0.1.0/.github/workflows/ci.yml +76 -0
- pytest_zygote-0.1.0/.gitignore +8 -0
- pytest_zygote-0.1.0/CHANGELOG.md +11 -0
- pytest_zygote-0.1.0/LICENSE +21 -0
- pytest_zygote-0.1.0/PKG-INFO +170 -0
- pytest_zygote-0.1.0/README.md +140 -0
- pytest_zygote-0.1.0/pyproject.toml +57 -0
- pytest_zygote-0.1.0/src/pytest_zygote/__init__.py +0 -0
- pytest_zygote-0.1.0/src/pytest_zygote/plugin.py +402 -0
- pytest_zygote-0.1.0/tests/conftest.py +1 -0
- pytest_zygote-0.1.0/tests/test_plugin.py +193 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
tags: ["v*"]
|
|
7
|
+
pull_request:
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
runs-on: ${{ matrix.os }}
|
|
13
|
+
strategy:
|
|
14
|
+
fail-fast: false
|
|
15
|
+
matrix:
|
|
16
|
+
os: [ubuntu-latest]
|
|
17
|
+
python: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
18
|
+
include:
|
|
19
|
+
# The plugin forks, so macOS is a separate platform, not just another interpreter.
|
|
20
|
+
- os: macos-latest
|
|
21
|
+
python: "3.12"
|
|
22
|
+
# Oldest supported pytest, against the newest interpreter that accepts it.
|
|
23
|
+
- os: ubuntu-latest
|
|
24
|
+
python: "3.11"
|
|
25
|
+
pytest: "==7.0.0"
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
- uses: actions/setup-python@v5
|
|
29
|
+
with:
|
|
30
|
+
python-version: ${{ matrix.python }}
|
|
31
|
+
- run: pip install -e ".[test]" "pytest${{ matrix.pytest }}"
|
|
32
|
+
- run: pytest -q
|
|
33
|
+
env:
|
|
34
|
+
OBJC_DISABLE_INITIALIZE_FORK_SAFETY: YES
|
|
35
|
+
|
|
36
|
+
lint:
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@v4
|
|
40
|
+
- uses: actions/setup-python@v5
|
|
41
|
+
with:
|
|
42
|
+
python-version: "3.12"
|
|
43
|
+
- run: pip install ruff
|
|
44
|
+
- run: ruff check .
|
|
45
|
+
- run: ruff format --check .
|
|
46
|
+
|
|
47
|
+
build:
|
|
48
|
+
runs-on: ubuntu-latest
|
|
49
|
+
needs: [test, lint]
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/checkout@v4
|
|
52
|
+
- uses: actions/setup-python@v5
|
|
53
|
+
with:
|
|
54
|
+
python-version: "3.12"
|
|
55
|
+
- run: pip install build twine
|
|
56
|
+
- run: python -m build
|
|
57
|
+
- run: twine check dist/*
|
|
58
|
+
- uses: actions/upload-artifact@v4
|
|
59
|
+
with:
|
|
60
|
+
name: dist
|
|
61
|
+
path: dist/
|
|
62
|
+
|
|
63
|
+
publish:
|
|
64
|
+
# Tags only. Uses PyPI trusted publishing, so there is no token to store.
|
|
65
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
66
|
+
runs-on: ubuntu-latest
|
|
67
|
+
needs: [build]
|
|
68
|
+
environment: pypi
|
|
69
|
+
permissions:
|
|
70
|
+
id-token: write
|
|
71
|
+
steps:
|
|
72
|
+
- uses: actions/download-artifact@v4
|
|
73
|
+
with:
|
|
74
|
+
name: dist
|
|
75
|
+
path: dist/
|
|
76
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- `--workers N` (or `auto`) runs tests in workers forked from the collecting process.
|
|
8
|
+
- `gc.freeze()` before the fork, so the garbage collector stops dirtying shared pages.
|
|
9
|
+
- Workers that die mid-test are reported against the test they were running and replaced;
|
|
10
|
+
`--workers-max-restarts` caps that.
|
|
11
|
+
- `--workers-no-freeze` for measuring what the freeze is worth.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pedro Fuentes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pytest-zygote
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run pytest in parallel workers forked from one warm process, so the imports are paid once
|
|
5
|
+
Project-URL: Homepage, https://github.com/pedrofuentes79/pytest-zygote
|
|
6
|
+
Project-URL: Issues, https://github.com/pedrofuentes79/pytest-zygote/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/pedrofuentes79/pytest-zygote/blob/main/CHANGELOG.md
|
|
8
|
+
Author-email: Pedro Fuentes <pedrofuentes7799@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: fork,memory,parallel,plugin,pytest,xdist
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: Pytest
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Software Development :: Testing
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Requires-Dist: pytest>=7.0
|
|
27
|
+
Provides-Extra: test
|
|
28
|
+
Requires-Dist: pytest-xdist>=3.0; extra == 'test'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# pytest-zygote
|
|
32
|
+
|
|
33
|
+
Run pytest in parallel without paying for the imports N times.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install pytest-zygote
|
|
37
|
+
pytest --workers 4
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Tested on Python 3.9-3.14 and pytest 7.0-9.1.
|
|
41
|
+
|
|
42
|
+
## The problem
|
|
43
|
+
|
|
44
|
+
A pytest-xdist worker is a fresh interpreter. execnet's popen gateway starts each one as
|
|
45
|
+
`python -u -B -c ...`, so every worker imports your whole dependency tree from scratch and
|
|
46
|
+
keeps its own copy in memory. If importing your app costs 400 MB — a few ML or LLM libraries
|
|
47
|
+
will do it — then `-n 4` costs 2 GB before a single assertion runs, and almost none of it is
|
|
48
|
+
shareable: it is private anonymous heap, not file-backed pages.
|
|
49
|
+
|
|
50
|
+
That is what puts a ceiling on parallelism in a container. You want more workers because your
|
|
51
|
+
tests wait on the network; you can't have them because each one costs another 400 MB.
|
|
52
|
+
|
|
53
|
+
## What this does
|
|
54
|
+
|
|
55
|
+
Collect in the parent, `gc.freeze()`, then `fork()` a worker per slot. Workers inherit the
|
|
56
|
+
already-imported heap as copy-on-write pages, so the imports are paid once for the whole run.
|
|
57
|
+
|
|
58
|
+
Measured on a suite of 1089 tests whose imports cost ~430 MB (litellm, pydantic-ai, pandas,
|
|
59
|
+
matplotlib), peak memory of the whole container:
|
|
60
|
+
|
|
61
|
+
| | wall | peak memory |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| one process | 28.3 s | 510 MB |
|
|
64
|
+
| `pytest -n 4` (xdist) | 21.3 s | 1945 MB |
|
|
65
|
+
| `pytest --workers 4` | 16.1 s | 810 MB |
|
|
66
|
+
| `pytest --workers 8` | 14.7 s | 1004 MB |
|
|
67
|
+
| `pytest --workers 12` | 13.8 s | 1104 MB |
|
|
68
|
+
|
|
69
|
+
**A worker costs ~46 MB here; an xdist worker costs ~490 MB.** It is also faster than xdist,
|
|
70
|
+
because a forked worker starts warm and skips the import that each xdist worker repeats.
|
|
71
|
+
|
|
72
|
+
The saving is real only because of `gc.freeze()`. CPython's generational collector writes to
|
|
73
|
+
every object header when it runs, which dirties copy-on-write pages and un-shares them one
|
|
74
|
+
collection at a time. `gc.freeze()` moves everything imported so far into a permanent
|
|
75
|
+
generation the collector never walks. Without it a worker's private dirty memory here is
|
|
76
|
+
~195 MB instead of ~22 MB. The technique is Instagram's, upstreamed as `gc.freeze()` in
|
|
77
|
+
Python 3.7.
|
|
78
|
+
|
|
79
|
+
## Compared with pytest-xdist
|
|
80
|
+
|
|
81
|
+
The difference is entirely in how a worker is born.
|
|
82
|
+
|
|
83
|
+
| | pytest-xdist | pytest-zygote |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| how a worker starts | a new interpreter (`python -u -B -c ...` via execnet) | `fork()` of the process that just collected |
|
|
86
|
+
| who imports your code | every worker, separately | the controller, once |
|
|
87
|
+
| who collects | every worker repeats collection | the controller only |
|
|
88
|
+
| memory per worker* | ~490 MB | ~46 MB |
|
|
89
|
+
| time before the first test | each worker pays the full import | workers start warm |
|
|
90
|
+
| distribution | `load`, `loadscope`, `loadfile`, `loadgroup`, `worksteal`, `each` | dynamic, per test, with a one-test lookahead |
|
|
91
|
+
| workers on other machines | yes, over ssh or a socket | no, this box only |
|
|
92
|
+
| Windows | yes | no — `fork()` only, so Linux and macOS |
|
|
93
|
+
| `--looponfail` | yes | no |
|
|
94
|
+
| a worker that dies | `--max-worker-restart` | replaced, and the test it killed is reported as failed |
|
|
95
|
+
| maturity | since 2010, used everywhere | new |
|
|
96
|
+
|
|
97
|
+
\* On the suite in the table above, whose imports cost ~430 MB. If your imports are cheap,
|
|
98
|
+
both numbers collapse and the distinction stops mattering.
|
|
99
|
+
|
|
100
|
+
**Use xdist** when you need workers on other machines, a `--dist` mode that pins related tests
|
|
101
|
+
to one worker, Windows, or simply when it already works for you. It is the mature, general
|
|
102
|
+
answer and this plugin does not try to replace it.
|
|
103
|
+
|
|
104
|
+
**Use zygote** when your imports are expensive and memory is what caps your parallelism —
|
|
105
|
+
a container with a hard limit, CI with a small runner, a laptop that swaps at `-n 8`. The
|
|
106
|
+
worse your import cost, the bigger the gap. The suite above needs 1945 MB to run four xdist
|
|
107
|
+
workers; it runs twelve forked workers in 1104 MB.
|
|
108
|
+
|
|
109
|
+
They are mutually exclusive in one run: passing `--workers` and `-n` together is an error,
|
|
110
|
+
not a silent choice of one.
|
|
111
|
+
|
|
112
|
+
## Options
|
|
113
|
+
|
|
114
|
+
| | |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `--workers N` | run tests in N forked workers, or `auto` for one per core. Default `0`, which leaves pytest alone |
|
|
117
|
+
| `--workers-max-restarts N` | cap how many dead workers get replaced. Default: one per worker; `0` disables |
|
|
118
|
+
| `--workers-no-freeze` | skip `gc.freeze()`. Diagnostic — it costs most of the saving |
|
|
119
|
+
|
|
120
|
+
The worker count is capped at the number of collected tests. Each worker gets
|
|
121
|
+
`PYTEST_ZYGOTE_WORKER=gw<n>` in its environment, like xdist's `PYTEST_XDIST_WORKER`.
|
|
122
|
+
|
|
123
|
+
## When a worker dies
|
|
124
|
+
|
|
125
|
+
A worker killed mid-test — in a container, usually the memory cgroup — would otherwise take
|
|
126
|
+
its test down silently, and with `-q` you would not even learn which one. The controller sees
|
|
127
|
+
the socket close, reports that test as failed with the signal name, puts the worker's queued
|
|
128
|
+
work back, and forks a replacement:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
zygote worker gw0 killed by signal 9 (SIGKILL) while running this test.
|
|
132
|
+
In a container SIGKILL here is almost always the memory cgroup.
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## How reports get back
|
|
136
|
+
|
|
137
|
+
Workers run `runtestprotocol(item, log=False)` and stream their reports to the controller as
|
|
138
|
+
length-prefixed JSON over a `socketpair()`. Only the controller calls
|
|
139
|
+
`pytest_runtest_logreport`, so the terminal reporter, `--junit-xml`, coverage and any plugin
|
|
140
|
+
of your own that listens to reports all see one ordered stream from one process. Nothing needs
|
|
141
|
+
locking and nothing is written twice.
|
|
142
|
+
|
|
143
|
+
Each worker is also told the test *after* the one it is running. pytest finalizes a fixture
|
|
144
|
+
when the next test no longer needs it, so without that lookahead every module- and
|
|
145
|
+
class-scoped fixture would tear down after each test.
|
|
146
|
+
|
|
147
|
+
## Limits
|
|
148
|
+
|
|
149
|
+
- **Needs `os.fork()`** — Linux and macOS. Not Windows.
|
|
150
|
+
- **Mutually exclusive with xdist's `-n`.** Pass one or the other.
|
|
151
|
+
- **`--workers` is also pytest-parallel's flag.** That plugin was archived in May 2024; if you
|
|
152
|
+
have both installed, argparse fails at startup and you have to drop one.
|
|
153
|
+
- **Scheduling is per test**, so a module-scoped fixture is set up once per worker that draws a
|
|
154
|
+
test from that module — the same trade xdist makes without `--dist loadfile`.
|
|
155
|
+
- **`-x` can overshoot** by a test or two, since several are in flight when the first failure
|
|
156
|
+
lands.
|
|
157
|
+
- **Your conftest must not open things at import time.** A module-level DB connection, event
|
|
158
|
+
loop or thread pool created during collection is inherited by every fork. Anything created
|
|
159
|
+
inside a fixture is fine, because that runs in the worker.
|
|
160
|
+
|
|
161
|
+
## macOS
|
|
162
|
+
|
|
163
|
+
CI runs the suite on macOS, but the numbers above and the day-to-day use are Linux. macOS
|
|
164
|
+
treats forking a process that has already initialized certain Objective-C frameworks as
|
|
165
|
+
unsafe; if you hit `__NSPlaceholderDictionary initialize` crashes, set
|
|
166
|
+
`OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES`, the same workaround xdist users apply.
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# pytest-zygote
|
|
2
|
+
|
|
3
|
+
Run pytest in parallel without paying for the imports N times.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install pytest-zygote
|
|
7
|
+
pytest --workers 4
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Tested on Python 3.9-3.14 and pytest 7.0-9.1.
|
|
11
|
+
|
|
12
|
+
## The problem
|
|
13
|
+
|
|
14
|
+
A pytest-xdist worker is a fresh interpreter. execnet's popen gateway starts each one as
|
|
15
|
+
`python -u -B -c ...`, so every worker imports your whole dependency tree from scratch and
|
|
16
|
+
keeps its own copy in memory. If importing your app costs 400 MB — a few ML or LLM libraries
|
|
17
|
+
will do it — then `-n 4` costs 2 GB before a single assertion runs, and almost none of it is
|
|
18
|
+
shareable: it is private anonymous heap, not file-backed pages.
|
|
19
|
+
|
|
20
|
+
That is what puts a ceiling on parallelism in a container. You want more workers because your
|
|
21
|
+
tests wait on the network; you can't have them because each one costs another 400 MB.
|
|
22
|
+
|
|
23
|
+
## What this does
|
|
24
|
+
|
|
25
|
+
Collect in the parent, `gc.freeze()`, then `fork()` a worker per slot. Workers inherit the
|
|
26
|
+
already-imported heap as copy-on-write pages, so the imports are paid once for the whole run.
|
|
27
|
+
|
|
28
|
+
Measured on a suite of 1089 tests whose imports cost ~430 MB (litellm, pydantic-ai, pandas,
|
|
29
|
+
matplotlib), peak memory of the whole container:
|
|
30
|
+
|
|
31
|
+
| | wall | peak memory |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| one process | 28.3 s | 510 MB |
|
|
34
|
+
| `pytest -n 4` (xdist) | 21.3 s | 1945 MB |
|
|
35
|
+
| `pytest --workers 4` | 16.1 s | 810 MB |
|
|
36
|
+
| `pytest --workers 8` | 14.7 s | 1004 MB |
|
|
37
|
+
| `pytest --workers 12` | 13.8 s | 1104 MB |
|
|
38
|
+
|
|
39
|
+
**A worker costs ~46 MB here; an xdist worker costs ~490 MB.** It is also faster than xdist,
|
|
40
|
+
because a forked worker starts warm and skips the import that each xdist worker repeats.
|
|
41
|
+
|
|
42
|
+
The saving is real only because of `gc.freeze()`. CPython's generational collector writes to
|
|
43
|
+
every object header when it runs, which dirties copy-on-write pages and un-shares them one
|
|
44
|
+
collection at a time. `gc.freeze()` moves everything imported so far into a permanent
|
|
45
|
+
generation the collector never walks. Without it a worker's private dirty memory here is
|
|
46
|
+
~195 MB instead of ~22 MB. The technique is Instagram's, upstreamed as `gc.freeze()` in
|
|
47
|
+
Python 3.7.
|
|
48
|
+
|
|
49
|
+
## Compared with pytest-xdist
|
|
50
|
+
|
|
51
|
+
The difference is entirely in how a worker is born.
|
|
52
|
+
|
|
53
|
+
| | pytest-xdist | pytest-zygote |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| how a worker starts | a new interpreter (`python -u -B -c ...` via execnet) | `fork()` of the process that just collected |
|
|
56
|
+
| who imports your code | every worker, separately | the controller, once |
|
|
57
|
+
| who collects | every worker repeats collection | the controller only |
|
|
58
|
+
| memory per worker* | ~490 MB | ~46 MB |
|
|
59
|
+
| time before the first test | each worker pays the full import | workers start warm |
|
|
60
|
+
| distribution | `load`, `loadscope`, `loadfile`, `loadgroup`, `worksteal`, `each` | dynamic, per test, with a one-test lookahead |
|
|
61
|
+
| workers on other machines | yes, over ssh or a socket | no, this box only |
|
|
62
|
+
| Windows | yes | no — `fork()` only, so Linux and macOS |
|
|
63
|
+
| `--looponfail` | yes | no |
|
|
64
|
+
| a worker that dies | `--max-worker-restart` | replaced, and the test it killed is reported as failed |
|
|
65
|
+
| maturity | since 2010, used everywhere | new |
|
|
66
|
+
|
|
67
|
+
\* On the suite in the table above, whose imports cost ~430 MB. If your imports are cheap,
|
|
68
|
+
both numbers collapse and the distinction stops mattering.
|
|
69
|
+
|
|
70
|
+
**Use xdist** when you need workers on other machines, a `--dist` mode that pins related tests
|
|
71
|
+
to one worker, Windows, or simply when it already works for you. It is the mature, general
|
|
72
|
+
answer and this plugin does not try to replace it.
|
|
73
|
+
|
|
74
|
+
**Use zygote** when your imports are expensive and memory is what caps your parallelism —
|
|
75
|
+
a container with a hard limit, CI with a small runner, a laptop that swaps at `-n 8`. The
|
|
76
|
+
worse your import cost, the bigger the gap. The suite above needs 1945 MB to run four xdist
|
|
77
|
+
workers; it runs twelve forked workers in 1104 MB.
|
|
78
|
+
|
|
79
|
+
They are mutually exclusive in one run: passing `--workers` and `-n` together is an error,
|
|
80
|
+
not a silent choice of one.
|
|
81
|
+
|
|
82
|
+
## Options
|
|
83
|
+
|
|
84
|
+
| | |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `--workers N` | run tests in N forked workers, or `auto` for one per core. Default `0`, which leaves pytest alone |
|
|
87
|
+
| `--workers-max-restarts N` | cap how many dead workers get replaced. Default: one per worker; `0` disables |
|
|
88
|
+
| `--workers-no-freeze` | skip `gc.freeze()`. Diagnostic — it costs most of the saving |
|
|
89
|
+
|
|
90
|
+
The worker count is capped at the number of collected tests. Each worker gets
|
|
91
|
+
`PYTEST_ZYGOTE_WORKER=gw<n>` in its environment, like xdist's `PYTEST_XDIST_WORKER`.
|
|
92
|
+
|
|
93
|
+
## When a worker dies
|
|
94
|
+
|
|
95
|
+
A worker killed mid-test — in a container, usually the memory cgroup — would otherwise take
|
|
96
|
+
its test down silently, and with `-q` you would not even learn which one. The controller sees
|
|
97
|
+
the socket close, reports that test as failed with the signal name, puts the worker's queued
|
|
98
|
+
work back, and forks a replacement:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
zygote worker gw0 killed by signal 9 (SIGKILL) while running this test.
|
|
102
|
+
In a container SIGKILL here is almost always the memory cgroup.
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## How reports get back
|
|
106
|
+
|
|
107
|
+
Workers run `runtestprotocol(item, log=False)` and stream their reports to the controller as
|
|
108
|
+
length-prefixed JSON over a `socketpair()`. Only the controller calls
|
|
109
|
+
`pytest_runtest_logreport`, so the terminal reporter, `--junit-xml`, coverage and any plugin
|
|
110
|
+
of your own that listens to reports all see one ordered stream from one process. Nothing needs
|
|
111
|
+
locking and nothing is written twice.
|
|
112
|
+
|
|
113
|
+
Each worker is also told the test *after* the one it is running. pytest finalizes a fixture
|
|
114
|
+
when the next test no longer needs it, so without that lookahead every module- and
|
|
115
|
+
class-scoped fixture would tear down after each test.
|
|
116
|
+
|
|
117
|
+
## Limits
|
|
118
|
+
|
|
119
|
+
- **Needs `os.fork()`** — Linux and macOS. Not Windows.
|
|
120
|
+
- **Mutually exclusive with xdist's `-n`.** Pass one or the other.
|
|
121
|
+
- **`--workers` is also pytest-parallel's flag.** That plugin was archived in May 2024; if you
|
|
122
|
+
have both installed, argparse fails at startup and you have to drop one.
|
|
123
|
+
- **Scheduling is per test**, so a module-scoped fixture is set up once per worker that draws a
|
|
124
|
+
test from that module — the same trade xdist makes without `--dist loadfile`.
|
|
125
|
+
- **`-x` can overshoot** by a test or two, since several are in flight when the first failure
|
|
126
|
+
lands.
|
|
127
|
+
- **Your conftest must not open things at import time.** A module-level DB connection, event
|
|
128
|
+
loop or thread pool created during collection is inherited by every fork. Anything created
|
|
129
|
+
inside a fixture is fine, because that runs in the worker.
|
|
130
|
+
|
|
131
|
+
## macOS
|
|
132
|
+
|
|
133
|
+
CI runs the suite on macOS, but the numbers above and the day-to-day use are Linux. macOS
|
|
134
|
+
treats forking a process that has already initialized certain Objective-C frameworks as
|
|
135
|
+
unsafe; if you hit `__NSPlaceholderDictionary initialize` crashes, set
|
|
136
|
+
`OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES`, the same workaround xdist users apply.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pytest-zygote"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Run pytest in parallel workers forked from one warm process, so the imports are paid once"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "Pedro Fuentes", email = "pedrofuentes7799@gmail.com" }]
|
|
14
|
+
keywords = ["pytest", "parallel", "fork", "memory", "xdist", "plugin"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Framework :: Pytest",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: MacOS",
|
|
20
|
+
"Operating System :: POSIX :: Linux",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
28
|
+
"Topic :: Software Development :: Testing",
|
|
29
|
+
]
|
|
30
|
+
dependencies = ["pytest>=7.0"]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
test = ["pytest-xdist>=3.0"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/pedrofuentes79/pytest-zygote"
|
|
37
|
+
Issues = "https://github.com/pedrofuentes79/pytest-zygote/issues"
|
|
38
|
+
Changelog = "https://github.com/pedrofuentes79/pytest-zygote/blob/main/CHANGELOG.md"
|
|
39
|
+
|
|
40
|
+
[project.entry-points.pytest11]
|
|
41
|
+
zygote = "pytest_zygote.plugin"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/pytest_zygote"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 103
|
|
51
|
+
# The plugin runs on 3.9; 3.14's PEP 758 would let the formatter strip the parentheses
|
|
52
|
+
# from `except (A, B)`, which every older Python rejects.
|
|
53
|
+
target-version = "py39"
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
# Pinned rather than left to the default, so CI lints the same as any contributor's machine.
|
|
57
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "C4", "RUF"]
|
|
File without changes
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""Run tests in workers forked from one warmed-up process instead of fresh interpreters.
|
|
2
|
+
|
|
3
|
+
pytest-xdist starts each worker as a new interpreter (execnet's popen gateway), so every
|
|
4
|
+
worker re-imports the whole dependency tree and pays its full resident cost. Here collection
|
|
5
|
+
and every import happen once, in the controller; workers are ``fork()`` children that share
|
|
6
|
+
that heap copy-on-write, so N workers cost roughly one worker plus what the tests allocate.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import contextlib
|
|
13
|
+
import gc
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import selectors
|
|
17
|
+
import signal
|
|
18
|
+
import socket
|
|
19
|
+
import struct
|
|
20
|
+
import sys
|
|
21
|
+
import traceback
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import pytest
|
|
25
|
+
from _pytest.reports import TestReport
|
|
26
|
+
from _pytest.runner import runtestprotocol
|
|
27
|
+
|
|
28
|
+
_HEADER = struct.Struct("!I")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _PeerGone(OSError):
|
|
32
|
+
"""The other end of a worker socket closed; an OSError so one except covers socket errors too."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _count(value: str) -> int:
|
|
36
|
+
"""Worker count, or 'auto' for one per core — the spelling xdist and pytest-parallel both take."""
|
|
37
|
+
if value == "auto":
|
|
38
|
+
return os.cpu_count() or 1
|
|
39
|
+
try:
|
|
40
|
+
count = int(value)
|
|
41
|
+
except ValueError:
|
|
42
|
+
raise argparse.ArgumentTypeError(f"expected a number or 'auto', got {value!r}") from None
|
|
43
|
+
if count < 0:
|
|
44
|
+
raise argparse.ArgumentTypeError("cannot be negative")
|
|
45
|
+
return count
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
49
|
+
group = parser.getgroup("zygote", "forked workers that share the controller's memory")
|
|
50
|
+
group.addoption(
|
|
51
|
+
"--workers",
|
|
52
|
+
metavar="N",
|
|
53
|
+
type=_count,
|
|
54
|
+
default=0,
|
|
55
|
+
help="Run tests in N workers forked from this process, or 'auto' for one per core.",
|
|
56
|
+
)
|
|
57
|
+
group.addoption(
|
|
58
|
+
"--workers-no-freeze",
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="Skip gc.freeze() before forking; diagnostic, costs most of the memory saving.",
|
|
61
|
+
)
|
|
62
|
+
group.addoption(
|
|
63
|
+
"--workers-max-restarts",
|
|
64
|
+
metavar="N",
|
|
65
|
+
type=int,
|
|
66
|
+
default=-1,
|
|
67
|
+
help="Replace at most N workers that die mid-run (default: one per worker; 0 disables).",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.hookimpl(tryfirst=True)
|
|
72
|
+
def pytest_runtestloop(session: pytest.Session) -> object | None:
|
|
73
|
+
config = session.config
|
|
74
|
+
workers: int = config.getoption("workers") or 0
|
|
75
|
+
if workers < 1 or config.option.collectonly:
|
|
76
|
+
return None
|
|
77
|
+
if not hasattr(os, "fork"):
|
|
78
|
+
raise pytest.UsageError("--workers needs os.fork(), which this platform lacks")
|
|
79
|
+
if config.getoption("numprocesses", None):
|
|
80
|
+
raise pytest.UsageError("--workers and -n are alternatives; pass only one")
|
|
81
|
+
if session.testsfailed and not config.option.continue_on_collection_errors:
|
|
82
|
+
raise session.Failed(f"{session.testsfailed} error(s) during collection")
|
|
83
|
+
if session.items:
|
|
84
|
+
_Controller(session, workers).run()
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --------------------------------------------------------------------------- wire
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _send(sock: socket.socket, msg: dict[str, Any]) -> None:
|
|
92
|
+
payload = json.dumps(msg).encode()
|
|
93
|
+
sock.sendall(_HEADER.pack(len(payload)) + payload)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _recv_one(sock: socket.socket) -> dict[str, Any]:
|
|
97
|
+
"""Block until one whole message has arrived; raise _PeerGone if the peer closed first."""
|
|
98
|
+
|
|
99
|
+
def exact(n: int) -> bytes:
|
|
100
|
+
buf = b""
|
|
101
|
+
while len(buf) < n:
|
|
102
|
+
chunk = sock.recv(n - len(buf))
|
|
103
|
+
if not chunk:
|
|
104
|
+
raise _PeerGone
|
|
105
|
+
buf += chunk
|
|
106
|
+
return buf
|
|
107
|
+
|
|
108
|
+
(size,) = _HEADER.unpack(exact(_HEADER.size))
|
|
109
|
+
return json.loads(exact(size))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _Reader:
|
|
113
|
+
"""Reassembles length-prefixed messages from a non-blocking socket."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, sock: socket.socket) -> None:
|
|
116
|
+
self._sock = sock
|
|
117
|
+
self._buf = bytearray()
|
|
118
|
+
|
|
119
|
+
def read(self) -> list[dict[str, Any]]:
|
|
120
|
+
chunk = self._sock.recv(1 << 16)
|
|
121
|
+
if not chunk:
|
|
122
|
+
raise _PeerGone
|
|
123
|
+
self._buf += chunk
|
|
124
|
+
out = []
|
|
125
|
+
while len(self._buf) >= _HEADER.size:
|
|
126
|
+
(size,) = _HEADER.unpack_from(self._buf)
|
|
127
|
+
end = _HEADER.size + size
|
|
128
|
+
if len(self._buf) < end:
|
|
129
|
+
break
|
|
130
|
+
out.append(json.loads(bytes(self._buf[_HEADER.size : end])))
|
|
131
|
+
del self._buf[:end]
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --------------------------------------------------------------------------- controller
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class _Worker:
|
|
139
|
+
def __init__(self, wid: int, pid: int, sock: socket.socket) -> None:
|
|
140
|
+
self.id = wid
|
|
141
|
+
self.pid = pid
|
|
142
|
+
self.sock = sock
|
|
143
|
+
self.reader = _Reader(sock)
|
|
144
|
+
self.running: int | None = None # item index in flight, for crash attribution
|
|
145
|
+
self.committed: int | None = None # item promised as this worker's next
|
|
146
|
+
self.alive = True
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _Controller:
|
|
150
|
+
def __init__(self, session: pytest.Session, workers: int) -> None:
|
|
151
|
+
self.session = session
|
|
152
|
+
self.config = session.config
|
|
153
|
+
self.items = session.items
|
|
154
|
+
self.count = max(1, min(workers, len(session.items)))
|
|
155
|
+
self.queue = list(range(len(session.items)))
|
|
156
|
+
self.workers: list[_Worker] = []
|
|
157
|
+
restarts = self.config.getoption("workers_max_restarts")
|
|
158
|
+
restarts = self.count if restarts is None or restarts < 0 else int(restarts)
|
|
159
|
+
self.restarts_left: int = restarts
|
|
160
|
+
self._capman: Any = None
|
|
161
|
+
self._next_id = 0
|
|
162
|
+
|
|
163
|
+
def run(self) -> None:
|
|
164
|
+
capman: Any = self.config.pluginmanager.getplugin("capturemanager")
|
|
165
|
+
self._capman = capman
|
|
166
|
+
if capman:
|
|
167
|
+
# Fork with the real stdout/stderr fds restored: a child that inherited the
|
|
168
|
+
# controller's capture would write into its temp files, interleaving with
|
|
169
|
+
# every other child.
|
|
170
|
+
capman.suspend_global_capture(in_=True)
|
|
171
|
+
if not self.config.getoption("workers_no_freeze"):
|
|
172
|
+
gc.collect()
|
|
173
|
+
gc.freeze()
|
|
174
|
+
self._announce()
|
|
175
|
+
self._spawn(capman)
|
|
176
|
+
try:
|
|
177
|
+
self._dispatch()
|
|
178
|
+
except BaseException:
|
|
179
|
+
self._signal_all(signal.SIGKILL)
|
|
180
|
+
raise
|
|
181
|
+
finally:
|
|
182
|
+
self._reap()
|
|
183
|
+
if capman:
|
|
184
|
+
capman.resume_global_capture()
|
|
185
|
+
|
|
186
|
+
def _announce(self) -> None:
|
|
187
|
+
reporter: Any = self.config.pluginmanager.getplugin("terminalreporter")
|
|
188
|
+
if reporter is None:
|
|
189
|
+
return
|
|
190
|
+
frozen = (
|
|
191
|
+
""
|
|
192
|
+
if self.config.getoption("workers_no_freeze")
|
|
193
|
+
else f", {gc.get_freeze_count()} objects frozen"
|
|
194
|
+
)
|
|
195
|
+
plural = "worker" if self.count == 1 else "workers"
|
|
196
|
+
reporter.write_line(f"zygote: {self.count} {plural} forked from pid {os.getpid()}{frozen}")
|
|
197
|
+
|
|
198
|
+
def _spawn(self, capman: Any) -> None:
|
|
199
|
+
for _ in range(self.count):
|
|
200
|
+
self.workers.append(self._fork_one(capman))
|
|
201
|
+
|
|
202
|
+
def _fork_one(self, capman: Any) -> _Worker:
|
|
203
|
+
wid = self._next_id
|
|
204
|
+
self._next_id += 1
|
|
205
|
+
ours, theirs = socket.socketpair()
|
|
206
|
+
pid = os.fork()
|
|
207
|
+
if pid == 0:
|
|
208
|
+
ours.close()
|
|
209
|
+
os._exit(_worker_main(self.session, theirs, wid, capman))
|
|
210
|
+
theirs.close()
|
|
211
|
+
ours.setblocking(False)
|
|
212
|
+
return _Worker(wid, pid, ours)
|
|
213
|
+
|
|
214
|
+
def _dispatch(self) -> None:
|
|
215
|
+
selector = selectors.DefaultSelector()
|
|
216
|
+
for worker in self.workers:
|
|
217
|
+
selector.register(worker.sock, selectors.EVENT_READ, worker)
|
|
218
|
+
live = len(self.workers)
|
|
219
|
+
while live:
|
|
220
|
+
for key, _ in selector.select():
|
|
221
|
+
worker = key.data
|
|
222
|
+
try:
|
|
223
|
+
messages = worker.reader.read()
|
|
224
|
+
except OSError:
|
|
225
|
+
selector.unregister(worker.sock)
|
|
226
|
+
live -= 1
|
|
227
|
+
self._bury(worker)
|
|
228
|
+
replacement = self._replace(worker)
|
|
229
|
+
if replacement is not None:
|
|
230
|
+
selector.register(replacement.sock, selectors.EVENT_READ, replacement)
|
|
231
|
+
live += 1
|
|
232
|
+
continue
|
|
233
|
+
for message in messages:
|
|
234
|
+
self._handle(worker, message)
|
|
235
|
+
if not worker.alive:
|
|
236
|
+
selector.unregister(worker.sock)
|
|
237
|
+
live -= 1
|
|
238
|
+
selector.close()
|
|
239
|
+
|
|
240
|
+
def _replace(self, dead: _Worker) -> _Worker | None:
|
|
241
|
+
"""Fork a fresh worker so a killed one does not strand the tests still queued."""
|
|
242
|
+
if not self.queue or self._should_stop() or self.restarts_left <= 0:
|
|
243
|
+
return None
|
|
244
|
+
self.restarts_left -= 1
|
|
245
|
+
worker = self._fork_one(self._capman)
|
|
246
|
+
self.workers.append(worker)
|
|
247
|
+
reporter: Any = self.config.pluginmanager.getplugin("terminalreporter")
|
|
248
|
+
if reporter is not None:
|
|
249
|
+
reporter.write_line(f"zygote: replaced dead gw{dead.id} with gw{worker.id}")
|
|
250
|
+
return worker
|
|
251
|
+
|
|
252
|
+
def _handle(self, worker: _Worker, message: dict[str, Any]) -> None:
|
|
253
|
+
hook = self.config.hook
|
|
254
|
+
kind = message["t"]
|
|
255
|
+
if kind == "idle":
|
|
256
|
+
self._assign(worker)
|
|
257
|
+
elif kind == "start":
|
|
258
|
+
hook.pytest_runtest_logstart(nodeid=message["nodeid"], location=tuple(message["location"]))
|
|
259
|
+
elif kind == "report":
|
|
260
|
+
report = hook.pytest_report_from_serializable(config=self.config, data=message["data"])
|
|
261
|
+
hook.pytest_runtest_logreport(report=_retuple(report))
|
|
262
|
+
elif kind == "finish":
|
|
263
|
+
worker.running = None
|
|
264
|
+
hook.pytest_runtest_logfinish(
|
|
265
|
+
nodeid=message["nodeid"], location=tuple(message["location"])
|
|
266
|
+
)
|
|
267
|
+
elif kind == "bye":
|
|
268
|
+
worker.alive = False
|
|
269
|
+
|
|
270
|
+
def _assign(self, worker: _Worker) -> None:
|
|
271
|
+
"""Hand out the next test plus the one after it, so teardown knows its nextitem.
|
|
272
|
+
|
|
273
|
+
pytest finalizes a fixture when the following test no longer needs it, which means
|
|
274
|
+
the worker must know that test before it tears down. Committing one item ahead buys
|
|
275
|
+
that without giving up dynamic scheduling.
|
|
276
|
+
"""
|
|
277
|
+
if self._should_stop():
|
|
278
|
+
_send(worker.sock, {"t": "stop"})
|
|
279
|
+
return
|
|
280
|
+
index = worker.committed if worker.committed is not None else self._take()
|
|
281
|
+
if index is None:
|
|
282
|
+
_send(worker.sock, {"t": "stop"})
|
|
283
|
+
return
|
|
284
|
+
worker.committed = None if self._should_stop() else self._take()
|
|
285
|
+
worker.running = index
|
|
286
|
+
_send(worker.sock, {"t": "run", "i": index, "next": worker.committed})
|
|
287
|
+
|
|
288
|
+
def _take(self) -> int | None:
|
|
289
|
+
return self.queue.pop(0) if self.queue else None
|
|
290
|
+
|
|
291
|
+
def _should_stop(self) -> bool:
|
|
292
|
+
return bool(self.session.shouldstop or self.session.shouldfail)
|
|
293
|
+
|
|
294
|
+
def _bury(self, worker: _Worker) -> None:
|
|
295
|
+
"""Report the in-flight test as failed, so an OOM-killed worker never loses one silently."""
|
|
296
|
+
worker.alive = False
|
|
297
|
+
status = 0
|
|
298
|
+
with contextlib.suppress(ChildProcessError):
|
|
299
|
+
_, status = os.waitpid(worker.pid, 0)
|
|
300
|
+
if worker.committed is not None:
|
|
301
|
+
self.queue.insert(0, worker.committed)
|
|
302
|
+
worker.committed = None
|
|
303
|
+
if worker.running is None:
|
|
304
|
+
return
|
|
305
|
+
item = self.items[worker.running]
|
|
306
|
+
worker.running = None
|
|
307
|
+
self.config.hook.pytest_runtest_logreport(report=_crash_report(item, worker.id, status))
|
|
308
|
+
|
|
309
|
+
def _signal_all(self, sig: int) -> None:
|
|
310
|
+
for worker in self.workers:
|
|
311
|
+
with contextlib.suppress(ProcessLookupError):
|
|
312
|
+
os.kill(worker.pid, sig)
|
|
313
|
+
|
|
314
|
+
def _reap(self) -> None:
|
|
315
|
+
for worker in self.workers:
|
|
316
|
+
with contextlib.suppress(ChildProcessError):
|
|
317
|
+
os.waitpid(worker.pid, 0)
|
|
318
|
+
worker.sock.close()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _retuple(report: Any) -> Any:
|
|
322
|
+
"""Undo JSON's list-for-tuple substitution; the terminal reporter asserts on a skip's tuple."""
|
|
323
|
+
if isinstance(report.longrepr, list):
|
|
324
|
+
report.longrepr = tuple(report.longrepr)
|
|
325
|
+
if isinstance(report.location, list):
|
|
326
|
+
report.location = tuple(report.location)
|
|
327
|
+
return report
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _crash_report(item: pytest.Item, wid: int, status: int) -> TestReport:
|
|
331
|
+
signum = status & 0x7F
|
|
332
|
+
how = (
|
|
333
|
+
f"killed by signal {signum} ({signal.Signals(signum).name})"
|
|
334
|
+
if signum
|
|
335
|
+
else f"exited with code {status >> 8}"
|
|
336
|
+
)
|
|
337
|
+
hint = (
|
|
338
|
+
"\nIn a container SIGKILL here is almost always the memory cgroup."
|
|
339
|
+
if signum == signal.SIGKILL
|
|
340
|
+
else ""
|
|
341
|
+
)
|
|
342
|
+
return TestReport(
|
|
343
|
+
nodeid=item.nodeid,
|
|
344
|
+
location=item.location,
|
|
345
|
+
keywords={},
|
|
346
|
+
outcome="failed",
|
|
347
|
+
longrepr=f"zygote worker gw{wid} {how} while running this test.{hint}",
|
|
348
|
+
when="call",
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# --------------------------------------------------------------------------- worker
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _worker_main(session: pytest.Session, sock: socket.socket, wid: int, capman: Any) -> int:
|
|
356
|
+
config = session.config
|
|
357
|
+
try:
|
|
358
|
+
reporter = config.pluginmanager.getplugin("terminalreporter")
|
|
359
|
+
if reporter is not None:
|
|
360
|
+
config.pluginmanager.unregister(reporter)
|
|
361
|
+
if capman:
|
|
362
|
+
capman.stop_global_capturing()
|
|
363
|
+
capman.start_global_capturing()
|
|
364
|
+
capman.suspend_global_capture(in_=True)
|
|
365
|
+
os.environ["PYTEST_ZYGOTE_WORKER"] = f"gw{wid}"
|
|
366
|
+
_worker_loop(session, sock)
|
|
367
|
+
return 0
|
|
368
|
+
# A worker must report why it stopped, whatever stopped it.
|
|
369
|
+
except BaseException:
|
|
370
|
+
traceback.print_exc(file=sys.stderr)
|
|
371
|
+
return 1
|
|
372
|
+
finally:
|
|
373
|
+
# os._exit() skips stdio flushing, and under -s the worker's own prints are still buffered.
|
|
374
|
+
for stream in (sys.stdout, sys.stderr):
|
|
375
|
+
with contextlib.suppress(OSError, ValueError):
|
|
376
|
+
stream.flush()
|
|
377
|
+
with contextlib.suppress(OSError):
|
|
378
|
+
sock.close()
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _worker_loop(session: pytest.Session, sock: socket.socket) -> None:
|
|
382
|
+
config = session.config
|
|
383
|
+
items = session.items
|
|
384
|
+
while True:
|
|
385
|
+
_send(sock, {"t": "idle"})
|
|
386
|
+
try:
|
|
387
|
+
message = _recv_one(sock)
|
|
388
|
+
except _PeerGone:
|
|
389
|
+
return
|
|
390
|
+
if message["t"] == "stop":
|
|
391
|
+
_send(sock, {"t": "bye"})
|
|
392
|
+
return
|
|
393
|
+
item = items[message["i"]]
|
|
394
|
+
nextitem = items[message["next"]] if message["next"] is not None else None
|
|
395
|
+
location = list(item.location)
|
|
396
|
+
_send(sock, {"t": "start", "nodeid": item.nodeid, "location": location})
|
|
397
|
+
# log=False keeps pytest_runtest_logreport out of the worker: the controller is the
|
|
398
|
+
# only process that fires it, so terminal output and result files stay single-writer.
|
|
399
|
+
for report in runtestprotocol(item, log=False, nextitem=nextitem):
|
|
400
|
+
data = config.hook.pytest_report_to_serializable(config=config, report=report)
|
|
401
|
+
_send(sock, {"t": "report", "data": data})
|
|
402
|
+
_send(sock, {"t": "finish", "nodeid": item.nodeid, "location": location})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pytest_plugins = ["pytester"]
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Zygote's own suite: every case runs a real pytest in a subprocess and checks what it reported.
|
|
2
|
+
|
|
3
|
+
The plugin forks, so these cannot run in-process — a nested fork inside the suite that is
|
|
4
|
+
already under test tells you nothing about either.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import importlib.util
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def run(pytester):
|
|
15
|
+
def go(*args, workers=2):
|
|
16
|
+
return pytester.runpytest_subprocess(f"--workers={workers}", *args)
|
|
17
|
+
|
|
18
|
+
return go
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_matches_a_serial_run(pytester, run):
|
|
22
|
+
pytester.makepyfile(
|
|
23
|
+
test_mixed="""
|
|
24
|
+
import pytest
|
|
25
|
+
|
|
26
|
+
@pytest.mark.parametrize("n", range(12))
|
|
27
|
+
def test_passes(n): assert n >= 0
|
|
28
|
+
|
|
29
|
+
def test_fails(): assert False
|
|
30
|
+
|
|
31
|
+
@pytest.mark.skip(reason="deliberate")
|
|
32
|
+
def test_skipped(): pass
|
|
33
|
+
|
|
34
|
+
@pytest.mark.xfail(reason="deliberate")
|
|
35
|
+
def test_xfails(): assert False
|
|
36
|
+
"""
|
|
37
|
+
)
|
|
38
|
+
serial = pytester.runpytest_subprocess()
|
|
39
|
+
forked = run("-rsxX")
|
|
40
|
+
assert forked.parseoutcomes() == serial.parseoutcomes()
|
|
41
|
+
forked.assert_outcomes(passed=12, failed=1, skipped=1, xfailed=1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_failure_keeps_traceback_and_captured_output(pytester, run):
|
|
45
|
+
pytester.makepyfile(
|
|
46
|
+
test_loud="""
|
|
47
|
+
def test_loud():
|
|
48
|
+
print("MARKER-STDOUT")
|
|
49
|
+
raise RuntimeError("MARKER-BOOM")
|
|
50
|
+
"""
|
|
51
|
+
)
|
|
52
|
+
result = run(workers=1)
|
|
53
|
+
result.stdout.fnmatch_lines(
|
|
54
|
+
["*RuntimeError: MARKER-BOOM*", "*Captured stdout call*", "MARKER-STDOUT"]
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_skip_reason_survives_serialization(pytester, run):
|
|
59
|
+
"""JSON has no tuples, and the terminal reporter asserts a skip's longrepr is one."""
|
|
60
|
+
pytester.makepyfile(
|
|
61
|
+
test_skips="""
|
|
62
|
+
import pytest
|
|
63
|
+
|
|
64
|
+
@pytest.mark.skip(reason="MARKER-REASON")
|
|
65
|
+
def test_skipped(): pass
|
|
66
|
+
"""
|
|
67
|
+
)
|
|
68
|
+
result = run("-rs", workers=1)
|
|
69
|
+
result.stdout.fnmatch_lines(["*MARKER-REASON*"])
|
|
70
|
+
assert "INTERNALERROR" not in result.stdout.str()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_dead_worker_is_reported_not_lost(pytester, run):
|
|
74
|
+
pytester.makepyfile(
|
|
75
|
+
test_crash="""
|
|
76
|
+
import os, signal
|
|
77
|
+
|
|
78
|
+
def test_before(): assert True
|
|
79
|
+
|
|
80
|
+
def test_kills_worker():
|
|
81
|
+
os.kill(os.getpid(), signal.SIGKILL)
|
|
82
|
+
|
|
83
|
+
def test_after(): assert True
|
|
84
|
+
"""
|
|
85
|
+
)
|
|
86
|
+
result = run(workers=1)
|
|
87
|
+
result.assert_outcomes(passed=2, failed=1)
|
|
88
|
+
result.stdout.fnmatch_lines(["*killed by signal 9*"])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_dead_worker_is_replaced_so_the_queue_drains(pytester, run):
|
|
92
|
+
followers = "\n".join(f"def test_after_{i}(): assert True" for i in range(8))
|
|
93
|
+
pytester.makepyfile(
|
|
94
|
+
test_crash="import os, signal\n"
|
|
95
|
+
"def test_kills_worker():\n"
|
|
96
|
+
" os.kill(os.getpid(), signal.SIGKILL)\n" + followers
|
|
97
|
+
)
|
|
98
|
+
result = run(workers=1)
|
|
99
|
+
result.assert_outcomes(passed=8, failed=1)
|
|
100
|
+
result.stdout.fnmatch_lines(["*replaced dead gw0*"])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_restarts_can_be_disabled(pytester, run):
|
|
104
|
+
pytester.makepyfile(
|
|
105
|
+
test_crash="""
|
|
106
|
+
import os, signal
|
|
107
|
+
|
|
108
|
+
def test_kills_worker():
|
|
109
|
+
os.kill(os.getpid(), signal.SIGKILL)
|
|
110
|
+
|
|
111
|
+
def test_never_reached(): assert True
|
|
112
|
+
"""
|
|
113
|
+
)
|
|
114
|
+
result = run("--workers-max-restarts=0", workers=1)
|
|
115
|
+
result.assert_outcomes(failed=1)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_a_scoped_fixture_is_torn_down_once_per_worker(pytester, run):
|
|
119
|
+
"""One worker means one setup for the module fixture, and a teardown only after the last test."""
|
|
120
|
+
pytester.makepyfile(
|
|
121
|
+
test_scoped="""
|
|
122
|
+
import pytest
|
|
123
|
+
|
|
124
|
+
@pytest.fixture(scope="module")
|
|
125
|
+
def resource():
|
|
126
|
+
print("SETUP-MARKER")
|
|
127
|
+
yield
|
|
128
|
+
print("TEARDOWN-MARKER")
|
|
129
|
+
|
|
130
|
+
def test_one(resource): assert True
|
|
131
|
+
def test_two(resource): assert True
|
|
132
|
+
def test_three(resource): assert True
|
|
133
|
+
"""
|
|
134
|
+
)
|
|
135
|
+
result = run("-s", workers=1)
|
|
136
|
+
assert result.stdout.str().count("SETUP-MARKER") == 1
|
|
137
|
+
assert result.stdout.str().count("TEARDOWN-MARKER") == 1
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_maxfail_stops_the_run(pytester, run):
|
|
141
|
+
pytester.makepyfile(
|
|
142
|
+
test_many="""
|
|
143
|
+
import pytest
|
|
144
|
+
|
|
145
|
+
@pytest.mark.parametrize("n", range(40))
|
|
146
|
+
def test_fails(n): assert False
|
|
147
|
+
"""
|
|
148
|
+
)
|
|
149
|
+
result = run("--maxfail=2", workers=2)
|
|
150
|
+
assert result.parseoutcomes()["failed"] < 40
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_zero_workers_leaves_pytest_alone(pytester):
|
|
154
|
+
pytester.makepyfile(test_plain="def test_ok(): assert True")
|
|
155
|
+
result = pytester.runpytest_subprocess()
|
|
156
|
+
result.assert_outcomes(passed=1)
|
|
157
|
+
assert "zygote:" not in result.stdout.str()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_auto_means_one_worker_per_core(pytester):
|
|
161
|
+
"""The count is also capped at the number of tests, so give it more tests than cores."""
|
|
162
|
+
cores = os.cpu_count() or 1
|
|
163
|
+
pytester.makepyfile(
|
|
164
|
+
test_plenty="\n".join(f"def test_{i}(): assert True" for i in range(cores * 2))
|
|
165
|
+
)
|
|
166
|
+
result = pytester.runpytest_subprocess("--workers", "auto")
|
|
167
|
+
result.assert_outcomes(passed=cores * 2)
|
|
168
|
+
result.stdout.fnmatch_lines([f"zygote: {cores} worker* forked*"])
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_a_bad_worker_count_is_rejected(pytester):
|
|
172
|
+
pytester.makepyfile(test_plain="def test_ok(): assert True")
|
|
173
|
+
result = pytester.runpytest_subprocess("--workers", "many")
|
|
174
|
+
result.stderr.fnmatch_lines(["*expected a number or 'auto'*"])
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_worker_identity_is_exported(pytester, run):
|
|
178
|
+
pytester.makepyfile(
|
|
179
|
+
test_env="""
|
|
180
|
+
import os
|
|
181
|
+
|
|
182
|
+
def test_worker_env():
|
|
183
|
+
assert os.environ["PYTEST_ZYGOTE_WORKER"].startswith("gw")
|
|
184
|
+
"""
|
|
185
|
+
)
|
|
186
|
+
run(workers=1).assert_outcomes(passed=1)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@pytest.mark.skipif(importlib.util.find_spec("xdist") is None, reason="needs pytest-xdist to own -n")
|
|
190
|
+
def test_rejects_xdist_at_the_same_time(pytester, run):
|
|
191
|
+
pytester.makepyfile(test_plain="def test_ok(): assert True")
|
|
192
|
+
result = run("-n", "2")
|
|
193
|
+
result.stderr.fnmatch_lines(["*alternatives*"])
|