pytest-gc-steward 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_gc_steward-0.1.0/.gitignore +64 -0
- pytest_gc_steward-0.1.0/CHANGELOG.md +7 -0
- pytest_gc_steward-0.1.0/LICENSE +21 -0
- pytest_gc_steward-0.1.0/PKG-INFO +229 -0
- pytest_gc_steward-0.1.0/README.md +173 -0
- pytest_gc_steward-0.1.0/pyproject.toml +66 -0
- pytest_gc_steward-0.1.0/src/pytest_gc_steward/__init__.py +3 -0
- pytest_gc_steward-0.1.0/src/pytest_gc_steward/plugin.py +345 -0
- pytest_gc_steward-0.1.0/src/pytest_gc_steward/rss.py +47 -0
- pytest_gc_steward-0.1.0/tests/test_plugin.py +129 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
*.egg
|
|
7
|
+
*.egg-info/
|
|
8
|
+
.eggs/
|
|
9
|
+
build/
|
|
10
|
+
dist/
|
|
11
|
+
.installed.cfg
|
|
12
|
+
MANIFEST
|
|
13
|
+
|
|
14
|
+
# Virtualenvs
|
|
15
|
+
.venv/
|
|
16
|
+
venv/
|
|
17
|
+
ENV/
|
|
18
|
+
env/
|
|
19
|
+
|
|
20
|
+
# uv
|
|
21
|
+
.uv/
|
|
22
|
+
uv.lock
|
|
23
|
+
|
|
24
|
+
# Tooling
|
|
25
|
+
.mypy_cache/
|
|
26
|
+
.ruff_cache/
|
|
27
|
+
.pytest_cache/
|
|
28
|
+
.coverage
|
|
29
|
+
.coverage.*
|
|
30
|
+
htmlcov/
|
|
31
|
+
coverage.xml
|
|
32
|
+
*.cover
|
|
33
|
+
|
|
34
|
+
# IDE
|
|
35
|
+
.idea/
|
|
36
|
+
.vscode/
|
|
37
|
+
*.swp
|
|
38
|
+
*.swo
|
|
39
|
+
|
|
40
|
+
# OS
|
|
41
|
+
.DS_Store
|
|
42
|
+
Thumbs.db
|
|
43
|
+
|
|
44
|
+
# django-run-site / django-dev-helpers dotfiles
|
|
45
|
+
.dev_helpers_token
|
|
46
|
+
.dev_helpers_port
|
|
47
|
+
.dev_helpers_pg_port
|
|
48
|
+
.dev_helpers_redis_port
|
|
49
|
+
.run-site-*
|
|
50
|
+
|
|
51
|
+
# Django
|
|
52
|
+
*.log
|
|
53
|
+
local_settings.py
|
|
54
|
+
db.sqlite3
|
|
55
|
+
db.sqlite3-journal
|
|
56
|
+
|
|
57
|
+
# Test artifacts
|
|
58
|
+
.tox/
|
|
59
|
+
.cache/
|
|
60
|
+
test-output/
|
|
61
|
+
|
|
62
|
+
# Examples — generated at runtime
|
|
63
|
+
examples/test_site/.venv/
|
|
64
|
+
examples/test_site/db.sqlite3
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michał Pasternak
|
|
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,229 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pytest-gc-steward
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Keep pytest workers from bloating: gc.freeze(), periodic or RSS-triggered garbage collection, and reports of what the cyclic GC collects.
|
|
5
|
+
Project-URL: Funding, https://buymeacoffee.com/mpasternak
|
|
6
|
+
Project-URL: Homepage, https://github.com/iplweb/pytest-gc-steward
|
|
7
|
+
Project-URL: Source, https://github.com/iplweb/pytest-gc-steward
|
|
8
|
+
Project-URL: Issues, https://github.com/iplweb/pytest-gc-steward/issues
|
|
9
|
+
Author-email: Michał Pasternak <michal.dtz@gmail.com>
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 Michał Pasternak
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: garbage collection,gc,memory,pytest,reference cycles,xdist
|
|
33
|
+
Classifier: Development Status :: 3 - Alpha
|
|
34
|
+
Classifier: Framework :: Pytest
|
|
35
|
+
Classifier: Intended Audience :: Developers
|
|
36
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
37
|
+
Classifier: Operating System :: OS Independent
|
|
38
|
+
Classifier: Programming Language :: Python
|
|
39
|
+
Classifier: Programming Language :: Python :: 3
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
45
|
+
Classifier: Topic :: Software Development :: Testing
|
|
46
|
+
Requires-Python: >=3.10
|
|
47
|
+
Requires-Dist: pytest>=7
|
|
48
|
+
Provides-Extra: dev
|
|
49
|
+
Requires-Dist: psutil>=5; extra == 'dev'
|
|
50
|
+
Requires-Dist: pytest-xdist>=3; extra == 'dev'
|
|
51
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
52
|
+
Requires-Dist: ruff<0.16,>=0.15; extra == 'dev'
|
|
53
|
+
Provides-Extra: rss
|
|
54
|
+
Requires-Dist: psutil>=5; extra == 'rss'
|
|
55
|
+
Description-Content-Type: text/markdown
|
|
56
|
+
|
|
57
|
+
# pytest-gc-steward
|
|
58
|
+
|
|
59
|
+
**Stop pytest workers from bloating.** A pytest plugin that takes charge of
|
|
60
|
+
Python's cyclic garbage collector during a test session — `gc.freeze()` at
|
|
61
|
+
startup, collections every N tests or after N MB of growth — and tells you
|
|
62
|
+
**what** the collector keeps reclaiming, so you can fix the source.
|
|
63
|
+
|
|
64
|
+
[](https://buymeacoffee.com/mpasternak)
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install pytest-gc-steward
|
|
68
|
+
pytest --gc-freeze --gc-every 25 # keep memory flat
|
|
69
|
+
pytest --gc-report gc.json # find out where the garbage comes from
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Why would I need this?
|
|
75
|
+
|
|
76
|
+
Most Python objects die the moment their reference count drops to zero. Objects
|
|
77
|
+
caught in a **reference cycle** (A → B → A) never get there — they wait for the
|
|
78
|
+
cyclic garbage collector. Test suites produce a lot of them: test-client
|
|
79
|
+
responses, rendered templates and their contexts, tracebacks holding frames
|
|
80
|
+
holding every local variable...
|
|
81
|
+
|
|
82
|
+
If the collector reclaims that garbage more slowly than your tests produce it,
|
|
83
|
+
**each process quietly grows** for the whole session. With `pytest-xdist` you
|
|
84
|
+
pay that once per worker.
|
|
85
|
+
|
|
86
|
+
### A real case
|
|
87
|
+
|
|
88
|
+
This plugin was written while debugging a Django project with ~11 000 tests.
|
|
89
|
+
`pytest -n auto` (14 workers) on a 14 GB laptop froze the machine: swap
|
|
90
|
+
thrashing, hard reset. What we found:
|
|
91
|
+
|
|
92
|
+
| | time | RSS of one worker after 1266 tests |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| before | 247 s | **1801 MB** |
|
|
95
|
+
| `gc.collect()` after *every* test | 719 s | 581 MB |
|
|
96
|
+
| `--gc-freeze --gc-every 25` | **239 s** | **624 MB** |
|
|
97
|
+
|
|
98
|
+
`--gc-report` then showed *why*: about 85% of what the collector reclaimed was
|
|
99
|
+
compiled template nodes. Three things had stacked up:
|
|
100
|
+
|
|
101
|
+
1. **Python 3.14.0–3.14.4** shipped an incremental GC that, on large heaps,
|
|
102
|
+
collected cycles far too slowly. It was
|
|
103
|
+
[reverted in 3.14.5](https://discuss.python.org/t/reverting-the-incremental-gc-in-python-3-14-and-3-15/107014).
|
|
104
|
+
2. The project's test settings used **uncached template loaders**, so every
|
|
105
|
+
render re-parsed every template.
|
|
106
|
+
3. **Django's test client** puts every response in a reference cycle
|
|
107
|
+
(`response.json = partial(self._parse_json, response)`), and the response
|
|
108
|
+
carries all rendered templates and contexts with it.
|
|
109
|
+
|
|
110
|
+
Fixing the source (cached loaders, a current Python) brought the worker to the
|
|
111
|
+
same 625 MB **without** forced collections. The plugin pointed at all three;
|
|
112
|
+
the reproducer for the third is being reported upstream.
|
|
113
|
+
|
|
114
|
+
### When it helps
|
|
115
|
+
|
|
116
|
+
- Memory of test processes grows steadily over a session, and `gc.collect()`
|
|
117
|
+
makes it drop. That's the signature of cyclic garbage, not of a real leak.
|
|
118
|
+
- You run many xdist workers and RAM, not CPU, is what limits `-n`.
|
|
119
|
+
- You are stuck on a Python with lazy cycle collection (e.g. 3.14.0–3.14.4).
|
|
120
|
+
- You want to *find* which tests and which object types produce cycles.
|
|
121
|
+
|
|
122
|
+
If `gc.collect()` does **not** reduce memory, you have objects that are still
|
|
123
|
+
referenced (caches, globals, fixtures): a real leak. This plugin won't fix that,
|
|
124
|
+
although the report still shows you what isn't garbage.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Usage
|
|
129
|
+
|
|
130
|
+
Everything is **off by default**. Installing the plugin changes nothing until
|
|
131
|
+
you turn something on, from the command line or `pytest.ini` /
|
|
132
|
+
`pyproject.toml`:
|
|
133
|
+
|
|
134
|
+
| Option | ini | What it does |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `--gc-freeze` | `gc_steward_freeze = true` | Before the first test: `gc.collect()` + `gc.freeze()`. Everything imported and collected so far (often millions of objects in a Django project) moves to the permanent generation, so later collections don't rescan it. This is what makes frequent collections cheap. |
|
|
137
|
+
| `--gc-every N` | `gc_steward_every = N` | Force `gc.collect()` after every N tests. |
|
|
138
|
+
| `--gc-rss-growth MB` | `gc_steward_rss_growth = MB` | Force `gc.collect()` when the process grew by MB since the last forced collection. The baseline resets after each collection, because CPython rarely gives memory back to the OS. |
|
|
139
|
+
| `--gc-disable` | `gc_steward_disable = true` | Turn the automatic collector off during tests (setup before the first test still runs with it on). Use together with `--gc-every` / `--gc-rss-growth`, or you may be the one who freezes the laptop. |
|
|
140
|
+
| `--gc-report PATH` | `gc_steward_report = PATH` | Diagnostics: after **every** test collect with `gc.DEBUG_SAVEALL`, count what was reclaimed by object type, test and module, print the top entries and write a JSON report. Slow — run it once, on a subset. |
|
|
141
|
+
| `--gc-report-top N` | `gc_steward_report_top = N` | Entries per report section (default 25). |
|
|
142
|
+
|
|
143
|
+
Command-line options win over ini values.
|
|
144
|
+
|
|
145
|
+
### Recommended setup
|
|
146
|
+
|
|
147
|
+
```ini
|
|
148
|
+
# pytest.ini
|
|
149
|
+
[pytest]
|
|
150
|
+
gc_steward_freeze = true
|
|
151
|
+
gc_steward_every = 25
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`--gc-every 25` with `--gc-freeze` cost us nothing measurable; the run was even
|
|
155
|
+
slightly faster, because the process wasn't dragging a 1.8 GB heap around.
|
|
156
|
+
Without `--gc-freeze`, each full collection rescans the whole heap — in our
|
|
157
|
+
project ~0.4 s each.
|
|
158
|
+
|
|
159
|
+
### pytest-xdist
|
|
160
|
+
|
|
161
|
+
Each worker steers its own collector. The controller gathers the numbers and
|
|
162
|
+
prints one line per worker, and `--gc-report` merges all workers into one file:
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
============================== gc-steward ==============================
|
|
166
|
+
gw0: 412 tests, RSS 351 -> 598 MB (peak 640); forced 16x (1.84 s, 1203391 objects); automatic gen0 1832, gen1 166, gen2 3 (0.91 s, 88213 objects); frozen 2154007
|
|
167
|
+
gw1: 397 tests, RSS 350 -> 571 MB (peak 612); forced 15x (1.71 s, 1176502 objects); automatic gen0 1790, gen1 162, gen2 2 (0.88 s, 84117 objects); frozen 2154011
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Reading a report
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
pytest tests/admin --gc-report gc.json -p no:randomly
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
objects reclaimed by the cyclic GC, by type (top 25):
|
|
178
|
+
2132705 django.template.base.Token
|
|
179
|
+
1201038 django.template.base.TextNode
|
|
180
|
+
988340 django.template.base.FilterExpression
|
|
181
|
+
...
|
|
182
|
+
by test (top 25):
|
|
183
|
+
281339 tests/admin/test_home_links.py::test_admin_home_links_resolve
|
|
184
|
+
...
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
- **Types** say *what* is in the cycles. Template nodes point at template
|
|
188
|
+
compilation; `frame` / `traceback` point at stored exceptions; your own
|
|
189
|
+
classes point at your own code.
|
|
190
|
+
- **Tests / modules** say *where*. A handful of tests standing out is a
|
|
191
|
+
local bug; an even spread points at shared infrastructure (a test client,
|
|
192
|
+
a base class, middleware).
|
|
193
|
+
- To find the cycle itself, take one suspect object and walk
|
|
194
|
+
`gc.get_referents()` until you are back where you started. Usually it's one
|
|
195
|
+
attribute pointing back at its owner (`self.callback = partial(f, self)`).
|
|
196
|
+
|
|
197
|
+
The JSON file holds per-process stats (`processes`) and merged counters
|
|
198
|
+
(`merged.types`, `merged.tests_top`, `merged.modules`).
|
|
199
|
+
|
|
200
|
+
### Terminal line, explained
|
|
201
|
+
|
|
202
|
+
`forced` are the collections the plugin ran; `automatic` are the interpreter's
|
|
203
|
+
own, per generation, observed through `gc.callbacks`. `objects` is what each
|
|
204
|
+
kind reclaimed. If `forced` reclaims a lot while `automatic gen2` stays near
|
|
205
|
+
zero, the interpreter wasn't getting around to your cycles.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## How it works
|
|
210
|
+
|
|
211
|
+
Hooks around `pytest_runtest_protocol`: the first call optionally collects and
|
|
212
|
+
freezes; after each test it decides whether to collect (counter or RSS
|
|
213
|
+
growth). RSS comes from `/proc/self/statm` on Linux, `psutil` elsewhere if it is
|
|
214
|
+
installed (`pip install pytest-gc-steward[rss]`), and peak RSS from
|
|
215
|
+
`resource.getrusage` as a last resort. At session end the freeze is undone and
|
|
216
|
+
the automatic collector re-enabled.
|
|
217
|
+
|
|
218
|
+
Requires Python ≥ 3.10 and pytest ≥ 7.
|
|
219
|
+
|
|
220
|
+
## Support
|
|
221
|
+
|
|
222
|
+
If this saved your laptop (or your CI bill), you can
|
|
223
|
+
[buy me a coffee](https://buymeacoffee.com/mpasternak) ☕.
|
|
224
|
+
|
|
225
|
+
Issues and pull requests: https://github.com/iplweb/pytest-gc-steward
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
MIT
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# pytest-gc-steward
|
|
2
|
+
|
|
3
|
+
**Stop pytest workers from bloating.** A pytest plugin that takes charge of
|
|
4
|
+
Python's cyclic garbage collector during a test session — `gc.freeze()` at
|
|
5
|
+
startup, collections every N tests or after N MB of growth — and tells you
|
|
6
|
+
**what** the collector keeps reclaiming, so you can fix the source.
|
|
7
|
+
|
|
8
|
+
[](https://buymeacoffee.com/mpasternak)
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install pytest-gc-steward
|
|
12
|
+
pytest --gc-freeze --gc-every 25 # keep memory flat
|
|
13
|
+
pytest --gc-report gc.json # find out where the garbage comes from
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Why would I need this?
|
|
19
|
+
|
|
20
|
+
Most Python objects die the moment their reference count drops to zero. Objects
|
|
21
|
+
caught in a **reference cycle** (A → B → A) never get there — they wait for the
|
|
22
|
+
cyclic garbage collector. Test suites produce a lot of them: test-client
|
|
23
|
+
responses, rendered templates and their contexts, tracebacks holding frames
|
|
24
|
+
holding every local variable...
|
|
25
|
+
|
|
26
|
+
If the collector reclaims that garbage more slowly than your tests produce it,
|
|
27
|
+
**each process quietly grows** for the whole session. With `pytest-xdist` you
|
|
28
|
+
pay that once per worker.
|
|
29
|
+
|
|
30
|
+
### A real case
|
|
31
|
+
|
|
32
|
+
This plugin was written while debugging a Django project with ~11 000 tests.
|
|
33
|
+
`pytest -n auto` (14 workers) on a 14 GB laptop froze the machine: swap
|
|
34
|
+
thrashing, hard reset. What we found:
|
|
35
|
+
|
|
36
|
+
| | time | RSS of one worker after 1266 tests |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| before | 247 s | **1801 MB** |
|
|
39
|
+
| `gc.collect()` after *every* test | 719 s | 581 MB |
|
|
40
|
+
| `--gc-freeze --gc-every 25` | **239 s** | **624 MB** |
|
|
41
|
+
|
|
42
|
+
`--gc-report` then showed *why*: about 85% of what the collector reclaimed was
|
|
43
|
+
compiled template nodes. Three things had stacked up:
|
|
44
|
+
|
|
45
|
+
1. **Python 3.14.0–3.14.4** shipped an incremental GC that, on large heaps,
|
|
46
|
+
collected cycles far too slowly. It was
|
|
47
|
+
[reverted in 3.14.5](https://discuss.python.org/t/reverting-the-incremental-gc-in-python-3-14-and-3-15/107014).
|
|
48
|
+
2. The project's test settings used **uncached template loaders**, so every
|
|
49
|
+
render re-parsed every template.
|
|
50
|
+
3. **Django's test client** puts every response in a reference cycle
|
|
51
|
+
(`response.json = partial(self._parse_json, response)`), and the response
|
|
52
|
+
carries all rendered templates and contexts with it.
|
|
53
|
+
|
|
54
|
+
Fixing the source (cached loaders, a current Python) brought the worker to the
|
|
55
|
+
same 625 MB **without** forced collections. The plugin pointed at all three;
|
|
56
|
+
the reproducer for the third is being reported upstream.
|
|
57
|
+
|
|
58
|
+
### When it helps
|
|
59
|
+
|
|
60
|
+
- Memory of test processes grows steadily over a session, and `gc.collect()`
|
|
61
|
+
makes it drop. That's the signature of cyclic garbage, not of a real leak.
|
|
62
|
+
- You run many xdist workers and RAM, not CPU, is what limits `-n`.
|
|
63
|
+
- You are stuck on a Python with lazy cycle collection (e.g. 3.14.0–3.14.4).
|
|
64
|
+
- You want to *find* which tests and which object types produce cycles.
|
|
65
|
+
|
|
66
|
+
If `gc.collect()` does **not** reduce memory, you have objects that are still
|
|
67
|
+
referenced (caches, globals, fixtures): a real leak. This plugin won't fix that,
|
|
68
|
+
although the report still shows you what isn't garbage.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Usage
|
|
73
|
+
|
|
74
|
+
Everything is **off by default**. Installing the plugin changes nothing until
|
|
75
|
+
you turn something on, from the command line or `pytest.ini` /
|
|
76
|
+
`pyproject.toml`:
|
|
77
|
+
|
|
78
|
+
| Option | ini | What it does |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `--gc-freeze` | `gc_steward_freeze = true` | Before the first test: `gc.collect()` + `gc.freeze()`. Everything imported and collected so far (often millions of objects in a Django project) moves to the permanent generation, so later collections don't rescan it. This is what makes frequent collections cheap. |
|
|
81
|
+
| `--gc-every N` | `gc_steward_every = N` | Force `gc.collect()` after every N tests. |
|
|
82
|
+
| `--gc-rss-growth MB` | `gc_steward_rss_growth = MB` | Force `gc.collect()` when the process grew by MB since the last forced collection. The baseline resets after each collection, because CPython rarely gives memory back to the OS. |
|
|
83
|
+
| `--gc-disable` | `gc_steward_disable = true` | Turn the automatic collector off during tests (setup before the first test still runs with it on). Use together with `--gc-every` / `--gc-rss-growth`, or you may be the one who freezes the laptop. |
|
|
84
|
+
| `--gc-report PATH` | `gc_steward_report = PATH` | Diagnostics: after **every** test collect with `gc.DEBUG_SAVEALL`, count what was reclaimed by object type, test and module, print the top entries and write a JSON report. Slow — run it once, on a subset. |
|
|
85
|
+
| `--gc-report-top N` | `gc_steward_report_top = N` | Entries per report section (default 25). |
|
|
86
|
+
|
|
87
|
+
Command-line options win over ini values.
|
|
88
|
+
|
|
89
|
+
### Recommended setup
|
|
90
|
+
|
|
91
|
+
```ini
|
|
92
|
+
# pytest.ini
|
|
93
|
+
[pytest]
|
|
94
|
+
gc_steward_freeze = true
|
|
95
|
+
gc_steward_every = 25
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`--gc-every 25` with `--gc-freeze` cost us nothing measurable; the run was even
|
|
99
|
+
slightly faster, because the process wasn't dragging a 1.8 GB heap around.
|
|
100
|
+
Without `--gc-freeze`, each full collection rescans the whole heap — in our
|
|
101
|
+
project ~0.4 s each.
|
|
102
|
+
|
|
103
|
+
### pytest-xdist
|
|
104
|
+
|
|
105
|
+
Each worker steers its own collector. The controller gathers the numbers and
|
|
106
|
+
prints one line per worker, and `--gc-report` merges all workers into one file:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
============================== gc-steward ==============================
|
|
110
|
+
gw0: 412 tests, RSS 351 -> 598 MB (peak 640); forced 16x (1.84 s, 1203391 objects); automatic gen0 1832, gen1 166, gen2 3 (0.91 s, 88213 objects); frozen 2154007
|
|
111
|
+
gw1: 397 tests, RSS 350 -> 571 MB (peak 612); forced 15x (1.71 s, 1176502 objects); automatic gen0 1790, gen1 162, gen2 2 (0.88 s, 84117 objects); frozen 2154011
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Reading a report
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
pytest tests/admin --gc-report gc.json -p no:randomly
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
objects reclaimed by the cyclic GC, by type (top 25):
|
|
122
|
+
2132705 django.template.base.Token
|
|
123
|
+
1201038 django.template.base.TextNode
|
|
124
|
+
988340 django.template.base.FilterExpression
|
|
125
|
+
...
|
|
126
|
+
by test (top 25):
|
|
127
|
+
281339 tests/admin/test_home_links.py::test_admin_home_links_resolve
|
|
128
|
+
...
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
- **Types** say *what* is in the cycles. Template nodes point at template
|
|
132
|
+
compilation; `frame` / `traceback` point at stored exceptions; your own
|
|
133
|
+
classes point at your own code.
|
|
134
|
+
- **Tests / modules** say *where*. A handful of tests standing out is a
|
|
135
|
+
local bug; an even spread points at shared infrastructure (a test client,
|
|
136
|
+
a base class, middleware).
|
|
137
|
+
- To find the cycle itself, take one suspect object and walk
|
|
138
|
+
`gc.get_referents()` until you are back where you started. Usually it's one
|
|
139
|
+
attribute pointing back at its owner (`self.callback = partial(f, self)`).
|
|
140
|
+
|
|
141
|
+
The JSON file holds per-process stats (`processes`) and merged counters
|
|
142
|
+
(`merged.types`, `merged.tests_top`, `merged.modules`).
|
|
143
|
+
|
|
144
|
+
### Terminal line, explained
|
|
145
|
+
|
|
146
|
+
`forced` are the collections the plugin ran; `automatic` are the interpreter's
|
|
147
|
+
own, per generation, observed through `gc.callbacks`. `objects` is what each
|
|
148
|
+
kind reclaimed. If `forced` reclaims a lot while `automatic gen2` stays near
|
|
149
|
+
zero, the interpreter wasn't getting around to your cycles.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## How it works
|
|
154
|
+
|
|
155
|
+
Hooks around `pytest_runtest_protocol`: the first call optionally collects and
|
|
156
|
+
freezes; after each test it decides whether to collect (counter or RSS
|
|
157
|
+
growth). RSS comes from `/proc/self/statm` on Linux, `psutil` elsewhere if it is
|
|
158
|
+
installed (`pip install pytest-gc-steward[rss]`), and peak RSS from
|
|
159
|
+
`resource.getrusage` as a last resort. At session end the freeze is undone and
|
|
160
|
+
the automatic collector re-enabled.
|
|
161
|
+
|
|
162
|
+
Requires Python ≥ 3.10 and pytest ≥ 7.
|
|
163
|
+
|
|
164
|
+
## Support
|
|
165
|
+
|
|
166
|
+
If this saved your laptop (or your CI bill), you can
|
|
167
|
+
[buy me a coffee](https://buymeacoffee.com/mpasternak) ☕.
|
|
168
|
+
|
|
169
|
+
Issues and pull requests: https://github.com/iplweb/pytest-gc-steward
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pytest-gc-steward"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Keep pytest workers from bloating: gc.freeze(), periodic or RSS-triggered garbage collection, and reports of what the cyclic GC collects."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Michał Pasternak", email = "michal.dtz@gmail.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["pytest", "gc", "garbage collection", "memory", "xdist", "reference cycles"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Framework :: Pytest",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Programming Language :: Python :: 3.14",
|
|
29
|
+
"Topic :: Software Development :: Testing",
|
|
30
|
+
]
|
|
31
|
+
dependencies = ["pytest>=7"]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
rss = ["psutil>=5"]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=8",
|
|
37
|
+
"pytest-xdist>=3",
|
|
38
|
+
"psutil>=5",
|
|
39
|
+
"ruff>=0.15,<0.16",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.entry-points.pytest11]
|
|
43
|
+
gc_steward = "pytest_gc_steward.plugin"
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Funding = "https://buymeacoffee.com/mpasternak"
|
|
47
|
+
Homepage = "https://github.com/iplweb/pytest-gc-steward"
|
|
48
|
+
Source = "https://github.com/iplweb/pytest-gc-steward"
|
|
49
|
+
Issues = "https://github.com/iplweb/pytest-gc-steward/issues"
|
|
50
|
+
|
|
51
|
+
[tool.hatch.build.targets.wheel]
|
|
52
|
+
packages = ["src/pytest_gc_steward"]
|
|
53
|
+
|
|
54
|
+
[tool.hatch.build.targets.sdist]
|
|
55
|
+
include = ["src/", "tests/", "README.md", "CHANGELOG.md", "LICENSE"]
|
|
56
|
+
|
|
57
|
+
[tool.pytest.ini_options]
|
|
58
|
+
testpaths = ["tests"]
|
|
59
|
+
addopts = "-p pytester"
|
|
60
|
+
|
|
61
|
+
[tool.ruff]
|
|
62
|
+
line-length = 88
|
|
63
|
+
target-version = "py310"
|
|
64
|
+
|
|
65
|
+
[tool.ruff.lint]
|
|
66
|
+
select = ["E", "F", "W", "I", "B", "UP"]
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""pytest plugin: steer the cyclic garbage collector and report what it finds.
|
|
2
|
+
|
|
3
|
+
Why this exists: objects caught in reference cycles are not freed by reference
|
|
4
|
+
counting; they wait for the cyclic GC. In long test sessions (test clients,
|
|
5
|
+
templates, tracebacks...) that garbage can pile up faster than the automatic
|
|
6
|
+
collector reclaims it, and every pytest-xdist worker bloats. The plugin can:
|
|
7
|
+
|
|
8
|
+
* ``gc.freeze()`` everything that exists before the first test (imported
|
|
9
|
+
modules, collected items), so later collections don't rescan it;
|
|
10
|
+
* force ``gc.collect()`` every N tests and/or whenever RSS grew by N MB since
|
|
11
|
+
the last forced collection;
|
|
12
|
+
* optionally disable the automatic collector for the session;
|
|
13
|
+
* report, per object type / test / module, what the collector reclaims, to
|
|
14
|
+
find where the cycles come from.
|
|
15
|
+
|
|
16
|
+
All options are off by default: installing the plugin changes nothing until
|
|
17
|
+
you enable something.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import gc
|
|
23
|
+
import json
|
|
24
|
+
import time
|
|
25
|
+
from collections import Counter
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import pytest
|
|
30
|
+
|
|
31
|
+
from .rss import current_rss_mb
|
|
32
|
+
|
|
33
|
+
_KEY = "gc_steward"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
37
|
+
group = parser.getgroup("gc-steward", "garbage collector control and diagnostics")
|
|
38
|
+
group.addoption(
|
|
39
|
+
"--gc-freeze",
|
|
40
|
+
action="store_true",
|
|
41
|
+
default=None,
|
|
42
|
+
help="gc.freeze() all objects alive before the first test, so later "
|
|
43
|
+
"collections skip them (ini: gc_steward_freeze).",
|
|
44
|
+
)
|
|
45
|
+
group.addoption(
|
|
46
|
+
"--gc-every",
|
|
47
|
+
type=int,
|
|
48
|
+
default=None,
|
|
49
|
+
metavar="N",
|
|
50
|
+
help="force gc.collect() after every N tests; 0 = off (ini: gc_steward_every).",
|
|
51
|
+
)
|
|
52
|
+
group.addoption(
|
|
53
|
+
"--gc-rss-growth",
|
|
54
|
+
type=float,
|
|
55
|
+
default=None,
|
|
56
|
+
metavar="MB",
|
|
57
|
+
help="force gc.collect() when RSS grew by MB since the last forced "
|
|
58
|
+
"collection; 0 = off (ini: gc_steward_rss_growth).",
|
|
59
|
+
)
|
|
60
|
+
group.addoption(
|
|
61
|
+
"--gc-disable",
|
|
62
|
+
action="store_true",
|
|
63
|
+
default=None,
|
|
64
|
+
help="disable the automatic collector during tests; combine with "
|
|
65
|
+
"--gc-every / --gc-rss-growth (ini: gc_steward_disable).",
|
|
66
|
+
)
|
|
67
|
+
group.addoption(
|
|
68
|
+
"--gc-report",
|
|
69
|
+
default=None,
|
|
70
|
+
metavar="PATH",
|
|
71
|
+
help="collect after EVERY test with gc.DEBUG_SAVEALL and write a JSON "
|
|
72
|
+
"report of what the GC reclaimed (by type, test and module). Slow: "
|
|
73
|
+
"diagnostics only (ini: gc_steward_report).",
|
|
74
|
+
)
|
|
75
|
+
group.addoption(
|
|
76
|
+
"--gc-report-top",
|
|
77
|
+
type=int,
|
|
78
|
+
default=None,
|
|
79
|
+
metavar="N",
|
|
80
|
+
help="how many entries to keep / show per report section "
|
|
81
|
+
"(default 25, ini: gc_steward_report_top).",
|
|
82
|
+
)
|
|
83
|
+
parser.addini("gc_steward_freeze", "see --gc-freeze", type="bool", default=False)
|
|
84
|
+
parser.addini("gc_steward_every", "see --gc-every", default="0")
|
|
85
|
+
parser.addini("gc_steward_rss_growth", "see --gc-rss-growth", default="0")
|
|
86
|
+
parser.addini("gc_steward_disable", "see --gc-disable", type="bool", default=False)
|
|
87
|
+
parser.addini("gc_steward_report", "see --gc-report", default="")
|
|
88
|
+
parser.addini("gc_steward_report_top", "see --gc-report-top", default="25")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _opt(config: pytest.Config, name: str, ini: str, cast):
|
|
92
|
+
value = config.getoption(name)
|
|
93
|
+
if value is None:
|
|
94
|
+
value = config.getini(ini)
|
|
95
|
+
return cast(value) if value not in ("", None) else cast()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _type_key(obj: object) -> str:
|
|
99
|
+
cls = type(obj)
|
|
100
|
+
module = cls.__dict__.get("__module__")
|
|
101
|
+
if not isinstance(module, str) or module == "builtins":
|
|
102
|
+
return cls.__qualname__
|
|
103
|
+
return f"{module}.{cls.__qualname__}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Steward:
|
|
107
|
+
def __init__(self, config: pytest.Config) -> None:
|
|
108
|
+
self.config = config
|
|
109
|
+
self.freeze = bool(_opt(config, "gc_freeze", "gc_steward_freeze", bool))
|
|
110
|
+
self.every = _opt(config, "gc_every", "gc_steward_every", int)
|
|
111
|
+
self.rss_growth = _opt(config, "gc_rss_growth", "gc_steward_rss_growth", float)
|
|
112
|
+
self.disable = bool(_opt(config, "gc_disable", "gc_steward_disable", bool))
|
|
113
|
+
self.report_path = _opt(config, "gc_report", "gc_steward_report", str)
|
|
114
|
+
self.top = _opt(config, "gc_report_top", "gc_steward_report_top", int) or 25
|
|
115
|
+
self.active = bool(
|
|
116
|
+
self.freeze or self.every or self.rss_growth or self.disable
|
|
117
|
+
) or bool(self.report_path)
|
|
118
|
+
|
|
119
|
+
self.tests = 0
|
|
120
|
+
self.started = False
|
|
121
|
+
self.was_enabled = gc.isenabled()
|
|
122
|
+
self.rss_start = current_rss_mb()
|
|
123
|
+
self.rss_peak = self.rss_start or 0.0
|
|
124
|
+
self.rss_baseline = self.rss_start
|
|
125
|
+
self.frozen = 0
|
|
126
|
+
self.forced = 0
|
|
127
|
+
self.forced_seconds = 0.0
|
|
128
|
+
self.forced_objects = 0
|
|
129
|
+
self.auto = Counter() # generation -> runs
|
|
130
|
+
self.auto_seconds = 0.0
|
|
131
|
+
self.auto_objects = 0
|
|
132
|
+
self._auto_t0 = 0.0
|
|
133
|
+
self._in_forced = False
|
|
134
|
+
self.types: Counter[str] = Counter()
|
|
135
|
+
self.per_test: Counter[str] = Counter()
|
|
136
|
+
self.per_module: Counter[str] = Counter()
|
|
137
|
+
|
|
138
|
+
# -- automatic collections (observed, not driven) -----------------------
|
|
139
|
+
def _callback(self, phase: str, info: dict[str, Any]) -> None:
|
|
140
|
+
if self._in_forced:
|
|
141
|
+
return
|
|
142
|
+
if phase == "start":
|
|
143
|
+
self._auto_t0 = time.perf_counter()
|
|
144
|
+
else:
|
|
145
|
+
self.auto_seconds += time.perf_counter() - self._auto_t0
|
|
146
|
+
self.auto[info.get("generation", -1)] += 1
|
|
147
|
+
self.auto_objects += info.get("collected", 0)
|
|
148
|
+
|
|
149
|
+
# -- forced collections --------------------------------------------------
|
|
150
|
+
def _collect(self) -> int:
|
|
151
|
+
self._in_forced = True
|
|
152
|
+
t0 = time.perf_counter()
|
|
153
|
+
try:
|
|
154
|
+
collected = gc.collect()
|
|
155
|
+
finally:
|
|
156
|
+
self.forced_seconds += time.perf_counter() - t0
|
|
157
|
+
self._in_forced = False
|
|
158
|
+
self.forced += 1
|
|
159
|
+
self.forced_objects += collected
|
|
160
|
+
return collected
|
|
161
|
+
|
|
162
|
+
def _census(self, nodeid: str) -> None:
|
|
163
|
+
"""Collect with DEBUG_SAVEALL and count what would have been freed."""
|
|
164
|
+
old = gc.get_debug()
|
|
165
|
+
gc.set_debug(old | gc.DEBUG_SAVEALL)
|
|
166
|
+
try:
|
|
167
|
+
self._collect()
|
|
168
|
+
finally:
|
|
169
|
+
gc.set_debug(old)
|
|
170
|
+
found = Counter(_type_key(o) for o in gc.garbage)
|
|
171
|
+
gc.garbage.clear()
|
|
172
|
+
self._collect() # now really free what DEBUG_SAVEALL kept alive
|
|
173
|
+
total = sum(found.values())
|
|
174
|
+
if total:
|
|
175
|
+
self.types.update(found)
|
|
176
|
+
self.per_test[nodeid] += total
|
|
177
|
+
self.per_module[nodeid.split("::", 1)[0]] += total
|
|
178
|
+
|
|
179
|
+
# -- hooks ---------------------------------------------------------------
|
|
180
|
+
def start(self) -> None:
|
|
181
|
+
self.started = True
|
|
182
|
+
gc.callbacks.append(self._callback)
|
|
183
|
+
if self.freeze:
|
|
184
|
+
self._collect()
|
|
185
|
+
gc.freeze()
|
|
186
|
+
self.frozen = gc.get_freeze_count()
|
|
187
|
+
if self.disable:
|
|
188
|
+
gc.disable()
|
|
189
|
+
self.rss_baseline = current_rss_mb()
|
|
190
|
+
|
|
191
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
192
|
+
def pytest_runtest_protocol(self, item: pytest.Item, nextitem):
|
|
193
|
+
if not self.started:
|
|
194
|
+
self.start()
|
|
195
|
+
yield
|
|
196
|
+
self.tests += 1
|
|
197
|
+
rss = current_rss_mb()
|
|
198
|
+
if rss is not None:
|
|
199
|
+
self.rss_peak = max(self.rss_peak, rss)
|
|
200
|
+
if self.report_path:
|
|
201
|
+
self._census(item.nodeid)
|
|
202
|
+
self.rss_baseline = current_rss_mb()
|
|
203
|
+
return
|
|
204
|
+
due = bool(self.every) and self.tests % self.every == 0
|
|
205
|
+
if not due and self.rss_growth and rss is not None and self.rss_baseline:
|
|
206
|
+
due = rss - self.rss_baseline >= self.rss_growth
|
|
207
|
+
if due:
|
|
208
|
+
self._collect()
|
|
209
|
+
self.rss_baseline = current_rss_mb()
|
|
210
|
+
|
|
211
|
+
def stop(self) -> None:
|
|
212
|
+
if self._callback in gc.callbacks:
|
|
213
|
+
gc.callbacks.remove(self._callback)
|
|
214
|
+
if self.freeze:
|
|
215
|
+
gc.unfreeze()
|
|
216
|
+
if self.disable and self.was_enabled:
|
|
217
|
+
gc.enable()
|
|
218
|
+
|
|
219
|
+
def stats(self) -> dict[str, Any]:
|
|
220
|
+
return {
|
|
221
|
+
"tests": self.tests,
|
|
222
|
+
"frozen": self.frozen,
|
|
223
|
+
"forced": self.forced,
|
|
224
|
+
"forced_seconds": round(self.forced_seconds, 3),
|
|
225
|
+
"forced_objects": self.forced_objects,
|
|
226
|
+
"auto": {str(k): v for k, v in sorted(self.auto.items())},
|
|
227
|
+
"auto_seconds": round(self.auto_seconds, 3),
|
|
228
|
+
"auto_objects": self.auto_objects,
|
|
229
|
+
"rss_start_mb": round(self.rss_start, 1) if self.rss_start else None,
|
|
230
|
+
"rss_end_mb": _round(current_rss_mb()),
|
|
231
|
+
"rss_peak_mb": round(self.rss_peak, 1) if self.rss_peak else None,
|
|
232
|
+
"types": dict(self.types.most_common(self.top * 4)),
|
|
233
|
+
"tests_top": dict(self.per_test.most_common(self.top * 4)),
|
|
234
|
+
"modules": dict(self.per_module.most_common(self.top * 4)),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _round(value: float | None) -> float | None:
|
|
239
|
+
return round(value, 1) if value is not None else None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _is_xdist_controller(config: pytest.Config) -> bool:
|
|
243
|
+
return config.pluginmanager.hasplugin("dsession") and not hasattr(
|
|
244
|
+
config, "workerinput"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@pytest.hookimpl(trylast=True)
|
|
249
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
250
|
+
steward = Steward(config)
|
|
251
|
+
config.stash[_STASH] = steward
|
|
252
|
+
config.stash[_WORKERS] = {}
|
|
253
|
+
if not steward.active:
|
|
254
|
+
return
|
|
255
|
+
if not _is_xdist_controller(config):
|
|
256
|
+
config.pluginmanager.register(steward, "gc-steward-runtime")
|
|
257
|
+
if config.pluginmanager.hasplugin("xdist"):
|
|
258
|
+
config.pluginmanager.register(_XdistController(config), "gc-steward-xdist")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def pytest_unconfigure(config: pytest.Config) -> None:
|
|
262
|
+
steward = config.stash.get(_STASH, None)
|
|
263
|
+
if steward is not None and steward.started:
|
|
264
|
+
steward.stop()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def pytest_sessionfinish(session: pytest.Session) -> None:
|
|
268
|
+
config = session.config
|
|
269
|
+
steward = config.stash.get(_STASH, None)
|
|
270
|
+
if steward is None or not steward.active:
|
|
271
|
+
return
|
|
272
|
+
if hasattr(config, "workerinput"): # xdist worker: ship stats to controller
|
|
273
|
+
config.workeroutput[_KEY] = steward.stats()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class _XdistController:
|
|
277
|
+
def __init__(self, config: pytest.Config) -> None:
|
|
278
|
+
self.config = config
|
|
279
|
+
|
|
280
|
+
@pytest.hookimpl(optionalhook=True)
|
|
281
|
+
def pytest_testnodedown(self, node, error) -> None:
|
|
282
|
+
stats = getattr(node, "workeroutput", {}).get(_KEY)
|
|
283
|
+
if stats:
|
|
284
|
+
self.config.stash[_WORKERS][node.gateway.id] = stats
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
_STASH = pytest.StashKey[Steward]()
|
|
288
|
+
_WORKERS = pytest.StashKey[dict]()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _merge(stats: list[dict[str, Any]]) -> dict[str, Counter]:
|
|
292
|
+
merged = {"types": Counter(), "tests_top": Counter(), "modules": Counter()}
|
|
293
|
+
for s in stats:
|
|
294
|
+
for key in merged:
|
|
295
|
+
merged[key].update(s.get(key, {}))
|
|
296
|
+
return merged
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _line(name: str, s: dict[str, Any]) -> str:
|
|
300
|
+
auto = ", ".join(f"gen{g} {n}" for g, n in s["auto"].items()) or "none"
|
|
301
|
+
rss = "?"
|
|
302
|
+
if s["rss_start_mb"] is not None:
|
|
303
|
+
rss = f"{s['rss_start_mb']:.0f} -> {s['rss_end_mb']:.0f} MB"
|
|
304
|
+
rss += f" (peak {s['rss_peak_mb']:.0f})"
|
|
305
|
+
return (
|
|
306
|
+
f"{name}: {s['tests']} tests, RSS {rss}; forced {s['forced']}x "
|
|
307
|
+
f"({s['forced_seconds']:.2f} s, {s['forced_objects']} objects); "
|
|
308
|
+
f"automatic {auto} ({s['auto_seconds']:.2f} s, "
|
|
309
|
+
f"{s['auto_objects']} objects); frozen {s['frozen']}"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def pytest_terminal_summary(terminalreporter, exitstatus, config: pytest.Config):
|
|
314
|
+
steward = config.stash.get(_STASH, None)
|
|
315
|
+
if steward is None or not steward.active or hasattr(config, "workerinput"):
|
|
316
|
+
return
|
|
317
|
+
workers = config.stash.get(_WORKERS, {})
|
|
318
|
+
per_process = (
|
|
319
|
+
dict(sorted(workers.items())) if workers else {"main": steward.stats()}
|
|
320
|
+
)
|
|
321
|
+
tr = terminalreporter
|
|
322
|
+
tr.write_sep("=", "gc-steward")
|
|
323
|
+
for name, s in per_process.items():
|
|
324
|
+
tr.write_line(_line(name, s))
|
|
325
|
+
if not steward.report_path:
|
|
326
|
+
return
|
|
327
|
+
merged = _merge(list(per_process.values()))
|
|
328
|
+
top = steward.top
|
|
329
|
+
for title, key in (
|
|
330
|
+
("objects reclaimed by the cyclic GC, by type", "types"),
|
|
331
|
+
("by test", "tests_top"),
|
|
332
|
+
("by module", "modules"),
|
|
333
|
+
):
|
|
334
|
+
tr.write_line("")
|
|
335
|
+
tr.write_line(f"{title} (top {top}):")
|
|
336
|
+
for name, count in merged[key].most_common(top):
|
|
337
|
+
tr.write_line(f" {count:>10} {name}")
|
|
338
|
+
report = {
|
|
339
|
+
"processes": per_process,
|
|
340
|
+
"merged": {k: dict(v.most_common()) for k, v in merged.items()},
|
|
341
|
+
}
|
|
342
|
+
path = Path(steward.report_path)
|
|
343
|
+
path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n")
|
|
344
|
+
tr.write_line("")
|
|
345
|
+
tr.write_line(f"gc-steward report written to {path}")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Current resident set size of this process, in megabytes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
_PAGE = os.sysconf("SC_PAGE_SIZE") if hasattr(os, "sysconf") else 4096
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _linux() -> float | None:
|
|
12
|
+
try:
|
|
13
|
+
with open("/proc/self/statm", "rb") as f:
|
|
14
|
+
return int(f.read().split()[1]) * _PAGE / 1048576
|
|
15
|
+
except (OSError, ValueError, IndexError):
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _psutil() -> float | None:
|
|
20
|
+
try:
|
|
21
|
+
import psutil
|
|
22
|
+
except ImportError:
|
|
23
|
+
return None
|
|
24
|
+
return psutil.Process().memory_info().rss / 1048576
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _peak() -> float | None:
|
|
28
|
+
"""Peak RSS: a last resort; it never goes down."""
|
|
29
|
+
try:
|
|
30
|
+
import resource
|
|
31
|
+
except ImportError:
|
|
32
|
+
return None
|
|
33
|
+
peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
34
|
+
# Linux reports KiB, macOS bytes.
|
|
35
|
+
return peak / (1048576 if sys.platform == "darwin" else 1024)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def current_rss_mb() -> float | None:
|
|
39
|
+
"""Best available measure of the current RSS, or ``None`` if unknown."""
|
|
40
|
+
if sys.platform.startswith("linux"):
|
|
41
|
+
value = _linux()
|
|
42
|
+
if value is not None:
|
|
43
|
+
return value
|
|
44
|
+
value = _psutil()
|
|
45
|
+
if value is not None:
|
|
46
|
+
return value
|
|
47
|
+
return _peak()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
CYCLES = """
|
|
6
|
+
import gc
|
|
7
|
+
|
|
8
|
+
class Leaky:
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.me = self
|
|
11
|
+
self.payload = [object() for _ in range(10)]
|
|
12
|
+
|
|
13
|
+
def make():
|
|
14
|
+
for _ in range(50):
|
|
15
|
+
Leaky()
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_inactive_by_default(pytester):
|
|
20
|
+
pytester.makepyfile("def test_a():\n pass\n")
|
|
21
|
+
result = pytester.runpytest()
|
|
22
|
+
result.assert_outcomes(passed=1)
|
|
23
|
+
result.stdout.no_fnmatch_line("*= gc-steward =*")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_freeze_moves_startup_objects_to_permanent_generation(pytester):
|
|
27
|
+
pytester.makepyfile(
|
|
28
|
+
"""
|
|
29
|
+
import gc
|
|
30
|
+
|
|
31
|
+
def test_frozen():
|
|
32
|
+
assert gc.get_freeze_count() > 0
|
|
33
|
+
"""
|
|
34
|
+
)
|
|
35
|
+
result = pytester.runpytest("--gc-freeze")
|
|
36
|
+
result.assert_outcomes(passed=1)
|
|
37
|
+
result.stdout.fnmatch_lines(["*gc-steward*", "main: 1 tests*frozen [1-9]*"])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_gc_every_forces_collections(pytester):
|
|
41
|
+
pytester.makepyfile("\n".join(f"def test_{i}():\n pass\n" for i in range(10)))
|
|
42
|
+
result = pytester.runpytest("--gc-every", "3")
|
|
43
|
+
result.assert_outcomes(passed=10)
|
|
44
|
+
result.stdout.fnmatch_lines(["main: 10 tests*forced 3x*"])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_ini_options(pytester):
|
|
48
|
+
pytester.makeini("[pytest]\ngc_steward_every = 2\n")
|
|
49
|
+
pytester.makepyfile("\n".join(f"def test_{i}():\n pass\n" for i in range(4)))
|
|
50
|
+
result = pytester.runpytest()
|
|
51
|
+
result.stdout.fnmatch_lines(["main: 4 tests*forced 2x*"])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_cli_overrides_ini(pytester):
|
|
55
|
+
pytester.makeini("[pytest]\ngc_steward_every = 2\n")
|
|
56
|
+
pytester.makepyfile("\n".join(f"def test_{i}():\n pass\n" for i in range(4)))
|
|
57
|
+
result = pytester.runpytest("--gc-every", "4")
|
|
58
|
+
result.stdout.fnmatch_lines(["main: 4 tests*forced 1x*"])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_disable_turns_off_automatic_gc_during_tests_only(pytester):
|
|
62
|
+
pytester.makepyfile(
|
|
63
|
+
"""
|
|
64
|
+
import gc
|
|
65
|
+
|
|
66
|
+
def test_off():
|
|
67
|
+
assert not gc.isenabled()
|
|
68
|
+
"""
|
|
69
|
+
)
|
|
70
|
+
result = pytester.runpytest("--gc-disable", "--gc-every", "1")
|
|
71
|
+
result.assert_outcomes(passed=1)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_rss_growth_triggers_collection(pytester):
|
|
75
|
+
pytester.makepyfile(
|
|
76
|
+
"""
|
|
77
|
+
import gc
|
|
78
|
+
|
|
79
|
+
keep = []
|
|
80
|
+
|
|
81
|
+
def test_grow():
|
|
82
|
+
keep.append(bytearray(64 * 1024 * 1024))
|
|
83
|
+
|
|
84
|
+
def test_after():
|
|
85
|
+
pass
|
|
86
|
+
"""
|
|
87
|
+
)
|
|
88
|
+
result = pytester.runpytest_subprocess("--gc-rss-growth", "32")
|
|
89
|
+
result.assert_outcomes(passed=2)
|
|
90
|
+
result.stdout.fnmatch_lines(["main: 2 tests*forced [1-9]x*"])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_report_counts_cycle_types_per_test(pytester):
|
|
94
|
+
pytester.makepyfile(
|
|
95
|
+
test_cycles=CYCLES
|
|
96
|
+
+ """
|
|
97
|
+
def test_leaks():
|
|
98
|
+
make()
|
|
99
|
+
|
|
100
|
+
def test_clean():
|
|
101
|
+
[object() for _ in range(100)]
|
|
102
|
+
"""
|
|
103
|
+
)
|
|
104
|
+
report = pytester.path / "gc.json"
|
|
105
|
+
result = pytester.runpytest("--gc-report", str(report))
|
|
106
|
+
result.assert_outcomes(passed=2)
|
|
107
|
+
result.stdout.fnmatch_lines(
|
|
108
|
+
["*objects reclaimed by the cyclic GC, by type*", "*test_cycles.Leaky*"]
|
|
109
|
+
)
|
|
110
|
+
data = json.loads(report.read_text())
|
|
111
|
+
merged = data["merged"]
|
|
112
|
+
assert merged["types"]["test_cycles.Leaky"] == 50
|
|
113
|
+
assert "test_cycles.py::test_leaks" in merged["tests_top"]
|
|
114
|
+
assert "test_cycles.py::test_clean" not in merged["tests_top"]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_xdist_workers_are_aggregated(pytester):
|
|
118
|
+
pytest.importorskip("xdist")
|
|
119
|
+
pytester.makepyfile(
|
|
120
|
+
test_cycles=CYCLES
|
|
121
|
+
+ "\n".join(f"\ndef test_{i}():\n make()\n" for i in range(6))
|
|
122
|
+
)
|
|
123
|
+
report = pytester.path / "gc.json"
|
|
124
|
+
result = pytester.runpytest("-n", "2", "--gc-report", str(report))
|
|
125
|
+
result.assert_outcomes(passed=6)
|
|
126
|
+
result.stdout.fnmatch_lines(["gw0: * tests*", "gw1: * tests*"])
|
|
127
|
+
data = json.loads(report.read_text())
|
|
128
|
+
assert set(data["processes"]) == {"gw0", "gw1"}
|
|
129
|
+
assert data["merged"]["types"]["test_cycles.Leaky"] == 300
|