Skip to content

Commit 584686c

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

File tree

6 files changed

+148
-66
lines changed

6 files changed

+148
-66
lines changed

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

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")

tests/unit/test_lifespan.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
from fastapi import FastAPI
12
from pytest import raises
23

4+
app = FastAPI()
5+
36

47
async def test_it_returns_state(environ):
58
from fastsqla import lifespan
69

7-
async with lifespan(None) as state:
10+
async with lifespan(app) as state:
811
assert "fastsqla_engine" in state
912

1013

@@ -13,7 +16,7 @@ async def test_it_binds_an_sqla_engine_to_sessionmaker(environ):
1316

1417
assert SessionFactory.kw["bind"] is None
1518

16-
async with lifespan(None):
19+
async with lifespan(app):
1720
engine = SessionFactory.kw["bind"]
1821
assert engine is not None
1922
assert str(engine.url) == environ["SQLALCHEMY_URL"]
@@ -26,7 +29,7 @@ async def test_it_fails_on_a_missing_sqlalchemy_url(monkeypatch):
2629

2730
monkeypatch.delenv("SQLALCHEMY_URL", raising=False)
2831
with raises(Exception) as raise_info:
29-
async with lifespan(None):
32+
async with lifespan(app):
3033
pass
3134

3235
assert raise_info.value.args[0] == "Missing sqlalchemy_url in environ."
@@ -37,7 +40,26 @@ async def test_it_fails_on_not_async_engine(monkeypatch):
3740

3841
monkeypatch.setenv("SQLALCHEMY_URL", "sqlite:///:memory:")
3942
with raises(Exception) as raise_info:
40-
async with lifespan(None):
43+
async with lifespan(app):
4144
pass
4245

4346
assert "'pysqlite' is not async." in raise_info.value.args[0]
47+
48+
49+
async def test_new_lifespan_with_connect_args(sqlalchemy_url):
50+
from fastsqla import new_lifespan
51+
52+
lifespan = new_lifespan(sqlalchemy_url, connect_args={"autocommit": False})
53+
54+
async with lifespan(app):
55+
pass
56+
57+
58+
async def test_new_lifespan_fails_with_invalid_connect_args(sqlalchemy_url):
59+
from fastsqla import new_lifespan
60+
61+
lifespan = new_lifespan(sqlalchemy_url, connect_args={"this is wrong": False})
62+
63+
with raises(TypeError):
64+
async with lifespan(app):
65+
pass

0 commit comments

Comments
 (0)