Skip to content

Commit fa61531

Browse files
Merge branch 'xdist-early-activation'
2 parents 9f0f7a6 + 4a7cde2 commit fa61531

2 files changed

Lines changed: 281 additions & 24 deletions

File tree

src/slipcover/pytest_plugin.py

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -36,37 +36,29 @@ def _get_worker_id() -> str:
3636
return os.environ.get("PYTEST_XDIST_WORKER", "main")
3737

3838

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.
39+
def _activate_worker():
40+
"""Activates instrumentation for an xdist worker as early as possible.
41+
42+
This runs at module import time (see the call at the bottom of this file),
43+
not inside pytest_configure(): pytest_configure() only fires after pytest
44+
has already auto-loaded every pytest11 entry-point plugin and read the
45+
initial conftest.py files, so anything they import would already have
46+
bypassed ImportManager by the time pytest_configure() ran. This module is
47+
itself imported via that same entry-point autoload mechanism, so
48+
top-level code here runs before conftest.py is ever read.
4549
"""
4650
global _slipcover_instance, _file_matcher, _import_manager, _coverage_dir
4751

48-
# Only activate if SLIPCOVER_ENABLED is set (by __main__.py when running pytest)
49-
if not os.environ.get("SLIPCOVER_ENABLED"):
52+
# Only activate if SLIPCOVER_ENABLED is set (by __main__.py when running
53+
# pytest) and we're in an xdist worker -- the controller is already
54+
# handled by __main__.py, and this same guard is what pytest_configure()
55+
# used before, just evaluated earlier.
56+
if not (os.environ.get("SLIPCOVER_ENABLED") and _is_xdist_worker()):
5057
return
5158

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)
59+
# Coverage directory (workers inherit this from the controller's env)
6460
_coverage_dir = os.environ.get("SLIPCOVER_COVERAGE_DIR")
6561

66-
# Only set up slipcover in workers - controller is handled by __main__.py
67-
if not _is_xdist_worker():
68-
return
69-
7062
# Parse configuration from environment (set by __main__.py)
7163
branch = os.environ.get("SLIPCOVER_BRANCH") == "1"
7264
source = os.environ.get("SLIPCOVER_SOURCE")
@@ -97,6 +89,33 @@ def pytest_configure(config):
9789
_import_manager.__enter__()
9890

9991

92+
_activate_worker()
93+
94+
95+
def pytest_configure(config):
96+
"""Sets up the shared coverage directory in the controller.
97+
98+
Worker activation itself already happened at import time, above -- this
99+
hook now only handles the controller side, which does need the `config`
100+
object (to detect xdist via config.option.numprocesses).
101+
"""
102+
global _coverage_dir
103+
104+
# Only activate if SLIPCOVER_ENABLED is set (by __main__.py when running pytest)
105+
if not os.environ.get("SLIPCOVER_ENABLED"):
106+
return
107+
108+
# Check if xdist is being used (look for -n option or xdist plugin config)
109+
# Note: PYTEST_XDIST_TESTRUNUID is set later, so we check numprocesses
110+
is_xdist = hasattr(config.option, 'numprocesses') and config.option.numprocesses
111+
112+
# Controller creates shared coverage directory for workers to write to
113+
# We detect controller as: xdist is being used AND we're not a worker
114+
if is_xdist and not _is_xdist_worker():
115+
_coverage_dir = tempfile.mkdtemp(prefix="slipcover-xdist-")
116+
os.environ["SLIPCOVER_COVERAGE_DIR"] = _coverage_dir
117+
118+
100119
def pytest_unconfigure(config):
101120
"""Clean up import manager on shutdown."""
102121
global _import_manager

tests/test_xdist.py

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,244 @@ def test_xdist_four_workers(tmp_path):
219219
assert [] == file_cov['missing_lines']
220220

221221

