Skip to main content

Greyhorse SqlAlchemy library

Greyhorse framework library for SQLAlchemy support (sync and async, on PostgreSQL, MySQL/MariaDB and SQLite).

A database engine becomes a managed resource of the greyhorse framework — built, started, health-checked, repaired and torn down by it — plus transactional connection and session contexts, a repository over them, and migrations declared next to the engine as a transport that reads its DSN, with file-based profiles still supported as a second road.

Requires Python 3.14+ and SQLAlchemy 2.0.

Install

Pick the driver extras you need; the base package brings none of them.

pip install 'greyhorse-sqla[pg]'         # PostgreSQL: asyncpg + psycopg2
pip install 'greyhorse-sqla[sqlite]'     # SQLite: aiosqlite
pip install 'greyhorse-sqla[mysql]'      # MySQL/MariaDB: aiomysql + pymysql
pip install 'greyhorse-sqla[migration]'  # the `migration` CLI (alembic)

Usage

Every snippet below is a runnable program. The longer, commented versions live in examples/ and are executed by the test suite, so they cannot rot silently.

One engine, one connection

from sqlalchemy import text

from greyhorse.run import wrap_sync
from greyhorse.strand import running
from greyhorse_sqla import EngineConf, SqlaSyncConnCtx, SqlaSyncModule, SqlEngineType


def main() -> None:
    conf = EngineConf(type=SqlEngineType.SQLITE, dsn='sqlite:///:memory:')

    with running(SqlaSyncModule, args={EngineConf: conf}) as module:
        conn_ctx = module.get(SqlaSyncConnCtx).unwrap()
        with conn_ctx as conn:
            print(conn.execute(text('SELECT 1')).scalar_one())


wrap_sync(main)

The engine is created, its pool started, one query served, and everything stopped — on the way out of the with.

Async

Same shape, different border. The DSL declaration does not change: only the module, the context type and the await do. The plain sqlite:// DSN is enough — AsyncSqlaEngineFactory swaps in the aiosqlite driver itself, the same way postgresql:// becomes postgresql+asyncpg://.

from sqlalchemy import text

from greyhorse.run import run
from greyhorse.strand import running
from greyhorse_sqla import EngineConf, SqlaAsyncConnCtx, SqlaAsyncModule, SqlEngineType


async def main() -> None:
    conf = EngineConf(type=SqlEngineType.SQLITE, dsn='sqlite:///:memory:')

    with running(SqlaAsyncModule, args={EngineConf: conf}) as module:
        conn_ctx = module.get(SqlaAsyncConnCtx).unwrap()
        async with conn_ctx as conn:
            result = await conn.execute(text('SELECT 1'))
            print(result.scalar_one())


run(main)

The pieces, and why you will usually want them directly

SqlaSyncModule/SqlaAsyncModule above are convenience wrappers for the single-storage case. The library's real API is the three pieces they bundle:

piece job
SqlaSyncFragment / SqlaAsyncFragment material — declares how the engine is built
SqlaSyncBorder / SqlaAsyncBorder lifecycle — starts, health-checks and stops it
SqlaSyncSessions / SqlaAsyncSessions access — hands out connections and sessions

An application that needs several storages lists the pieces it wants from each library on its own module — no subclassing, no multiple inheritance. See examples/03_multi_storage.py.

Connections and sessions differ on re-entry

Both products are outcome contexts: apply() commits, and a forgotten apply() or an exception rolls back. They part ways when a borrow is RE-ENTERED — a helper or repository opening the same context inside an outer borrow:

nested apply()
connection() settles just the nested scope, via a real SAVEPOINT
session() refuses, raising InvalidContextStateError

A SQLAlchemy Session.commit() settles everything the session has done — it has no notion of "just my scope" — so a nested apply() there would publish the outer borrow's work and leave it committed if the outer operation later failed. Refusing is loud and safe: it changes nothing and does not consume the borrow, so the outer scope can still apply() or cancel(). If you need nested units of work with independent outcomes, use connection(), or take a separate session.

When cleanup itself fails

A borrow ends by doing things you never asked for by name: rolling back a transaction that was not applied, closing the transaction object, returning the connection to the pool, closing the session. The rule for when any of that fails:

on failure
apply() / cancel(), called by you raises — you asked for an outcome and it did not happen
the rollback/close that ends the borrow logged at WARNING, swallowed

So the exception that reaches you is the one that caused the unwind — your own domain error — never a secondary failure of the package's tidying up. You do not need a try/except around a borrow to protect the error you already have.

The warning names the engine, the operation (rollback, transaction-close, connection-release, session-close) and the exception's TYPE. It deliberately carries neither the exception's text nor a traceback: a driver's connection error routinely quotes the DSN back, password included. The DSN in the line is redacted (greyhorse.data.redact_dsn). Full reasoning in greyhorse_sqla/cleanup.py.

Health and repair

The border's check() asks a real connectivity question (SyncSqlaEngine.is_alive() / AsyncSqlaEngine.is_alive()), not a start/stop counter — so a database that has gone away is reported as such and the framework can repair the resource. Probes are single-flight per DSN and briefly cached, so a health check on every tick does not turn into a connection storm while the database is down.

Migrations

migration --help

Alembic under the hood — importable on a base install with neither typer nor alembic present; the migration extra brings both. Two equally valid roads to a runnable migration, chosen by which flag you pass:

As a transport (--app)

Declare a migration set next to the engine it migrates, in its own component — it reads the DSN from that engine's EngineConf, so nothing duplicates the password into a second file:

from pathlib import Path
from typing import ClassVar

