3

The existing posts didn't provide a useful answer to me.

I'm trying to run asynchronous database tests using Pytest (db is Postgres with asyncpg), and I'd like to initialize my database using my Alembic migrations so that I can verify that they work properly in the meantime.

My first attempt was this:

@pytest.fixture(scope="session")
async def tables():
    """Initialize a database before the tests, and then tear it down again"""
    alembic_config: config.Config = config.Config('alembic.ini')
    command.upgrade(alembic_config, "head")
    yield
    command.downgrade(alembic_config, "base")

which didn't actually do anything at all (migrations were never applied to the database, tables not created).

Both Alembic's documentation & Pytest-Alembic's documentation say that async migrations should be run by configuring your env like this:

async def run_migrations_online() -> None:
    """Run migrations in 'online' mode.

    In this scenario we need to create an Engine
    and associate a connection with the context.

    """
    connectable = engine

    async with connectable.connect() as connection:
        await connection.run_sync(do_run_migrations)

    await connectable.dispose()

asyncio.run(run_migrations_online())

but this doesn't resolve the issue (however it does work for production migrations outside of pytest).

I stumpled upon a library called pytest-alembic that provides some built-in tests for this.

When running pytest --test-alembic, I get the following exception:

got Future attached to a different loop

A few comments on pytest-asyncio's GitHub repository suggest that the following fixture might fix it:

@pytest.fixture(scope="session")
def event_loop() -> Generator:
    loop = asyncio.get_event_loop_policy().new_event_loop()
    yield loop
    loop.close()

but it doesn't (same exception remains).

Next I tried to run the upgrade test manually, using:

async def test_migrations(alembic_runner):
    alembic_runner.migrate_up_to("revision_tag_here")

which gives me

alembic_runner.migrate_up_to("revision_tag_here")

venv/lib/python3.9/site-packages/pytest_alembic/runner.py:264: in run_connection_task return asyncio.run(run(engine))

RuntimeError: asyncio.run() cannot be called from a running event loop

However this is an internal call by pytest-alembic, I'm not calling asyncio.run() myself, so I can't apply any of the online fixes for this (try-catching to check if there is an existing event loop to use, etc.). I'm sure this isn't related to my own asyncio.run() defined in the alembic env, because if I add a breakpoint - or just raise an exception above it - the line is actually never executed.

Lastly, I've also tried nest-asyncio.apply(), which just hangs forever.

A few more blog posts suggest to use this fixture to initialize database tables for tests:

    async with engine.begin() as connection:
        await connection.run_sync(Base.metadata.create_all)

which works for the purpose of creating a database to run tests against, but this doesn't run through the migrations so that doesn't help my case.

I feel like I've tried everything there is & visited every docs page, but I've got no luck so far. Running an async migration test surely can't be this difficult?

If any extra info is required I'm happy to provide it.

