Skip to content

Commit 8884dbd

Browse files
emerybergerclaude
andcommitted
Add coverage support for Alembic migrations (Issue #76)
Alembic loads migration files using importlib.util.spec_from_file_location() which bypasses Slipcover's sys.meta_path import hooks. This adds wrap_alembic() to intercept Alembic's load_module_py function and instrument migration code before execution, similar to the existing wrap_pytest() approach. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 03f0468 commit 8884dbd

4 files changed

Lines changed: 247 additions & 1 deletion

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
3+
from .importer import FileMatcher, ImportManager, wrap_pytest, wrap_alembic
44
from .fuzz import wrap_function

src/slipcover/__main__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,8 @@ def main():
234234
if not args.dont_wrap_pytest:
235235
sc.wrap_pytest(sci, file_matcher)
236236

237+
sc.wrap_alembic(sci, file_matcher)
238+
237239
# Set environment variables for pytest-xdist workers to pick up
238240
if args.module and args.module[0] == 'pytest':
239241
os.environ["SLIPCOVER_ENABLED"] = "1"

src/slipcover/importer.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,3 +279,45 @@ def write_pyc(*args, **kwargs):
279279
pyrewrite._read_pyc = read_pyc
280280
pyrewrite._write_pyc = write_pyc
281281
pyrewrite.rewrite_asserts = rewrite_asserts_wrapper
282+
283+
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
290+
291+
import importlib.util
292+
import ast
293+
from importlib import machinery
294+
295+
orig_load_module_py = pyfiles.load_module_py
296+
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)
301+
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)
306+
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]
313+
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]
320+
321+
return module
322+
323+
pyfiles.load_module_py = load_module_py_wrapper

