Internals¶
Architecture overview for contributors and advanced users.
Package Structure¶
src/delembic/
├── __init__.py # public API: DataMigration, __version__
├── migration.py # DataMigration ABC
├── registry.py # dynamic class loader
├── dag.py # topological sort (Kahn's algorithm)
├── db.py # SQLAlchemy table definitions, record_result
├── executor.py # run_upgrade, dual-connection architecture
├── config.py # Config, find_config
├── alembic_compat.py # Alembic integration (lazy imports)
└── cli.py # Click commands
Execution Model¶
Dual-Connection Architecture¶
run_upgrade opens two separate database connections per migration:
meta_conn ─── delembic_version, delembic_run_history ─── always committed
work_conn ─── your migration SQL ─── rolled back on failure
This solves a real production problem: if a single connection is used, conn.rollback() on migration failure would also roll back the failure record in delembic_version. With dual connections, the audit trail always survives.
with engine.connect() as meta_conn:
# reads applied set, writes audit records
with engine.connect() as work_conn:
try:
instance.upgrade(work_conn)
instance.validate(work_conn)
work_conn.commit()
record_result(meta_conn, ..., "success")
meta_conn.commit()
except Exception:
work_conn.rollback()
record_result(meta_conn, ..., "failed")
meta_conn.commit()
raise SystemExit(1)
Retry Semantics¶
record_result does DELETE + INSERT on delembic_version (not upsert):
conn.execute(sa.delete(version_table).where(version_table.c.revision == revision))
conn.execute(sa.insert(version_table).values(...))
conn.execute(sa.insert(history_table).values(...)) # always append
This means:
delembic_versionalways reflects the latest outcomedelembic_run_historyis append-only — full audit of every attempt
DAG Module¶
dag.py implements Kahn’s algorithm:
Build adjacency list and in-degree map from
depends_onExternal deps (IDs not in the migrations dict) are excluded from the graph — they don’t affect sort order, only runtime checks
Process zero-in-degree queue
Any remaining nodes after full processing →
CycleError
def topological_sort(migrations: dict[str, Type[DataMigration]]) -> list[str]:
...
# external deps silently skipped — treated as satisfied by the sort
in_degree = {rev: 0 for rev in migrations}
for rev, cls in migrations.items():
for dep in cls.depends_on:
if dep in migrations: # only internal deps affect the graph
in_degree[rev] += 1
graph[dep].append(rev)
...
Registry Module¶
registry.py uses importlib to load migration classes dynamically:
Glob
versions/*.py, skip files starting with_importlib.util.spec_from_file_location+module_from_spec+exec_moduleInspect all module attributes; collect subclasses of
DataMigrationwith arevisionattributeReturn
{revision: class}dict
Config Resolution¶
find_config() walks up from Path.cwd() looking for delembic.ini. This means you can run delembic commands from any subdirectory.
alembic_config is resolved as an absolute path relative to delembic.ini’s directory — not relative to cwd.
Alembic Compatibility Module¶
alembic_compat.py uses lazy imports:
def get_current_heads(conn):
try:
from alembic.runtime.migration import MigrationContext
except ImportError:
raise ImportError("alembic is not installed. Run: pip install alembic")
...
This keeps alembic as an optional dependency — the package works without it unless Alembic integration is actually used.
get_alembic_applied_revisions walks the full ancestry of current heads using ScriptDirectory.iterate_revisions(head, "base"). This returns all applied revisions, not just the tip — so depends_on = ["old_revision"] passes even after Alembic has advanced further.
Testing¶
pip install -e ".[dev]"
pytest
Tests use:
pytestwithCliRunnerfor CLI testssqlite:///:memory:for most DB testsFile-based SQLite (
sqlite:///tmp/test.db) where cross-connection state persistence is needed (failure recording tests)unittest.mock.patchfor Alembic integration tests (no real Alembic needed)tmp_pathfixture +runner.isolated_filesystem()for CLI tests that read/write files