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.
@@ -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,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .venv/
6
+ dist/
7
+ build/
8
+ *.egg-info/
@@ -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*"])