Skip to content

Commit ca9960a

Browse files
emerybergerclaude
andcommitted
Generalize dynamic file coverage to wrap spec_from_file_location()
Instead of wrapping Alembic's load_module_py specifically, wrap importlib.util.spec_from_file_location() which is the standard way for tools to dynamically load Python files. This provides coverage for Alembic and any other tool using this mechanism. Addresses PR review feedback from @Ar-b-ra. Co-Authored-By: Claude (global.anthropic.claude-opus-4-5-20251101-v1:0) <noreply@anthropic.com>
1 parent 8884dbd commit ca9960a

4 files changed

Lines changed: 143 additions & 37 deletions

File tree

src/slipcover/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
from .version import __version__
22
from .slipcover import Slipcover, merge_coverage, print_coverage, print_xml
3-
from .importer import FileMatcher, ImportManager, wrap_pytest, wrap_alembic
3+
from .importer import FileMatcher, ImportManager, wrap_pytest, wrap_spec_from_file_location
44
from .fuzz import wrap_function

src/slipcover/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ def main():
234234
if not args.dont_wrap_pytest:
235235
sc.wrap_pytest(sci, file_matcher)
236236

237-
sc.wrap_alembic(sci, file_matcher)
237+
sc.wrap_spec_from_file_location(sci, file_matcher)
238238

239239
# Set environment variables for pytest-xdist workers to pick up
240240
if args.module and args.module[0] == 'pytest':

src/slipcover/importer.py

Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -281,43 +281,41 @@ def write_pyc(*args, **kwargs):
281281
pyrewrite.rewrite_asserts = rewrite_asserts_wrapper
282282

283283

284-
def wrap_alembic(sci: Slipcover, file_matcher: FileMatcher):
285-
"""Wraps Alembic's module loading to instrument migration files."""
286-
try:
287-
import alembic.util.pyfiles as pyfiles
288-
except ModuleNotFoundError:
289-
return
284+
def wrap_spec_from_file_location(sci: Slipcover, file_matcher: FileMatcher):
285+
"""Wraps importlib.util.spec_from_file_location() to instrument dynamically loaded files.
290286
287+
This provides coverage for tools like Alembic, and any other code that uses
288+
spec_from_file_location() to dynamically load Python files.
289+
"""
291290
import importlib.util
292-
import ast
293-
from importlib import machinery
294291

295-
orig_load_module_py = pyfiles.load_module_py
292+
orig_spec_from_file_location = importlib.util.spec_from_file_location
293+
294+
def spec_from_file_location_wrapper(name, location=None, *, loader=None, submodule_search_locations=None):
295+
spec = orig_spec_from_file_location(
296+
name, location, loader=loader,
297+
submodule_search_locations=submodule_search_locations
298+
)
296299

297-
def load_module_py_wrapper(module_id, path):
298-
path = Path(path)
299-
if not file_matcher.matches(path):
300-
return orig_load_module_py(module_id, path)
300+
if spec is None or spec.loader is None:
301+
return spec
301302

302-
# Load and instrument the module
303-
spec = importlib.util.spec_from_file_location(module_id, path)
304-
assert spec and spec.loader
305-
module = importlib.util.module_from_spec(spec)
303+
# Skip pytest's assertion rewriting hook - wrap_pytest handles those.
304+
# AssertionRewritingHook doesn't have get_code() method.
305+
loader_type = type(spec.loader).__name__
306+
if loader_type == 'AssertionRewritingHook':
307+
return spec
306308

307-
# Get the code object - handle branch pre-instrumentation if needed
308-
if sci.branch and isinstance(spec.loader, machinery.SourceFileLoader) and path.exists():
309-
t = br.preinstrument(ast.parse(path.read_bytes()))
310-
code = compile(t, str(path), "exec")
311-
else:
312-
code = spec.loader.get_code(module_id) # type: ignore[attr-defined]
309+
# Skip extension file loaders - can't instrument native extensions
310+
if isinstance(spec.loader, machinery.ExtensionFileLoader):
311+
return spec
313312

314-
if code is not None:
315-
code = sci.instrument(code)
316-
exec(code, module.__dict__)
317-
else:
318-
# Fallback to original if we can't get the code
319-
spec.loader.exec_module(module) # type: ignore[union-attr]
313+
# Check if this file should be instrumented
314+
origin = spec.origin or (str(location) if location else None)
315+
if origin and file_matcher.matches(origin):
316+
# Wrap the loader with our instrumented loader
317+
spec.loader = SlipcoverLoader(sci, spec.loader, origin)
320318

321-
return module
319+
return spec
322320

323-
pyfiles.load_module_py = load_module_py_wrapper
321+
importlib.util.spec_from_file_location = spec_from_file_location_wrapper

tests/test_importer.py

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,65 @@ def test_run_script_argv_is_str(tmp_path):
293293

294294

