Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ pip install uvicorn aiosqlite fastsqla
```
Let's run the app:
```
sqlalchemy_url=sqlite+aiosqlite:///db.sqlite?check_same_thread=false \
sqlalchemy_url=sqlite+aiosqlite:///db.sqlite \
uvicorn example:app
```

Expand Down
4 changes: 2 additions & 2 deletions docs/pagination.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
``` py title="example.py" hl_lines="25 26 27"
from fastapi import FastAPI
from fastsqla import Base, Paginate, Page, lifespan
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.orm import Mapped, mapped_column

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


class HeroModel(HeroBase):
class HeroModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
Expand Down
26 changes: 21 additions & 5 deletions docs/setup.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
# Setup

FastSQLA provides two ways to configure your SQLAlchemy database connection:

- **Environment variables** ([`lifespan`][fastsqla.lifespan]): Simple configuration
following [12-factor app](https://12factor.net/config) principles, ideal for most use cases.
- **Programmatic** ([`new_lifespan`][fastsqla.new_lifespan]): Direct SQLAlchemy engine
configuration for advanced customization needs

## `fastsqla.lifespan`

::: fastsqla.lifespan
options:
heading_level: false
show_source: false

## Configuration
### Lifespan configuration

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

sqlite+aiosqlite:////tmp/test.db?check_same_thread=false
sqlite+aiosqlite:////tmp/test.db

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

### Examples
#### Examples

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

```bash
export sqlalchemy_url=sqlite+aiosqlite:///tmp/test.db?check_same_thread=false
export sqlalchemy_pool_size=10
export sqlalchemy_url=sqlite+aiosqlite:///tmp/test.db
export sqlalchemy_pool_size=50
```

3. :simple-mariadb: MariaDB url using [`aiomysql`][sqlalchemy.dialects.mysql.aiomysql]
Expand All @@ -53,3 +60,12 @@ variables, with each parameter name prefixed by **`SQLALCHEMY_`**.
export sqlalchemy_url=mysql+aiomysql://bob:[email protected]/app
export sqlalchemy_echo=true
```



## `fastsqla.new_lifespan`

::: fastsqla.new_lifespan
options:
heading_level: false
show_source: false
133 changes: 85 additions & 48 deletions src/fastsqla.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,79 +78,116 @@ class State(TypedDict):
fastsqla_engine: AsyncEngine


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[State, None]:
"""Use `fastsqla.lifespan` to set up SQLAlchemy.

In an ASGI application, [lifespan events](https://asgi.readthedocs.io/en/latest/specs/lifespan.html)
are used to communicate startup & shutdown events.
def new_lifespan(url: str | None = None, **kw):
"""Create a new lifespan async context manager.

The [`lifespan`](https://fastapi.tiangolo.com/advanced/events/#lifespan) parameter of
the `FastAPI` app can be assigned to a context manager, which is opened when the app
starts and closed when the app stops.
It expects the exact same parameters as
[`sqlalchemy.ext.asyncio.create_async_engine`][sqlalchemy.ext.asyncio.create_async_engine]

In order for `FastSQLA` to setup `SQLAlchemy` before the app is started, set
`lifespan` parameter to `fastsqla.lifespan`:
Example:

```python
from fastapi import FastAPI
from fastsqla import lifespan
from fastsqla import new_lifespan

lifespan = new_lifespan(
"sqlite+aiosqlite:///app/db.sqlite", connect_args={"autocommit": False}
)

app = FastAPI(lifespan=lifespan)
```

If multiple lifespan contexts are required, create an async context manager function
to handle them and set it as the app's lifespan:
Args:
url (str): Database url.
kw (dict): Configuration parameters as expected by [`sqlalchemy.ext.asyncio.create_async_engine`][sqlalchemy.ext.asyncio.create_async_engine]
"""

```python
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
has_config = url is not None

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

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

@asynccontextmanager
async def lifespan(app:FastAPI) -> AsyncGenerator[dict, None]:
async with AsyncExitStack() as stack:
yield {
**stack.enter_async_context(lifespan(app)),
**stack.enter_async_context(another_lifespan(app)),
}
try:
engine = async_engine_from_config(sqla_config, prefix=prefix)

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

app = FastAPI(lifespan=lifespan)
```
async with engine.begin() as conn:
await conn.run_sync(Base.prepare)

To learn more about lifespan protocol:
SessionFactory.configure(bind=engine)

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

yield {"fastsqla_engine": engine}

SessionFactory.configure(bind=None)
await engine.dispose()

await logger.ainfo("Cleared SQLAlchemy config.")

return lifespan

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

async with engine.begin() as conn:
await conn.run_sync(Base.prepare)
lifespan = new_lifespan()
"""Use `fastsqla.lifespan` to set up SQLAlchemy directly from environment variables.

SessionFactory.configure(bind=engine)
In an ASGI application, [lifespan events](https://asgi.readthedocs.io/en/latest/specs/lifespan.html)
are used to communicate startup & shutdown events.

await logger.ainfo("Configured SQLAlchemy.")
The [`lifespan`](https://fastapi.tiangolo.com/advanced/events/#lifespan) parameter of
the `FastAPI` app can be assigned to a context manager, which is opened when the app
starts and closed when the app stops.

yield {"fastsqla_engine": engine}
In order for `FastSQLA` to setup `SQLAlchemy` before the app is started, set
`lifespan` parameter to `fastsqla.lifespan`:

SessionFactory.configure(bind=None)
await engine.dispose()
```python
from fastapi import FastAPI
from fastsqla import lifespan


app = FastAPI(lifespan=lifespan)
```

If multiple lifespan contexts are required, create an async context manager function
to handle them and set it as the app's lifespan:

```python
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastsqla import lifespan as fastsqla_lifespan
from this_other_library import another_lifespan

await logger.ainfo("Cleared SQLAlchemy config.")

@asynccontextmanager
async def lifespan(app:FastAPI) -> AsyncGenerator[dict, None]:
async with AsyncExitStack() as stack:
yield {
**stack.enter_async_context(lifespan(app)),
**stack.enter_async_context(another_lifespan(app)),
}


app = FastAPI(lifespan=lifespan)
```

To learn more about lifespan protocol:

* [Lifespan Protocol](https://asgi.readthedocs.io/en/latest/specs/lifespan.html)
* [Use Lifespan State instead of `app.state`](https://github.com/Kludex/fastapi-tips?tab=readme-ov-file#6-use-lifespan-state-instead-of-appstate)
* [FastAPI lifespan documentation](https://fastapi.tiangolo.com/advanced/events/)
"""


@asynccontextmanager
Expand Down
12 changes: 7 additions & 5 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ def pytest_configure(config):


@fixture
def environ(tmp_path):
values = {
"PYTHONASYNCIODEBUG": "1",
"SQLALCHEMY_URL": f"sqlite+aiosqlite:///{tmp_path}/test.db",
}
def sqlalchemy_url(tmp_path):
return f"sqlite+aiosqlite:///{tmp_path}/test.db"


@fixture
def environ(sqlalchemy_url):
values = {"PYTHONASYNCIODEBUG": "1", "SQLALCHEMY_URL": sqlalchemy_url}

with patch.dict("os.environ", values=values, clear=True):
yield values
Expand Down
5 changes: 4 additions & 1 deletion tests/integration/test_base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from fastapi import FastAPI
from pytest import fixture
from sqlalchemy import text

app = FastAPI()


@fixture(autouse=True)
async def setup_tear_down(engine):
Expand All @@ -26,7 +29,7 @@ class User(Base):
assert not hasattr(User, "email")
assert not hasattr(User, "name")

async with lifespan(None):
async with lifespan(app):
assert hasattr(User, "id")
assert hasattr(User, "email")
assert hasattr(User, "name")
20 changes: 16 additions & 4 deletions tests/unit/test_lifespan.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from fastapi import FastAPI
from pytest import raises

app = FastAPI()


async def test_it_returns_state(environ):
from fastsqla import lifespan

async with lifespan(None) as state:
async with lifespan(app) as state:
assert "fastsqla_engine" in state


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

assert SessionFactory.kw["bind"] is None

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

monkeypatch.delenv("SQLALCHEMY_URL", raising=False)
with raises(Exception) as raise_info:
async with lifespan(None):
async with lifespan(app):
pass

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

monkeypatch.setenv("SQLALCHEMY_URL", "sqlite:///:memory:")
with raises(Exception) as raise_info:
async with lifespan(None):
async with lifespan(app):
pass

assert "'pysqlite' is not async." in raise_info.value.args[0]


async def test_new_lifespan_with_connect_args(sqlalchemy_url):
from fastsqla import new_lifespan

lifespan = new_lifespan(sqlalchemy_url, connect_args={"autocommit": False})

async with lifespan(app):
pass
Loading