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.
Files changed (28) hide show
  1. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/CHANGELOG.md +20 -0
  2. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/PKG-INFO +1 -1
  3. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/pyproject.toml +1 -1
  4. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/__init__.py +1 -1
  5. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/_alembic.py +10 -0
  6. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/_database.py +18 -4
  7. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/_session.py +8 -3
  8. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/tests/test_plugin.py +1 -1
  9. hawkapi_sqlalchemy-0.2.0/tests/test_security.py +41 -0
  10. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/uv.lock +1 -1
  11. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/.github/workflows/ci.yml +0 -0
  12. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/.github/workflows/release.yml +0 -0
  13. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/.gitignore +0 -0
  14. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/LICENSE +0 -0
  15. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/README.md +0 -0
  16. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/_engine.py +0 -0
  17. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/_health.py +0 -0
  18. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/_models.py +0 -0
  19. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/_testing.py +0 -0
  20. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/alembic.py +0 -0
  21. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/src/hawkapi_sqlalchemy/py.typed +0 -0
  22. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/tests/__init__.py +0 -0
  23. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/tests/conftest.py +0 -0
  24. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/tests/test_database.py +0 -0
  25. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/tests/test_engine.py +0 -0
  26. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/tests/test_health.py +0 -0
  27. {hawkapi_sqlalchemy-0.1.0 → hawkapi_sqlalchemy-0.2.0}/tests/test_models.py +0 -0
  28. {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.1.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.1.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.1.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 configured.
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
- _ACTIVE_DATABASES: dict[int, Database] = {}
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
- _ACTIVE_DATABASES[id(app)] = database
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
- db = _ACTIVE_DATABASES.get(id(app))
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", commit: bool = True) -> AsyncSession:
55
- """Open a raw session without going through DI. Caller is responsible for closing it."""
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(id(app), None)
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
+ ]
@@ -147,7 +147,7 @@ wheels = [
147
147
 
148
148
  [[package]]
149
149
  name = "hawkapi-sqlalchemy"
150
- version = "0.1.0"
150
+ version = "0.2.0"
151
151
  source = { editable = "." }
152
152
  dependencies = [
153
153
  { name = "aiosqlite" },