tests/test_importer.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,3 +290,205 @@ def test_run_script_argv_is_str(tmp_path):
290290
""")
291291

292292
subprocess.run([sys.executable, "-m", "slipcover", "--silent", cmdfile], check=True)
293+
294+
295+
@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."""
298+
import json
299+
300+
# Create a minimal alembic setup
301+
migrations_dir = tmp_path / "migrations"
302+
versions_dir = migrations_dir / "versions"
303+
versions_dir.mkdir(parents=True)
304+
305+
# Create alembic.ini
306+
alembic_ini = tmp_path / "alembic.ini"
307+
alembic_ini.write_text(f"""
308+
[alembic]
309+
script_location = {migrations_dir}
310+
sqlalchemy.url = sqlite:///:memory:
311+
""")
312+
313+
# Create env.py
314+
env_py = migrations_dir / "env.py"
315+
env_py.write_text("""
316+
from alembic import context
317+
318+
def run_migrations_offline():
319+
context.configure(url="sqlite:///:memory:", literal_binds=True)
320+
with context.begin_transaction():
321+
context.run_migrations()
322+
323+
def run_migrations_online():
324+
from sqlalchemy import create_engine
325+
connectable = create_engine("sqlite:///:memory:")
326+
with connectable.connect() as connection:
327+
context.configure(connection=connection)
328+
with context.begin_transaction():
329+
context.run_migrations()
330+
331+
if context.is_offline_mode():
332+
run_migrations_offline()
333+
else:
334+
run_migrations_online()
335+
""")
336+
337+
# Create script.py.mako (required by alembic)
338+
script_mako = migrations_dir / "script.py.mako"
339+
script_mako.write_text("")
340+
341+
# Create a migration file
342+
migration_file = versions_dir / "001_test_migration.py"
343+
migration_file.write_text('''
344+
"""test migration"""
345+
revision = '001'
346+
down_revision = None
347+
348+
def upgrade():
349+
x = 1 # line 8
350+
y = 2 # line 9
351+
352+
def downgrade():
353+
pass # line 12
354+
''')
355+
356+
# Create a script that runs the alembic migration
357+
script = tmp_path / "run_migration.py"
358+
script.write_text(f"""
359+
import sys
360+
sys.path.insert(0, '{tmp_path}')
361+
from alembic.config import Config
362+
from alembic import command
363+
364+
alembic_cfg = Config('{alembic_ini}')
365+
command.upgrade(alembic_cfg, 'head')
366+
""")
367+
368+
monkeypatch.chdir(tmp_path)
369+
370+
out = tmp_path / "coverage.json"
371+
result = subprocess.run(
372+
[sys.executable, "-m", "slipcover", "--json", "--out", str(out),
373+
"--source", str(versions_dir), str(script)],
374+
capture_output=True,
375+
text=True
376+
)
377+
378+
# The script should complete (may have warnings, but not crash)
379+
assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}"
380+
381+
with out.open() as f:
382+
cov = json.load(f)
383+
384+
# Check that the migration file was covered
385+
# The path might be stored as relative or absolute depending on the working directory
386+
migration_files = [k for k in cov['files'].keys() if '001_test_migration.py' in k]
387+
assert migration_files, f"Migration file not in coverage: {list(cov['files'].keys())}"
388+
389+
# Check that lines in the upgrade function were executed
390+
file_cov = cov['files'][migration_files[0]]
391+
executed_lines = file_cov['executed_lines']
392+
# Lines 7 and 8 are inside upgrade() function (x=1, y=2)
393+
assert 7 in executed_lines or 8 in executed_lines, f"upgrade() lines not executed, executed: {executed_lines}"
394+
395+
396+
@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."""
399+
import json
400+
401+
# Create a minimal alembic setup
402+
migrations_dir = tmp_path / "migrations"
403+
versions_dir = migrations_dir / "versions"
404+
versions_dir.mkdir(parents=True)
405+
406+
# Create alembic.ini
407+
alembic_ini = tmp_path / "alembic.ini"
408+
alembic_ini.write_text(f"""
409+
[alembic]
410+
script_location = {migrations_dir}
411+
sqlalchemy.url = sqlite:///:memory:
412+
""")
413+
414+
# Create env.py
415+
env_py = migrations_dir / "env.py"
416+
env_py.write_text("""
417+
from alembic import context
418+
419+
def run_migrations_offline():
420+
context.configure(url="sqlite:///:memory:", literal_binds=True)
421+
with context.begin_transaction():
422+
context.run_migrations()
423+
424+
def run_migrations_online():
425+
from sqlalchemy import create_engine
426+
connectable = create_engine("sqlite:///:memory:")
427+
with connectable.connect() as connection:
428+
context.configure(connection=connection)
429+
with context.begin_transaction():
430+
context.run_migrations()
431+
432+
if context.is_offline_mode():
433+
run_migrations_offline()
434+
else:
435+
run_migrations_online()
436+
""")
437+
438+
# Create script.py.mako (required by alembic)
439+
script_mako = migrations_dir / "script.py.mako"
440+
script_mako.write_text("")
441+
442+
# Create a migration file with a branch
443+
migration_file = versions_dir / "001_test_migration.py"
444+
migration_file.write_text('''
445+
"""test migration"""
446+
revision = '001'
447+
down_revision = None
448+
449+
def upgrade():
450+
x = 1
451+
if x > 0: # branch
452+
y = 2
453+
else:
454+
y = 3
455+
456+
def downgrade():
457+
pass
458+
''')
459+
460+
# Create a script that runs the alembic migration
461+
script = tmp_path / "run_migration.py"
462+
script.write_text(f"""
463+
import sys
464+
sys.path.insert(0, '{tmp_path}')
465+
from alembic.config import Config
466+
from alembic import command
467+
468+
alembic_cfg = Config('{alembic_ini}')
469+
command.upgrade(alembic_cfg, 'head')
470+
""")
471+
472+
monkeypatch.chdir(tmp_path)
473+
474+
out = tmp_path / "coverage.json"
475+
result = subprocess.run(
476+
[sys.executable, "-m", "slipcover", "--branch", "--json", "--out", str(out),
477+
"--source", str(versions_dir), str(script)],
478+
capture_output=True,
479+
text=True
480+
)
481+
482+
# The script should complete (may have warnings, but not crash)
483+
assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}"
484+
485+
with out.open() as f:
486+
cov = json.load(f)
487+
488+
# Check that the migration file was covered
489+
migration_files = [k for k in cov['files'].keys() if '001_test_migration.py' in k]
490+
assert migration_files, f"Migration file not in coverage: {list(cov['files'].keys())}"
491+
492+
# Check that branch info is present
493+
file_cov = cov['files'][migration_files[0]]
494+
assert 'executed_branches' in file_cov, "Branch coverage not recorded"

0 commit comments

Comments
 (0)