Skip to content

Commit 5b1932f

Browse files
emerybergerclaude
andcommitted
Add pytest-xdist support for parallel test coverage
- Add pytest plugin (pytest_plugin.py) that coordinates coverage collection across xdist workers using shared temp directory for coverage files - Set environment variables (SLIPCOVER_ENABLED, etc.) when running pytest so xdist workers can initialize slipcover - Merge worker coverage in controller and pass to main process - Exclude slipcover's own modules from instrumentation - Skip wrapping pytest's AssertionRewritingHook (handled by wrap_pytest) - Add integration tests for xdist with 2 and 4 workers, branch coverage Closes #9 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent cf329ae commit 5b1932f

5 files changed

Lines changed: 427 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ dependencies = [
2828
[project.scripts]
2929
slipcover = "slipcover.__main__:main"
3030

31+
[project.entry-points.pytest11]
32+
slipcover = "slipcover.pytest_plugin"
33+
3134
[project.urls]
3235
"Homepage" = "https://github.com/plasma-umass/slipcover"
3336
"Repository" = "https://github.com/plasma-umass/slipcover"
@@ -48,6 +51,7 @@ build-backend = "setuptools.build_meta"
4851
[project.optional-dependencies]
4952
test = [
5053
'pytest',
51-
'pytest-forked'
54+
'pytest-forked',
55+
'pytest-xdist'
5256
]
5357

src/slipcover/__main__.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import tempfile
1212
import json
1313
import warnings
14+
import shutil
1415

1516
# Used for fork() support
1617
input_tmpfiles = []
@@ -43,10 +44,12 @@ def wrapper(*pargs, **kwargs):
4344

4445

4546
def get_coverage(sci):
46-
"""Combines this process' coverage with that of any previously forked children."""
47+
"""Combines this process' coverage with that of any previously forked children and xdist workers."""
4748
global input_tmpfiles, output_tmpfile
4849

4950
cov = sci.get_coverage()
51+
52+
# Merge coverage from forked children (pytest-forked)
5053
if input_tmpfiles:
5154
for f in input_tmpfiles:
5255
try:
@@ -65,6 +68,24 @@ def get_coverage(sci):
6568
except FileNotFoundError:
6669
pass
6770

71+
# Merge coverage from xdist workers (pytest-xdist)
72+
coverage_dir = os.environ.get("SLIPCOVER_COVERAGE_DIR")
73+
if coverage_dir:
74+
coverage_dir = Path(coverage_dir)
75+
merged_file = coverage_dir / "merged.json"
76+
if merged_file.exists():
77+
try:
78+
with open(merged_file) as f:
79+
xdist_cov = json.load(f)
80+
sc.merge_coverage(cov, xdist_cov)
81+
except Exception as e:
82+
warnings.warn(f"Error reading xdist coverage: {e}")
83+
# Clean up the xdist coverage directory
84+
try:
85+
shutil.rmtree(coverage_dir)
86+
except Exception:
87+
pass
88+
6889
return cov
6990

7091

@@ -213,6 +234,16 @@ def main():
213234
if not args.dont_wrap_pytest:
214235
sc.wrap_pytest(sci, file_matcher)
215236

237+
# Set environment variables for pytest-xdist workers to pick up
238+
if args.module and args.module[0] == 'pytest':
239+
os.environ["SLIPCOVER_ENABLED"] = "1"
240+
if args.branch:
241+
os.environ["SLIPCOVER_BRANCH"] = "1"
242+
if args.source:
243+
source_str = ",".join(args.source) if isinstance(args.source, list) else args.source
244+
os.environ["SLIPCOVER_SOURCE"] = source_str
245+
if args.omit:
246+
os.environ["SLIPCOVER_OMIT"] = args.omit
216247

217248
if platform.system() != 'Windows':
218249
os.fork = fork_shim(sci)

src/slipcover/importer.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ def __init__(self):
7070
Path(sysconfig.get_path("stdlib")).resolve(),
7171
Path(sysconfig.get_path("purelib")).resolve(),
7272
)
73+
# Don't instrument slipcover's own modules
74+
self._slipcover_path = Path(__file__).resolve().parent
7375

