Skip to content

Commit 2115d9b

Browse files
committed
feat: new_lifespan to allow configuring sqla engine directly
1 parent 3be5771 commit 2115d9b

File tree

6 files changed

+128
-61
lines changed

6 files changed

+128
-61
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ pip install uvicorn aiosqlite fastsqla
266266
```
267267
Let's run the app:
268268
```
269-
sqlalchemy_url=sqlite+aiosqlite:///db.sqlite?check_same_thread=false \
269+
sqlalchemy_url=sqlite+aiosqlite:///db.sqlite \
270270
uvicorn example:app
271271
```
272272

docs/setup.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
# Setup
22

3+
To configure just using environment variables, check [`lifespan`][fastsqla.lifespan].
4+
5+
To configure programatically, check [`new_lifespan`][fastsqla.new_lifespan]
6+
37
## `fastsqla.lifespan`
48

59
::: fastsqla.lifespan
610
options:
711
heading_level: false
812
show_source: false
913

10-
## Configuration
14+
### Lifespan configuration
1115

1216
Configuration is done exclusively via environment variables, adhering to the
1317
[**Twelve-Factor App methodology**](https://12factor.net/config).
@@ -26,7 +30,7 @@ variables, with each parameter name prefixed by **`SQLALCHEMY_`**.
2630
FastSQLA is **case-insensitive** when reading environment variables, so parameter
2731
names prefixed with **`SQLALCHEMY_`** can be provided in any letter case.
2832

29-
### Examples
33+
#### Examples
3034

3135
1. :simple-postgresql: PostgreSQL url using
3236
[`asyncpg`][sqlalchemy.dialects.postgresql.asyncpg] driver with a
@@ -53,3 +57,12 @@ variables, with each parameter name prefixed by **`SQLALCHEMY_`**.
5357
export sqlalchemy_url=mysql+aiomysql://bob:password!@db.example.com/app
5458
export sqlalchemy_echo=true
5559
```
60+
61+
62+
63+
## `fastsqla.new_lifespan`
64+
65+
::: fastsqla.new_lifespan
66+
options:
67+
heading_level: false
68+
show_source: false

src/fastsqla.py

Lines changed: 85 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -78,79 +78,116 @@ 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(url: str | None = None, **kw):
82+
"""Create a new lifespan async context manager.
8783
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.
84+
It expects the exact same parameters as
85+
[`sqlalchemy.ext.asyncio.create_async_engine`][sqlalchemy.ext.asyncio.create_async_engine]
9186
92-
In order for `FastSQLA` to setup `SQLAlchemy` before the app is started, set
93-
`lifespan` parameter to `fastsqla.lifespan`:
87+
Example:
9488
9589
```python
9690
from fastapi import FastAPI
97-
from fastsqla import lifespan
91+
from fastsqla import new_lifespan
9892
93+
lifespan = new_lifespan(
94+
"sqlite+aiosqlite:///app/db.sqlite"), connect_args={"autocommit": False}
95+
)
9996
10097
app = FastAPI(lifespan=lifespan)
10198
```
10299
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:
100+
Args:
101+
url (str): Database url.
102+
kw (dict): Configuration parameters as expected by [`sqlalchemy.ext.asyncio.create_async_engine`][sqlalchemy.ext.asyncio.create_async_engine]
103+
"""
105104

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

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

113+
else:
114+
prefix = "sqlalchemy_"
115+
sqla_config = {k.lower(): v for k, v in os.environ.items()}
114116

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-
}
117+
try:
118+
engine = async_engine_from_config(sqla_config, prefix=prefix)
122119

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

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

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

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)
128+
await logger.ainfo("Configured SQLAlchemy.")
129+
130+
yield {"fastsqla_engine": engine}
131+
132+
SessionFactory.configure(bind=None)
133+
await engine.dispose()
134+
135+
await logger.ainfo("Cleared SQLAlchemy config.")
136+
137+
return lifespan
137138

138-
except KeyError as exc:
139-
raise Exception(f"Missing {prefix}{exc.args[0]} in environ.") from exc
140139

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

155192

156193
@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: 16 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,16 @@ 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

0 commit comments

Comments
 (0)