hawkapi-sqlalchemy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ """hawkapi-sqlalchemy — SQLAlchemy integration for HawkAPI.
2
+
3
+ Async engines, multi-database routing (primary / replica / shards), per-request
4
+ sessions with auto-commit/rollback, Alembic helpers, and pytest fixtures.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ._alembic import run_migrations
10
+ from ._database import Database, init_database, resolve_database
11
+ from ._engine import DatabaseConfig, Engine, create_engine
12
+ from ._health import all_healthy, database_healthy, session_healthy
13
+ from ._models import Base, DataclassBase, TimestampMixin, UUIDMixin
14
+ from ._session import get_replica_session, get_session, open_session, session_for
15
+ from ._testing import temporary_database, temporary_session
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ __all__ = [
20
+ "Base",
21
+ "DataclassBase",
22
+ "Database",
23
+ "DatabaseConfig",
24
+ "Engine",
25
+ "TimestampMixin",
26
+ "UUIDMixin",
27
+ "__version__",
28
+ "all_healthy",
29
+ "create_engine",
30
+ "database_healthy",
31
+ "get_replica_session",
32
+ "get_session",
33
+ "init_database",
34
+ "open_session",
35
+ "resolve_database",
36
+ "run_migrations",
37
+ "session_for",
38
+ "session_healthy",
39
+ "temporary_database",
40
+ "temporary_session",
41
+ ]
@@ -0,0 +1,99 @@
1
+ """Alembic env.py helpers.
2
+
3
+ Drop this into your migrations ``env.py``:
4
+
5
+ .. code-block:: python
6
+
7
+ from hawkapi_sqlalchemy.alembic import run_migrations
8
+ from myapp.db import Base, settings # your declarative Base + URL
9
+
10
+ run_migrations(target_metadata=Base.metadata, url=settings.database_url)
11
+
12
+ That single call handles both online (live connection) and offline (`--sql`) modes.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ from sqlalchemy import pool
21
+ from sqlalchemy.engine import Connection
22
+ from sqlalchemy.ext.asyncio import create_async_engine
23
+
24
+ if TYPE_CHECKING: # pragma: no cover
25
+ from sqlalchemy import MetaData
26
+
27
+
28
+ def run_migrations(
29
+ *,
30
+ target_metadata: MetaData,
31
+ url: str,
32
+ compare_type: bool = True,
33
+ render_as_batch: bool | None = None,
34
+ configure_kwargs: dict[str, Any] | None = None,
35
+ ) -> None:
36
+ """Drive Alembic for the current command context.
37
+
38
+ Picks online vs offline mode based on ``context.is_offline_mode()``.
39
+ """
40
+ from alembic import context # imported lazily so this module is import-safe outside Alembic
41
+
42
+ if render_as_batch is None:
43
+ render_as_batch = url.startswith("sqlite")
44
+
45
+ if context.is_offline_mode():
46
+ context.configure(
47
+ url=url,
48
+ target_metadata=target_metadata,
49
+ literal_binds=True,
50
+ compare_type=compare_type,
51
+ render_as_batch=render_as_batch,
52
+ dialect_opts={"paramstyle": "named"},
53
+ **(configure_kwargs or {}),
54
+ )
55
+ with context.begin_transaction():
56
+ context.run_migrations()
57
+ return
58
+
59
+ asyncio.run(_run_online(target_metadata, url, compare_type, render_as_batch, configure_kwargs))
60
+
61
+
62
+ async def _run_online(
63
+ target_metadata: MetaData,
64
+ url: str,
65
+ compare_type: bool,
66
+ render_as_batch: bool,
67
+ configure_kwargs: dict[str, Any] | None,
68
+ ) -> None:
69
+ connectable = create_async_engine(url, poolclass=pool.NullPool, future=True)
70
+ async with connectable.connect() as connection:
71
+ await connection.run_sync(
72
+ lambda sync_conn: _apply(
73
+ sync_conn, target_metadata, compare_type, render_as_batch, configure_kwargs
74
+ )
75
+ )
76
+ await connectable.dispose()
77
+
78
+
79
+ def _apply(
80
+ connection: Connection,
81
+ target_metadata: MetaData,
82
+ compare_type: bool,
83
+ render_as_batch: bool,
84
+ configure_kwargs: dict[str, Any] | None,
85
+ ) -> None:
86
+ from alembic import context
87
+
88
+ context.configure(
89
+ connection=connection,
90
+ target_metadata=target_metadata,
91
+ compare_type=compare_type,
92
+ render_as_batch=render_as_batch,
93
+ **(configure_kwargs or {}),
94
+ )
95
+ with context.begin_transaction():
96
+ context.run_migrations()
97
+
98
+
99
+ __all__ = ["run_migrations"]
@@ -0,0 +1,133 @@
1
+ """Database registry — multi-engine routing + plugin entry point.
2
+
3
+ A :class:`Database` owns one or more :class:`Engine` instances keyed by name.
4
+ The convention is:
5
+
6
+ * ``primary`` — read/write engine (the default).
7
+ * ``replica`` — optional read-only engine; if absent we fall back to ``primary``.
8
+
9
+ You can register any number of named engines (e.g. ``analytics``, ``shard_1``)
10
+ and ask for them by name via :func:`get_session(name=...)`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import AsyncGenerator
16
+ from contextlib import asynccontextmanager
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+ from sqlalchemy.ext.asyncio import AsyncSession
21
+
22
+ from ._engine import DatabaseConfig, Engine, create_engine
23
+
24
+
25
+ @dataclass
26
+ class Database:
27
+ engines: dict[str, Engine] = field(default_factory=dict)
28
+
29
+ def add(self, name: str, config: DatabaseConfig) -> Engine:
30
+ engine = create_engine(config)
31
+ self.engines[name] = engine
32
+ return engine
33
+
34
+ def get(self, name: str = "primary") -> Engine:
35
+ if name in self.engines:
36
+ return self.engines[name]
37
+ if name != "primary" and "primary" in self.engines:
38
+ # Fall back to primary for replicas / analytics shards that are not configured.
39
+ return self.engines["primary"]
40
+ raise KeyError(f"no engine registered under {name!r}")
41
+
42
+ @asynccontextmanager
43
+ async def session(
44
+ self, name: str = "primary", *, commit: bool = True
45
+ ) -> AsyncGenerator[AsyncSession, None]:
46
+ """Open a session bound to ``name``. Commits on clean exit; rolls back on exception."""
47
+ engine = self.get(name)
48
+ async with engine.sessionmaker() as sess:
49
+ try:
50
+ yield sess
51
+ if commit:
52
+ await sess.commit()
53
+ except Exception:
54
+ await sess.rollback()
55
+ raise
56
+
57
+ async def dispose(self) -> None:
58
+ for engine in self.engines.values():
59
+ await engine.dispose()
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Plugin registry
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ class _StateNamespace:
68
+ db: Any
69
+
70
+
71
+ _ACTIVE_DATABASES: dict[int, Database] = {}
72
+ _LAST_DATABASE: list[Database | None] = [None]
73
+
74
+
75
+ def init_database(
76
+ app: Any,
77
+ *,
78
+ url: str | None = None,
79
+ config: DatabaseConfig | None = None,
80
+ databases: dict[str, DatabaseConfig] | None = None,
81
+ dispose_on_shutdown: bool = True,
82
+ ) -> Database:
83
+ """Attach a :class:`Database` to ``app.state.db`` and register it for DI lookup.
84
+
85
+ The three argument shapes (mutually exclusive):
86
+
87
+ 1. ``url=...`` — convenience, creates a single ``primary`` engine.
88
+ 2. ``config=DatabaseConfig(...)`` — single ``primary`` engine with custom config.
89
+ 3. ``databases={"primary": ..., "replica": ...}`` — register multiple engines.
90
+ """
91
+ if sum(x is not None for x in (url, config, databases)) != 1:
92
+ raise ValueError("init_database requires exactly one of url=, config=, or databases=")
93
+
94
+ database = Database()
95
+ if url is not None:
96
+ database.add("primary", DatabaseConfig(url=url))
97
+ elif config is not None:
98
+ database.add("primary", config)
99
+ else:
100
+ assert databases is not None
101
+ if "primary" not in databases:
102
+ raise ValueError("databases must include a 'primary' entry")
103
+ for name, cfg in databases.items():
104
+ database.add(name, cfg)
105
+
106
+ if getattr(app, "state", None) is None:
107
+ app.state = _StateNamespace()
108
+ app.state.db = database
109
+ _ACTIVE_DATABASES[id(app)] = database
110
+ _LAST_DATABASE[0] = database
111
+
112
+ if dispose_on_shutdown and hasattr(app, "on_shutdown"):
113
+
114
+ async def _dispose() -> None:
115
+ await database.dispose()
116
+
117
+ app.on_shutdown(_dispose)
118
+ return database
119
+
120
+
121
+ def resolve_database(app: Any) -> Database | None:
122
+ if app is None:
123
+ return _LAST_DATABASE[0]
124
+ db = _ACTIVE_DATABASES.get(id(app))
125
+ if db is not None:
126
+ return db
127
+ state = getattr(app, "state", None)
128
+ if state is not None and hasattr(state, "db"):
129
+ return state.db # type: ignore[no-any-return]
130
+ return _LAST_DATABASE[0]
131
+
132
+
133
+ __all__ = ["Database", "init_database", "resolve_database"]
@@ -0,0 +1,67 @@
1
+ """Engine + sessionmaker creation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from sqlalchemy.ext.asyncio import (
9
+ AsyncEngine,
10
+ AsyncSession,
11
+ async_sessionmaker,
12
+ create_async_engine,
13
+ )
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class DatabaseConfig:
18
+ url: str
19
+ echo: bool = False
20
+ pool_size: int = 5
21
+ max_overflow: int = 10
22
+ pool_timeout: float = 30.0
23
+ pool_recycle: int = 3600
24
+ pool_pre_ping: bool = True
25
+ connect_args: dict[str, Any] = field(default_factory=dict)
26
+ engine_kwargs: dict[str, Any] = field(default_factory=dict)
27
+ session_kwargs: dict[str, Any] = field(default_factory=dict)
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class Engine:
32
+ """Engine + sessionmaker pair."""
33
+
34
+ engine: AsyncEngine
35
+ sessionmaker: async_sessionmaker[AsyncSession]
36
+ config: DatabaseConfig
37
+
38
+ async def dispose(self) -> None:
39
+ await self.engine.dispose()
40
+
41
+
42
+ def create_engine(config: DatabaseConfig) -> Engine:
43
+ kwargs: dict[str, Any] = {
44
+ "echo": config.echo,
45
+ "pool_pre_ping": config.pool_pre_ping,
46
+ "pool_recycle": config.pool_recycle,
47
+ }
48
+ # SQLite (aiosqlite) does not accept pool_size / max_overflow / pool_timeout.
49
+ if not config.url.startswith("sqlite"):
50
+ kwargs["pool_size"] = config.pool_size
51
+ kwargs["max_overflow"] = config.max_overflow
52
+ kwargs["pool_timeout"] = config.pool_timeout
53
+ if config.connect_args:
54
+ kwargs["connect_args"] = config.connect_args
55
+ kwargs.update(config.engine_kwargs)
56
+
57
+ engine = create_async_engine(config.url, **kwargs)
58
+ smaker = async_sessionmaker(
59
+ bind=engine,
60
+ expire_on_commit=False,
61
+ autoflush=False,
62
+ **config.session_kwargs,
63
+ )
64
+ return Engine(engine=engine, sessionmaker=smaker, config=config)
65
+
66
+
67
+ __all__ = ["DatabaseConfig", "Engine", "create_engine"]
@@ -0,0 +1,35 @@
1
+ """Healthcheck helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sqlalchemy import text
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from ._database import Database
9
+
10
+
11
+ async def session_healthy(session: AsyncSession) -> bool:
12
+ """Run a trivial ``SELECT 1`` against an open session — returns False on any error."""
13
+ try:
14
+ result = await session.execute(text("SELECT 1"))
15
+ row = result.scalar_one()
16
+ return row == 1
17
+ except Exception:
18
+ return False
19
+
20
+
21
+ async def database_healthy(database: Database, *, name: str = "primary") -> bool:
22
+ """Open a fresh session against ``name`` and verify connectivity."""
23
+ try:
24
+ async with database.session(name, commit=False) as sess:
25
+ return await session_healthy(sess)
26
+ except Exception:
27
+ return False
28
+
29
+
30
+ async def all_healthy(database: Database) -> dict[str, bool]:
31
+ """Run :func:`database_healthy` against every registered engine."""
32
+ return {name: await database_healthy(database, name=name) for name in database.engines}
33
+
34
+
35
+ __all__ = ["all_healthy", "database_healthy", "session_healthy"]
@@ -0,0 +1,52 @@
1
+ """Base declarative class + reusable mixins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import UTC, datetime
7
+
8
+ from sqlalchemy import DateTime, String, func
9
+ from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column
10
+
11
+
12
+ class Base(DeclarativeBase):
13
+ """Declarative base for hawkapi-sqlalchemy users.
14
+
15
+ Subclass this for ordinary ORM models. Use :class:`DataclassBase` if you want
16
+ SQLAlchemy 2.0's dataclass-style declarative mode.
17
+ """
18
+
19
+
20
+ class DataclassBase(MappedAsDataclass, DeclarativeBase, kw_only=True):
21
+ """Same as :class:`Base` but with dataclass-style ``__init__`` / ``__repr__``."""
22
+
23
+
24
+ class TimestampMixin:
25
+ """Adds ``created_at`` / ``updated_at`` columns with DB-side defaults."""
26
+
27
+ created_at: Mapped[datetime] = mapped_column(
28
+ DateTime(timezone=True),
29
+ default=lambda: datetime.now(UTC),
30
+ server_default=func.current_timestamp(),
31
+ nullable=False,
32
+ )
33
+ updated_at: Mapped[datetime] = mapped_column(
34
+ DateTime(timezone=True),
35
+ default=lambda: datetime.now(UTC),
36
+ server_default=func.current_timestamp(),
37
+ onupdate=lambda: datetime.now(UTC),
38
+ nullable=False,
39
+ )
40
+
41
+
42
+ class UUIDMixin:
43
+ """Adds a string ``id`` column with a uuid4 default."""
44
+
45
+ id: Mapped[str] = mapped_column(
46
+ String(36),
47
+ primary_key=True,
48
+ default=lambda: str(uuid.uuid4()),
49
+ )
50
+
51
+
52
+ __all__ = ["Base", "DataclassBase", "TimestampMixin", "UUIDMixin"]
@@ -0,0 +1,64 @@
1
+ """DI helpers — per-request sessions with auto-commit / rollback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator, Callable
6
+ from typing import Any
7
+
8
+ from hawkapi import HTTPException, Request
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from ._database import resolve_database
12
+
13
+
14
+ async def get_session(request: Request) -> AsyncIterator[AsyncSession]:
15
+ """DI dependency — yields a ``primary`` session, commits on success, rolls back on error."""
16
+ async for sess in _session_iter(request, name="primary"):
17
+ yield sess
18
+
19
+
20
+ async def get_replica_session(request: Request) -> AsyncIterator[AsyncSession]:
21
+ """DI dependency — yields a ``replica`` session (falls back to ``primary`` if unset).
22
+
23
+ Never auto-commits — replicas are read-only by convention.
24
+ """
25
+ async for sess in _session_iter(request, name="replica", commit=False):
26
+ yield sess
27
+
28
+
29
+ def session_for(
30
+ name: str, *, commit: bool = True
31
+ ) -> Callable[[Request], AsyncIterator[AsyncSession]]:
32
+ """Build a DI dependency that opens a session against the engine named ``name``."""
33
+
34
+ async def _dep(request: Request) -> AsyncIterator[AsyncSession]:
35
+ async for sess in _session_iter(request, name=name, commit=commit):
36
+ yield sess
37
+
38
+ return _dep
39
+
40
+
41
+ async def _session_iter(
42
+ request: Request, *, name: str, commit: bool = True
43
+ ) -> AsyncIterator[AsyncSession]:
44
+ db = resolve_database(request.scope.get("app"))
45
+ if db is None:
46
+ raise HTTPException(
47
+ 500, detail="Database not configured — call init_database(app, ...) first"
48
+ )
49
+ async with db.session(name, commit=commit) as sess:
50
+ yield sess
51
+
52
+
53
+ # Helper for non-handler code — e.g. background workers.
54
+ async def open_session(app: Any, *, name: str = "primary", commit: bool = True) -> AsyncSession:
55
+ """Open a raw session without going through DI. Caller is responsible for closing it."""
56
+ db = resolve_database(app)
57
+ if db is None:
58
+ raise RuntimeError("Database not configured — call init_database(app, ...) first")
59
+ engine = db.get(name)
60
+ _ = commit # callers manage commit themselves
61
+ return engine.sessionmaker()
62
+
63
+
64
+ __all__ = ["get_replica_session", "get_session", "open_session", "session_for"]
@@ -0,0 +1,51 @@
1
+ """Pytest helpers — build an in-memory engine, manage schema lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncGenerator
6
+ from contextlib import asynccontextmanager
7
+ from typing import TYPE_CHECKING
8
+
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from ._database import Database
12
+ from ._engine import DatabaseConfig
13
+
14
+ if TYPE_CHECKING: # pragma: no cover
15
+ from sqlalchemy import MetaData
16
+
17
+
18
+ @asynccontextmanager
19
+ async def temporary_database(
20
+ metadata: MetaData,
21
+ *,
22
+ url: str = "sqlite+aiosqlite:///:memory:",
23
+ ) -> AsyncGenerator[Database, None]:
24
+ """Yields a :class:`Database` whose schema is created up-front and dropped on exit."""
25
+ db = Database()
26
+ db.add("primary", DatabaseConfig(url=url))
27
+ engine = db.get("primary").engine
28
+ async with engine.begin() as conn:
29
+ await conn.run_sync(metadata.create_all)
30
+ try:
31
+ yield db
32
+ finally:
33
+ try:
34
+ async with engine.begin() as conn:
35
+ await conn.run_sync(metadata.drop_all)
36
+ finally:
37
+ await db.dispose()
38
+
39
+
40
+ @asynccontextmanager
41
+ async def temporary_session(
42
+ metadata: MetaData,
43
+ *,
44
+ url: str = "sqlite+aiosqlite:///:memory:",
45
+ ) -> AsyncGenerator[AsyncSession, None]:
46
+ """Convenience — yield a session against a fresh in-memory database."""
47
+ async with temporary_database(metadata, url=url) as db, db.session() as sess:
48
+ yield sess
49
+
50
+
51
+ __all__ = ["temporary_database", "temporary_session"]
@@ -0,0 +1,8 @@
1
+ """Public alias for the Alembic helper.
2
+
3
+ Imported from your ``alembic/env.py`` as ``from hawkapi_sqlalchemy.alembic import run_migrations``.
4
+ """
5
+
6
+ from ._alembic import run_migrations
7
+
8
+ __all__ = ["run_migrations"]
File without changes
@@ -0,0 +1,217 @@
1
+ Metadata-Version: 2.4
2
+ Name: hawkapi-sqlalchemy
3
+ Version: 0.1.0
4
+ Summary: SQLAlchemy integration for HawkAPI — async sessions, multi-database routing, Alembic helpers, pytest fixtures
5
+ Project-URL: Homepage, https://pypi.org/project/hawkapi-sqlalchemy/
6
+ Project-URL: Repository, https://github.com/ashimov/hawkapi-sqlalchemy
7
+ Project-URL: Issues, https://github.com/ashimov/hawkapi-sqlalchemy/issues
8
+ Author-email: HawkAPI Contributors <hawkapi@users.noreply.github.com>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 HawkAPI Contributors
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: alembic,async,database,hawkapi,mysql,postgres,sqlalchemy
32
+ Classifier: Development Status :: 5 - Production/Stable
33
+ Classifier: Framework :: AsyncIO
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Programming Language :: Python :: 3.13
39
+ Classifier: Topic :: Database
40
+ Classifier: Topic :: Database :: Front-Ends
41
+ Classifier: Typing :: Typed
42
+ Requires-Python: >=3.12
43
+ Requires-Dist: aiosqlite>=0.20
44
+ Requires-Dist: alembic>=1.13
45
+ Requires-Dist: hawkapi>=0.1.7
46
+ Requires-Dist: sqlalchemy[asyncio]>=2.0
47
+ Provides-Extra: dev
48
+ Requires-Dist: pyright>=1.1; extra == 'dev'
49
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
50
+ Requires-Dist: pytest>=8.0; extra == 'dev'
51
+ Requires-Dist: ruff>=0.8; extra == 'dev'
52
+ Provides-Extra: mysql
53
+ Requires-Dist: aiomysql>=0.2; extra == 'mysql'
54
+ Provides-Extra: postgres
55
+ Requires-Dist: asyncpg>=0.29; extra == 'postgres'
56
+ Description-Content-Type: text/markdown
57
+
58
+ # hawkapi-sqlalchemy
59
+
60
+ SQLAlchemy integration for [HawkAPI](https://github.com/ashimov/HawkAPI). Async sessions, multi-database routing (primary/replica/shards), Alembic helpers, and pytest fixtures.
61
+
62
+ ## Install
63
+
64
+ ```bash
65
+ pip install hawkapi-sqlalchemy # SQLite included
66
+ pip install 'hawkapi-sqlalchemy[postgres]' # + asyncpg
67
+ pip install 'hawkapi-sqlalchemy[mysql]' # + aiomysql
68
+ ```
69
+
70
+ ## Quickstart
71
+
72
+ ```python
73
+ from hawkapi import Depends, HawkAPI
74
+ from sqlalchemy.ext.asyncio import AsyncSession
75
+ from sqlalchemy.orm import Mapped, mapped_column
76
+
77
+ from hawkapi_sqlalchemy import Base, TimestampMixin, get_session, init_database
78
+
79
+
80
+ class User(Base, TimestampMixin):
81
+ __tablename__ = "users"
82
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
83
+ email: Mapped[str] = mapped_column(unique=True)
84
+
85
+
86
+ app = HawkAPI()
87
+ init_database(app, url="postgresql+asyncpg://user:pw@localhost/app")
88
+
89
+
90
+ @app.post("/users")
91
+ async def create(email: str, sess: AsyncSession = Depends(get_session)):
92
+ sess.add(User(email=email))
93
+ await sess.flush()
94
+ return {"ok": True}
95
+ ```
96
+
97
+ The `get_session` dependency opens a fresh session per request, **commits on success**, and **rolls back on exception** — no boilerplate.
98
+
99
+ ## Multiple databases
100
+
101
+ ```python
102
+ from hawkapi_sqlalchemy import DatabaseConfig, init_database, session_for
103
+
104
+ init_database(
105
+ app,
106
+ databases={
107
+ "primary": DatabaseConfig(url="postgresql+asyncpg://…/primary"),
108
+ "replica": DatabaseConfig(url="postgresql+asyncpg://…/replica"),
109
+ "analytics": DatabaseConfig(url="postgresql+asyncpg://…/analytics"),
110
+ },
111
+ )
112
+
113
+ # DI helpers:
114
+ from hawkapi_sqlalchemy import get_session, get_replica_session
115
+
116
+ get_analytics = session_for("analytics", commit=False)
117
+
118
+
119
+ @app.get("/report")
120
+ async def report(sess: AsyncSession = Depends(get_analytics)):
121
+ ...
122
+ ```
123
+
124
+ `get_replica_session` falls back to `primary` if no replica is registered, so you can switch on without changing handlers.
125
+
126
+ ## Mixins
127
+
128
+ ```python
129
+ from hawkapi_sqlalchemy import Base, TimestampMixin, UUIDMixin
130
+
131
+
132
+ class Doc(Base, UUIDMixin, TimestampMixin):
133
+ __tablename__ = "docs"
134
+ title: Mapped[str] = mapped_column()
135
+ ```
136
+
137
+ - `TimestampMixin` — `created_at` / `updated_at` with DB-side defaults + Python `onupdate`.
138
+ - `UUIDMixin` — string `id` column with a `uuid4()` default.
139
+ - Prefer `DataclassBase` over `Base` to get SQLAlchemy 2.0's dataclass-style declarative.
140
+
141
+ ## Alembic
142
+
143
+ In your `alembic/env.py`:
144
+
145
+ ```python
146
+ from hawkapi_sqlalchemy.alembic import run_migrations
147
+ from myapp.db import Base, settings # your Base + URL
148
+
149
+ run_migrations(target_metadata=Base.metadata, url=settings.database_url)
150
+ ```
151
+
152
+ That's it — handles both online (live connection) and offline (`--sql`) modes; uses `NullPool` for migrations; enables `render_as_batch=True` automatically for SQLite.
153
+
154
+ ## Healthchecks
155
+
156
+ ```python
157
+ from hawkapi_sqlalchemy import all_healthy
158
+
159
+
160
+ @app.get("/healthz")
161
+ async def healthz():
162
+ return await all_healthy(app.state.db)
163
+ ```
164
+
165
+ Returns `{"primary": True, "replica": True, ...}`.
166
+
167
+ ## Testing
168
+
169
+ ```python
170
+ import pytest
171
+ from hawkapi_sqlalchemy import Base, temporary_database
172
+
173
+
174
+ @pytest.fixture
175
+ async def db():
176
+ async with temporary_database(Base.metadata) as database:
177
+ yield database
178
+
179
+
180
+ async def test_something(db):
181
+ async with db.session() as sess:
182
+ ...
183
+ ```
184
+
185
+ `temporary_database` creates an in-memory SQLite engine, calls `Base.metadata.create_all`, yields, then drops the schema and disposes.
186
+
187
+ ## DatabaseConfig
188
+
189
+ ```python
190
+ DatabaseConfig(
191
+ url="postgresql+asyncpg://…",
192
+ echo=False,
193
+ pool_size=5,
194
+ max_overflow=10,
195
+ pool_timeout=30.0,
196
+ pool_recycle=3600,
197
+ pool_pre_ping=True,
198
+ connect_args={"server_settings": {"jit": "off"}},
199
+ engine_kwargs={...}, # forwarded to create_async_engine
200
+ session_kwargs={...}, # forwarded to async_sessionmaker
201
+ )
202
+ ```
203
+
204
+ ## Development
205
+
206
+ ```bash
207
+ git clone https://github.com/ashimov/hawkapi-sqlalchemy.git
208
+ cd hawkapi-sqlalchemy
209
+ uv sync --extra dev
210
+ uv run pytest -q
211
+ uv run ruff check . && uv run ruff format --check .
212
+ uv run pyright src/
213
+ ```
214
+
215
+ ## License
216
+
217
+ MIT.
@@ -0,0 +1,14 @@
1
+ hawkapi_sqlalchemy/__init__.py,sha256=-nWx7ZV4vk4pXBN_hY6dyisb2T7571yovLAIpEHCwmc,1168
2
+ hawkapi_sqlalchemy/_alembic.py,sha256=c2lffjJ83tQl8qFFgWyXMildUymnjbWBwqnoSPmHvrQ,2807
3
+ hawkapi_sqlalchemy/_database.py,sha256=MEKZhL6VRPYTKI768Qj8lwM7-BF_peJly9YSvdRcPpg,4326
4
+ hawkapi_sqlalchemy/_engine.py,sha256=V8XA5ObxZqklLksFOXMPH_9uuHnLcb8_QqcB0xtqNvc,1879
5
+ hawkapi_sqlalchemy/_health.py,sha256=p-Yww5H4ykZdbN3JkkkmGVVn28KCTJe8m_cM6i_90rk,1109
6
+ hawkapi_sqlalchemy/_models.py,sha256=m3nUvX_KjKkMwQ5fkq0s5WUNTfE9M9eOm4RsaysbZMs,1492
7
+ hawkapi_sqlalchemy/_session.py,sha256=ar58VVkSvYeUZLkOzQTuMp3hKo_aacMFLk5ehE5HTD0,2276
8
+ hawkapi_sqlalchemy/_testing.py,sha256=lvqhtwRXPssovfJnZKJqIDUAYqXXH9c850MTwe2ldFk,1486
9
+ hawkapi_sqlalchemy/alembic.py,sha256=FLLaQmin9xcp3ln73hUDe2vKeKdRrMAU0X5oF12jfbo,213
10
+ hawkapi_sqlalchemy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ hawkapi_sqlalchemy-0.1.0.dist-info/METADATA,sha256=I12xN4tDGOQxEjrFvV3m_2RF7g23Qfd8BOybFrNiXkQ,6913
12
+ hawkapi_sqlalchemy-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ hawkapi_sqlalchemy-0.1.0.dist-info/licenses/LICENSE,sha256=_RpjhvsfLqqeG_gv2cRatjIxCTGXTpXhKU9jqLZXYa4,1077
14
+ hawkapi_sqlalchemy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HawkAPI Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.