pytest-xdist-memory 0.1.0__py3-none-any.whl

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,3 @@
1
+ """Size pytest-xdist by available RAM, not by CPU count."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,122 @@
1
+ """Measuring memory: process subtrees, available RAM, docker containers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+
9
+ import psutil
10
+
11
+ MB = 1024 * 1024
12
+
13
+ _UNITS = (("GiB", 1024.0), ("MiB", 1.0), ("KiB", 1 / 1024), ("GB", 1000.0))
14
+
15
+
16
+ def process_mb(proc: psutil.Process) -> int:
17
+ """Memory of one process in MB.
18
+
19
+ PSS on Linux: a fair share of pages shared with other processes. Browsers
20
+ (Chromium runs several processes sharing most of their pages) would be
21
+ counted several times over by summing RSS. Elsewhere RSS.
22
+ """
23
+ if sys.platform.startswith("linux"):
24
+ try:
25
+ return proc.memory_full_info().pss // MB
26
+ except psutil.AccessDenied:
27
+ pass # no access to smaps -- fall back to RSS below
28
+ return proc.memory_info().rss // MB
29
+
30
+
31
+ def subtree_mb(proc: psutil.Process) -> int:
32
+ """Memory of a process and ALL its descendants (browsers, servers...)."""
33
+ try:
34
+ procs = [proc, *proc.children(recursive=True)]
35
+ except psutil.NoSuchProcess:
36
+ return 0
37
+ total = 0
38
+ for p in procs:
39
+ try:
40
+ total += process_mb(p)
41
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
42
+ continue # exited between listing and measuring
43
+ return total
44
+
45
+
46
+ def available_mb() -> int:
47
+ return psutil.virtual_memory().available // MB
48
+
49
+
50
+ def _parse_usage(usage: str) -> int | None:
51
+ used = usage.split("/")[0].strip()
52
+ for unit, mult in _UNITS:
53
+ if used.endswith(unit):
54
+ try:
55
+ return int(float(used[: -len(unit)]) * mult)
56
+ except ValueError:
57
+ return None
58
+ return None
59
+
60
+
61
+ def _docker(*args: str) -> str | None:
62
+ try:
63
+ return subprocess.run(
64
+ ["docker", *args], capture_output=True, text=True, timeout=20
65
+ ).stdout
66
+ except (subprocess.TimeoutExpired, OSError):
67
+ return None
68
+
69
+
70
+ def docker_mb(match: str = "", port: str | None = None) -> int | None:
71
+ """Total memory of running containers whose name contains ``match``
72
+ and/or which publish host ``port``.
73
+
74
+ ``None`` when docker is unavailable or nothing matches. Containers are
75
+ not children of pytest (e.g. a database started by testcontainers), so
76
+ they cannot be found by walking the process tree. Matching by the
77
+ published port picks exactly THIS run's container even when parallel
78
+ runs (other checkouts) have containers with similar names.
79
+ """
80
+ if not shutil.which("docker") or not (match or port):
81
+ return None
82
+ names: list[str] = []
83
+ if port:
84
+ out = _docker("ps", "--filter", f"publish={port}", "--format", "{{.Names}}")
85
+ names = (out or "").split()
86
+ if not names:
87
+ return None
88
+ out = _docker("stats", "--no-stream", "--format", "{{.Name}} {{.MemUsage}}", *names)
89
+ if out is None:
90
+ return None
91
+ total = None
92
+ for line in out.splitlines():
93
+ name, _, usage = line.partition(" ")
94
+ if match not in name:
95
+ continue
96
+ mb = _parse_usage(usage)
97
+ if mb is not None:
98
+ total = (total or 0) + mb
99
+ return total
100
+
101
+
102
+ def cpu_cap(performance_cores: bool) -> int:
103
+ """Upper bound on workers from the CPU.
104
+
105
+ With ``performance_cores`` on Apple Silicon: only the performance cores.
106
+ Efficiency cores are too slow to drive e.g. their own browser and only
107
+ add contention.
108
+ """
109
+ cpus = psutil.cpu_count(logical=True) or 1
110
+ if performance_cores and sys.platform == "darwin":
111
+ try:
112
+ out = subprocess.run(
113
+ ["sysctl", "-n", "hw.perflevel0.logicalcpu"],
114
+ capture_output=True,
115
+ text=True,
116
+ timeout=5,
117
+ ).stdout.strip()
118
+ except (subprocess.TimeoutExpired, OSError):
119
+ return cpus
120
+ if out.isdigit():
121
+ return int(out)
122
+ return cpus
@@ -0,0 +1,419 @@
1
+ """pytest plugin: size pytest-xdist by RAM, not by CPU count.
2
+
3
+ Why this exists: on a developer machine (laptop with a desktop) the
4
+ bottleneck of a parallel test run is usually memory, not CPU. ``-n auto``
5
+ spawns one worker per core; with heavy workers (Django + a database,
6
+ a browser per worker for Playwright) that is enough to push the machine
7
+ into swap thrashing and freeze it.
8
+
9
+ The plugin, once enabled with ``--xdist-memory``:
10
+
11
+ * measures, in every worker, the peak memory of the worker AND all of its
12
+ descendants (browsers, live servers...) -- PSS on Linux, RSS elsewhere;
13
+ * optionally measures docker containers by name or by published port (a test
14
+ database started by testcontainers is not a child of pytest);
15
+ * records the peaks in a per-machine profile file, separately per ``-m``
16
+ marker expression ("suite");
17
+ * answers ``-n auto`` / ``-n logical`` with as many workers as fit into the
18
+ currently available RAM (capped by CPU count);
19
+ * optionally (``--xdist-memory-watchdog``) aborts the run when the whole
20
+ process tree exceeds the budget -- a portable, best-effort stand-in for a
21
+ cgroup limit (``systemd-run --scope -p MemoryMax=...``).
22
+
23
+ Nothing changes until you enable it.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import sys
30
+ import threading
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ import psutil
35
+ import pytest
36
+
37
+ from . import memory, profile
38
+
39
+ _KEY = "xdist_memory"
40
+
41
+
42
+ def pytest_addoption(parser: pytest.Parser) -> None:
43
+ group = parser.getgroup("xdist-memory", "size pytest-xdist by available RAM")
44
+ group.addoption(
45
+ "--xdist-memory",
46
+ action="store_true",
47
+ default=None,
48
+ help="measure worker memory, record it in the profile and answer "
49
+ "-n auto from it (ini: xdist_memory).",
50
+ )
51
+ group.addoption(
52
+ "--xdist-memory-profile",
53
+ default=None,
54
+ metavar="PATH",
55
+ help="profile file, relative to rootdir; keep it out of version "
56
+ "control (ini: xdist_memory_profile; default .pytest-xdist-memory.json).",
57
+ )
58
+ group.addoption(
59
+ "--xdist-memory-reserve",
60
+ type=int,
61
+ default=None,
62
+ metavar="MB",
63
+ help="memory to leave for the desktop / system "
64
+ "(ini: xdist_memory_reserve; default 2048).",
65
+ )
66
+ group.addoption(
67
+ "--xdist-memory-safety",
68
+ type=float,
69
+ default=None,
70
+ metavar="FACTOR",
71
+ help="multiplier on the measured worker peak "
72
+ "(ini: xdist_memory_safety; default 1.25).",
73
+ )
74
+ group.addoption(
75
+ "--xdist-memory-default-worker",
76
+ type=int,
77
+ default=None,
78
+ metavar="MB",
79
+ help="worker peak assumed while the profile has no runs for this suite "
80
+ "(ini: xdist_memory_default_worker; default 1024).",
81
+ )
82
+ group.addoption(
83
+ "--xdist-memory-extra",
84
+ type=int,
85
+ default=None,
86
+ metavar="MB",
87
+ help="fixed extra memory per worker outside the process tree, e.g. "
88
+ "its database (ini: xdist_memory_extra; default 0).",
89
+ )
90
+ group.addoption(
91
+ "--xdist-memory-docker",
92
+ default=None,
93
+ metavar="SUBSTRING",
94
+ help="also measure running docker containers whose name contains "
95
+ "SUBSTRING and count their peak / workers as extra per worker "
96
+ "(ini: xdist_memory_docker).",
97
+ )
98
+ group.addoption(
99
+ "--xdist-memory-docker-port-env",
100
+ default=None,
101
+ metavar="VAR",
102
+ help="also measure the docker container publishing the host port in "
103
+ "environment variable VAR, e.g. the one testcontainers exports for "
104
+ "the test database; exact even with parallel runs "
105
+ "(ini: xdist_memory_docker_port_env).",
106
+ )
107
+ group.addoption(
108
+ "--xdist-memory-performance-cores",
109
+ action="store_true",
110
+ default=None,
111
+ help="on Apple Silicon cap workers at the number of performance cores "
112
+ "(ini: xdist_memory_performance_cores).",
113
+ )
114
+ group.addoption(
115
+ "--xdist-memory-min-tests",
116
+ type=int,
117
+ default=None,
118
+ metavar="N",
119
+ help="record a run in the profile only if at least N tests ran "
120
+ "(ini: xdist_memory_min_tests; default 20).",
121
+ )
122
+ group.addoption(
123
+ "--xdist-memory-watchdog",
124
+ action="store_true",
125
+ default=None,
126
+ help="kill the run when the process tree (+ measured containers) "
127
+ "exceeds the memory available at start minus the reserve "
128
+ "(ini: xdist_memory_watchdog).",
129
+ )
130
+ group.addoption(
131
+ "--xdist-memory-interval",
132
+ type=float,
133
+ default=None,
134
+ metavar="SECONDS",
135
+ help="sampling interval (ini: xdist_memory_interval; default 2).",
136
+ )
137
+ for name, kind, default in (
138
+ ("xdist_memory", "bool", False),
139
+ ("xdist_memory_profile", "string", ".pytest-xdist-memory.json"),
140
+ ("xdist_memory_reserve", "string", "2048"),
141
+ ("xdist_memory_safety", "string", "1.25"),
142
+ ("xdist_memory_default_worker", "string", "1024"),
143
+ ("xdist_memory_extra", "string", "0"),
144
+ ("xdist_memory_docker", "string", ""),
145
+ ("xdist_memory_docker_port_env", "string", ""),
146
+ ("xdist_memory_performance_cores", "bool", False),
147
+ ("xdist_memory_min_tests", "string", "20"),
148
+ ("xdist_memory_watchdog", "bool", False),
149
+ ("xdist_memory_interval", "string", "2"),
150
+ ):
151
+ help_text = f"see --{name.replace('_', '-')}"
152
+ parser.addini(name, help_text, type=kind, default=default)
153
+
154
+
155
+ class Settings:
156
+ def __init__(self, config: pytest.Config) -> None:
157
+ def opt(name: str, cast):
158
+ value = config.getoption(name)
159
+ if value is None:
160
+ value = config.getini(name)
161
+ return cast(value)
162
+
163
+ self.enabled = opt("xdist_memory", bool)
164
+ self.profile = config.rootpath / opt("xdist_memory_profile", str)
165
+ self.reserve_mb = opt("xdist_memory_reserve", int)
166
+ self.safety = opt("xdist_memory_safety", float)
167
+ self.default_worker_mb = opt("xdist_memory_default_worker", int)
168
+ self.extra_mb = opt("xdist_memory_extra", int)
169
+ self.docker = opt("xdist_memory_docker", str)
170
+ self.docker_port_env = opt("xdist_memory_docker_port_env", str)
171
+ self.performance_cores = opt("xdist_memory_performance_cores", bool)
172
+ self.min_tests = opt("xdist_memory_min_tests", int)
173
+ self.watchdog = opt("xdist_memory_watchdog", bool)
174
+ self.interval = opt("xdist_memory_interval", float)
175
+ self.suite = profile.suite_key(config.getoption("markexpr", None))
176
+
177
+ def estimate(self) -> profile.Estimate:
178
+ return profile.estimate(
179
+ profile.load(self.profile),
180
+ self.suite,
181
+ available_mb=memory.available_mb(),
182
+ reserve_mb=self.reserve_mb,
183
+ safety=self.safety,
184
+ cpu_cap=memory.cpu_cap(self.performance_cores),
185
+ default_worker_mb=self.default_worker_mb,
186
+ # Unknown before the first measured run; the controller is a
187
+ # plain pytest process, rarely more than a few hundred MB.
188
+ default_controller_mb=250,
189
+ extra_per_worker_mb=self.extra_mb,
190
+ )
191
+
192
+
193
+ @pytest.hookimpl(tryfirst=True, optionalhook=True)
194
+ def pytest_xdist_auto_num_workers(config: pytest.Config) -> int | None:
195
+ if os.environ.get("PYTEST_XDIST_AUTO_NUM_WORKERS"):
196
+ return None # explicit override wins; let xdist's own hook read it
197
+ settings = Settings(config)
198
+ if not settings.enabled:
199
+ return None
200
+ est = settings.estimate()
201
+ config.stash[_ESTIMATE] = est
202
+ return est.workers
203
+
204
+
205
+ class Sampler:
206
+ """Background thread recording the peak memory of a process."""
207
+
208
+ def __init__(
209
+ self,
210
+ interval: float,
211
+ subtree: bool,
212
+ docker: str = "",
213
+ docker_port_env: str = "",
214
+ ) -> None:
215
+ self.proc = psutil.Process()
216
+ self.interval = interval
217
+ self.subtree = subtree
218
+ self.docker = docker
219
+ self.docker_port_env = docker_port_env
220
+ self.peak_mb = 0
221
+ self.docker_peak_mb = 0
222
+ self.on_sample = None
223
+ self._stop = threading.Event()
224
+ self._thread = threading.Thread(
225
+ target=self._run, name="xdist-memory", daemon=True
226
+ )
227
+
228
+ def start(self) -> None:
229
+ self.sample()
230
+ self._thread.start()
231
+
232
+ def stop(self) -> None:
233
+ self._stop.set()
234
+ if self._thread.is_alive():
235
+ self._thread.join(timeout=5)
236
+ self.sample()
237
+
238
+ def sample(self) -> int:
239
+ if self.subtree:
240
+ mb = memory.subtree_mb(self.proc)
241
+ else:
242
+ mb = memory.process_mb(self.proc)
243
+ self.peak_mb = max(self.peak_mb, mb)
244
+ return mb
245
+
246
+ def sample_docker(self) -> None:
247
+ # Read the port at sample time: plugins (testcontainers) may export
248
+ # it after we were configured.
249
+ port = os.environ.get(self.docker_port_env) if self.docker_port_env else None
250
+ if self.docker_port_env and not port:
251
+ return
252
+ mb = memory.docker_mb(self.docker, port) or 0
253
+ self.docker_peak_mb = max(self.docker_peak_mb, mb)
254
+
255
+ def _run(self) -> None:
256
+ # docker stats is slow (~1 s); sample containers less often.
257
+ docker_every = max(1, int(10 / max(self.interval, 0.1)))
258
+ tick = 0
259
+ while not self._stop.wait(self.interval):
260
+ mb = self.sample()
261
+ if (self.docker or self.docker_port_env) and tick % docker_every == 0:
262
+ self.sample_docker()
263
+ tick += 1
264
+ if self.on_sample is not None:
265
+ self.on_sample(mb)
266
+
267
+
268
+ class Watchdog:
269
+ """Kill the run before it takes the machine down.
270
+
271
+ Best effort: it reacts within one sampling interval, so a very fast
272
+ allocation spike can still get through. A cgroup limit (``systemd-run
273
+ --user --scope -p MemoryMax=...``) is the hard guarantee on Linux.
274
+ """
275
+
276
+ def __init__(self, settings: Settings, sampler: Sampler) -> None:
277
+ self.sampler = sampler
278
+ own = memory.process_mb(sampler.proc)
279
+ self.limit_mb = max(memory.available_mb() - settings.reserve_mb + own, 512)
280
+
281
+ def __call__(self, _own_mb: int) -> None:
282
+ total = memory.subtree_mb(self.sampler.proc) + self.sampler.docker_peak_mb
283
+ if total <= self.limit_mb:
284
+ return
285
+ sys.stderr.write(
286
+ f"\npytest-xdist-memory: process tree uses {total} MB, over the "
287
+ f"limit of {self.limit_mb} MB -- killing the test run.\n"
288
+ )
289
+ sys.stderr.flush()
290
+ for child in self.sampler.proc.children(recursive=True):
291
+ try:
292
+ child.kill()
293
+ except psutil.NoSuchProcess:
294
+ continue # already gone
295
+ os._exit(3)
296
+
297
+
298
+ def _is_worker(config: pytest.Config) -> bool:
299
+ return hasattr(config, "workerinput")
300
+
301
+
302
+ def _is_controller(config: pytest.Config) -> bool:
303
+ # Same test as xdist's own is_xdist_controller(); the "dsession" plugin is
304
+ # not registered yet when our pytest_configure runs.
305
+ return (
306
+ not _is_worker(config)
307
+ and getattr(config.option, "dist", "no") != "no"
308
+ and bool(getattr(config.option, "tx", None))
309
+ )
310
+
311
+
312
+ class Runtime:
313
+ def __init__(self, config: pytest.Config, settings: Settings) -> None:
314
+ self.config = config
315
+ self.settings = settings
316
+ self.worker = _is_worker(config)
317
+ self.controller = _is_controller(config)
318
+ # A worker (or a plain non-xdist run) measures its whole subtree; the
319
+ # controller only itself, its children are the workers.
320
+ self.sampler = Sampler(
321
+ settings.interval,
322
+ subtree=not self.controller,
323
+ docker=settings.docker if not self.worker else "",
324
+ docker_port_env=settings.docker_port_env if not self.worker else "",
325
+ )
326
+ self.workers: dict[str, dict[str, Any]] = {}
327
+ self.tests = 0
328
+ self.watchdog = None
329
+ if settings.watchdog and not self.worker:
330
+ self.watchdog = Watchdog(settings, self.sampler)
331
+ self.sampler.on_sample = self.watchdog
332
+
333
+ def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
334
+ if report.when == "call" or (report.when == "setup" and report.skipped):
335
+ self.tests += 1
336
+
337
+ @pytest.hookimpl(optionalhook=True)
338
+ def pytest_testnodedown(self, node, error) -> None:
339
+ data = getattr(node, "workeroutput", {}).get(_KEY)
340
+ if data:
341
+ self.workers[node.gateway.id] = data
342
+
343
+ def finish(self, session: pytest.Session) -> None:
344
+ self.sampler.stop()
345
+ if self.worker:
346
+ self.config.workeroutput[_KEY] = {"peak_mb": self.sampler.peak_mb}
347
+ return
348
+ if not self.controller:
349
+ return # plain run: nothing to size, just report in the summary
350
+ peaks = sorted((w["peak_mb"] for w in self.workers.values()), reverse=True)
351
+ if not peaks:
352
+ return
353
+ n = len(peaks)
354
+ self.run = {
355
+ "host": os.uname().nodename if hasattr(os, "uname") else "",
356
+ "metric": "pss" if sys.platform.startswith("linux") else "rss",
357
+ "workers": n,
358
+ "tests": self.tests,
359
+ "worker_peak_mb": peaks[0],
360
+ "worker_peaks_mb": peaks,
361
+ "controller_peak_mb": self.sampler.peak_mb,
362
+ "extra_per_worker_mb": (self.sampler.docker_peak_mb // n) or None,
363
+ }
364
+ interrupted = session.shouldstop or session.shouldfail
365
+ if self.tests >= self.settings.min_tests and not interrupted:
366
+ profile.record_run(self.settings.profile, self.settings.suite, self.run)
367
+ self.recorded = True
368
+
369
+
370
+ _RUNTIME = pytest.StashKey[Runtime]()
371
+ _ESTIMATE = pytest.StashKey[profile.Estimate]()
372
+
373
+
374
+ @pytest.hookimpl(trylast=True)
375
+ def pytest_configure(config: pytest.Config) -> None:
376
+ settings = Settings(config)
377
+ if not settings.enabled:
378
+ return
379
+ runtime = Runtime(config, settings)
380
+ config.stash[_RUNTIME] = runtime
381
+ config.pluginmanager.register(runtime, "xdist-memory-runtime")
382
+ runtime.sampler.start()
383
+
384
+
385
+ def pytest_sessionfinish(session: pytest.Session) -> None:
386
+ runtime = session.config.stash.get(_RUNTIME, None)
387
+ if runtime is not None:
388
+ runtime.finish(session)
389
+
390
+
391
+ def pytest_terminal_summary(terminalreporter, exitstatus, config: pytest.Config):
392
+ runtime = config.stash.get(_RUNTIME, None)
393
+ if runtime is None or runtime.worker:
394
+ return
395
+ tr = terminalreporter
396
+ tr.write_sep("=", "xdist-memory")
397
+ est = config.stash.get(_ESTIMATE, None)
398
+ if est is not None:
399
+ tr.write_line(f"-n auto [{runtime.settings.suite}]: {est.describe()}")
400
+ run = getattr(runtime, "run", None)
401
+ if run is None:
402
+ tr.write_line(f"peak (whole tree, no xdist): {runtime.sampler.peak_mb} MB")
403
+ return
404
+ extra = run["extra_per_worker_mb"]
405
+ tr.write_line(
406
+ f"peak per worker {run['worker_peak_mb']} MB (all: {run['worker_peaks_mb']}),"
407
+ f" controller {run['controller_peak_mb']} MB"
408
+ + (f", containers {extra} MB/worker" if extra else "")
409
+ )
410
+ path = Path(runtime.settings.profile)
411
+ if getattr(runtime, "recorded", False):
412
+ tr.write_line(f"recorded in {path.name} [{runtime.settings.suite}]")
413
+ else:
414
+ tr.write_line(
415
+ f"not recorded ({runtime.tests} tests < "
416
+ f"{runtime.settings.min_tests} or run interrupted)"
417
+ )
418
+ after = runtime.settings.estimate()
419
+ tr.write_line(f"next -n auto would give: {after.describe()}")
@@ -0,0 +1,115 @@
1
+ """The memory profile file and the worker-count calculation.
2
+
3
+ The profile is per machine (keep it out of version control). It holds, per
4
+ "suite" (the ``-m`` marker expression, or ``default``), the last few runs.
5
+ The estimate takes the MAXIMUM over them: one lucky light run should not let
6
+ the next one spawn too many workers.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ VERSION = 1
19
+ KEEP_RUNS = 5
20
+
21
+
22
+ def suite_key(markexpr: str | None) -> str:
23
+ return (markexpr or "").strip() or "default"
24
+
25
+
26
+ def load(path: Path) -> dict[str, Any]:
27
+ try:
28
+ data = json.loads(path.read_text())
29
+ except FileNotFoundError:
30
+ return {"version": VERSION, "suites": {}}
31
+ except (OSError, ValueError) as exc:
32
+ raise ProfileError(f"cannot read memory profile {path}: {exc}") from exc
33
+ if data.get("version") != VERSION:
34
+ return {"version": VERSION, "suites": {}}
35
+ return data
36
+
37
+
38
+ class ProfileError(Exception):
39
+ pass
40
+
41
+
42
+ def record_run(path: Path, key: str, run: dict[str, Any]) -> None:
43
+ data = load(path)
44
+ runs = data["suites"].setdefault(key, {"runs": []})["runs"]
45
+ runs.append({"measured_at": time.strftime("%Y-%m-%d %H:%M:%S"), **run})
46
+ del runs[:-KEEP_RUNS]
47
+ tmp = path.with_name(path.name + f".{os.getpid()}.tmp")
48
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
49
+ tmp.replace(path)
50
+
51
+
52
+ @dataclass
53
+ class Estimate:
54
+ workers: int
55
+ from_profile: bool
56
+ worker_mb: int
57
+ controller_mb: int
58
+ extra_per_worker_mb: int
59
+ per_worker_mb: int
60
+ available_mb: int
61
+ reserve_mb: int
62
+ budget_mb: int
63
+ cpu_cap: int
64
+ runs: int = 0
65
+ notes: list[str] = field(default_factory=list)
66
+
67
+ def describe(self) -> str:
68
+ src = f"profile ({self.runs} runs)" if self.from_profile else "defaults"
69
+ return (
70
+ f"{self.workers} workers: available {self.available_mb} MB"
71
+ f" - reserve {self.reserve_mb} - controller {self.controller_mb}"
72
+ f" = {self.budget_mb} MB; per worker {self.per_worker_mb} MB"
73
+ f" ({self.worker_mb} x safety + extra {self.extra_per_worker_mb});"
74
+ f" cpu cap {self.cpu_cap}; from {src}"
75
+ )
76
+
77
+
78
+ def _peak(runs: list[dict[str, Any]], name: str) -> int | None:
79
+ values = [r[name] for r in runs if r.get(name)]
80
+ return max(values) if values else None
81
+
82
+
83
+ def estimate(
84
+ data: dict[str, Any],
85
+ key: str,
86
+ *,
87
+ available_mb: int,
88
+ reserve_mb: int,
89
+ safety: float,
90
+ cpu_cap: int,
91
+ default_worker_mb: int,
92
+ default_controller_mb: int,
93
+ extra_per_worker_mb: int,
94
+ ) -> Estimate:
95
+ runs = data.get("suites", {}).get(key, {}).get("runs", [])
96
+ worker = _peak(runs, "worker_peak_mb") or default_worker_mb
97
+ controller = _peak(runs, "controller_peak_mb") or default_controller_mb
98
+ measured_extra = _peak(runs, "extra_per_worker_mb")
99
+ extra = max(extra_per_worker_mb, measured_extra or 0)
100
+ per_worker = int(worker * safety) + extra
101
+ budget = available_mb - reserve_mb - controller
102
+ n = max(1, min(budget // max(per_worker, 1), cpu_cap))
103
+ return Estimate(
104
+ workers=n,
105
+ from_profile=bool(runs),
106
+ worker_mb=worker,
107
+ controller_mb=controller,
108
+ extra_per_worker_mb=extra,
109
+ per_worker_mb=per_worker,
110
+ available_mb=available_mb,
111
+ reserve_mb=reserve_mb,
112
+ budget_mb=budget,
113
+ cpu_cap=cpu_cap,
114
+ runs=len(runs),
115
+ )
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.5
2
+ Name: pytest-xdist-memory
3
+ Version: 0.1.0
4
+ Summary: Size pytest-xdist by available RAM: measure worker memory (with browsers and other child processes), keep a per-machine profile, answer -n auto from it, and optionally kill a run before it freezes the machine.
5
+ Project-URL: Homepage, https://github.com/iplweb/pytest-xdist-memory
6
+ Project-URL: Source, https://github.com/iplweb/pytest-xdist-memory
7
+ Project-URL: Issues, https://github.com/iplweb/pytest-xdist-memory/issues
8
+ Author-email: Michał Pasternak <michal.dtz@gmail.com>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Michał Pasternak
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: RAM,memory,oom,parallel,playwright,pytest,xdist
32
+ Classifier: Development Status :: 3 - Alpha
33
+ Classifier: Framework :: Pytest
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Operating System :: OS Independent
37
+ Classifier: Programming Language :: Python
38
+ Classifier: Programming Language :: Python :: 3
39
+ Classifier: Programming Language :: Python :: 3.10
40
+ Classifier: Programming Language :: Python :: 3.11
41
+ Classifier: Programming Language :: Python :: 3.12
42
+ Classifier: Programming Language :: Python :: 3.13
43
+ Classifier: Programming Language :: Python :: 3.14
44
+ Classifier: Topic :: Software Development :: Testing
45
+ Requires-Python: >=3.10
46
+ Requires-Dist: psutil>=5
47
+ Requires-Dist: pytest>=7
48
+ Provides-Extra: dev
49
+ Requires-Dist: pytest-xdist>=3; extra == 'dev'
50
+ Requires-Dist: pytest>=8; extra == 'dev'
51
+ Requires-Dist: ruff<0.16,>=0.15; extra == 'dev'
52
+ Description-Content-Type: text/markdown
53
+
54
+ # pytest-xdist-memory
55
+
56
+ Size [pytest-xdist](https://github.com/pytest-dev/pytest-xdist) by **available
57
+ RAM**, not by CPU count.
58
+
59
+ On a developer laptop the bottleneck of a parallel test run is usually memory.
60
+ `pytest -n auto` starts one worker per core; with heavy workers (Django and
61
+ a database, a browser per worker for Playwright, a live server...) a 14-core,
62
+ 14 GB laptop goes into swap thrashing and freezes. This plugin measures how
63
+ much a worker of *your* test suite needs on *this* machine and answers
64
+ `-n auto` with as many workers as fit into the memory available right now.
65
+
66
+ > Status: alpha.
67
+
68
+ ## What it does
69
+
70
+ With `--xdist-memory` (or `xdist_memory = true` in the ini file):
71
+
72
+ - **Measures** the peak memory of every worker **together with all its child
73
+ processes**: browsers, live servers, subprocesses. Linux uses PSS (a fair
74
+ share of pages shared between processes, so the several processes of one
75
+ Chromium are not counted several times over); other systems use RSS.
76
+ - **Measures containers** too, if you ask it to: a test database started by
77
+ testcontainers is not a child of pytest. Containers can be matched by name
78
+ (`--xdist-memory-docker SUBSTR`) or, more precisely, by the host port they
79
+ publish (`--xdist-memory-docker-port-env VAR`, where `VAR` is the
80
+ environment variable your fixtures export with the port). Matching by port
81
+ is exact even when parallel runs from other checkouts have containers with
82
+ similar names. The peak is divided by the number of workers.
83
+ - **Records** the peaks in a per-machine profile, `.pytest-xdist-memory.json`.
84
+ Each `-m` marker expression is a separate "suite": `-m playwright` workers
85
+ are much heavier than `-m "not playwright"` ones. The last 5 runs are kept,
86
+ and the estimate uses the maximum. Runs with fewer than 20 tests, or
87
+ interrupted runs, are not recorded.
88
+ - **Answers `-n auto` / `-n logical`**:
89
+ `workers = (available - reserve - controller) / (worker_peak × safety + extra)`,
90
+ capped by the CPU count. With `--xdist-memory-performance-cores`, Apple
91
+ Silicon is capped at the number of performance cores.
92
+ - **Optional watchdog** (`--xdist-memory-watchdog`): kills the run when the
93
+ whole process tree, plus the measured containers, exceeds the memory
94
+ available at start minus the reserve.
95
+
96
+ Nothing changes until you enable it. `PYTEST_XDIST_AUTO_NUM_WORKERS` still
97
+ wins over the estimate.
98
+
99
+ ## Usage
100
+
101
+ ```bash
102
+ pip install pytest-xdist-memory
103
+
104
+ # First run: no profile yet, so it assumes 1024 MB per worker; the run is measured.
105
+ pytest -n auto --xdist-memory
106
+
107
+ # Later runs use what was measured (per -m expression):
108
+ pytest -n auto --xdist-memory -m "not playwright"
109
+ pytest -n auto --xdist-memory -m playwright --xdist-memory-performance-cores
110
+ ```
111
+
112
+ The terminal summary shows the measured peaks and the calculation:
113
+
114
+ ```
115
+ ================================ xdist-memory =================================
116
+ -n auto [default]: 3 workers: available 7282 MB - reserve 2048 - controller 215 = 5019 MB; per worker 1357 MB (910 x safety + extra 220); cpu cap 14; from profile (4 runs)
117
+ peak per worker 905 MB (all: [905, 871, 860]), controller 210 MB, containers 214 MB/worker
118
+ recorded in .pytest-xdist-memory.json [default]
119
+ ```
120
+
121
+ Add `.pytest-xdist-memory.json` to `.gitignore`: the profile describes one
122
+ machine.
123
+
124
+ ## Options
125
+
126
+ | Option | ini | Default | |
127
+ |---|---|---|---|
128
+ | `--xdist-memory` | `xdist_memory` | off | enable |
129
+ | `--xdist-memory-profile PATH` | `xdist_memory_profile` | `.pytest-xdist-memory.json` | relative to rootdir |
130
+ | `--xdist-memory-reserve MB` | `xdist_memory_reserve` | 2048 | left for the desktop/system |
131
+ | `--xdist-memory-safety F` | `xdist_memory_safety` | 1.25 | multiplier on the worker peak |
132
+ | `--xdist-memory-default-worker MB` | `xdist_memory_default_worker` | 1024 | used until a run is recorded |
133
+ | `--xdist-memory-extra MB` | `xdist_memory_extra` | 0 | fixed extra per worker (e.g. its DB) |
134
+ | `--xdist-memory-docker SUBSTR` | `xdist_memory_docker` | | measure containers by name |
135
+ | `--xdist-memory-docker-port-env VAR` | `xdist_memory_docker_port_env` | | measure the container publishing port `$VAR` |
136
+ | `--xdist-memory-performance-cores` | `xdist_memory_performance_cores` | off | Apple Silicon: P-cores only |
137
+ | `--xdist-memory-min-tests N` | `xdist_memory_min_tests` | 20 | smaller runs are not recorded |
138
+ | `--xdist-memory-watchdog` | `xdist_memory_watchdog` | off | kill the run over budget |
139
+ | `--xdist-memory-interval S` | `xdist_memory_interval` | 2 | sampling interval |
140
+
141
+ ## Watchdog vs. a cgroup limit
142
+
143
+ The watchdog is portable but **best effort**: it checks once per sampling
144
+ interval, so a very fast allocation spike can get past it. When it fires, it
145
+ kills the workers and exits with code 3, without pytest's teardown: containers
146
+ are left to their own reaper (Ryuk for testcontainers).
147
+
148
+ On Linux with systemd, a cgroup limit is the hard guarantee: the kernel kills
149
+ the run instead of swapping the desktop to death. You can use both:
150
+
151
+ ```bash
152
+ systemd-run --user --scope -q -p MemoryMax=6G -p MemorySwapMax=0 \
153
+ pytest -n auto --xdist-memory
154
+ ```
155
+
156
+ **Neither is needed for measuring.** Measuring uses only `psutil` (and
157
+ `docker stats`, if you ask for containers).
158
+
159
+ ## Requirements
160
+
161
+ Python ≥ 3.10, pytest ≥ 7, psutil. pytest-xdist is optional: without it, the
162
+ plugin only reports the peak of the whole process tree.
163
+
164
+ ## License
165
+
166
+ MIT
@@ -0,0 +1,9 @@
1
+ pytest_xdist_memory/__init__.py,sha256=CmCUsg3GR3F0S13sGHGELI2pzQJjke-NSqbts5K1rlw,83
2
+ pytest_xdist_memory/memory.py,sha256=7v8532oL0i4CS-qXEXt8mNbYQ9s3t_AmjU0B2fUsXr0,3876
3
+ pytest_xdist_memory/plugin.py,sha256=Q1Z05ipi3mn_oieeGzyObtAFsr6Xi89PYPPN6bHh0AM,15565
4
+ pytest_xdist_memory/profile.py,sha256=hRd4YDqjJ1FEvTW0ZPsd2Qr9iwh5QtimbNiR-RVABJQ,3507
5
+ pytest_xdist_memory-0.1.0.dist-info/METADATA,sha256=zi-WevdNPhag6EsCTpJPmjbY4J3yFwy0S9fiv0RJnnM,8124
6
+ pytest_xdist_memory-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ pytest_xdist_memory-0.1.0.dist-info/entry_points.txt,sha256=lIcj__I8p7_rVjnN7TxF3aFQES95THPgInTjttgI-mY,53
8
+ pytest_xdist_memory-0.1.0.dist-info/licenses/LICENSE,sha256=fQLyTbtsufwLCs29hXQS3S9n1sUXFIMqdAGo8So3sgg,1074
9
+ pytest_xdist_memory-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ xdist_memory = pytest_xdist_memory.plugin
@@ -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.