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.
@@ -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,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ - First release: `--gc-freeze`, `--gc-every`, `--gc-rss-growth`, `--gc-disable`,
6
+ `--gc-report` (per type / test / module census of what the cyclic GC
7
+ reclaims), ini equivalents, pytest-xdist aggregation.
@@ -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
+ [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-%E2%98%95-yellow)](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
+ [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-%E2%98%95-yellow)](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,3 @@
1
+ """pytest-gc-steward: garbage-collector control and diagnostics for pytest."""
2
+
3
+ __version__ = "0.1.0"
@@ -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