alembic-gauntlet for AI agents¶
One page holding everything a coding assistant needs to wire alembic-gauntlet into a test suite and read what it reports, plus a map of where the rest of the documentation keeps the details it leaves out. Give an agent this page rather than the whole site.
| Package | alembic-gauntlet on PyPI, import root alembic_gauntlet |
| Requires | Python 3.10+, PostgreSQL, SQLAlchemy 2, Alembic 1.8+, pytest 7+ |
| Install | pip install "alembic-gauntlet[asyncio]" · extras: asyncio (pytest-asyncio), testcontainers |
| Also install | an async PostgreSQL driver (asyncpg); pytest-asyncio comes with the asyncio extra and has to run in asyncio_mode = "auto" |
| Entry point | MigrationTestBase — inherit it, supply two fixtures, get seven tests |
| Pytest plugin | alembic_gauntlet.fixtures is registered under pytest11, so alembic_config and migration_engine exist with no import and no conftest entry |
| Async / sync | everything that touches the database is a coroutine over an AsyncEngine; the naming, diff and validation helpers are ordinary sync functions |
| Source | https://github.com/bedrock-python/alembic-gauntlet |
How to read this page¶
Every page of this site is also served as raw Markdown at its own URL with .md in place
of the trailing slash — this page is /agents.md, the quick start is
/guide/quickstart.md — so anything the map below points at can be fetched as plain text
rather than scraped out of HTML. The Copy page control at the top of a page does the
same thing for a human with a chat window open. The one exception is the API reference:
its Markdown is a list of instructions to a docstring renderer rather than the API, so it
carries neither the control nor a .md twin — read it as HTML, or read the docstrings in
the source.
Top to bottom before writing code. Rules that hold or break the code is the section correctness lives in — those are the things the library will not save you from. Every name used below exists in this version; if you need something not listed here, fetch the page the documentation map points at rather than guessing a method that sounds plausible.
Scope¶
It does run your real Alembic history against a real PostgreSQL database, inside a throwaway schema, as seven pytest tests you inherit: every revision up and back down one step at a time, a full downgrade to base, an autogenerate diff of the migrated database against your ORM metadata, a name-by-name comparison of its CHECK constraints, a value-by-value comparison of its enum types, a single-head check, and a naming check over every index, foreign key, check, unique and primary key constraint. The pieces underneath — upgrade, downgrade, current revision, all revisions, an isolated schema, the two comparisons — are public, so you can write your own checks with them.
It does not create the database, write your env.py, or run migrations anywhere but a
test. It never shells out to the alembic command; it drives alembic.command in-process
on a connection it injects. There is no sync API and no non-PostgreSQL support: schema
isolation, the reserved-word check and the constraint inspection are PostgreSQL. It seeds
no data, asserts nothing about your data, and cleans up nothing outside the schema it
created.
Mental model¶
Five nouns and one contract.
- Two fixtures are yours.
migration_db_url— an async DSN to a live PostgreSQL — andorm_metadata— theMetaDatayour models hang off. Everything else has a default. alembic_configcomes from the plugin:Config("alembic.ini")read from the process working directory, withscript_locationdefaulting tomigrationswhen the ini leaves it empty. Override the fixture when your ini lives elsewhere.migration_enginecomes from the plugin:create_async_engine(migration_db_url, poolclass=NullPool), function-scoped, disposed after each test.NullPoolis the point — no connection is reused between tests, which is what makes parallel runs safe.isolated_migration_schemacomes fromMigrationSchemaMixin: a freshtest_mig_<8 hex chars>schema created on its own engine, yielded to the test, then dropped withCASCADE. One schema per test.- The env.py contract is the half you own. Before calling
alembic.command.upgrade/downgrade, the runner setsconfig.attributes["connection"](a live syncConnection, already insideengine.begin()) andconfig.attributes["target_schema"](the isolated schema name), and pops both in afinally. Yourenv.pymust use the injected connection when it is there, and must put the version table and thesearch_pathintarget_schema. Anenv.pythat ignores them migrates the wrong database in the wrong schema and every test above becomes theatre. See Configuring env.py. MigrationTestBaseisMigrationSchemaMixin+MigrationConsistencyMixin+MigrationNamingMixin. Inherit the mixins directly when you want fewer than seven tests;MigrationSchemaMixinhas to be among them, because the other two requestisolated_migration_schema.
Wiring¶
tests/conftest.py — a container for the database, or your own URL:
# either: a managed PostgreSQL 17 container (extra: testcontainers)
from alembic_gauntlet.contrib.testcontainers import migration_db_url # noqa: F401
# or: your own, session-scoped
import pytest
@pytest.fixture(scope="session")
def migration_db_url() -> str:
return "postgresql+asyncpg://postgres:postgres@localhost:5432/test_db"
tests/test_migrations.py:
import pytest
from alembic.config import Config
from sqlalchemy import MetaData
from alembic_gauntlet import MigrationTestBase
from myapp.db import Base
@pytest.mark.integration
class TestMigrations(MigrationTestBase):
@pytest.fixture
def orm_metadata(self) -> MetaData:
return Base.metadata
@pytest.fixture
def alembic_config(self) -> Config: # only when alembic.ini is not in the CWD
config = Config("myapp/alembic.ini")
config.set_main_option("script_location", "myapp/alembic")
return config
pyproject.toml — the tests and fixtures are async def with no marker, so auto mode is
not optional:
That is the whole integration: seven tests, named in the table below, collected from the base class.
The API¶
Root exports — from alembic_gauntlet import ...¶
| Name | Kind | Signature and result |
|---|---|---|
MigrationTestBase |
class | the base class carrying all seven tests |
run_alembic_upgrade |
coroutine | (engine, alembic_config, target_schema="public", revision="head") -> None |
run_alembic_downgrade |
coroutine | (engine, alembic_config, target_schema="public", revision="base") -> None |
get_current_revision |
coroutine | (engine, target_schema="public") -> str | None — None means base |
get_all_revisions |
sync | (alembic_config) -> list[str], base → head |
create_isolated_migration_schema |
async generator | (migration_db_url) -> AsyncGenerator[str, None], one schema, dropped on close |
SchemaValidationError and four subclasses |
exceptions | see Errors |
engine is the first positional argument of every runner, and alembic_config the
second. Both target_schema and revision are ordinary positional-or-keyword arguments;
pass them by name.
alembic_gauntlet.testing¶
| Name | What it carries |
|---|---|
MigrationTestBase |
the three mixins, plus migration_diff_ignore_tables: ClassVar[list[str]] = [] |
MigrationSchemaMixin |
the isolated_migration_schema fixture, and nothing else |
MigrationConsistencyMixin |
test_stairway_upgrade_downgrade, test_migrations_up_to_date, test_check_constraints_match, test_enum_values_match, test_single_head_revision, test_downgrade_all_the_way, and migration_diff_compare_server_default: ClassVar[bool] = False |
MigrationNamingMixin |
test_naming_conventions, the ten allowed_* class attributes and the rule resolution |
MigrationDiff |
type alias list[tuple[MigrateOperation, ...]] — what compare_metadata() returns |
Fixtures¶
| Fixture | Provided by | Scope | Value |
|---|---|---|---|
migration_db_url |
you | yours, session is usual | async DSN to a live PostgreSQL |
orm_metadata |
you | yours | MetaData of your models |
migration_db_url |
alembic_gauntlet.contrib.testcontainers, imported into a conftest |
session | starts postgres:17-alpine, yields its DSN rewritten to postgresql+asyncpg:// |
alembic_config |
plugin (alembic_gauntlet.fixtures) |
function | Config("alembic.ini") from the CWD; FileNotFoundError if it is not there |
migration_engine |
plugin | function | AsyncEngine with NullPool, disposed afterwards |
isolated_migration_schema |
MigrationSchemaMixin |
function | test_mig_<8 hex chars>, dropped with CASCADE |
The seven tests¶
| Test | Requests | Asserts |
|---|---|---|
test_stairway_upgrade_downgrade |
config, engine, schema | for each revision base → head: upgrade to it, current revision matches, downgrade one step, current revision matches the step below, upgrade back |
test_migrations_up_to_date |
config, engine, schema, orm_metadata |
after a full upgrade, compare_metadata() against your metadata is empty once ignored tables are filtered out; server defaults take part only when migration_diff_compare_server_default is on |
test_check_constraints_match |
config, engine, schema, orm_metadata |
after a full upgrade, every named CHECK constraint in your metadata exists under the name the DDL would give it, and a table whose model names all of its check constraints has no others |
test_enum_values_match |
config, engine, schema, orm_metadata |
after a full upgrade, every native, named Enum column's values equal the database type's labels, in order |
test_single_head_revision |
config | ScriptDirectory.get_revisions("heads") has exactly one entry |
test_downgrade_all_the_way |
config, engine, schema | upgrade to head, then step down through every revision to base; the final current revision is None |
test_naming_conventions |
config, engine, schema, orm_metadata |
after a full upgrade, every index, foreign key, check, unique and primary key name in the schema matches the resolved rules |
The stairway and downgrade tests call pytest.skip("No migrations found.") when the
history is empty. Failures are plain assert failures with the offending name in the
message — there is no custom exception for a failed check.
Class attributes¶
| Attribute | Default | Effect |
|---|---|---|
migration_diff_ignore_tables |
[] |
added to DEFAULT_IGNORE_TABLES ({"alembic_version"}) for the diff test, and drops those tables from the check constraint, enum and naming tests |
migration_diff_compare_server_default |
False |
passes compare_server_default to Alembic in the diff test; rule 9 below says what agrees and what is reported |
allowed_index_prefixes |
["idx_", "uq_"] |
index names |
allowed_index_suffixes |
["_idx", "_pkey", "_key"] |
index names, one optional trailing digit tolerated (users_pkey1) |
allowed_fk_prefixes / allowed_fk_suffixes |
["fk_"] / ["_fkey"] |
foreign key constraint names |
allowed_check_prefixes / allowed_check_suffixes |
["chk_"] / [] |
check constraint names |
allowed_uq_prefixes / allowed_uq_suffixes |
["uq_"] / [] |
unique constraint names |
allowed_pk_prefixes / allowed_pk_suffixes |
["pk_"] / ["_pkey"] |
primary key constraint names |
Rules resolve in three layers, last wins: the defaults above, then whatever
rules_from_metadata(orm_metadata) extracts from orm_metadata.naming_convention, then
attributes set explicitly on your class. Layer two takes the literal text before the first
%( as a prefix and the literal text after the last )s as a suffix, replaces a category
only when it found one, and ignores SQLAlchemy's own built-in convention
({"ix": "ix_%(column_0_label)s"} alone). A uq template also joins the index rules,
because PostgreSQL implements a unique constraint as an index.
Helpers below the fixtures¶
| Import | Signature | Use |
|---|---|---|
alembic_gauntlet.utils.naming.fetch_table_naming_results |
(sync_conn, schema) -> dict[str, TableNamingResults] |
sync; alembic_version always excluded |
alembic_gauntlet.utils.naming.validate_naming_results |
(results, allowed_index_prefixes, …, allowed_pk_suffixes) -> None |
asserts; every allowed_* argument is required |
alembic_gauntlet.utils.diff.is_ignored_diff_item |
(diff_item, ignore_tables) -> bool |
filters remove_table and remove_index items only |
alembic_gauntlet.utils.diff.DEFAULT_IGNORE_TABLES |
frozenset({"alembic_version"}) |
the baseline of the diff filter |
alembic_gauntlet.utils.diff.compare_check_constraints |
(sync_conn, metadata, schema, ignore_tables=frozenset()) -> list[str] |
sync; one line per CHECK constraint that differs by name, empty when in sync |
alembic_gauntlet.utils.diff.compare_enums |
(sync_conn, metadata, schema, ignore_tables=frozenset()) -> list[str] |
sync; one line per enum type whose values differ, in order, empty when in sync |
alembic_gauntlet.utils.validation.validate_schema_name |
(name, connection=None) -> None |
format, 63-byte length, and reserved words when a connection is given |
alembic_gauntlet.utils.validation.get_pg_reserved_words |
(connection) -> set[str] |
reads pg_get_keywords() |
alembic_gauntlet.utils.convention.rules_from_metadata |
(metadata) -> NamingConventionRules |
the layer-two extraction, as a dataclass of ten lists |
TableNamingResults and ForeignKeyInfo are TypedDicts in alembic_gauntlet.utils.naming;
a result maps a table name to indexes, fks, check_constraints, unique_constraints
and pk_constraint.
Rules that hold or break the code¶
asyncio_mode = "auto". The inherited tests and the library's fixtures areasync defdeclared with plain@pytest.fixtureand noasynciomarker. Under pytest-asyncio's default strict mode the tests fail with async def functions are not natively supported and the fixtures arrive as unawaited generators. Auto mode is a requirement, not a preference, and pytest-asyncio is not a hard dependency of this package — take it from theasyncioextra or install it yourself.- The DSN must be async.
create_async_engineis called onmigration_db_urlverbatim, sopostgresql+asyncpg://…(or another async driver) and the driver installed. A barepostgresql://…selects psycopg2 and never reaches the database:InvalidRequestError: The asyncio extension requires an async driverwhere psycopg2 is installed,ModuleNotFoundErrorwhere it is not. - Your
env.pydecides whether any of this is real. It must run onconfig.attributes["connection"]when that key is present, and must passversion_table_schema=target_schemaandSET LOCAL search_pathtoconfig.attributes["target_schema"]. Ignore either and the tests pass while migratingpublicon a connection nobody rolls back. UseSET LOCAL, never plainSET— plainSEToutlives the transaction and follows the connection back into the pool. - Migrations run inside one transaction. The runner opens
engine.begin()and handsalembic.commandthat connection, so anything that cannot run in a transaction block —CREATE INDEX CONCURRENTLY,ALTER TYPE … ADD VALUEon older servers — fails here even though it works in production. alembic.iniis read from the process working directory. The default fixture isConfig("alembic.ini")and raisesFileNotFoundErrorwith the absolute path when it is missing. Run pytest from the service root, or overridealembic_config.- The stairway test assumes a linear history. It steps to
revisions[i - 1]inwalk_revisions()order; a branched history makes those steps meaningless, which is whattest_single_head_revisionis there to catch first. - The isolated schema is dropped with
CASCADE. Everything a migration created inside it is gone at the end of the test — and nothing it created outside it is cleaned up at all. migration_diff_ignore_tablesonly silences removals in the diff test.is_ignored_diff_itemrecognisesremove_tableandremove_index, meaning tables in the database that your models do not know about. A table your models declare and the migrations never created is always reported, whatever you list. The check constraint, enum and naming tests skip the listed tables outright.- Server defaults are compared only on request.
compare_metadata()skips them unlesscompare_server_defaultis set, and the diff test passesmigration_diff_compare_server_default, which isFalse. With it on, Alembic's PostgreSQL comparison compares the two texts and, when they differ, asks the server whether the expressions are equal:text("true"),sa.true()and"true"all agree with a column defaulted totrue,func.now()agrees withnow()andCURRENT_TIMESTAMP,"0"with0,text("'{}'")with'{}'::jsonb, andgen_random_uuid()with itself. Reported: a different value, two different volatile functions (clock_timestamp()againstnow()), and a default present on one side only — a Python-sidedefault=in the model is not a server default. A serial or identity primary key is never compared. - CHECK constraints are compared by name, and only named ones.
test_check_constraints_matchresolves each metadata constraint to the name the DDL would give it — the convention applied, a deferredBoolean(create_constraint=True)name filled in, a name over 63 characters truncated — and compares that withget_check_constraints. Expressions are never compared; PostgreSQL rewritesamount > 0as(amount > (0)::numeric). An unnamed metadata constraint is skipped, and on its table the test also stops reporting database constraints the models lack, because PostgreSQL gave the unnamed one a name of its own. Name every check constraint; acktemplate with%(constraint_name)senforces that. Alembic's own name-based detection is a plugin that was on by default in 1.19.0 and 1.19.1 and is opt-in from 1.19.2; this test does not use it, and on those two versions the diff test reports named CHECK constraints as well. - Enum values are compared in order, per type the models use.
test_enum_values_matchreadsget_enums()and compares the labels withEnum.enumsas lists for every native, namedEnumcolumn, looking inEnum.schemawhen set and in the isolated schema otherwise. A type nothing references, a non-native enum and an unnamed one are not compared. A migration that adds a value withALTER TYPE … ADD VALUEhas to put it where the model has it. - A name passes on a prefix or a suffix. The checks are not per-object-type
exclusive and not anchored to your convention:
users_pkeypasses the primary key rule on_pkeyeven when you setallowed_pk_prefixes = ["pk_"], because the default suffix is still in the resolved set. Empty both lists for a category and nothing can pass it. - Layer two replaces, layer three overrides, and the walk stops at the mixin. An
explicit
allowed_*attribute counts when it is set on your class or an intermediate base; the MRO walk breaks atMigrationNamingMixin, so its own sentinel values never win. A convention template with no literal part —"%(table_name)s_%(column_0_name)s"— contributes nothing and leaves the defaults in place. - PostgreSQL only. Isolated schemas,
pg_get_keywords(),DROP SCHEMA … CASCADEand the constraint inspection are PostgreSQL. There is no SQLite or MySQL path, and acockroachdb+asyncpgURL is not a supported target. - Mixin fixtures are class fixtures.
isolated_migration_schemais defined onMigrationSchemaMixin, so it exists only inside a class that inherits it. A module-level test function cannot request it; callcreate_isolated_migration_schemainstead. create_isolated_migration_schemais an async generator, not a context manager. Drive it withasync for, or wrap it in your own fixture; it has no__aenter__, soasync withfails before the schema is ever created.- Keep
NullPoolif you replacemigration_engine. Function scope plusNullPoolis what keeps a schema-scopedsearch_pathfrom leaking into the next test and what makespytest -n autosafe. - Schema names are validated before they reach SQL.
validate_schema_nameruns on everytarget_schemathe runners are given, and rejects anything that is not a plain identifier, is longer than 63 characters, or is a PostgreSQL reserved word. Do not build schema names from unvalidated input and interpolate them yourself.
Common mistakes¶
# WRONG — a mixin that does not exist in this package
from alembic_gauntlet.contrib.testcontainers import TestcontainersDatabaseMixin
class TestMigrations(TestcontainersDatabaseMixin, MigrationTestBase): ...
# RIGHT — contrib ships one fixture; import it into a conftest
# tests/conftest.py
from alembic_gauntlet.contrib.testcontainers import migration_db_url # noqa: F401
# WRONG — config first, and a string where the schema goes
await run_alembic_upgrade(alembic_config, migration_engine, "head")
# RIGHT — engine first, and the schema named
await run_alembic_upgrade(
migration_engine, alembic_config, target_schema=isolated_migration_schema, revision="head"
)
# WRONG — awaiting a sync function, and a sync connection helper on an async one
revisions = await get_all_revisions(alembic_config)
results = await fetch_table_naming_results(conn, schema)
# RIGHT
revisions = get_all_revisions(alembic_config) # plain call, base -> head
async with migration_engine.connect() as conn:
results = await conn.run_sync(lambda sc: fetch_table_naming_results(sc, schema=schema))
# WRONG — a sync DSN, and strict-mode pytest-asyncio
def migration_db_url() -> str:
return "postgresql://postgres:postgres@localhost:5432/test_db"
# RIGHT — an async driver, and auto mode in pyproject.toml
def migration_db_url() -> str:
return "postgresql+asyncpg://postgres:postgres@localhost:5432/test_db"
# [tool.pytest.ini_options]
# asyncio_mode = "auto"
# WRONG — expecting the diff test to report a wrong server default
class TestMigrations(MigrationTestBase):
... # passes with is_active DEFAULT false where the model says true
# RIGHT — turn the comparison on; CHECK constraints and enum values have their own tests
class TestMigrations(MigrationTestBase):
migration_diff_compare_server_default = True
# WRONG — an unnamed CHECK constraint is never compared, and a migration that spells the
# conventional name out gets the convention applied to it again (chk_orders_chk_orders_…)
__table_args__ = (CheckConstraint("amount > 0"),)
op.create_table("orders", ..., sa.CheckConstraint("amount > 0", name="chk_orders_amount_positive"))
# RIGHT — name it in the model and let the convention resolve it; op.f() keeps a name as written
__table_args__ = (CheckConstraint("amount > 0", name="amount_positive"),) # chk_orders_amount_positive
op.create_table("orders", ..., sa.CheckConstraint("amount > 0", name=op.f("chk_orders_amount_positive")))
# WRONG — an env.py that builds its own engine and ignores the injected one
def run_migrations_online() -> None:
engine = create_engine(config.get_main_option("sqlalchemy.url"))
with engine.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
# RIGHT — use what the runner injected, in the schema it named
target_schema = config.attributes.get("target_schema") or os.getenv("MIGRATION_SCHEMA", "public")
connection = config.attributes.get("connection")
if connection is not None:
do_run_migrations(connection) # configures version_table_schema=target_schema
else:
asyncio.run(run_migrations_online())
Errors¶
Every exception derives from SchemaValidationError, and all of them come out of
validate_schema_name.
| Exception | Means |
|---|---|
SchemaValidationError |
the base — catch this to catch all four |
EmptySchemaNameError |
the name is "" |
InvalidSchemaNameError |
the name is not [A-Za-z_][A-Za-z0-9_]* |
SchemaNameTooLongError |
longer than PostgreSQL's 63-character identifier limit |
ReservedWordSchemaNameError |
the name is a PostgreSQL reserved word — only raised when a Connection was passed, since the list is read from pg_get_keywords() |
Everything else surfaces as what raised it: FileNotFoundError from the default
alembic_config, ImportError from the testcontainers fixture without the extra,
Alembic's own CommandError for a bad revision, SQLAlchemy's InvalidRequestError for a
sync DSN, and AssertionError for a check that failed.
Documentation map¶
Fetch a page when the task is the one named beside it.
| Page | Read it when |
|---|---|
| Home | the one-paragraph pitch and the shortest possible example |
| Quick start | setting the suite up step by step, and what each of the seven tests catches |
| Configuration | every fixture and class attribute, with worked examples and the naming resolution order |
| Configuring env.py | the connection / target_schema contract, SET LOCAL, advisory locks, include_object filtering |
| Advanced | composing mixins by hand, custom checks on the isolated schema, partitioned tables, CI shapes |
| API reference | an exact signature or docstring — HTML only, see above |
| Changelog | what changed between versions |