hawkapi-sqlalchemy 0.1.0__tar.gz → 0.2.0__tar.gz
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-0.1.0 → hawkapi_sqlalchemy-0.2.0}/CHANGELOG.md +20 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/PKG-INFO +1 -1
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/pyproject.toml +1 -1
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/__init__.py +1 -1
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/_alembic.py +10 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/_database.py +18 -4
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/_session.py +8 -3
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/tests/test_plugin.py +1 -1
- hawkapi_sqlalchemy-0.2.0/tests/test_security.py +41 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/uv.lock +1 -1
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/.github/workflows/ci.yml +0 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/.github/workflows/release.yml +0 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/.gitignore +0 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/LICENSE +0 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/README.md +0 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/_engine.py +0 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/_health.py +0 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/_models.py +0 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/_testing.py +0 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/alembic.py +0 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/py.typed +0 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/tests/__init__.py +0 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/tests/conftest.py +0 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/tests/test_database.py +0 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/tests/test_engine.py +0 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/tests/test_health.py +0 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/tests/test_models.py +0 -0
- {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/tests/test_testing.py +0 -0
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 — 2026-05-16
|
|
4
|
+
|
|
5
|
+
Security hardening.
|
|
6
|
+
|
|
7
|
+
### Breaking
|
|
8
|
+
|
|
9
|
+
- `open_session(app, *, name=...)` no longer accepts a `commit` parameter.
|
|
10
|
+
It was silently ignored — callers expecting auto-commit ran without one.
|
|
11
|
+
Manage the session lifecycle (close/commit/rollback) yourself.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- `Database.get` emits a WARNING when a non-`primary` engine is requested
|
|
16
|
+
but only `primary` is registered, so silently-misrouted replicas are
|
|
17
|
+
discoverable from logs.
|
|
18
|
+
- `run_migrations` raises a clear `RuntimeError` instead of nested-loop
|
|
19
|
+
errors when invoked from inside a running event loop.
|
|
20
|
+
- The active-database registry uses `WeakKeyDictionary` to eliminate the
|
|
21
|
+
`id(app)` ABA hazard.
|
|
22
|
+
|
|
3
23
|
## 0.1.0 — 2026-05-16
|
|
4
24
|
|
|
5
25
|
Initial release.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hawkapi-sqlalchemy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: SQLAlchemy integration for HawkAPI — async sessions, multi-database routing, Alembic helpers, pytest fixtures
|
|
5
5
|
Project-URL: Homepage, https://pypi.org/project/hawkapi-sqlalchemy/
|
|
6
6
|
Project-URL: Repository, https://github.com/ashimov/hawkapi-sqlalchemy
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hawkapi-sqlalchemy"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "SQLAlchemy integration for HawkAPI — async sessions, multi-database routing, Alembic helpers, pytest fixtures"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -14,7 +14,7 @@ from ._models import Base, DataclassBase, TimestampMixin, UUIDMixin
|
|
|
14
14
|
from ._session import get_replica_session, get_session, open_session, session_for
|
|
15
15
|
from ._testing import temporary_database, temporary_session
|
|
16
16
|
|
|
17
|
-
__version__ = "0.
|
|
17
|
+
__version__ = "0.2.0"
|
|
18
18
|
|
|
19
19
|
__all__ = [
|
|
20
20
|
"Base",
|
|
@@ -56,6 +56,16 @@ def run_migrations(
|
|
|
56
56
|
context.run_migrations()
|
|
57
57
|
return
|
|
58
58
|
|
|
59
|
+
# ``asyncio.run`` cannot be invoked from inside a running loop — produce a
|
|
60
|
+
# clear error rather than the obscure RuntimeError that nested loops give.
|
|
61
|
+
try:
|
|
62
|
+
asyncio.get_running_loop()
|
|
63
|
+
except RuntimeError:
|
|
64
|
+
running = False
|
|
65
|
+
else:
|
|
66
|
+
running = True
|
|
67
|
+
if running:
|
|
68
|
+
raise RuntimeError("run_migrations must be called from a non-async context")
|
|
59
69
|
asyncio.run(_run_online(target_metadata, url, compare_type, render_as_batch, configure_kwargs))
|
|
60
70
|
|
|
61
71
|
|
|
@@ -12,15 +12,20 @@ and ask for them by name via :func:`get_session(name=...)`.
|
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
+
import contextlib
|
|
16
|
+
import logging
|
|
15
17
|
from collections.abc import AsyncGenerator
|
|
16
18
|
from contextlib import asynccontextmanager
|
|
17
19
|
from dataclasses import dataclass, field
|
|
18
20
|
from typing import Any
|
|
21
|
+
from weakref import WeakKeyDictionary
|
|
19
22
|
|
|
20
23
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
21
24
|
|
|
22
25
|
from ._engine import DatabaseConfig, Engine, create_engine
|
|
23
26
|
|
|
27
|
+
logger = logging.getLogger("hawkapi_sqlalchemy")
|
|
28
|
+
|
|
24
29
|
|
|
25
30
|
@dataclass
|
|
26
31
|
class Database:
|
|
@@ -35,7 +40,10 @@ class Database:
|
|
|
35
40
|
if name in self.engines:
|
|
36
41
|
return self.engines[name]
|
|
37
42
|
if name != "primary" and "primary" in self.engines:
|
|
38
|
-
# Fall back to primary for replicas / analytics shards that are not
|
|
43
|
+
# Fall back to primary for replicas / analytics shards that are not
|
|
44
|
+
# configured. Warn so misconfigured replicas don't silently route
|
|
45
|
+
# to the primary forever (CWE-440-ish).
|
|
46
|
+
logger.warning("falling back to primary for engine %r — not configured", name)
|
|
39
47
|
return self.engines["primary"]
|
|
40
48
|
raise KeyError(f"no engine registered under {name!r}")
|
|
41
49
|
|
|
@@ -68,7 +76,9 @@ class _StateNamespace:
|
|
|
68
76
|
db: Any
|
|
69
77
|
|
|
70
78
|
|
|
71
|
-
|
|
79
|
+
# WeakKeyDictionary avoids the ``id(app)`` ABA hazard if an app is GC'd and
|
|
80
|
+
# Python reuses the address for a new object.
|
|
81
|
+
_ACTIVE_DATABASES: WeakKeyDictionary[Any, Database] = WeakKeyDictionary()
|
|
72
82
|
_LAST_DATABASE: list[Database | None] = [None]
|
|
73
83
|
|
|
74
84
|
|
|
@@ -106,7 +116,8 @@ def init_database(
|
|
|
106
116
|
if getattr(app, "state", None) is None:
|
|
107
117
|
app.state = _StateNamespace()
|
|
108
118
|
app.state.db = database
|
|
109
|
-
|
|
119
|
+
with contextlib.suppress(TypeError):
|
|
120
|
+
_ACTIVE_DATABASES[app] = database
|
|
110
121
|
_LAST_DATABASE[0] = database
|
|
111
122
|
|
|
112
123
|
if dispose_on_shutdown and hasattr(app, "on_shutdown"):
|
|
@@ -121,7 +132,10 @@ def init_database(
|
|
|
121
132
|
def resolve_database(app: Any) -> Database | None:
|
|
122
133
|
if app is None:
|
|
123
134
|
return _LAST_DATABASE[0]
|
|
124
|
-
|
|
135
|
+
try:
|
|
136
|
+
db = _ACTIVE_DATABASES.get(app)
|
|
137
|
+
except TypeError:
|
|
138
|
+
db = None
|
|
125
139
|
if db is not None:
|
|
126
140
|
return db
|
|
127
141
|
state = getattr(app, "state", None)
|
|
@@ -51,13 +51,18 @@ async def _session_iter(
|
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
# Helper for non-handler code — e.g. background workers.
|
|
54
|
-
async def open_session(app: Any, *, name: str = "primary"
|
|
55
|
-
"""Open a raw session without going through DI.
|
|
54
|
+
async def open_session(app: Any, *, name: str = "primary") -> AsyncSession:
|
|
55
|
+
"""Open a raw session without going through DI.
|
|
56
|
+
|
|
57
|
+
The caller owns the session lifecycle — close, commit, and rollback are the
|
|
58
|
+
caller's responsibility. This API intentionally has no ``commit`` flag:
|
|
59
|
+
historically one was accepted but ignored, which encouraged callers to
|
|
60
|
+
assume auto-commit semantics that never existed (CWE-400).
|
|
61
|
+
"""
|
|
56
62
|
db = resolve_database(app)
|
|
57
63
|
if db is None:
|
|
58
64
|
raise RuntimeError("Database not configured — call init_database(app, ...) first")
|
|
59
65
|
engine = db.get(name)
|
|
60
|
-
_ = commit # callers manage commit themselves
|
|
61
66
|
return engine.sessionmaker()
|
|
62
67
|
|
|
63
68
|
|
|
@@ -96,7 +96,7 @@ def test_get_session_500_when_not_configured() -> None:
|
|
|
96
96
|
|
|
97
97
|
saved = _d._LAST_DATABASE[0]
|
|
98
98
|
_d._LAST_DATABASE[0] = None
|
|
99
|
-
_d._ACTIVE_DATABASES.pop(
|
|
99
|
+
_d._ACTIVE_DATABASES.pop(app, None)
|
|
100
100
|
try:
|
|
101
101
|
client = TestClient(app)
|
|
102
102
|
r = client.get("/x")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Regression tests for 0.2.0 hardening fixes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from hawkapi import HawkAPI
|
|
9
|
+
|
|
10
|
+
from hawkapi_sqlalchemy import (
|
|
11
|
+
Database,
|
|
12
|
+
DatabaseConfig,
|
|
13
|
+
init_database,
|
|
14
|
+
open_session,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_open_session_no_commit_param() -> None:
|
|
19
|
+
"""The misleading ``commit`` kwarg must be gone."""
|
|
20
|
+
app = HawkAPI(openapi_url=None, docs_url=None, redoc_url=None, scalar_url=None)
|
|
21
|
+
init_database(app, url="sqlite+aiosqlite:///:memory:")
|
|
22
|
+
import asyncio
|
|
23
|
+
|
|
24
|
+
async def _go() -> None:
|
|
25
|
+
with pytest.raises(TypeError):
|
|
26
|
+
await open_session(app, commit=True) # type: ignore[call-arg]
|
|
27
|
+
|
|
28
|
+
asyncio.run(_go())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_replica_fallback_warns(caplog: pytest.LogCaptureFixture) -> None:
|
|
32
|
+
"""Looking up an unregistered engine must log a WARNING when it falls back."""
|
|
33
|
+
db = Database()
|
|
34
|
+
db.add("primary", DatabaseConfig(url="sqlite+aiosqlite:///:memory:"))
|
|
35
|
+
|
|
36
|
+
with caplog.at_level(logging.WARNING, logger="hawkapi_sqlalchemy"):
|
|
37
|
+
engine = db.get("replica")
|
|
38
|
+
assert engine is db.engines["primary"]
|
|
39
|
+
assert any("falling back to primary" in record.message for record in caplog.records), [
|
|
40
|
+
r.message for r in caplog.records
|
|
41
|
+
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|