stijndcl
  • 5,294
  • 1
  • 9
  • 23
  • did you take a look at this comment —> https://github.com/schireson/pytest-alembic/issues/44#issuecomment-997500709 it might provide what you need – gold_cy Jun 29 '22 at 00:51
  • @gold_cy that doesn't seem to resolve the issue, I still get `relation "" does not exist`, even though it should be created by the migrations. They work just fine in production, but they don't seem to get applied when running in pytest. – stijndcl Jun 29 '22 at 09:31
  • @stijndcl Hi. Seeing your [comment](https://stackoverflow.com/questions/72794483/pytest-alembic-initialize-database-with-async-migrations#comment129003131_73021119) where you say the answer works and that you accepted the answer, I suggested the edit. Is it no longer the case? Are there any problems with the answer? – PythonForEver Nov 14 '22 at 11:52
  • 1
    @Riya the edit is just unnecessary, the line is there to indicate that this isn't a duplicate of other existing posts as there are a lot of _very similar_ questions. It still holds true that all other posts don't provide a solution to the problem. With "the existsing posts" I don't mean "the answer to this question isn't useful", I mean "every other question on SO, GitHub, and other programming sites didn't work". – stijndcl Nov 14 '22 at 14:01
  • Oh, I misunderstood. You mean "other questions" not "other posts". Thanks for clarify – PythonForEver Nov 14 '22 at 14:07

1 Answers1

5

I got this up and running pretty easily with the following

env.py - the main idea here is that the migration can be run synchronously

import asyncio
from logging.config import fileConfig

from alembic import context
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import AsyncEngine

config = context.config

if config.config_file_name is not None:
    fileConfig(config.config_file_name)

target_metadata = mymodel.Base.metadata


def run_migrations_online():
    connectable = context.config.attributes.get("connection", None)
    if connectable is None:
        connectable = AsyncEngine(
            engine_from_config(
                context.config.get_section(context.config.config_ini_section),
                prefix="sqlalchemy.",
                poolclass=pool.NullPool,
                future=True
            )
        )

    if isinstance(connectable, AsyncEngine):
        asyncio.run(run_async_migrations(connectable))
    else:
        do_run_migrations(connectable)


async def run_async_migrations(connectable):
    async with connectable.connect() as connection:
        await connection.run_sync(do_run_migrations)
    await connectable.dispose()


def do_run_migrations(connection):
    context.configure(
        connection=connection,
        target_metadata=target_metadata,
        compare_type=True,
    )
    with context.begin_transaction():
        context.run_migrations()


run_migrations_online()

then I added a simple db init script
init_db.py

from alembic import command
from alembic.config import Config
from sqlalchemy.ext.asyncio import create_async_engine

__config_path__ = "/path/to/alembic.ini"
__migration_path__ = "/path/to/folder/with/env.py"

cfg = Config(__config_path__)
cfg.set_main_option("script_location", __migration_path__)


async def migrate_db(conn_url: str):
    async_engine = create_async_engine(conn_url, echo=True)
    async with async_engine.begin() as conn:
        await conn.run_sync(__execute_upgrade)


def __execute_upgrade(connection):
    cfg.attributes["connection"] = connection
    command.upgrade(cfg, "head")

then your pytest fixture can look like this
conftest.py

...

@pytest_asyncio.fixture(autouse=True)
async def migrate():
    await migrate_db(conn_url)
    yield

...

Note: I don't scope my migrate fixture to the test session, I tend to drop and migrate after each test.

ArcX
  • 687
  • 8
  • 20
  • In my post I already use 'run_sync' multiple times so I'm well aware that you're supposed to run them synchronously. What exactly does this answer do differently? – stijndcl Jul 18 '22 at 21:04
  • @stijndcl so the main issue I ran into with alembic running in async was if you did something like `asyncio.run(run_migrations_online)` in your `env.py` global context, I had to wrap it into a method and make the global context synchronous, otherwise this brought up issues like call not awaited or running in an already started loop etc... the other thing to note is that even though the `command.upgrade` method is called with an `AsyncEngine` a synchronous connection is still used to run the migrations, this is because the actual revisions can't be defined in an async manner – ArcX Jul 19 '22 at 06:53
  • @stijndcl also looking at your answer, I see you call `await connection.run_sync(Base.metadata.create_all)` this does not run your migrations, this only creates you db objects through SQLAlchemy, what you want to run sync is the `command.upgrade` as I have it in the `migrate_db` method – ArcX Jul 19 '22 at 07:08
  • Not sure if you read my post but in the section where I have `create_all` I mention that it comes from another post, and that it doesn't run migrations at all. The entirety of my post contains `command.upgrade`. You basically just explained to me what I already wrote in the post myself. I don't call `create_all`, I mention that a post online does it and that it's not what I'm looking for..... – stijndcl Jul 19 '22 at 11:36
  • Quote from that section of my post: "which works for the purpose of creating a database to run tests against, but this doesn't run through the migrations so that doesn't help my case." – stijndcl Jul 19 '22 at 11:37
  • @stijndcl have you tried the suggestion in my first comment?? – ArcX Jul 19 '22 at 11:44
  • I'll try it when I get home later today. – stijndcl Jul 19 '22 at 12:03
  • 1
    After some slight tweaking to fit my current setup this ended up working, so wrapping it in a separate method and passing a connection in seems to be the fix. My fixture is still session-scoped and it still works, so I can confirm that there's no need to re-create & drop for every test (which slows them down considerably if you have a large amount of tests). – stijndcl Jul 19 '22 at 17:00
  • Man, thank you so much for these small pieces of code. I was unable to find how to run migrations with async setup and this worked like a charm. Thanks again! – Sherlock Bourne Nov 23 '22 at 09:53