Skip to content

Commit bb454da

Browse files
authored
fix: use lifespan context manager in Starlette and FastAPI (#368)
The Starlette and FastAPI integrations now use a lifespan context manager instead of the deprecated startup/shutdown hooks
1 parent 3ab6494 commit bb454da

7 files changed

Lines changed: 462 additions & 252 deletions

File tree

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ repos:
2222
- id: unasyncd
2323
additional_dependencies: ["ruff"]
2424
- repo: https://github.com/charliermarsh/ruff-pre-commit
25-
rev: "v0.9.2"
25+
rev: "v0.9.3"
2626
hooks:
2727
# Run the linter.
2828
- id: ruff
@@ -32,7 +32,7 @@ repos:
3232
- id: ruff-format
3333
types_or: [ python, pyi ]
3434
- repo: https://github.com/codespell-project/codespell
35-
rev: v2.3.0
35+
rev: v2.4.0
3636
hooks:
3737
- id: codespell
3838
exclude: "uv.lock|examples/us_state_lookup.json"

advanced_alchemy/config/common.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,15 @@ def __post_init__(self) -> None:
202202

203203
event.listen(Session, "before_flush", touch_updated_timestamp)
204204

205-
def __hash__(self) -> int:
206-
return hash((self.__class__.__qualname__, self.bind_key))
205+
def __hash__(self) -> int: # pragma: no cover
206+
return hash(
207+
(
208+
self.__class__.__qualname__,
209+
self.connection_string,
210+
self.engine_config.__class__.__qualname__,
211+
self.bind_key,
212+
)
213+
)
207214

208215
def __eq__(self, other: object) -> bool:
209216
return self.__hash__() == other.__hash__()
@@ -250,7 +257,7 @@ def get_engine(self) -> EngineT:
250257
del engine_config["json_serializer"]
251258
return self.create_engine_callable(self.connection_string, **engine_config)
252259

253-
def create_session_maker(self) -> Callable[[], SessionT]:
260+
def create_session_maker(self) -> Callable[[], SessionT]: # pragma: no cover
254261
"""Get a session maker. If none exists yet, create one.
255262
256263
Returns:

advanced_alchemy/extensions/starlette/config.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,6 @@ def init_app(self, app: Starlette) -> None:
140140
)
141141

142142
app.add_middleware(BaseHTTPMiddleware, dispatch=self.middleware_dispatch)
143-
app.add_event_handler("startup", self.on_startup) # pyright: ignore[reportUnknownMemberType]
144-
app.add_event_handler("shutdown", self.on_shutdown) # pyright: ignore[reportUnknownMemberType]
145143

146144
async def on_startup(self) -> None:
147145
"""Initialize the Starlette application with this configuration."""
@@ -288,8 +286,6 @@ def init_app(self, app: Starlette) -> None:
288286
)
289287
_ = self.create_session_maker()
290288
app.add_middleware(BaseHTTPMiddleware, dispatch=self.middleware_dispatch)
291-
app.add_event_handler("startup", self.on_startup) # pyright: ignore[reportUnknownMemberType]
292-
app.add_event_handler("shutdown", self.on_shutdown) # pyright: ignore[reportUnknownMemberType]
293289

294290
async def on_startup(self) -> None:
295291
"""Initialize the Starlette application with this configuration."""
@@ -381,5 +377,5 @@ async def on_shutdown(self) -> None: # pragma: no cover
381377
if self.app is not None:
382378
with contextlib.suppress(AttributeError, KeyError):
383379
delattr(self.app.state, self.engine_key)
384-
delattr(self.app.state, self.session_key)
385380
delattr(self.app.state, self.session_maker_key)
381+
delattr(self.app.state, self.session_key)

advanced_alchemy/extensions/starlette/extension.py

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,16 @@
33

44
import contextlib
55
from contextlib import asynccontextmanager, contextmanager
6-
from typing import TYPE_CHECKING, AsyncGenerator, Callable, Generator, Sequence, Union, overload
6+
from typing import (
7+
TYPE_CHECKING,
8+
Any,
9+
AsyncGenerator,
10+
Callable,
11+
Generator,
12+
Sequence,
13+
Union,
14+
overload,
15+
)
716

817
from starlette.applications import Starlette
918
from starlette.requests import Request # noqa: TC002
@@ -68,8 +77,33 @@ def init_app(self, app: Starlette) -> None:
6877

6978
app.state.advanced_alchemy = self
7079

80+
original_lifespan = app.router.lifespan_context
81+
82+
@asynccontextmanager
83+
async def wrapped_lifespan(app: Starlette) -> AsyncGenerator[Any, None]: # pragma: no cover
84+
async with self.lifespan(app), original_lifespan(app) as state:
85+
yield state
86+
87+
app.router.lifespan_context = wrapped_lifespan
88+
89+
@asynccontextmanager
90+
async def lifespan(self, app: Starlette) -> AsyncGenerator[Any, None]: # pragma: no cover
91+
"""Context manager for lifespan events.
92+
93+
Args:
94+
app: The starlette application.
95+
96+
Yields:
97+
None
98+
"""
99+
await self.on_startup()
100+
try:
101+
yield
102+
finally:
103+
await self.on_shutdown()
104+
71105
@property
72-
def app(self) -> Starlette:
106+
def app(self) -> Starlette: # pragma: no cover
73107
"""Returns the Starlette application instance.
74108
75109
Raises:
@@ -79,7 +113,7 @@ def app(self) -> Starlette:
79113
Returns:
80114
starlette.applications.Starlette: The Starlette application instance.
81115
"""
82-
if self._app is None:
116+
if self._app is None: # pragma: no cover
83117
msg = "Application not initialized. Did you forget to call init_app?"
84118
raise ImproperConfigurationError(msg)
85119

@@ -119,36 +153,38 @@ def get_config(self, key: str | None = None) -> Union[SQLAlchemyAsyncConfig, SQL
119153
if key == "default" and len(self.config) == 1:
120154
key = self.config[0].bind_key or "default"
121155
config = self._mapped_configs.get(key)
122-
if config is None:
156+
if config is None: # pragma: no cover
123157
msg = f"Config with key {key} not found"
124158
raise ImproperConfigurationError(msg)
125159
return config
126160

127161
def get_async_config(self, key: str | None = None) -> SQLAlchemyAsyncConfig:
128162
"""Get the async config for the given key."""
129163
config = self.get_config(key)
130-
if not isinstance(config, SQLAlchemyAsyncConfig):
164+
if not isinstance(config, SQLAlchemyAsyncConfig): # pragma: no cover
131165
msg = "Expected an async config, but got a sync config"
132166
raise ImproperConfigurationError(msg)
133167
return config
134168

135169
def get_sync_config(self, key: str | None = None) -> SQLAlchemySyncConfig:
136170
"""Get the sync config for the given key."""
137171
config = self.get_config(key)
138-
if not isinstance(config, SQLAlchemySyncConfig):
172+
if not isinstance(config, SQLAlchemySyncConfig): # pragma: no cover
139173
msg = "Expected a sync config, but got an async config"
140174
raise ImproperConfigurationError(msg)
141175
return config
142176

143177
@asynccontextmanager
144-
async def with_async_session(self, key: str | None = None) -> AsyncGenerator[AsyncSession, None]:
178+
async def with_async_session(
179+
self, key: str | None = None
180+
) -> AsyncGenerator[AsyncSession, None]: # pragma: no cover
145181
"""Context manager for getting an async session."""
146182
config = self.get_async_config(key)
147183
async with config.get_session() as session:
148184
yield session
149185

150186
@contextmanager
151-
def with_sync_session(self, key: str | None = None) -> Generator[Session, None]:
187+
def with_sync_session(self, key: str | None = None) -> Generator[Session, None]: # pragma: no cover
152188
"""Context manager for getting a sync session."""
153189
config = self.get_sync_config(key)
154190
with config.get_session() as session:
@@ -164,7 +200,8 @@ def _get_session_from_request(request: Request, config: SQLAlchemySyncConfig) ->
164200

165201
@staticmethod
166202
def _get_session_from_request(
167-
request: Request, config: SQLAlchemyAsyncConfig | SQLAlchemySyncConfig
203+
request: Request,
204+
config: SQLAlchemyAsyncConfig | SQLAlchemySyncConfig, # pragma: no cover
168205
) -> Session | AsyncSession: # pragma: no cover
169206
"""Get the session for the given key."""
170207
session = getattr(request.state, config.session_key, None)
@@ -173,22 +210,24 @@ def _get_session_from_request(
173210
setattr(request.state, config.session_key, session)
174211
return session
175212

176-
def get_session(self, request: Request, key: str | None = None) -> Session | AsyncSession:
213+
def get_session(self, request: Request, key: str | None = None) -> Session | AsyncSession: # pragma: no cover
177214
"""Get the session for the given key."""
178215
config = self.get_config(key)
179216
return self._get_session_from_request(request, config)
180217

181-
def get_async_session(self, request: Request, key: str | None = None) -> AsyncSession:
218+
def get_async_session(self, request: Request, key: str | None = None) -> AsyncSession: # pragma: no cover
182219
"""Get the async session for the given key."""
183220
config = self.get_async_config(key)
184221
return self._get_session_from_request(request, config)
185222

186-
def get_sync_session(self, request: Request, key: str | None = None) -> Session:
223+
def get_sync_session(self, request: Request, key: str | None = None) -> Session: # pragma: no cover
187224
"""Get the sync session for the given key."""
188225
config = self.get_sync_config(key)
189226
return self._get_session_from_request(request, config)
190227

191-
def provide_session(self, key: str | None = None) -> Callable[[Request], Session | AsyncSession]:
228+
def provide_session(
229+
self, key: str | None = None
230+
) -> Callable[[Request], Session | AsyncSession]: # pragma: no cover
192231
"""Get the session for the given key."""
193232
config = self.get_config(key)
194233

@@ -197,7 +236,7 @@ def _get_session(request: Request) -> Session | AsyncSession:
197236

198237
return _get_session
199238

200-
def provide_async_session(self, key: str | None = None) -> Callable[[Request], AsyncSession]:
239+
def provide_async_session(self, key: str | None = None) -> Callable[[Request], AsyncSession]: # pragma: no cover
201240
"""Get the async session for the given key."""
202241
config = self.get_async_config(key)
203242

@@ -206,7 +245,7 @@ def _get_session(request: Request) -> AsyncSession:
206245

207246
return _get_session
208247

209-
def provide_sync_session(self, key: str | None = None) -> Callable[[Request], Session]:
248+
def provide_sync_session(self, key: str | None = None) -> Callable[[Request], Session]: # pragma: no cover
210249
"""Get the sync session for the given key."""
211250
config = self.get_sync_config(key)
212251

@@ -220,12 +259,12 @@ def get_engine(self, key: str | None = None) -> Engine | AsyncEngine: # pragma:
220259
config = self.get_config(key)
221260
return config.get_engine()
222261

223-
def get_async_engine(self, key: str | None = None) -> AsyncEngine:
262+
def get_async_engine(self, key: str | None = None) -> AsyncEngine: # pragma: no cover
224263
"""Get the async engine for the given key."""
225264
config = self.get_async_config(key)
226265
return config.get_engine()
227266

228-
def get_sync_engine(self, key: str | None = None) -> Engine:
267+
def get_sync_engine(self, key: str | None = None) -> Engine: # pragma: no cover
229268
"""Get the sync engine for the given key."""
230269
config = self.get_sync_config(key)
231270
return config.get_engine()

tests/unit/test_extensions/test_fastapi.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import sys
2-
from typing import TYPE_CHECKING, Callable, Generator, Literal, Union, cast
2+
from contextlib import asynccontextmanager
3+
from typing import TYPE_CHECKING, AsyncGenerator, Callable, Generator, Literal, Union, cast
34
from unittest.mock import MagicMock
45

56
import pytest
@@ -356,3 +357,41 @@ def handler(
356357
)
357358
assert mock.call_args_list[0].kwargs["session"] is not mock.call_args_list[1].kwargs["session"]
358359
assert mock.call_args_list[0].kwargs["engine"] is not mock.call_args_list[1].kwargs["engine"]
360+
361+
362+
async def test_lifespan_startup_shutdown_called_fastapi(mocker: MockerFixture, app: FastAPI, config: AnyConfig) -> None:
363+
mock_startup = mocker.patch.object(AdvancedAlchemy, "on_startup")
364+
mock_shutdown = mocker.patch.object(AdvancedAlchemy, "on_shutdown")
365+
_alchemy = AdvancedAlchemy(config, app=app)
366+
367+
with TestClient(app=app) as _client: # TestClient context manager triggers lifespan events
368+
pass # App starts up and shuts down within this context
369+
370+
mock_startup.assert_called_once()
371+
mock_shutdown.assert_called_once()
372+
373+
374+
async def test_lifespan_with_custom_lifespan_fastapi(mocker: MockerFixture, app: FastAPI, config: AnyConfig) -> None:
375+
mock_aa_startup = mocker.patch.object(AdvancedAlchemy, "on_startup")
376+
mock_aa_shutdown = mocker.patch.object(AdvancedAlchemy, "on_shutdown")
377+
mock_custom_startup = mocker.MagicMock()
378+
mock_custom_shutdown = mocker.MagicMock()
379+
380+
@asynccontextmanager
381+
async def custom_lifespan(app_in: FastAPI) -> AsyncGenerator[None, None]:
382+
mock_custom_startup()
383+
yield
384+
mock_custom_shutdown()
385+
386+
app.router.lifespan_context = custom_lifespan # type: ignore[assignment] # Set a custom lifespan on the app
387+
_alchemy = AdvancedAlchemy(config, app=app)
388+
389+
with TestClient(app=app) as _client: # TestClient context manager triggers lifespan events
390+
pass # App starts up and shuts down within this context
391+
392+
mock_aa_startup.assert_called_once()
393+
mock_aa_shutdown.assert_called_once()
394+
mock_custom_startup.assert_called_once()
395+
mock_custom_shutdown.assert_called_once()
396+
397+
# Optionally assert the order of calls if needed, e.g., using mocker.call_order

tests/unit/test_extensions/test_starlette.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

33
import sys
4+
from collections.abc import AsyncGenerator
5+
from contextlib import asynccontextmanager
46
from typing import TYPE_CHECKING, Callable, Generator, Union, cast
57
from unittest.mock import MagicMock
68

@@ -476,3 +478,43 @@ async def handler(request: Request) -> Response:
476478
client.get("/")
477479

478480
assert alchemy_1.get_sync_engine() is not alchemy_1.get_async_engine("other")
481+
482+
483+
async def test_lifespan_startup_shutdown_called_starlette(
484+
mocker: MockerFixture, app: Starlette, config: AnyConfig
485+
) -> None:
486+
mock_startup = mocker.patch.object(AdvancedAlchemy, "on_startup")
487+
mock_shutdown = mocker.patch.object(AdvancedAlchemy, "on_shutdown")
488+
_alchemy = AdvancedAlchemy(config, app=app)
489+
490+
with TestClient(app=app) as _client: # TestClient context manager triggers lifespan events
491+
pass # App starts up and shuts down within this context
492+
493+
mock_startup.assert_called_once()
494+
mock_shutdown.assert_called_once()
495+
496+
497+
async def test_lifespan_with_custom_lifespan_starlette(
498+
mocker: MockerFixture, app: Starlette, config: AnyConfig
499+
) -> None:
500+
mock_aa_startup = mocker.patch.object(AdvancedAlchemy, "on_startup")
501+
mock_aa_shutdown = mocker.patch.object(AdvancedAlchemy, "on_shutdown")
502+
mock_custom_startup = mocker.MagicMock()
503+
mock_custom_shutdown = mocker.MagicMock()
504+
505+
@asynccontextmanager
506+
async def custom_lifespan(app_in: Starlette) -> AsyncGenerator[None, None]:
507+
mock_custom_startup()
508+
yield
509+
mock_custom_shutdown()
510+
511+
app.router.lifespan_context = custom_lifespan # type: ignore[assignment] # Set a custom lifespan on the app
512+
_alchemy = AdvancedAlchemy(config, app=app)
513+
514+
with TestClient(app=app) as _client: # TestClient context manager triggers lifespan events
515+
pass # App starts up and shuts down within this context
516+
517+
mock_aa_startup.assert_called_once()
518+
mock_aa_shutdown.assert_called_once()
519+
mock_custom_startup.assert_called_once()
520+
mock_custom_shutdown.assert_called_once()

0 commit comments

Comments
 (0)