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 0f0b2ec..49a9ad5 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -1,5 +1,12 @@ # 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 @@ -7,7 +14,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). @@ -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_`**. @@ -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 @@ -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] @@ -53,3 +60,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 diff --git a/src/fastsqla.py b/src/fastsqla.py index 7795620..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,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 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..626d650 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,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