Source code for trader.persistence.migrations
"""Alembic integration: stamping fresh databases, and upgrading existing ones.
**Why `create_all` still exists alongside migrations.** `init_db` builds a new
database with `create_all` rather than by replaying every migration, because
the test suite creates over a hundred throwaway databases and replaying the
history for each one costs far more than it proves. The risk that buys — the
two definitions drifting apart — is closed by
`tests/persistence/test_migrations.py`, which fails if the models and the
migrations disagree. That is this project's equivalent of Django's
`makemigrations --check`.
A database built by `create_all` is stamped at head immediately, so Alembic
knows its tables already exist and the next `upgrade` applies only genuinely
new revisions.
"""
from pathlib import Path
from alembic import command
from alembic.config import Config
__all__ = ["alembic_config", "current_revision", "stamp_head", "upgrade_to_head"]
#: Repo root: this file is `src/trader/persistence/migrations.py`.
_PROJECT_ROOT = Path(__file__).resolve().parents[3]
_ALEMBIC_INI = _PROJECT_ROOT / "alembic.ini"
[docs]
def alembic_config(database_url: str) -> Config:
"""An Alembic config pointed at `database_url`.
The URL is set here rather than read from `alembic.ini`, for the same
reason `env.py` resolves it from `Settings`: one place decides where the
database lives.
"""
config = Config(str(_ALEMBIC_INI))
config.set_main_option("script_location", str(_PROJECT_ROOT / "migrations"))
config.set_main_option("sqlalchemy.url", database_url)
return config
[docs]
def stamp_head(database_url: str) -> None:
"""Record `database_url` as being at the latest revision, creating nothing.
Used after `create_all` on a fresh database, and it is also how an existing
database adopts Alembic. Running `upgrade` on either would fail on the
first `CREATE TABLE`, because the tables are already there.
"""
command.stamp(alembic_config(database_url), "head")
[docs]
def upgrade_to_head(database_url: str) -> None:
"""Apply every migration `database_url` has not yet seen."""
command.upgrade(alembic_config(database_url), "head")
def head_revision(database_url: str) -> str | None:
"""The revision the migration scripts define as head.
Takes a URL only because `alembic_config` does; the answer depends on the
scripts on disk, not on the database. Used to refuse to start an
unattended daemon against a schema the code no longer matches — a
half-migrated database that a human would notice immediately can otherwise
fail quietly every fifteen minutes for a weekend.
"""
from alembic.script import ScriptDirectory
return ScriptDirectory.from_config(alembic_config(database_url)).get_current_head()
[docs]
def current_revision(database_url: str) -> str | None:
"""The revision `database_url` is stamped at, or `None` if unstamped."""
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine
engine = create_engine(database_url)
try:
with engine.connect() as connection:
return MigrationContext.configure(connection).get_current_revision()
finally:
engine.dispose()