7476
def addSource(self, source : Path):
7577
if isinstance(source, str):
@@ -94,6 +96,10 @@ def matches(self, filename : Optional[Path]):
9496

9597
filename = filename.resolve()
9698

99+
# Never instrument slipcover's own modules
100+
if filename.is_relative_to(self._slipcover_path):
101+
return False
102+
97103
if self.omit:
98104
from fnmatch import fnmatch
99105
if any(fnmatch(filename, o) for o in self.omit):
@@ -142,6 +148,12 @@ def find_spec(self, fullname, path, target=None):
142148
if isinstance(spec.loader, machinery.ExtensionFileLoader):
143149
return None
144150

151+
# skip pytest's assertion rewriting hook - wrap_pytest handles those
152+
# AssertionRewritingHook doesn't have get_code() method
153+
loader_type = type(spec.loader).__name__
154+
if loader_type == 'AssertionRewritingHook':
155+
return spec
156+
145157
if spec.origin and self.file_matcher.matches(spec.origin):
146158
if self.debug:
147159
print(f"instrumenting {fullname} from {spec.origin}")

src/slipcover/pytest_plugin.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""Pytest plugin for slipcover with pytest-xdist support.
2+
3+
This plugin enables coverage collection when using pytest-xdist for parallel testing.
4+
It is automatically activated when SLIPCOVER_ENABLED environment variable is set
5+
(which is done by slipcover's __main__.py when running pytest).
6+
7+
The plugin coordinates coverage collection across xdist workers by:
8+
1. Creating a shared temp directory for coverage files (controller)
9+
2. Having each worker write its coverage to the shared directory
10+
3. Merging all worker coverage in the controller at session end
11+
"""
12+
13+
import json
14+
import os
15+
import tempfile
16+
from pathlib import Path
17+
from typing import Optional
18+
19+
import slipcover as sc
20+
21+
22+
# Global state for the plugin
23+
_slipcover_instance: Optional[sc.Slipcover] = None
24+
_file_matcher: Optional[sc.FileMatcher] = None
25+
_import_manager: Optional[sc.ImportManager] = None
26+
_coverage_dir: Optional[str] = None
27+
28+
29+
def _is_xdist_worker() -> bool:
30+
"""Check if running as an xdist worker process."""
31+
return "PYTEST_XDIST_WORKER" in os.environ
32+
33+
34+
def _get_worker_id() -> str:
35+
"""Get the xdist worker ID (e.g., 'gw0', 'gw1'), or 'main' if not a worker."""
36+
return os.environ.get("PYTEST_XDIST_WORKER", "main")
37+
38+
39+
def pytest_configure(config):
40+
"""Initialize slipcover in xdist workers.
41+
42+
This hook runs in both the controller and worker processes.
43+
We only activate slipcover if SLIPCOVER_ENABLED is set AND we're in an xdist worker.
44+
The controller (main process) is already handled by __main__.py.
45+
"""
46+
global _slipcover_instance, _file_matcher, _import_manager, _coverage_dir
47+
48+
# Only activate if SLIPCOVER_ENABLED is set (by __main__.py when running pytest)
49+
if not os.environ.get("SLIPCOVER_ENABLED"):
50+
return
51+
52+
# Check if xdist is being used (look for -n option or xdist plugin config)
53+
# Note: PYTEST_XDIST_TESTRUNUID is set later, so we check numprocesses
54+
is_xdist = hasattr(config.option, 'numprocesses') and config.option.numprocesses
55+
56+
# Controller creates shared coverage directory for workers to write to
57+
# We detect controller as: xdist is being used AND we're not a worker
58+
if is_xdist and not _is_xdist_worker():
59+
_coverage_dir = tempfile.mkdtemp(prefix="slipcover-xdist-")
60+
os.environ["SLIPCOVER_COVERAGE_DIR"] = _coverage_dir
61+
return # Controller's slipcover is already set up by __main__.py
62+
63+
# Get coverage directory (workers inherit this from controller)
64+
_coverage_dir = os.environ.get("SLIPCOVER_COVERAGE_DIR")
65+
66+
# Only set up slipcover in workers - controller is handled by __main__.py
67+
if not _is_xdist_worker():
68+
return
69+
70+
# Parse configuration from environment (set by __main__.py)
71+
branch = os.environ.get("SLIPCOVER_BRANCH") == "1"
72+
source = os.environ.get("SLIPCOVER_SOURCE")
73+
omit = os.environ.get("SLIPCOVER_OMIT")
74+
75+
# Set up file matcher
76+
_file_matcher = sc.FileMatcher()
77+
if source:
78+
for s in source.split(","):
79+
s = s.strip()
80+
if s:
81+
_file_matcher.addSource(s)
82+
if omit:
83+
for o in omit.split(","):
84+
o = o.strip()
85+
if o:
86+
_file_matcher.addOmit(o)
87+
88+
# Create Slipcover instance
89+
source_list = [s.strip() for s in source.split(",")] if source else None
90+
_slipcover_instance = sc.Slipcover(branch=branch, source=source_list)
91+
92+
# Wrap pytest's assertion rewriter for instrumentation
93+
sc.wrap_pytest(_slipcover_instance, _file_matcher)
94+
95+
# Start import instrumentation
96+
_import_manager = sc.ImportManager(_slipcover_instance, _file_matcher)
97+
_import_manager.__enter__()
98+
99+
100+
def pytest_unconfigure(config):
101+
"""Clean up import manager on shutdown."""
102+
global _import_manager
103+
if _import_manager:
104+
_import_manager.__exit__(None, None, None)
105+
_import_manager = None
106+
107+
108+
def pytest_sessionfinish(session, exitstatus):
109+
"""Handle coverage collection at session end.
110+
111+
Workers: Write coverage to a file in the shared directory.
112+
Controller: Merge all worker coverage files into a single merged.json.
113+
"""
114+
global _slipcover_instance, _coverage_dir
115+
116+
if not os.environ.get("SLIPCOVER_ENABLED"):
117+
return
118+
119+
if _is_xdist_worker() and _slipcover_instance and _coverage_dir:
120+
# Worker: write coverage to shared directory
121+
coverage = _slipcover_instance.get_coverage()
122+
worker_id = _get_worker_id()
123+
cov_file = Path(_coverage_dir) / f"coverage-{worker_id}.json"
124+
try:
125+
with open(cov_file, "w") as f:
126+
json.dump(coverage, f)
127+
except Exception as e:
128+
import warnings
129+
warnings.warn(f"slipcover: failed to write worker coverage: {e}")
130+
131+
elif _coverage_dir and not _is_xdist_worker():
132+
# Controller: merge all worker coverage files
133+
# We know we're the controller if _coverage_dir is set and we're not a worker
134+
coverage_dir = Path(_coverage_dir)
135+
worker_files = list(coverage_dir.glob("coverage-gw*.json"))
136+
137+
if worker_files:
138+
# Start with first worker's coverage
139+
merged = None
140+
for cov_file in worker_files:
141+
try:
142+
with open(cov_file) as f:
143+
worker_cov = json.load(f)
144+
if merged is None:
145+
merged = worker_cov
146+
else:
147+
sc.merge_coverage(merged, worker_cov)
148+
except Exception as e:
149+
import warnings
150+
warnings.warn(f"slipcover: error reading {cov_file}: {e}")
151+
152+
if merged:
153+
# Write merged coverage for __main__.py to read
154+
merged_file = coverage_dir / "merged.json"
155+
try:
156+
with open(merged_file, "w") as f:
157+
json.dump(merged, f)
158+
except Exception as e:
159+
import warnings
160+
warnings.warn(f"slipcover: failed to write merged coverage: {e}")

0 commit comments

Comments
 (0)