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.
- hawkapi_sqlalchemy/__init__.py +41 -0
- hawkapi_sqlalchemy/_alembic.py +99 -0
- hawkapi_sqlalchemy/_database.py +133 -0
- hawkapi_sqlalchemy/_engine.py +67 -0
- hawkapi_sqlalchemy/_health.py +35 -0
- hawkapi_sqlalchemy/_models.py +52 -0
- hawkapi_sqlalchemy/_session.py +64 -0
- hawkapi_sqlalchemy/_testing.py +51 -0
- hawkapi_sqlalchemy/alembic.py +8 -0
- hawkapi_sqlalchemy/py.typed +0 -0
- hawkapi_sqlalchemy-0.1.0.dist-info/METADATA +217 -0
- hawkapi_sqlalchemy-0.1.0.dist-info/RECORD +14 -0
- hawkapi_sqlalchemy-0.1.0.dist-info/WHEEL +4 -0
- hawkapi_sqlalchemy-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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"]
|
|
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,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.
|