from greyhorse.strand import Component, Shared
from greyhorse_sqla import Migrations, SyncSqlaEngine
from greyhorse_sqla.migration.handlers import SqlaMigrations


class OrdersMigrations(SqlaMigrations):
    alembic_path: ClassVar = Path(__file__).parent / 'alembic'
    metadata_package: ClassVar = 'app.orders.models'


class OrdersMigrationsComponent(Component):
    imports: ClassVar = Shared[SyncSqlaEngine]
    exports: ClassVar = OrdersMigrations
    handlers: ClassVar = Migrations(OrdersMigrations, name='orders')

SqlaMigrations lives at that longer greyhorse_sqla.migration.handlers path, not at the top of the package, because it reaches alembic — absent from a base install — and importing it must stay opt-in.

Two rules this shape imposes, both consequences of every module tree in an Application needing a Gateway for each transport it carries:

  1. Migrations live in their OWN component, apart from anything serving HTTP — one component carrying both drags an HTTP gateway (and its port) into every target that mounts it, migration up included.
  2. Share the storage module between your production target and a migration target with a SubModule row, never by declaring the set twice.

A target — the place a real Application gets built, one per run mode — owns the gateway and the configuration; the CLI never reads a DSN on this road, and never fetches or substitutes args for you:

# app/targets/migrations.py
def build() -> Application:
    return Application(MigrateTarget, gateways=(MigrationGateway(),), args={...})
migration up --app app.targets.migrations:build
migration up --app app.targets.migrations:build --only orders

ATTR is either a ready Application or a zero-argument callable returning one — prefer the callable form, since importing the module must not itself open a pool (--help has to work without a database). examples/05_migrations.py runs both rules end to end, upgrading through one target and dispatching HTTP through another that shares the same storage submodule.

File profiles

The other road, unchanged: a single profile (--dsn/--alembic-path/ --metadata) or a TOML set of profiles (--config, plus --only NAME[,NAME] to filter) drives MigrationRunner directly — no Application/Module in sight. Reach for this when the caller has nothing but a DSN, e.g. a deploy image with no application tree to import.

Autogenerate is scoped either way: the generated env.py only ever considers tables that belong to your own metadata, so it will not propose dropping another application's tables sharing the database.

Schemas are created for you on PostgreSQL — every schema your metadata declares, whether on the MetaData itself or per-table through __table_args__ = {'schema': ...}. Where alembic's own alembic_version table lands follows metadata.schema; when the metadata is schema-less but its tables carry schemas, nothing can be inferred, so name one explicitly:

[[profiles]]
name = "reporting"
dsn = "postgresql://user:pass@host/db"
alembic_path = "alembic/reporting"
metadata = "app.models.reporting:metadata"
version_table_schema = "reporting"   # or --version-table-schema

Without it two schema-scoped applications in one database share a single public.alembic_version, and since revisions are numbered by file count every project's first revision is 001 — the second application either skips its own initial migration or cannot find 001 in its own scripts.

Examples

examples/ holds runnable programs, ordered so each adds one idea:

uv run python examples/01_minimal.py

They are covered by tests/test_examples.py, which runs each as a subprocess and asserts the exact lines it prints — so they cannot rot into prose that lies.

Development

# The extras are not optional for development: a bare `uv sync` REMOVES
# them, and the migration tests stop collecting without `typer`. `pg` is
# left out on purpose -- it pulls `psycopg2` from source, which needs a
# local libpq; the dev group brings `psycopg2-binary` instead.
uv sync --extra sqlite --extra mysql --extra migration

uv run pytest                      # sqlite + unit tests, no servers needed
uv run ruff check && uv run ruff format
uv run mypy greyhorse_sqla/ examples/

Postgres and MySQL tests are gated behind SQLA_TEST_POSTGRES_URI and SQLA_TEST_MYSQL_URI; unset, they skip. tests/docker-compose.yml brings up both.

License

MIT.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

greyhorse_sqla-0.5.5.tar.gz (257.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

greyhorse_sqla-0.5.5-py3-none-any.whl (117.0 kB view details)

Uploaded Python 3

File details

Details for the file greyhorse_sqla-0.5.5.tar.gz.

File metadata

  • Download URL: greyhorse_sqla-0.5.5.tar.gz
  • Upload date:
  • Size: 257.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.0

File hashes

Hashes for greyhorse_sqla-0.5.5.tar.gz
Algorithm Hash digest
SHA256 3064a0b671643b055c0e025ec49e2e10559f122c94034908805d6a190ddf0b91
MD5 25838e06005c785c4ea12b5d930de2c5
BLAKE2b-256 8c530dfc3215d9e97c4537a5bf74ae730003f1e38420a1e9bcf5e521c6c990db

See more details on using hashes here.

File details

Details for the file greyhorse_sqla-0.5.5-py3-none-any.whl.

File metadata

  • Download URL: greyhorse_sqla-0.5.5-py3-none-any.whl
  • Upload date:
  • Size: 117.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.0

File hashes

Hashes for greyhorse_sqla-0.5.5-py3-none-any.whl
Algorithm Hash digest
SHA256 fdc871c9d1595082585e573eab86c6bb8e702d1d3840683a78398070c19273a3
MD5 734fad17def2a4bfc610398387f0c84b
BLAKE2b-256 71229ad778e3fd14dc382164f71024320abad06ecfa03ccfe7d115bbfb570276

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.5.5 This release

2 files

0.4.26

2 files

0.4.25

2 files

0.4.24

2 files

0.4.23

2 files

0.4.22

2 files

0.4.18

2 files

0.4.17

2 files

0.4.16

2 files

0.4.15

2 files

0.4.0

2 files

0.3.1

2 files

0.3

2 files

0.2

2 files

0.1

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page