295295
@pytest.mark.skipif(sys.platform == 'win32', reason='Fails due to weird PermissionError')
296-
def test_wrap_alembic(tmp_path, monkeypatch):
297-
"""Test that Alembic migrations are covered when using wrap_alembic."""
296+
def test_wrap_spec_from_file_location(tmp_path, monkeypatch):
297+
"""Test that files loaded via spec_from_file_location are covered."""
298+
import json
299+
300+
# Create a Python file to be loaded dynamically
301+
dynamic_module = tmp_path / "dynamic_module.py"
302+
dynamic_module.write_text('''
303+
x = 1 # line 2
304+
y = 2 # line 3
305+
z = x + y # line 4
306+
''')
307+
308+
# Create a script that loads the module via spec_from_file_location
309+
script = tmp_path / "main_script.py"
310+
script.write_text(f"""
311+
import importlib.util
312+
spec = importlib.util.spec_from_file_location("dynamic_module", "{dynamic_module}")
313+
module = importlib.util.module_from_spec(spec)
314+
spec.loader.exec_module(module)
315+
assert module.z == 3
316+
""")
317+
318+
monkeypatch.chdir(tmp_path)
319+
320+
out = tmp_path / "coverage.json"
321+
result = subprocess.run(
322+
[sys.executable, "-m", "slipcover", "--json", "--out", str(out),
323+
"--source", str(tmp_path), str(script)],
324+
capture_output=True,
325+
text=True
326+
)
327+
328+
assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}"
329+
330+
with out.open() as f:
331+
cov = json.load(f)
332+
333+
# Check that the dynamically loaded file was covered
334+
dynamic_files = [k for k in cov['files'].keys() if 'dynamic_module.py' in k]
335+
assert dynamic_files, f"Dynamic module not in coverage: {list(cov['files'].keys())}"
336+
337+
# Check that lines were executed
338+
file_cov = cov['files'][dynamic_files[0]]
339+
executed_lines = file_cov['executed_lines']
340+
assert 2 in executed_lines and 3 in executed_lines and 4 in executed_lines, \
341+
f"Lines not executed, got: {executed_lines}"
342+
343+
344+
try:
345+
import alembic
346+
HAS_ALEMBIC = True
347+
except ImportError:
348+
HAS_ALEMBIC = False
349+
350+
351+
@pytest.mark.skipif(sys.platform == 'win32', reason='Fails due to weird PermissionError')
352+
@pytest.mark.skipif(not HAS_ALEMBIC, reason='Alembic not installed')
353+
def test_wrap_spec_from_file_location_alembic(tmp_path, monkeypatch):
354+
"""Test that Alembic migrations are covered (integration test for spec_from_file_location)."""
298355
import json
299356

300357
# Create a minimal alembic setup
@@ -394,8 +451,59 @@ def downgrade():
394451

395452

396453
@pytest.mark.skipif(sys.platform == 'win32', reason='Fails due to weird PermissionError')
397-
def test_wrap_alembic_with_branch(tmp_path, monkeypatch):
398-
"""Test that Alembic migrations are covered with branch coverage enabled."""
454+
def test_wrap_spec_from_file_location_with_branch(tmp_path, monkeypatch):
455+
"""Test that files loaded via spec_from_file_location get branch coverage."""
456+
import json
457+
458+
# Create a Python file with a branch to be loaded dynamically
459+
dynamic_module = tmp_path / "dynamic_module.py"
460+
dynamic_module.write_text('''
461+
x = 1
462+
if x > 0: # branch
463+
y = 2
464+
else:
465+
y = 3
466+
z = y
467+
''')
468+
469+
# Create a script that loads the module via spec_from_file_location
470+
script = tmp_path / "main_script.py"
471+
script.write_text(f"""
472+
import importlib.util
473+
spec = importlib.util.spec_from_file_location("dynamic_module", "{dynamic_module}")
474+
module = importlib.util.module_from_spec(spec)
475+
spec.loader.exec_module(module)
476+
assert module.z == 2
477+
""")
478+
479+
monkeypatch.chdir(tmp_path)
480+
481+
out = tmp_path / "coverage.json"
482+
result = subprocess.run(
483+
[sys.executable, "-m", "slipcover", "--branch", "--json", "--out", str(out),
484+
"--source", str(tmp_path), str(script)],
485+
capture_output=True,
486+
text=True
487+
)
488+
489+
assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}"
490+
491+
with out.open() as f:
492+
cov = json.load(f)
493+
494+
# Check that the dynamically loaded file was covered
495+
dynamic_files = [k for k in cov['files'].keys() if 'dynamic_module.py' in k]
496+
assert dynamic_files, f"Dynamic module not in coverage: {list(cov['files'].keys())}"
497+
498+
# Check that branch info is present
499+
file_cov = cov['files'][dynamic_files[0]]
500+
assert 'executed_branches' in file_cov, "Branch coverage not recorded"
501+
502+
503+
@pytest.mark.skipif(sys.platform == 'win32', reason='Fails due to weird PermissionError')
504+
@pytest.mark.skipif(not HAS_ALEMBIC, reason='Alembic not installed')
505+
def test_wrap_spec_from_file_location_with_branch_alembic(tmp_path, monkeypatch):
506+
"""Test that Alembic migrations get branch coverage (integration test)."""
399507
import json
400508

401509
# Create a minimal alembic setup

0 commit comments

Comments
 (0)