From 2115d9bc9054ce18a62522f312eb2afdd02b39f5 Mon Sep 17 00:00:00 2001 From: Hadrien David Date: Thu, 2 Oct 2025 19:51:49 -0300 Subject: [PATCH 1/5] feat: new_lifespan to allow configuring sqla engine directly --- README.md | 2 +- docs/setup.md | 17 ++++- src/fastsqla.py | 133 +++++++++++++++++++++------------ tests/conftest.py | 12 +-- tests/integration/test_base.py | 5 +- tests/unit/test_lifespan.py | 20 ++++- 6 files changed, 128 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index cdc1f7c..b7cf92b 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/docs/setup.md b/docs/setup.md index 0f0b2ec..11990d7 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -1,5 +1,9 @@ # Setup +To configure just using environment variables, check [`lifespan`][fastsqla.lifespan]. + +To configure programatically, check [`new_lifespan`][fastsqla.new_lifespan] + ## `fastsqla.lifespan` ::: fastsqla.lifespan @@ -7,7 +11,7 @@ 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). @@ -26,7 +30,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 @@ -53,3 +57,12 @@ variables, with each parameter name prefixed by **`SQLALCHEMY_`**. export sqlalchemy_url=mysql+aiomysql://bob:password!@db.example.com/app export sqlalchemy_echo=true ``` + + + +## `fastsqla.new_lifespan` + +::: fastsqla.new_lifespan + options: + heading_level: false + show_source: false \ No newline at end of file diff --git a/src/fastsqla.py b/src/fastsqla.py index 7795620..1009f5a 100644 --- a/src/fastsqla.py +++ b/src/fastsqla.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 0daebf9..be4fcad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/integration/test_base.py b/tests/integration/test_base.py index cc07211..609885d 100644 --- a/tests/integration/test_base.py +++ b/tests/integration/test_base.py @@ -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): @@ -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") diff --git a/tests/unit/test_lifespan.py b/tests/unit/test_lifespan.py index 60f63e6..d7d9793 100644 --- a/tests/unit/test_lifespan.py +++ b/tests/unit/test_lifespan.py @@ -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 @@ -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"] @@ -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." @@ -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 From c0dc7b99a7ee38358fee14766894af7bbc5e6df1 Mon Sep 17 00:00:00 2001 From: Hadrien David Date: Thu, 2 Oct 2025 23:57:32 -0400 Subject: [PATCH 2/5] docs + fix typos --- docs/pagination.md | 4 ++-- docs/setup.md | 15 +++++++++------ src/fastsqla.py | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/pagination.md b/docs/pagination.md index c4d4885..a0da588 100644 --- a/docs/pagination.md +++ b/docs/pagination.md @@ -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 @@ -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 diff --git a/docs/setup.md b/docs/setup.md index 11990d7..49a9ad5 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -1,8 +1,11 @@ # Setup -To configure just using environment variables, check [`lifespan`][fastsqla.lifespan]. +FastSQLA provides two ways to configure your SQLAlchemy database connection: -To configure programatically, check [`new_lifespan`][fastsqla.new_lifespan] +- **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` @@ -20,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_`**. @@ -46,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] @@ -65,4 +68,4 @@ variables, with each parameter name prefixed by **`SQLALCHEMY_`**. ::: fastsqla.new_lifespan options: heading_level: false - show_source: false \ No newline at end of file + show_source: false diff --git a/src/fastsqla.py b/src/fastsqla.py index 1009f5a..c2eb44a 100644 --- a/src/fastsqla.py +++ b/src/fastsqla.py @@ -91,7 +91,7 @@ def new_lifespan(url: str | None = None, **kw): from fastsqla import new_lifespan lifespan = new_lifespan( - "sqlite+aiosqlite:///app/db.sqlite"), connect_args={"autocommit": False} + "sqlite+aiosqlite:///app/db.sqlite", connect_args={"autocommit": False} ) app = FastAPI(lifespan=lifespan) From 425a39533e13ed9991913f33597a0d9e81d43afe Mon Sep 17 00:00:00 2001 From: Hadrien David Date: Fri, 3 Oct 2025 00:05:42 -0400 Subject: [PATCH 3/5] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b7cf92b..cdc1f7c 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,7 @@ pip install uvicorn aiosqlite fastsqla ``` Let's run the app: ``` -sqlalchemy_url=sqlite+aiosqlite:///db.sqlite \ +sqlalchemy_url=sqlite+aiosqlite:///db.sqlite?check_same_thread=false \ uvicorn example:app ``` From b557ae420fd0ce6a39cc624c0099e9a64baa01fe Mon Sep 17 00:00:00 2001 From: Hadrien David Date: Fri, 3 Oct 2025 00:21:10 -0400 Subject: [PATCH 4/5] add test --- tests/unit/test_lifespan.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit/test_lifespan.py b/tests/unit/test_lifespan.py index d7d9793..626d650 100644 --- a/tests/unit/test_lifespan.py +++ b/tests/unit/test_lifespan.py @@ -53,3 +53,13 @@ async def test_new_lifespan_with_connect_args(sqlalchemy_url): 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 From 794d8bc41c06db67a6c12d35d8c70dd6bfe1e653 Mon Sep 17 00:00:00 2001 From: Hadrien David Date: Fri, 3 Oct 2025 15:19:36 -0400 Subject: [PATCH 5/5] type --- src/fastsqla.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/fastsqla.py b/src/fastsqla.py index c2eb44a..594d401 100644 --- a/src/fastsqla.py +++ b/src/fastsqla.py @@ -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 @@ -78,7 +78,9 @@ class State(TypedDict): fastsqla_engine: AsyncEngine -def new_lifespan(url: str | None = None, **kw): +def new_lifespan( + url: str | None = None, **kw +) -> Callable[[FastAPI], _AsyncGeneratorContextManager[State, None]]: """Create a new lifespan async context manager. It expects the exact same parameters as