222+
# ---------------------------------------------------------------------------
223+
# Regression tests for modules imported by a conftest.py before pytest_configure()
224+
# runs in an xdist worker (issue #84's "pre-imported modules" report). Adapted
225+
# from PR #85 (https://github.com/plasma-umass/slipcover/pull/85, author @nurikk),
226+
# who first diagnosed this gap and prototyped a fix by retroactively instrumenting
227+
# already-imported objects after the fact. Unfortunately, that approach has some
228+
# significant issues: on Python <3.12 the rewritten bytecode wasn't reinstalled,
229+
# and on 3.12+ branch coverage came back as a false 100% (no AST-level branch
230+
# preinstrumentation for modules that were already compiled). The fix here takes
231+
# a different angle: activate ImportManager earlier, before conftest.py is ever
232+
# read, so these modules go through the normal instrumentation path from the
233+
# start and need no after-the-fact repair.
234+
# ---------------------------------------------------------------------------
235+
236+
237+
_CONFTEST_PREIMPORT = """\
238+
import sys
239+
sys.path.insert(0, str(__import__('pathlib').Path(__file__).resolve().parent))
240+
import target # noqa: F401 -- imported before pytest_configure() runs in the worker
241+
"""
242+
243+
244+
def test_xdist_preimported_module_covered(tmp_path, monkeypatch):
245+
"""A module imported by conftest.py before collection should still be covered."""
246+
monkeypatch.chdir(tmp_path)
247+
248+
(tmp_path / "target.py").write_text("""\
249+
def greet(name):
250+
return f"hello {name}"
251+
""")
252+
(tmp_path / "conftest.py").write_text(_CONFTEST_PREIMPORT)
253+
(tmp_path / "test_it.py").write_text("""\
254+
from target import greet
255+
256+
def test_greet():
257+
assert greet("world") == "hello world"
258+
""")
259+
260+
out = tmp_path / "out.json"
261+
result = subprocess.run(
262+
[sys.executable, '-m', 'slipcover', '--source', str(tmp_path),
263+
'--json', '--out', str(out),
264+
'-m', 'pytest', '-n', '2', '-q', 'test_it.py'],
265+
cwd=str(tmp_path), capture_output=True, text=True
266+
)
267+
assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}"
268+
269+
with out.open() as f:
270+
cov = json.load(f)
271+
check_summaries(cov)
272+
273+
keys = [k for k in cov['files'] if 'target.py' in k]
274+
assert keys, f"target.py not in coverage: {list(cov['files'].keys())}"
275+
assert cov['files'][keys[0]]['missing_lines'] == []
276+
277+
278+
def test_xdist_preimported_property_covered(tmp_path, monkeypatch):
279+
"""Property getter bodies in a pre-imported module should be covered."""
280+
monkeypatch.chdir(tmp_path)
281+
282+
(tmp_path / "target.py").write_text("""\
283+
class Config:
284+
@property
285+
def name(self):
286+
return "test"
287+
288+
@property
289+
def value(self):
290+
return 42
291+
""")
292+
(tmp_path / "conftest.py").write_text(_CONFTEST_PREIMPORT)
293+
(tmp_path / "test_it.py").write_text("""\
294+
from target import Config
295+
296+
def test_name():
297+
assert Config().name == "test"
298+
299+
def test_value():
300+
assert Config().value == 42
301+
""")
302+
303+
out = tmp_path / "out.json"
304+
result = subprocess.run(
305+
[sys.executable, '-m', 'slipcover', '--source', str(tmp_path),
306+
'--json', '--out', str(out),
307+
'-m', 'pytest', '-n', '2', '-q', 'test_it.py'],
308+
cwd=str(tmp_path), capture_output=True, text=True
309+
)
310+
assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}"
311+
312+
with out.open() as f:
313+
cov = json.load(f)
314+
check_summaries(cov)
315+
316+
keys = [k for k in cov['files'] if 'target.py' in k]
317+
assert keys, f"target.py not in coverage: {list(cov['files'].keys())}"
318+
assert cov['files'][keys[0]]['missing_lines'] == []
319+
320+
321+
def test_xdist_preimported_wrapped_function_covered(tmp_path, monkeypatch):
322+
"""functools.wraps-decorated function bodies in a pre-imported module should be covered."""
323+
monkeypatch.chdir(tmp_path)
324+
325+
(tmp_path / "target.py").write_text("""\
326+
import functools
327+
328+
def decorator(fn):
329+
@functools.wraps(fn)
330+
def wrapper(*args, **kwargs):
331+
return fn(*args, **kwargs)
332+
return wrapper
333+
334+
@decorator
335+
def compute(x):
336+
return x * 2
337+
""")
338+
(tmp_path / "conftest.py").write_text(_CONFTEST_PREIMPORT)
339+
(tmp_path / "test_it.py").write_text("""\
340+
from target import compute
341+
342+
def test_compute():
343+
assert compute(21) == 42
344+
""")
345+
346+
out = tmp_path / "out.json"
347+
result = subprocess.run(
348+
[sys.executable, '-m', 'slipcover', '--source', str(tmp_path),
349+
'--json', '--out', str(out),
350+
'-m', 'pytest', '-n', '2', '-q', 'test_it.py'],
351+
cwd=str(tmp_path), capture_output=True, text=True
352+
)
353+
assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}"
354+
355+
with out.open() as f:
356+
cov = json.load(f)
357+
check_summaries(cov)
358+
359+
keys = [k for k in cov['files'] if 'target.py' in k]
360+
assert keys, f"target.py not in coverage: {list(cov['files'].keys())}"
361+
# line 6: "return fn(*args, **kwargs)" inside the wrapped function body
362+
assert 6 not in cov['files'][keys[0]]['missing_lines']
363+
364+
365+
def test_xdist_preimported_nested_attr_covered(tmp_path, monkeypatch):
366+
"""A function stashed on a nested object attribute in a pre-imported module
367+
should be covered -- demonstrating early activation needs no bespoke
368+
object-graph walking to handle shapes like this."""
369+
monkeypatch.chdir(tmp_path)
370+
371+
(tmp_path / "target.py").write_text("""\
372+
class Task:
373+
def __init__(self, fn):
374+
self.fn = fn
375+
376+
class Workflow:
377+
def __init__(self, task):
378+
self._task = task
379+
380+
def _impl(x):
381+
return x + 1
382+
383+
workflow = Workflow(Task(_impl))
384+
""")
385+
(tmp_path / "conftest.py").write_text(_CONFTEST_PREIMPORT)
386+
(tmp_path / "test_it.py").write_text("""\
387+
from target import workflow
388+
389+
def test_workflow():
390+
assert workflow._task.fn(41) == 42
391+
""")
392+
393+
out = tmp_path / "out.json"
394+
result = subprocess.run(
395+
[sys.executable, '-m', 'slipcover', '--source', str(tmp_path),
396+
'--json', '--out', str(out),
397+
'-m', 'pytest', '-n', '2', '-q', 'test_it.py'],
398+
cwd=str(tmp_path), capture_output=True, text=True
399+
)
400+
assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}"
401+
402+
with out.open() as f:
403+
cov = json.load(f)
404+
check_summaries(cov)
405+
406+
keys = [k for k in cov['files'] if 'target.py' in k]
407+
assert keys, f"target.py not in coverage: {list(cov['files'].keys())}"
408+
# line 10: "return x + 1" inside _impl
409+
assert 10 not in cov['files'][keys[0]]['missing_lines']
410+
411+
412+
def test_xdist_preimported_module_branch_coverage(tmp_path, monkeypatch):
413+
"""Branch coverage for a pre-imported module must reflect real, not vacuous,
414+
coverage: only the branch actually exercised should be covered, and the
415+
untaken branch must show up in missing_branches rather than a false 100%."""
416+
monkeypatch.chdir(tmp_path)
417+
418+
(tmp_path / "target.py").write_text("""\
419+
def check(x):
420+
if x > 0:
421+
return "positive"
422+
else:
423+
return "non-positive"
424+
""")
425+
(tmp_path / "conftest.py").write_text(_CONFTEST_PREIMPORT)
426+
(tmp_path / "test_it.py").write_text("""\
427+
from target import check
428+
429+
def test_positive():
430+
assert check(1) == "positive"
431+
""")
432+
433+
out = tmp_path / "out.json"
434+
result = subprocess.run(
435+
[sys.executable, '-m', 'slipcover', '--branch', '--source', str(tmp_path),
436+
'--json', '--out', str(out),
437+
'-m', 'pytest', '-n', '2', '-q', 'test_it.py'],
438+
cwd=str(tmp_path), capture_output=True, text=True
439+
)
440+
assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}"
441+
442+
with out.open() as f:
443+
cov = json.load(f)
444+
check_summaries(cov)
445+
446+
keys = [k for k in cov['files'] if 'target.py' in k]
447+
assert keys, f"target.py not in coverage: {list(cov['files'].keys())}"
448+
module_cov = cov['files'][keys[0]]
449+
450+
executed_branches = [tuple(b) for b in module_cov.get('executed_branches', [])]
451+
missing_branches = [tuple(b) for b in module_cov.get('missing_branches', [])]
452+
453+
# Only the true branch (line 2 -> line 3) was exercised; the else branch
454+
# (line 2 -> line 5) was never taken and must show up as missing -- a vacuous
455+
# "0 missing branches" here is exactly the false-100% bug being guarded against.
456+
assert (2, 3) in executed_branches, f"true branch not covered: {executed_branches}"
457+
assert (2, 5) in missing_branches, f"else branch should be missing: {missing_branches}"
458+
459+
222460
def test_xdist_fail_under_uses_merged_coverage(tmp_path):
223461
"""--fail-under must be checked against the merged (all-workers) coverage,
224462
not just the coordinator process' own view. Under xdist, actual test code

0 commit comments

Comments
 (0)