Skip to content

Runner ​

SeederRunner orchestrates seeder execution: dependency resolution, environment filtering, parallel execution, and state tracking.

Creating a runner ​

python
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from seedling import SeederRunner

engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/mydb")
session_factory = async_sessionmaker(engine, expire_on_commit=False)

runner = SeederRunner(session_factory, env="development")

Constructor parameters ​

ParameterTypeDefaultDescription
session_factoryasync_sessionmakerrequiredSQLAlchemy async session factory
envstr"development"Environment string passed to environment filters
state_trackingboolTrueRecord executions in seedling_state table
transactionalboolFalseWrap the entire run in a single transaction
max_parallelint | NoneNoneCap concurrency within a dependency level

Registering seeders ​

Register explicitly:

python
runner.register(UserSeeder, PostSeeder, CommentSeeder)

Or discover all seeders in a package automatically:

python
runner.discover("myapp.seeders")

discover() imports every module under the package and registers all Seeder subclasses it finds. Calling it multiple times is safe — duplicates are ignored.

Running seeders ​

python
await runner.run()  # run all seeders for the current env
await runner.run(PostSeeder)  # run PostSeeder + its dependencies
await runner.run(new_only=True)  # skip seeders whose source hash matches latest success
await runner.run(force=True)  # override new_only — always run all
await runner.run(tags={"demo"})  # only run seeders tagged "demo"

Seeders at the same dependency level run concurrently via asyncio.gather. Dependent seeders always run after their dependencies.

run() parameters ​

ParameterTypeDefaultDescription
*seeder_classestype[Seeder]—Optional subset; defaults to all registered
new_onlyboolFalseSkip seeders with matching state hash
forceboolFalseOverride new_only — run regardless of state
tagsset[str] | NoneNoneFilter to seeders whose tags intersects this set

Fresh (truncate + reseed) ​

python
await runner.fresh()  # truncate all then reseed
await runner.fresh(PostSeeder)  # truncate + reseed PostSeeder and its deps
await runner.fresh(tags={"demo"})  # only fresh seeders tagged "demo"

Tables are truncated in reverse dependency order, then re-seeded in forward order. seed fresh also wipes seedling_state rows for affected seeders before truncating (clean-slate semantics). The re-seed phase goes through the same path as run(): runner-level hooks fire and transactional=True is honoured. Truncation itself always runs in its own committed transactions.

Listing seeders ​

python
ordered = runner.list_seeders()  # all, env-filtered, sorted
subset = runner.list_seeders(PostSeeder)  # PostSeeder + dependencies
tagged = runner.list_seeders(tags={"smoke"})  # only tagged seeders

Returns a flat list — no execution happens.

Transactional mode ​

transactional=True wraps the entire run in a single transaction. All seeders share one session; if any seeder raises, everything rolls back.

python
runner = SeederRunner(session_factory, env="test", transactional=True)
await runner.run()

Note: State tracking is skipped in transactional mode — if the transaction rolled back, any state rows would roll back too, making them unreliable. Use transactional mode for test isolation, not production audit.

When a seeder fails, the runner waits for the other seeders in the same level to finish before re-raising, so every seeder records a final state and no work is left running detached.

Parallel cap ​

python
runner = SeederRunner(session_factory, env="development", max_parallel=4)

By default all seeders within a dependency level run in parallel. max_parallel caps how many run simultaneously within each level.

Exporting and restoring data ​

python
data = await runner.export()
# {"users": [{"id": 1, "email": "..."}, ...], "posts": [...]}

total = await runner.restore(data)  # returns total rows inserted

Only models declared on Seeder.models are exported. The restore path uses bulk Core insert; table order must satisfy FK constraints (export order is safe to restore as-is).

Lookup by name ​

python
cls = runner.get_by_name("UserSeeder")  # raises ValueError for unknown names

Runner-level lifecycle hooks ​

Override these async methods on a SeederRunner subclass to react to run-level events:

python
class AuditingRunner(SeederRunner):
    async def before_run(self, run_id: str, env: str) -> None:
        print(f"run {run_id} starting for {env}")

    async def after_run(self, run_id: str, env: str) -> None:
        print(f"run {run_id} complete")

    async def on_run_error(self, run_id: str, env: str, exc: BaseException) -> None:
        print(f"run {run_id} failed: {exc}")

All three are no-ops by default. on_run_error does not suppress the exception.

pytest fixture ​

The seedling_runner fixture is provided by the pytest plugin:

conftest.py
python
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine


@pytest.fixture(scope="session")
def seedling_session_factory():
    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
    return async_sessionmaker(engine, expire_on_commit=False)


# test_something.py
async def test_with_users(seedling_runner):
    await seedling_runner.run(UserSeeder)
    ...