Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
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
137 changes: 88 additions & 49 deletions src/fastsqla.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import math
import os
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable
from contextlib import asynccontextmanager
from contextlib import _AsyncGeneratorContextManager, asynccontextmanager
from typing import Annotated, Generic, TypeVar, TypedDict

from fastapi import Depends, FastAPI, Query
Expand Down Expand Up @@ -78,79 +78,118 @@ 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
) -> Callable[[FastAPI], _AsyncGeneratorContextManager[State, None]]:
"""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.")

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

async with engine.begin() as conn:
await conn.run_sync(Base.prepare)
SessionFactory.configure(bind=None)
await engine.dispose()

SessionFactory.configure(bind=engine)
await logger.ainfo("Cleared SQLAlchemy config.")

await logger.ainfo("Configured SQLAlchemy.")
return lifespan

yield {"fastsqla_engine": engine}

SessionFactory.configure(bind=None)
await engine.dispose()
lifespan = new_lifespan()
"""Use `fastsqla.lifespan` to set up SQLAlchemy directly from environment variables.

await logger.ainfo("Cleared SQLAlchemy config.")
In an ASGI application, [lifespan events](https://asgi.readthedocs.io/en/latest/specs/lifespan.html)
are used to communicate startup & shutdown events.

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.

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

```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


@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")
30 changes: 26 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,26 @@ 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


async def test_new_lifespan_fails_with_invalid_connect_args(sqlalchemy_url):
from fastsqla import new_lifespan

lifespan = new_lifespan(sqlalchemy_url, connect_args={"this is wrong": False})

with raises(TypeError):
async with lifespan(app):
pass
Loading