Skip to content

Commit aa2642f

Browse files
committed
feat: new_lifespan to allow configuring sqla engine directly (#25)
1 parent 3be5771 commit aa2642f

File tree

9 files changed

+663
-515
lines changed

9 files changed

+663
-515
lines changed

.github/uv/action.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ runs:
44
using: "composite"
55
steps:
66
- name: ⚡️ setup uv
7-
uses: astral-sh/setup-uv@v2
7+
uses: astral-sh/setup-uv@v6
88
with:
99
version: "latest"
1010
enable-cache: true
11-
cache-suffix: 2024-09-08 09:10
11+
cache-suffix: 2025-10-03 16:00
1212
cache-dependency-glob: "uv.lock"

docs/pagination.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
``` py title="example.py" hl_lines="25 26 27"
2121
from fastapi import FastAPI
2222
from fastsqla import Base, Paginate, Page, lifespan
23-
from pydantic import BaseModel
23+
from pydantic import BaseModel, ConfigDict
2424
from sqlalchemy import select
2525
from sqlalchemy.orm import Mapped, mapped_column
2626

@@ -34,7 +34,7 @@ class Hero(Base):
3434
age: Mapped[int]
3535

3636

37-
class HeroModel(HeroBase):
37+
class HeroModel(BaseModel):
3838
model_config = ConfigDict(from_attributes=True)
3939
id: int
4040
name: str

docs/setup.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
# Setup
22

3+
FastSQLA provides two ways to configure your SQLAlchemy database connection:
4+
5+
- **Environment variables** ([`lifespan`][fastsqla.lifespan]): Simple configuration
6+
following [12-factor app](https://12factor.net/config) principles, ideal for most use cases.
7+
- **Programmatic** ([`new_lifespan`][fastsqla.new_lifespan]): Direct SQLAlchemy engine
8+
configuration for advanced customization needs
9+
310
## `fastsqla.lifespan`
411

512
::: fastsqla.lifespan
613
options:
714
heading_level: false
815
show_source: false
916

10-
## Configuration
17+
### Lifespan configuration
1118

1219
Configuration is done exclusively via environment variables, adhering to the
1320
[**Twelve-Factor App methodology**](https://12factor.net/config).
@@ -16,7 +23,7 @@ The only required key is **`SQLALCHEMY_URL`**, which defines the database URL. I
1623
specifies the database driver in the URL's scheme and allows embedding driver parameters
1724
in the query string. Example:
1825

19-
sqlite+aiosqlite:////tmp/test.db?check_same_thread=false
26+
sqlite+aiosqlite:////tmp/test.db
2027

2128
All parameters of [`sqlalchemy.create_engine`][] can be configured by setting environment
2229
variables, with each parameter name prefixed by **`SQLALCHEMY_`**.
@@ -26,7 +33,7 @@ variables, with each parameter name prefixed by **`SQLALCHEMY_`**.
2633
FastSQLA is **case-insensitive** when reading environment variables, so parameter
2734
names prefixed with **`SQLALCHEMY_`** can be provided in any letter case.
2835

29-
### Examples
36+
#### Examples
3037

3138
1. :simple-postgresql: PostgreSQL url using
3239
[`asyncpg`][sqlalchemy.dialects.postgresql.asyncpg] driver with a
@@ -42,8 +49,8 @@ variables, with each parameter name prefixed by **`SQLALCHEMY_`**.
4249
[`pool_size`][sqlalchemy.create_engine.params.pool_size] of 50:
4350

4451
```bash
45-
export sqlalchemy_url=sqlite+aiosqlite:///tmp/test.db?check_same_thread=false
46-
export sqlalchemy_pool_size=10
52+
export sqlalchemy_url=sqlite+aiosqlite:///tmp/test.db
53+
export sqlalchemy_pool_size=50
4754
```
4855

4956
3. :simple-mariadb: MariaDB url using [`aiomysql`][sqlalchemy.dialects.mysql.aiomysql]
@@ -53,3 +60,12 @@ variables, with each parameter name prefixed by **`SQLALCHEMY_`**.
5360
export sqlalchemy_url=mysql+aiomysql://bob:password!@db.example.com/app
5461
export sqlalchemy_echo=true
5562
```
63+
64+
65+
66+
## `fastsqla.new_lifespan`
67+
68+
::: fastsqla.new_lifespan
69+
options:
70+
heading_level: false
71+
show_source: false

pyproject.toml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ version = "0.3.0"
44
description = "SQLAlchemy extension for FastAPI that supports asynchronous sessions and includes built-in pagination."
55
readme = "README.md"
66
requires-python = ">=3.12"
7-
authors = [{ name = "Hadrien David", email = "bonjour@hadriendavid.com" }]
7+
authors = [{ name = "Hadrien David", email = "h@driendavid.com" }]
88
classifiers = [
99
"Development Status :: 4 - Beta",
1010
"Environment :: Web Environment",
@@ -15,7 +15,6 @@ classifiers = [
1515
"Intended Audience :: Developers",
1616
"Intended Audience :: Information Technology",
1717
"Intended Audience :: System Administrators",
18-
"License :: OSI Approved :: MIT License",
1918
"Operating System :: OS Independent",
2019
"Programming Language :: Python :: 3 :: Only",
2120
"Programming Language :: Python :: 3.12",
@@ -32,7 +31,8 @@ classifiers = [
3231
"Typing :: Typed",
3332
]
3433
keywords = ["FastAPI", "SQLAlchemy", "AsyncIO"]
35-
license = { text = "MIT License" }
34+
license = "MIT"
35+
license-files = ["LICENSE"]
3636
dependencies = ["fastapi>=0.115.6", "sqlalchemy[asyncio]>=2.0.37", "structlog>=24.4.0"]
3737

3838
[project.urls]
@@ -53,7 +53,9 @@ sqlmodel = ["sqlmodel>=0.0.22"]
5353

5454
[tool.uv]
5555
package = true
56-
dev-dependencies = [
56+
57+
[dependency-groups]
58+
dev = [
5759
"asgi-lifespan>=2.1.0",
5860
"coverage>=7.6.1",
5961
"faker>=28.4.1",
@@ -88,6 +90,7 @@ env = "GH_TOKEN"
8890

8991
[tool.semantic_release]
9092
version_toml = ["pyproject.toml:project.version"]
93+
allow_zero_version = true
9194

9295
[tool.semantic_release.changelog.default_templates]
9396
changelog_file = "./docs/changelog.md"

src/fastsqla.py

Lines changed: 88 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import math
22
import os
33
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable
4-
from contextlib import asynccontextmanager
4+
from contextlib import _AsyncGeneratorContextManager, asynccontextmanager
55
from typing import Annotated, Generic, TypeVar, TypedDict
66

77
from fastapi import Depends, FastAPI, Query
@@ -78,79 +78,118 @@ class State(TypedDict):
7878
fastsqla_engine: AsyncEngine
7979

8080

81-
@asynccontextmanager
82-
async def lifespan(app: FastAPI) -> AsyncGenerator[State, None]:
83-
"""Use `fastsqla.lifespan` to set up SQLAlchemy.
84-
85-
In an ASGI application, [lifespan events](https://asgi.readthedocs.io/en/latest/specs/lifespan.html)
86-
are used to communicate startup & shutdown events.
81+
def new_lifespan(
82+
url: str | None = None, **kw
83+
) -> Callable[[FastAPI], _AsyncGeneratorContextManager[State, None]]:
84+
"""Create a new lifespan async context manager.
8785
88-
The [`lifespan`](https://fastapi.tiangolo.com/advanced/events/#lifespan) parameter of
89-
the `FastAPI` app can be assigned to a context manager, which is opened when the app
90-
starts and closed when the app stops.
86+
It expects the exact same parameters as
87+
[`sqlalchemy.ext.asyncio.create_async_engine`][sqlalchemy.ext.asyncio.create_async_engine]
9188
92-
In order for `FastSQLA` to setup `SQLAlchemy` before the app is started, set
93-
`lifespan` parameter to `fastsqla.lifespan`:
89+
Example:
9490
9591
```python
9692
from fastapi import FastAPI
97-
from fastsqla import lifespan
93+
from fastsqla import new_lifespan
9894
95+
lifespan = new_lifespan(
96+
"sqlite+aiosqlite:///app/db.sqlite", connect_args={"autocommit": False}
97+
)
9998
10099
app = FastAPI(lifespan=lifespan)
101100
```
102101
103-
If multiple lifespan contexts are required, create an async context manager function
104-
to handle them and set it as the app's lifespan:
102+
Args:
103+
url (str): Database url.
104+
kw (dict): Configuration parameters as expected by [`sqlalchemy.ext.asyncio.create_async_engine`][sqlalchemy.ext.asyncio.create_async_engine]
105+
"""
105106

106-
```python
107-
from collections.abc import AsyncGenerator
108-
from contextlib import asynccontextmanager
107+
has_config = url is not None
109108

110-
from fastapi import FastAPI
111-
from fastsqla import lifespan as fastsqla_lifespan
112-
from this_other_library import another_lifespan
109+
@asynccontextmanager
110+
async def lifespan(app: FastAPI) -> AsyncGenerator[State, None]:
111+
if has_config:
112+
prefix = ""
113+
sqla_config = {**kw, **{"url": url}}
113114

115+
else:
116+
prefix = "sqlalchemy_"
117+
sqla_config = {k.lower(): v for k, v in os.environ.items()}
114118

115-
@asynccontextmanager
116-
async def lifespan(app:FastAPI) -> AsyncGenerator[dict, None]:
117-
async with AsyncExitStack() as stack:
118-
yield {
119-
**stack.enter_async_context(lifespan(app)),
120-
**stack.enter_async_context(another_lifespan(app)),
121-
}
119+
try:
120+
engine = async_engine_from_config(sqla_config, prefix=prefix)
122121

122+
except KeyError as exc:
123+
raise Exception(f"Missing {prefix}{exc.args[0]} in environ.") from exc
123124

124-
app = FastAPI(lifespan=lifespan)
125-
```
125+
async with engine.begin() as conn:
126+
await conn.run_sync(Base.prepare)
126127

127-
To learn more about lifespan protocol:
128+
SessionFactory.configure(bind=engine)
128129

129-
* [Lifespan Protocol](https://asgi.readthedocs.io/en/latest/specs/lifespan.html)
130-
* [Use Lifespan State instead of `app.state`](https://github.com/Kludex/fastapi-tips?tab=readme-ov-file#6-use-lifespan-state-instead-of-appstate)
131-
* [FastAPI lifespan documentation](https://fastapi.tiangolo.com/advanced/events/)
132-
"""
133-
prefix = "sqlalchemy_"
134-
sqla_config = {k.lower(): v for k, v in os.environ.items()}
135-
try:
136-
engine = async_engine_from_config(sqla_config, prefix=prefix)
130+
await logger.ainfo("Configured SQLAlchemy.")
137131

138-
except KeyError as exc:
139-
raise Exception(f"Missing {prefix}{exc.args[0]} in environ.") from exc
132+
yield {"fastsqla_engine": engine}
140133

141-
async with engine.begin() as conn:
142-
await conn.run_sync(Base.prepare)
134+
SessionFactory.configure(bind=None)
135+
await engine.dispose()
143136

144-
SessionFactory.configure(bind=engine)
137+
await logger.ainfo("Cleared SQLAlchemy config.")
145138

146-
await logger.ainfo("Configured SQLAlchemy.")
139+
return lifespan
147140

148-
yield {"fastsqla_engine": engine}
149141

150-
SessionFactory.configure(bind=None)
151-
await engine.dispose()
142+
lifespan = new_lifespan()
143+
"""Use `fastsqla.lifespan` to set up SQLAlchemy directly from environment variables.
152144
153-
await logger.ainfo("Cleared SQLAlchemy config.")
145+
In an ASGI application, [lifespan events](https://asgi.readthedocs.io/en/latest/specs/lifespan.html)
146+
are used to communicate startup & shutdown events.
147+
148+
The [`lifespan`](https://fastapi.tiangolo.com/advanced/events/#lifespan) parameter of
149+
the `FastAPI` app can be assigned to a context manager, which is opened when the app
150+
starts and closed when the app stops.
151+
152+
In order for `FastSQLA` to setup `SQLAlchemy` before the app is started, set
153+
`lifespan` parameter to `fastsqla.lifespan`:
154+
155+
```python
156+
from fastapi import FastAPI
157+
from fastsqla import lifespan
158+
159+
160+
app = FastAPI(lifespan=lifespan)
161+
```
162+
163+
If multiple lifespan contexts are required, create an async context manager function
164+
to handle them and set it as the app's lifespan:
165+
166+
```python
167+
from collections.abc import AsyncGenerator
168+
from contextlib import asynccontextmanager
169+
170+
from fastapi import FastAPI
171+
from fastsqla import lifespan as fastsqla_lifespan
172+
from this_other_library import another_lifespan
173+
174+
175+
@asynccontextmanager
176+
async def lifespan(app:FastAPI) -> AsyncGenerator[dict, None]:
177+
async with AsyncExitStack() as stack:
178+
yield {
179+
**stack.enter_async_context(lifespan(app)),
180+
**stack.enter_async_context(another_lifespan(app)),
181+
}
182+
183+
184+
app = FastAPI(lifespan=lifespan)
185+
```
186+
187+
To learn more about lifespan protocol:
188+
189+
* [Lifespan Protocol](https://asgi.readthedocs.io/en/latest/specs/lifespan.html)
190+
* [Use Lifespan State instead of `app.state`](https://github.com/Kludex/fastapi-tips?tab=readme-ov-file#6-use-lifespan-state-instead-of-appstate)
191+
* [FastAPI lifespan documentation](https://fastapi.tiangolo.com/advanced/events/)
192+
"""
154193

155194

156195
@asynccontextmanager

tests/conftest.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ def pytest_configure(config):
1111

1212

1313
@fixture
14-
def environ(tmp_path):
15-
values = {
16-
"PYTHONASYNCIODEBUG": "1",
17-
"SQLALCHEMY_URL": f"sqlite+aiosqlite:///{tmp_path}/test.db",
18-
}
14+
def sqlalchemy_url(tmp_path):
15+
return f"sqlite+aiosqlite:///{tmp_path}/test.db"
16+
17+
18+
@fixture
19+
def environ(sqlalchemy_url):
20+
values = {"PYTHONASYNCIODEBUG": "1", "SQLALCHEMY_URL": sqlalchemy_url}
1921

2022
with patch.dict("os.environ", values=values, clear=True):
2123
yield values

tests/integration/test_base.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
from fastapi import FastAPI
12
from pytest import fixture
23
from sqlalchemy import text
34

5+
app = FastAPI()
6+
47

58
@fixture(autouse=True)
69
async def setup_tear_down(engine):
@@ -26,7 +29,7 @@ class User(Base):
2629
assert not hasattr(User, "email")
2730
assert not hasattr(User, "name")
2831

29-
async with lifespan(None):
32+
async with lifespan(app):
3033
assert hasattr(User, "id")
3134
assert hasattr(User, "email")
3235
assert hasattr(User, "name")

0 commit comments

Comments
 (0)