mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-06 08:34:08 +00:00
Compare commits
10
Commits
v4.1.2
..
2c57ea3792
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c57ea3792
|
||
|
|
426f043df7
|
||
|
|
70e0b3b9d5 | ||
|
|
1e021005bc | ||
|
|
025f1907fd
|
||
|
|
9698a0743b | ||
|
|
22f307d0fc | ||
|
|
2641881df5
|
||
|
|
49b579bcec | ||
|
|
4bb4287922 |
@@ -31,7 +31,6 @@ Install only the extras you need:
|
|||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]"
|
uv add "fastapi-toolsets[cli]"
|
||||||
uv add "fastapi-toolsets[metrics]"
|
uv add "fastapi-toolsets[metrics]"
|
||||||
uv add "fastapi-toolsets[security]"
|
|
||||||
uv add "fastapi-toolsets[pytest]"
|
uv add "fastapi-toolsets[pytest]"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,7 +56,6 @@ uv add "fastapi-toolsets[all]"
|
|||||||
|
|
||||||
### Optional
|
### Optional
|
||||||
|
|
||||||
- **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers
|
|
||||||
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
||||||
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
||||||
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ Install only the extras you need:
|
|||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]"
|
uv add "fastapi-toolsets[cli]"
|
||||||
uv add "fastapi-toolsets[metrics]"
|
uv add "fastapi-toolsets[metrics]"
|
||||||
uv add "fastapi-toolsets[security]"
|
|
||||||
uv add "fastapi-toolsets[pytest]"
|
uv add "fastapi-toolsets[pytest]"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,7 +56,6 @@ uv add "fastapi-toolsets[all]"
|
|||||||
|
|
||||||
### Optional
|
### Optional
|
||||||
|
|
||||||
- **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers
|
|
||||||
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
||||||
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
||||||
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ The function creates and manages its own **dedicated session** internally, yield
|
|||||||
user.balance += 100
|
user.balance += 100
|
||||||
|
|
||||||
# With a custom lock mode
|
# With a custom lock mode
|
||||||
async with lock_tables(session, [Order], mode=LockMode.EXCLUSIVE):
|
async with lock_tables(session=session, tables=[Order], mode=LockMode.EXCLUSIVE):
|
||||||
await process_order(session, order_id)
|
await process_order(session, order_id)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -35,6 +35,6 @@ The function creates and manages its own **dedicated session** internally, yield
|
|||||||
user.balance += 100
|
user.balance += 100
|
||||||
|
|
||||||
# With a custom lock mode
|
# With a custom lock mode
|
||||||
async with lock_tables(session_maker, [Order], mode=LockMode.EXCLUSIVE) as session:
|
async with lock_tables(session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE) as session:
|
||||||
await process_order(session, order_id)
|
await process_order(session, order_id)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Migrating to v5.0
|
||||||
|
|
||||||
|
This page covers every breaking change introduced in **v5.0** and the steps required to update your code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
`db.py` is now the `db/` package, built around one object, [`Database`](../reference/db.md#fastapi_toolsets.db.Database), that owns the engine and sessionmaker. The free functions that took a `session_maker` you built and passed around yourself are gone from request-handling code; `Database` builds the sessionmaker for you.
|
||||||
|
|
||||||
|
### `create_db_dependency` / `create_db_context` removed in favor of `Database`
|
||||||
|
|
||||||
|
Build one `Database` with your URL (or an existing `engine=`), then use the instance directly as the FastAPI dependency, and `db.session()` for sessions outside request handlers.
|
||||||
|
|
||||||
|
=== "Before (`v4`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||||
|
from fastapi_toolsets.db import create_db_dependency, create_db_context
|
||||||
|
|
||||||
|
engine = create_async_engine("postgresql+asyncpg://...")
|
||||||
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
get_db = create_db_dependency(session_maker=SessionLocal)
|
||||||
|
get_db_context = create_db_context(session_maker=SessionLocal)
|
||||||
|
|
||||||
|
@app.get("/users")
|
||||||
|
async def list_users(session: AsyncSession = Depends(get_db)):
|
||||||
|
...
|
||||||
|
|
||||||
|
async def seed():
|
||||||
|
async with get_db_context() as session:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v5`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import Database
|
||||||
|
|
||||||
|
db = Database(url="postgresql+asyncpg://...")
|
||||||
|
|
||||||
|
@app.get("/users")
|
||||||
|
async def list_users(session: AsyncSession = Depends(db)):
|
||||||
|
...
|
||||||
|
|
||||||
|
async def seed():
|
||||||
|
async with db.session() as session:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Call `db.install(app)` to also commit before the response is sent (instead of in dependency teardown) and to dispose the engine on shutdown. See [the db module docs](../module/db.md#committing-before-the-response).
|
||||||
|
|
||||||
|
### `get_transaction` renamed to `transaction`
|
||||||
|
|
||||||
|
Same behavior (savepoint when already in a transaction, new transaction otherwise), new name, same import path.
|
||||||
|
|
||||||
|
=== "Before (`v4`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import get_transaction
|
||||||
|
|
||||||
|
async with get_transaction(session=session):
|
||||||
|
session.add(model)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v5`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import transaction
|
||||||
|
|
||||||
|
async with transaction(session=session):
|
||||||
|
session.add(model)
|
||||||
|
```
|
||||||
|
|
||||||
|
If you have a `Database` instance, `db.begin()` opens a session already inside a transaction:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with db.begin() as session:
|
||||||
|
session.add(User(name="ada"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### `lock_tables` is now also a `Database` method
|
||||||
|
|
||||||
|
The free `lock_tables(session_maker, tables, ...)` function still exists for callers who manage their own session factory, but prefer `db.lock_tables(tables, ...)`, which drops the `session_maker` argument:
|
||||||
|
|
||||||
|
=== "Before (`v4`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import lock_tables, LockMode
|
||||||
|
|
||||||
|
async with lock_tables(session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE) as session:
|
||||||
|
await process_order(session, order_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v5`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import LockMode
|
||||||
|
|
||||||
|
async with db.lock_tables(tables=[Order], mode=LockMode.EXCLUSIVE) as session:
|
||||||
|
await process_order(session, order_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `create_database` and `cleanup_tables` moved to `fastapi_toolsets.db.testing`
|
||||||
|
|
||||||
|
=== "Before (`v4`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import create_database, cleanup_tables
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v5`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db.testing import create_database, cleanup_tables
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
The security module has been removed and moved to a dedicated python package: [`fastapi-multiauth`](https://github.com/d3vyce/fastapi-multiauth).
|
||||||
|
|
||||||
|
Run `uv add fastapi-multiauth` and replace `from fastapi_toolsets.security import ...` with `from fastapi_multiauth import ...`.
|
||||||
+1
-1
@@ -167,7 +167,7 @@ user = await UserCrud.update(session, UserUpdate(credits=10), [User.id == user_i
|
|||||||
```
|
```
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
`with_for_update` requires an open transaction. Wrap your call in `async with session.begin()` or use the `get_transaction` helper if you are not already inside one.
|
`with_for_update` requires an open transaction. Wrap your call in `async with session.begin()` or use the `transaction` helper if you are not already inside one.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
`NOWAIT` raises `sqlalchemy.exc.OperationalError` immediately if the row is locked rather than waiting.
|
`NOWAIT` raises `sqlalchemy.exc.OperationalError` immediately if the row is locked rather than waiting.
|
||||||
|
|||||||
+114
-58
@@ -7,96 +7,156 @@ SQLAlchemy async session management with transactions, table locking, advisory l
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The `db` module provides helpers to create FastAPI dependencies and context managers for `AsyncSession`, along with utilities for nested transactions, table locks, advisory locks, and polling for row changes.
|
The `db` module is built around one object, [`Database`](../reference/db.md#fastapi_toolsets.db.Database), which owns the engine and sessionmaker and exposes the FastAPI dependency, a commit-before-response middleware, session/transaction context managers, and table locking. Free helpers cover savepoint-aware transactions, advisory locks, many-to-many association tables, and row-change polling.
|
||||||
|
|
||||||
## Session dependency
|
## Setup
|
||||||
|
|
||||||
Use [`create_db_dependency`](../reference/db.md#fastapi_toolsets.db.create_db_dependency) to create a FastAPI dependency that yields a session and auto-commits on success:
|
Create one `Database` for your app. Provide a **URL** (the facade builds and disposes the engine) or pass an existing **`engine=`** you own (e.g. for Alembic or `event.listen`). The session factory is built internally with `expire_on_commit=False`.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
from fastapi import Depends, FastAPI
|
||||||
from fastapi_toolsets.db import create_db_dependency
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
engine = create_async_engine(url="postgresql+asyncpg://...", future=True)
|
from fastapi_toolsets.db import Database
|
||||||
session_maker = async_sessionmaker(bind=engine, expire_on_commit=False)
|
|
||||||
|
|
||||||
get_db = create_db_dependency(session_maker=session_maker)
|
db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
|
||||||
|
|
||||||
@router.get("/users")
|
app = FastAPI()
|
||||||
async def list_users(session: AsyncSession = Depends(get_db)):
|
db.install(app) # commit middleware + engine disposal on shutdown
|
||||||
|
|
||||||
|
@app.get("/users")
|
||||||
|
async def list_users(session: AsyncSession = Depends(db)):
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The `Database` instance **is** the dependency: use it directly as `Depends(db)`. The whole request runs as a single transaction (CRUD writes use savepoints under it).
|
||||||
|
|
||||||
|
The **URL** may be a plain string or a Pydantic [`PostgresDsn`](https://docs.pydantic.dev/latest/api/networks/#pydantic.networks.PostgresDsn). In URL mode you can tune the engine: pass `connect_args` for DBAPI-level options and any other keyword for `create_async_engine` (e.g. `pool_size`, `echo`, `pool_pre_ping`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from pydantic import PostgresDsn
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
database_url: PostgresDsn
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
db = Database(
|
||||||
|
settings.database_url,
|
||||||
|
pool_size=20,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
connect_args={"server_settings": {"application_name": "myapp"}},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Committing before the response
|
||||||
|
|
||||||
|
[`db.install(app)`](../reference/db.md#fastapi_toolsets.db.Database) adds a middleware that commits the request's session when the response starts, after the endpoint returns and before the body is sent. With the middleware installed, the dependency does not commit again.
|
||||||
|
|
||||||
|
The request is committed as a single transaction:
|
||||||
|
|
||||||
|
- **Read-after-write**: a follow-up request sees the write.
|
||||||
|
- **Atomicity**: multi-write endpoints roll back as a unit on failure.
|
||||||
|
- **Errors roll back**: on a raised exception the session rolls back and nothing is committed.
|
||||||
|
|
||||||
|
Without `install`, the session commits in the dependency teardown, which runs after the response has been sent.
|
||||||
|
|
||||||
|
!!! warning "Streaming / SSE endpoints"
|
||||||
|
For a `StreamingResponse` / `EventSourceResponse`, the commit fires at the **start** of the stream. A stream that **writes** must open a short-lived session per write with [`db.session()`](#session-context-manager); the start-time commit will not flush writes made later during the stream.
|
||||||
|
|
||||||
|
## Lifespan
|
||||||
|
|
||||||
|
`db.install(app)` disposes the engine on shutdown, composing around your own lifespan:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app):
|
||||||
|
await warm_cache() # your startup
|
||||||
|
yield
|
||||||
|
await flush_metrics() # your shutdown
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
db.install(app) # your shutdown runs first, then the engine is disposed
|
||||||
|
```
|
||||||
|
|
||||||
|
If you have no lifespan of your own, [`db.lifespan`](../reference/db.md#fastapi_toolsets.db.Database) works standalone as `FastAPI(lifespan=db.lifespan)`. Engine disposal is idempotent and is a no-op when you passed your own `engine=`.
|
||||||
|
|
||||||
## Session context manager
|
## Session context manager
|
||||||
|
|
||||||
Use [`create_db_context`](../reference/db.md#fastapi_toolsets.db.create_db_context) for sessions outside request handlers (e.g. background tasks, CLI commands):
|
Use [`db.session()`](../reference/db.md#fastapi_toolsets.db.Database) for sessions outside request handlers (e.g. background tasks, CLI commands). It commits on clean exit and rolls back on exception:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import create_db_context
|
|
||||||
|
|
||||||
db_context = create_db_context(session_maker=session_maker)
|
|
||||||
|
|
||||||
async def seed():
|
async def seed():
|
||||||
async with db_context() as session:
|
async with db.session() as session:
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Nested transactions
|
## Transactions
|
||||||
|
|
||||||
[`get_transaction`](../reference/db.md#fastapi_toolsets.db.get_transaction) handles savepoints automatically, allowing safe nesting:
|
[`transaction`](../reference/db.md#fastapi_toolsets.db.transaction) opens a transaction on a session, using a savepoint when one is already open so it nests safely:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import get_transaction
|
from fastapi_toolsets.db import transaction
|
||||||
|
|
||||||
async def create_user_with_role(session=session):
|
async def create_user_with_role(session):
|
||||||
async with get_transaction(session=session):
|
async with transaction(session):
|
||||||
...
|
...
|
||||||
async with get_transaction(session=session): # uses savepoint
|
async with transaction(session): # uses a savepoint
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When you have a `Database`, [`db.begin()`](../reference/db.md#fastapi_toolsets.db.Database) opens a session already inside a transaction:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with db.begin() as session:
|
||||||
|
session.add(User(name="ada")) # commits on exit, rolls back on exception
|
||||||
|
```
|
||||||
|
|
||||||
## Table locking
|
## Table locking
|
||||||
|
|
||||||
[`lock_tables`](../reference/db.md#fastapi_toolsets.db.lock_tables) acquires PostgreSQL table-level locks before executing critical sections. It opens a **dedicated session** internally and yields it to the caller, so the lock is guaranteed to be released when the context exits:
|
[`db.lock_tables`](../reference/db.md#fastapi_toolsets.db.Database) acquires PostgreSQL table-level locks for a critical section. It opens a dedicated session internally and releases the lock when the context exits:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import lock_tables, LockMode
|
from fastapi_toolsets.db import LockMode
|
||||||
|
|
||||||
async with lock_tables(session_maker=session_maker, tables=[User], mode=LockMode.EXCLUSIVE) as session:
|
async with db.lock_tables([User], mode=LockMode.EXCLUSIVE) as session:
|
||||||
# No other transaction can modify User until this block exits
|
# No other transaction can modify User until this block exits
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
Available lock modes are defined in [`LockMode`](../reference/db.md#fastapi_toolsets.db.LockMode): `ACCESS_SHARE`, `ROW_SHARE`, `ROW_EXCLUSIVE`, `SHARE_UPDATE_EXCLUSIVE`, `SHARE`, `SHARE_ROW_EXCLUSIVE`, `EXCLUSIVE`, `ACCESS_EXCLUSIVE`.
|
Available lock modes are defined in [`LockMode`](../reference/db.md#fastapi_toolsets.db.LockMode): `ACCESS_SHARE`, `ROW_SHARE`, `ROW_EXCLUSIVE`, `SHARE_UPDATE_EXCLUSIVE`, `SHARE`, `SHARE_ROW_EXCLUSIVE`, `EXCLUSIVE`, `ACCESS_EXCLUSIVE`.
|
||||||
|
|
||||||
Pass `timeout` to limit how long the lock waits before giving up. On timeout, a [`LockTimeoutError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.LockTimeoutError) is raised instead of a raw database error:
|
Pass `timeout` to limit how long the lock waits. On timeout, a [`LockTimeoutError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.LockTimeoutError) is raised instead of a raw database error:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
async with lock_tables(session_maker, [Order], timeout="2s") as session:
|
async with db.lock_tables([Order], timeout="2s") as session:
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Advisory locking
|
## Advisory locking
|
||||||
|
|
||||||
[`advisory_lock`](../reference/db.md#fastapi_toolsets.db.advisory_lock) acquires a PostgreSQL session-level advisory lock. The lock is released explicitly when the context exits, regardless of whether the transaction has committed.
|
[`advisory_lock`](../reference/db.md#fastapi_toolsets.db.advisory_lock) acquires a PostgreSQL session-level advisory lock on a session you provide. The lock is released when the context exits:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import advisory_lock
|
from fastapi_toolsets.db import advisory_lock
|
||||||
|
|
||||||
# Blocking exclusive lock — waits until the lock is free
|
# Blocking exclusive lock: waits until the lock is free
|
||||||
async with advisory_lock(session=session, key=42):
|
async with advisory_lock(session=session, key=42):
|
||||||
...
|
...
|
||||||
|
|
||||||
# Non-blocking — yields False immediately if already held
|
# Non-blocking: yields False immediately if already held
|
||||||
async with advisory_lock(session=session, key=42, nowait=True) as acquired:
|
async with advisory_lock(session=session, key=42, nowait=True) as acquired:
|
||||||
if not acquired:
|
if not acquired:
|
||||||
raise HTTPException(409, "Resource is locked")
|
raise HTTPException(409, "Resource is locked")
|
||||||
|
|
||||||
# Blocking with a timeout — raises LockTimeoutError if not acquired in time
|
# Blocking with a timeout: raises LockTimeoutError if not acquired in time
|
||||||
async with advisory_lock(session=session, key=42, timeout="5s"):
|
async with advisory_lock(session=session, key=42, timeout="5s"):
|
||||||
...
|
...
|
||||||
|
|
||||||
# Shared — multiple readers allowed simultaneously, blocks exclusive writers
|
# Shared lock: multiple readers allowed simultaneously, blocks exclusive writers
|
||||||
async with advisory_lock(session=session, key=42, shared=True):
|
async with advisory_lock(session=session, key=42, shared=True):
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -106,11 +166,11 @@ async with advisory_lock(session=session, key=(1, user_id)):
|
|||||||
```
|
```
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
Advisory locks use PostgreSQL session-level functions (`pg_advisory_lock` / `pg_advisory_unlock`). The lock is tied to the database connection, not the SQLAlchemy transaction — it is released when the context exits, even if the surrounding transaction is still open.
|
Advisory locks use PostgreSQL session-level functions (`pg_advisory_lock` / `pg_advisory_unlock`). The lock is tied to the database connection, not the SQLAlchemy transaction, so it is released when the context exits even if the surrounding transaction is still open.
|
||||||
|
|
||||||
## Row-change polling
|
## Row-change polling
|
||||||
|
|
||||||
[`wait_for_row_change`](../reference/db.md#fastapi_toolsets.db.wait_for_row_change) polls a row until a specific column changes value, useful for waiting on async side effects:
|
[`wait_for_row_change`](../reference/db.md#fastapi_toolsets.db.wait_for_row_change) polls a row until a specific column changes value:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import wait_for_row_change
|
from fastapi_toolsets.db import wait_for_row_change
|
||||||
@@ -120,7 +180,7 @@ await wait_for_row_change(
|
|||||||
session=session,
|
session=session,
|
||||||
model=Order,
|
model=Order,
|
||||||
pk_value=order_id,
|
pk_value=order_id,
|
||||||
columns=[Order.status],
|
columns=["status"],
|
||||||
interval=1.0,
|
interval=1.0,
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
)
|
)
|
||||||
@@ -128,28 +188,24 @@ await wait_for_row_change(
|
|||||||
|
|
||||||
## Creating a database
|
## Creating a database
|
||||||
|
|
||||||
!!! info "Added in `v2.1`"
|
[`create_database`](../reference/db.md#fastapi_toolsets.db.testing.create_database) (in `fastapi_toolsets.db.testing`) connects to *server_url* and issues a `CREATE DATABASE` statement:
|
||||||
|
|
||||||
[`create_database`](../reference/db.md#fastapi_toolsets.db.create_database) creates a database at a given URL. It connects to *server_url* and issues a `CREATE DATABASE` statement:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import create_database
|
from fastapi_toolsets.db.testing import create_database
|
||||||
|
|
||||||
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
|
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
|
||||||
|
|
||||||
await create_database(db_name="myapp_test", server_url=SERVER_URL)
|
await create_database(db_name="myapp_test", server_url=SERVER_URL)
|
||||||
```
|
```
|
||||||
|
|
||||||
For test isolation with automatic cleanup, use [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_worker_database) from the `pytest` module instead — it handles drop-before, create, and drop-after automatically.
|
For test isolation with automatic cleanup, use [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_worker_database) from the `pytest` module, which handles drop-before, create, and drop-after.
|
||||||
|
|
||||||
## Cleaning up tables
|
## Cleaning up tables
|
||||||
|
|
||||||
!!! info "Added in `v2.1`"
|
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.testing.cleanup_tables) (in `fastapi_toolsets.db.testing`) truncates all tables:
|
||||||
|
|
||||||
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables) truncates all tables:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import cleanup_tables
|
from fastapi_toolsets.db.testing import cleanup_tables
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
async def clean(db_session):
|
async def clean(db_session):
|
||||||
@@ -159,50 +215,50 @@ async def clean(db_session):
|
|||||||
|
|
||||||
## Many-to-Many helpers
|
## Many-to-Many helpers
|
||||||
|
|
||||||
SQLAlchemy's ORM collection API triggers lazy-loads when you append to a relationship inside a savepoint (e.g. inside `lock_tables` or a nested `get_transaction`). The three `m2m_*` helpers bypass the ORM collection entirely and issue direct SQL against the association table.
|
The three `m2m_*` helpers modify a many-to-many association table with direct SQL, without loading the ORM collection.
|
||||||
|
|
||||||
### `m2m_add` — insert associations
|
### `m2m_add`: insert associations
|
||||||
|
|
||||||
[`m2m_add`](../reference/db.md#fastapi_toolsets.db.m2m_add) inserts one or more rows into a secondary table without touching the ORM collection:
|
[`m2m_add`](../reference/db.md#fastapi_toolsets.db.m2m_add) inserts one or more rows into a secondary table:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import lock_tables, m2m_add
|
from fastapi_toolsets.db import m2m_add
|
||||||
|
|
||||||
async with lock_tables(session_maker, [Tag]) as session:
|
async with db.lock_tables([Tag]) as session:
|
||||||
tag = await TagCrud.create(session, TagCreate(name="python"))
|
tag = await TagCrud.create(session, TagCreate(name="python"))
|
||||||
await m2m_add(session, post, Post.tags, tag)
|
await m2m_add(session, post, Post.tags, tag)
|
||||||
```
|
```
|
||||||
|
|
||||||
Pass `ignore_conflicts=True` to silently skip associations that already exist:
|
Pass `ignore_conflicts=True` to skip associations that already exist:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
await m2m_add(session, post, Post.tags, tag, ignore_conflicts=True)
|
await m2m_add(session, post, Post.tags, tag, ignore_conflicts=True)
|
||||||
```
|
```
|
||||||
|
|
||||||
### `m2m_remove` — delete associations
|
### `m2m_remove`: delete associations
|
||||||
|
|
||||||
[`m2m_remove`](../reference/db.md#fastapi_toolsets.db.m2m_remove) deletes specific association rows. Removing a non-existent association is a no-op:
|
[`m2m_remove`](../reference/db.md#fastapi_toolsets.db.m2m_remove) deletes specific association rows. Removing a non-existent association is a no-op:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import get_transaction, m2m_remove
|
from fastapi_toolsets.db import m2m_remove, transaction
|
||||||
|
|
||||||
async with get_transaction(session):
|
async with transaction(session):
|
||||||
await m2m_remove(session, post, Post.tags, tag1, tag2)
|
await m2m_remove(session, post, Post.tags, tag1, tag2)
|
||||||
```
|
```
|
||||||
|
|
||||||
### `m2m_set` — replace the full set
|
### `m2m_set`: replace the full set
|
||||||
|
|
||||||
[`m2m_set`](../reference/db.md#fastapi_toolsets.db.m2m_set) atomically replaces all associations: it deletes every existing row for the owner instance then inserts the new set. Passing no related instances clears the association entirely:
|
[`m2m_set`](../reference/db.md#fastapi_toolsets.db.m2m_set) replaces all associations: it deletes every existing row for the owner instance then inserts the new set. Passing no related instances clears the association:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import get_transaction, m2m_set
|
from fastapi_toolsets.db import m2m_set, transaction
|
||||||
|
|
||||||
# Replace all tags
|
# Replace all tags
|
||||||
async with get_transaction(session):
|
async with transaction(session):
|
||||||
await m2m_set(session, post, Post.tags, tag_a, tag_b)
|
await m2m_set(session, post, Post.tags, tag_a, tag_b)
|
||||||
|
|
||||||
# Clear all tags
|
# Clear all tags
|
||||||
async with get_transaction(session):
|
async with transaction(session):
|
||||||
await m2m_set(session, post, Post.tags)
|
await m2m_set(session, post, Post.tags)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=EventSe
|
|||||||
```
|
```
|
||||||
|
|
||||||
!!! info "Callbacks fire on `session.commit()` only — not on savepoints."
|
!!! info "Callbacks fire on `session.commit()` only — not on savepoints."
|
||||||
Savepoints created by [`get_transaction`](db.md) or `begin_nested()` do **not**
|
Savepoints created by [`transaction`](db.md) or `begin_nested()` do **not**
|
||||||
trigger callbacks. All events accumulated across flushes are dispatched once
|
trigger callbacks. All events accumulated across flushes are dispatched once
|
||||||
when the outermost `commit()` is called.
|
when the outermost `commit()` is called.
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ async with create_db_session(
|
|||||||
|
|
||||||
## Parallel testing with pytest-xdist
|
## Parallel testing with pytest-xdist
|
||||||
|
|
||||||
The fixtures above work with `pytest-xdist` out of the box. Each worker gets its own database suffixed with the worker name (e.g. `myapp_gw0`, `myapp_gw1`).
|
The fixtures above work with `pytest-xdist` out of the box. Each worker gets its own database named after the worker (e.g. `gw0`, `gw1`). Pass `prefix` to namespace the database (e.g. `prefix="myapp"` → `myapp_gw0`).
|
||||||
|
|
||||||
Use [`worker_database_url`](../reference/pytest.md#fastapi_toolsets.pytest.utils.worker_database_url) to derive the per-worker URL manually if needed:
|
Use [`worker_database_url`](../reference/pytest.md#fastapi_toolsets.pytest.utils.worker_database_url) to derive the per-worker URL manually if needed:
|
||||||
|
|
||||||
@@ -97,16 +97,20 @@ Use [`worker_database_url`](../reference/pytest.md#fastapi_toolsets.pytest.utils
|
|||||||
from fastapi_toolsets.pytest import worker_database_url
|
from fastapi_toolsets.pytest import worker_database_url
|
||||||
|
|
||||||
url = worker_database_url("postgresql+asyncpg://user:pass@localhost/myapp", default_test_db="test")
|
url = worker_database_url("postgresql+asyncpg://user:pass@localhost/myapp", default_test_db="test")
|
||||||
|
# → "postgresql+asyncpg://user:pass@localhost/gw0" under xdist
|
||||||
|
# → "postgresql+asyncpg://user:pass@localhost/test" otherwise
|
||||||
|
|
||||||
|
url = worker_database_url("postgresql+asyncpg://user:pass@localhost/myapp", default_test_db="test", prefix="myapp")
|
||||||
# → "postgresql+asyncpg://user:pass@localhost/myapp_gw0" under xdist
|
# → "postgresql+asyncpg://user:pass@localhost/myapp_gw0" under xdist
|
||||||
# → "postgresql+asyncpg://user:pass@localhost/myapp_test" otherwise
|
# → "postgresql+asyncpg://user:pass@localhost/myapp_test" otherwise
|
||||||
```
|
```
|
||||||
|
|
||||||
## Manual table cleanup
|
## Manual table cleanup
|
||||||
|
|
||||||
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables) truncates all tables in a single statement and can be called directly when you need more control:
|
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.testing.cleanup_tables) truncates all tables in a single statement and can be called directly when you need more control:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import cleanup_tables
|
from fastapi_toolsets.pytest import cleanup_tables
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
async def clean(db_session):
|
async def clean(db_session):
|
||||||
|
|||||||
@@ -1,354 +0,0 @@
|
|||||||
# Security
|
|
||||||
|
|
||||||
Composable authentication helpers for FastAPI that use `Security()` for OpenAPI documentation and accept user-provided validator functions with full type flexibility.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The `security` module provides four auth source classes, a `MultiAuth` factory, and a set of OAuth 2.0 / OIDC helper utilities. Each auth class wraps a FastAPI security scheme for OpenAPI and accepts a validator function called as:
|
|
||||||
|
|
||||||
```python
|
|
||||||
await validator(credential, **kwargs)
|
|
||||||
```
|
|
||||||
|
|
||||||
where `kwargs` are the extra keyword arguments provided at instantiation (roles, permissions, enums, etc.). The validator returns the authenticated identity (e.g. a `User` model) which becomes the route dependency value.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi import Security
|
|
||||||
from fastapi_toolsets.security import BearerTokenAuth
|
|
||||||
|
|
||||||
async def verify_token(token: str, *, role: str) -> User:
|
|
||||||
user = await db.get_by_token(token)
|
|
||||||
if not user or user.role != role:
|
|
||||||
raise UnauthorizedError()
|
|
||||||
return user
|
|
||||||
|
|
||||||
bearer_admin = BearerTokenAuth(verify_token, role="admin")
|
|
||||||
|
|
||||||
@app.get("/admin")
|
|
||||||
async def admin_route(user: User = Security(bearer_admin)):
|
|
||||||
return user
|
|
||||||
```
|
|
||||||
|
|
||||||
## Auth sources
|
|
||||||
|
|
||||||
### [`BearerTokenAuth`](../reference/security.md#fastapi_toolsets.security.BearerTokenAuth)
|
|
||||||
|
|
||||||
Reads the `Authorization: Bearer <token>` header. Wraps `HTTPBearer` for OpenAPI.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.security import BearerTokenAuth
|
|
||||||
|
|
||||||
bearer = BearerTokenAuth(validator=verify_token)
|
|
||||||
|
|
||||||
@app.get("/me")
|
|
||||||
async def me(user: User = Security(bearer)):
|
|
||||||
return user
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Token prefix
|
|
||||||
|
|
||||||
The optional `prefix` parameter restricts a `BearerTokenAuth` instance to tokens that start with a given string. The prefix is **kept** in the value passed to the validator — store and compare tokens with their prefix included.
|
|
||||||
|
|
||||||
This lets you deploy multiple `BearerTokenAuth` instances in the same application and disambiguate them efficiently in `MultiAuth`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
user_bearer = BearerTokenAuth(verify_user, prefix="user_") # matches "Bearer user_..."
|
|
||||||
org_bearer = BearerTokenAuth(verify_org, prefix="org_") # matches "Bearer org_..."
|
|
||||||
```
|
|
||||||
|
|
||||||
Use [`generate_token()`](#token-generation) to create correctly-prefixed tokens.
|
|
||||||
|
|
||||||
#### Token generation
|
|
||||||
|
|
||||||
`BearerTokenAuth.generate_token()` produces a secure random token ready to store in your database and return to the client. If a prefix is configured it is prepended automatically:
|
|
||||||
|
|
||||||
```python
|
|
||||||
bearer = BearerTokenAuth(verify_token, prefix="user_")
|
|
||||||
|
|
||||||
token = bearer.generate_token() # e.g. "user_Xk3mN..."
|
|
||||||
await db.store_token(user_id, token)
|
|
||||||
return {"access_token": token, "token_type": "bearer"}
|
|
||||||
```
|
|
||||||
|
|
||||||
The client sends `Authorization: Bearer user_Xk3mN...` and the validator receives the full token (prefix included) to compare against the stored value.
|
|
||||||
|
|
||||||
### [`CookieAuth`](../reference/security.md#fastapi_toolsets.security.CookieAuth)
|
|
||||||
|
|
||||||
Reads a named cookie. Wraps `APIKeyCookie` for OpenAPI.
|
|
||||||
|
|
||||||
Cookies are issued with the `Secure` flag set by default, meaning they are only transmitted over HTTPS. Set `secure=False` when running locally over plain HTTP:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.security import CookieAuth
|
|
||||||
|
|
||||||
# Production (HTTPS) — default
|
|
||||||
cookie_auth = CookieAuth("session", validator=verify_session)
|
|
||||||
|
|
||||||
# Local development (HTTP only)
|
|
||||||
cookie_auth = CookieAuth("session", validator=verify_session, secure=False)
|
|
||||||
|
|
||||||
@app.get("/me")
|
|
||||||
async def me(user: User = Security(cookie_auth)):
|
|
||||||
return user
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Signed cookies
|
|
||||||
|
|
||||||
Pass `secret_key` to enable HMAC-SHA256 signed, tamper-proof cookies. The cookie payload includes an expiry timestamp (`ttl`, default 24 h). No database entry is required — the signature is self-contained.
|
|
||||||
|
|
||||||
Use `set_cookie()` to issue the signed cookie on login and `delete_cookie()` to clear it on logout:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Production
|
|
||||||
cookie_auth = CookieAuth("session", verify_session, secret_key="your-secret")
|
|
||||||
|
|
||||||
# Local development
|
|
||||||
cookie_auth = CookieAuth("session", verify_session, secret_key="your-secret", secure=False)
|
|
||||||
|
|
||||||
@app.post("/login")
|
|
||||||
async def login(response: Response):
|
|
||||||
cookie_auth.set_cookie(response, user_id)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
@app.post("/logout")
|
|
||||||
async def logout(response: Response):
|
|
||||||
cookie_auth.delete_cookie(response)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
@app.get("/me")
|
|
||||||
async def me(user: User = Security(cookie_auth)):
|
|
||||||
return user
|
|
||||||
```
|
|
||||||
|
|
||||||
When `secret_key` is not set, the raw cookie value is passed directly to the validator (stateful session behaviour — you manage the session store).
|
|
||||||
|
|
||||||
### [`APIKeyHeaderAuth`](../reference/security.md#fastapi_toolsets.security.APIKeyHeaderAuth)
|
|
||||||
|
|
||||||
Reads an API key from a named HTTP header. Wraps `APIKeyHeader` for OpenAPI.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.security import APIKeyHeaderAuth
|
|
||||||
|
|
||||||
api_key_auth = APIKeyHeaderAuth("X-API-Key", validator=verify_api_key)
|
|
||||||
|
|
||||||
@app.get("/data")
|
|
||||||
async def data(user: User = Security(api_key_auth)):
|
|
||||||
return user
|
|
||||||
```
|
|
||||||
|
|
||||||
The header name is configurable — use any header your API defines (e.g. `"X-API-Key"`, `"Authorization"`, `"X-Service-Token"`).
|
|
||||||
|
|
||||||
## Typed validator kwargs
|
|
||||||
|
|
||||||
All auth classes forward extra instantiation keyword arguments to the validator. Arguments can be any type — enums, strings, integers, etc. The validator returns the authenticated identity, which FastAPI injects directly into the route handler.
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def verify_token(token: str, *, role: Role, permission: str) -> User:
|
|
||||||
user = await decode_token(token)
|
|
||||||
if user.role != role or permission not in user.permissions:
|
|
||||||
raise UnauthorizedError()
|
|
||||||
return user
|
|
||||||
|
|
||||||
bearer = BearerTokenAuth(verify_token, role=Role.ADMIN, permission="billing:read")
|
|
||||||
```
|
|
||||||
|
|
||||||
Each auth instance is self-contained — create a separate instance per distinct requirement instead of passing requirements through `Security(scopes=[...])`.
|
|
||||||
|
|
||||||
### Using `.require()` inline
|
|
||||||
|
|
||||||
If declaring a new top-level variable per role feels verbose, use `.require()` to create a configured clone directly in the route decorator. The original instance is not mutated:
|
|
||||||
|
|
||||||
```python
|
|
||||||
bearer = BearerTokenAuth(verify_token)
|
|
||||||
|
|
||||||
@app.get("/admin/stats")
|
|
||||||
async def admin_stats(user: User = Security(bearer.require(role=Role.ADMIN))):
|
|
||||||
return {"message": f"Hello admin {user.name}"}
|
|
||||||
|
|
||||||
@app.get("/profile")
|
|
||||||
async def profile(user: User = Security(bearer.require(role=Role.USER))):
|
|
||||||
return {"id": user.id, "name": user.name}
|
|
||||||
```
|
|
||||||
|
|
||||||
`.require()` kwargs are merged over existing ones — new values win on conflict.
|
|
||||||
The `prefix` (for `BearerTokenAuth`), cookie name and `secret_key` (for
|
|
||||||
`CookieAuth`), and header name (for `APIKeyHeaderAuth`) are always preserved.
|
|
||||||
|
|
||||||
## MultiAuth
|
|
||||||
|
|
||||||
[`MultiAuth`](../reference/security.md#fastapi_toolsets.security.MultiAuth) combines multiple auth sources into a single callable. Sources are tried in order; the first one that finds a credential wins.
|
|
||||||
|
|
||||||
If a credential is extracted but the validator raises, the exception propagates immediately — the remaining sources are **not** tried. This prevents silent fallthrough on invalid credentials.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.security import MultiAuth
|
|
||||||
|
|
||||||
multi = MultiAuth(user_bearer, org_bearer, cookie_auth)
|
|
||||||
|
|
||||||
@app.get("/data")
|
|
||||||
async def data_route(user = Security(multi)):
|
|
||||||
return user
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using `.require()` on MultiAuth
|
|
||||||
|
|
||||||
`MultiAuth` also supports `.require()`, which propagates the kwargs to every source that implements it. Sources that do not (e.g. custom `AuthSource` subclasses) are passed through unchanged:
|
|
||||||
|
|
||||||
```python
|
|
||||||
multi = MultiAuth(bearer, cookie)
|
|
||||||
|
|
||||||
@app.get("/admin")
|
|
||||||
async def admin(user: User = Security(multi.require(role=Role.ADMIN))):
|
|
||||||
return user
|
|
||||||
```
|
|
||||||
|
|
||||||
This is equivalent to calling `.require()` on each source individually:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# These two are identical
|
|
||||||
multi.require(role=Role.ADMIN)
|
|
||||||
|
|
||||||
MultiAuth(
|
|
||||||
bearer.require(role=Role.ADMIN),
|
|
||||||
cookie.require(role=Role.ADMIN),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prefix-based dispatch
|
|
||||||
|
|
||||||
Because `extract()` is pure string matching (no I/O), prefix-based source selection is essentially free. Only the matching source's validator (which may involve DB or network I/O) is ever called:
|
|
||||||
|
|
||||||
```python
|
|
||||||
user_bearer = BearerTokenAuth(verify_user, prefix="user_")
|
|
||||||
org_bearer = BearerTokenAuth(verify_org, prefix="org_")
|
|
||||||
|
|
||||||
multi = MultiAuth(user_bearer, org_bearer)
|
|
||||||
|
|
||||||
# "Bearer user_alice" → only verify_user runs, receives "user_alice"
|
|
||||||
# "Bearer org_acme" → only verify_org runs, receives "org_acme"
|
|
||||||
```
|
|
||||||
|
|
||||||
Tokens are stored and compared **with their prefix** — use `generate_token()` on each source to issue correctly-prefixed tokens:
|
|
||||||
|
|
||||||
```python
|
|
||||||
user_token = user_bearer.generate_token() # "user_..."
|
|
||||||
org_token = org_bearer.generate_token() # "org_..."
|
|
||||||
```
|
|
||||||
|
|
||||||
## Custom auth sources
|
|
||||||
|
|
||||||
Subclass [`AuthSource`](../reference/security.md#fastapi_toolsets.security.AuthSource) to implement any credential extraction strategy. You only need to implement `extract()` and `authenticate()`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.security import AuthSource
|
|
||||||
from fastapi_toolsets.exceptions import UnauthorizedError
|
|
||||||
|
|
||||||
class MTLSAuth(AuthSource):
|
|
||||||
async def extract(self, request) -> str | None:
|
|
||||||
return request.headers.get("X-Client-Cert-DN") or None
|
|
||||||
|
|
||||||
async def authenticate(self, credential: str):
|
|
||||||
dn = parse_dn(credential)
|
|
||||||
if dn.get("O") != "MyOrg":
|
|
||||||
raise UnauthorizedError()
|
|
||||||
return {"dn": credential}
|
|
||||||
```
|
|
||||||
|
|
||||||
Custom sources work transparently inside `MultiAuth`.
|
|
||||||
|
|
||||||
## OAuth 2.0 / OIDC helpers
|
|
||||||
|
|
||||||
The module provides standalone async utilities for building OAuth 2.0 / OIDC login flows. They handle provider discovery, authorization redirects, token exchange, and state encoding — leaving JWT validation and session management to your application.
|
|
||||||
|
|
||||||
### Provider discovery
|
|
||||||
|
|
||||||
[`oauth_resolve_provider_urls()`](../reference/security.md#fastapi_toolsets.security.oauth_resolve_provider_urls) fetches the OIDC discovery document and returns the endpoint URLs. Results are cached in-process to avoid repeated network calls:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.security import oauth_resolve_provider_urls
|
|
||||||
|
|
||||||
auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls(
|
|
||||||
"https://accounts.google.com/.well-known/openid-configuration"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns a `(authorization_url, token_url, userinfo_url)` tuple. `userinfo_url` is `None` when the provider does not advertise one.
|
|
||||||
|
|
||||||
### Authorization redirect
|
|
||||||
|
|
||||||
[`oauth_build_authorization_redirect()`](../reference/security.md#fastapi_toolsets.security.oauth_build_authorization_redirect) constructs the redirect to the provider's authorization page. It requires a `state_token` — a random CSRF token generated by [`oauth_generate_state_token()`](../reference/security.md#fastapi_toolsets.security.oauth_generate_state_token) — that must be stored server-side (e.g. in the session) and verified on the callback to prevent login-CSRF attacks ([RFC 6749 §10.12](https://datatracker.ietf.org/doc/html/rfc6749#section-10.12)):
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi import Request
|
|
||||||
from fastapi_toolsets.security import oauth_build_authorization_redirect, oauth_generate_state_token
|
|
||||||
|
|
||||||
@app.get("/auth/google/login")
|
|
||||||
async def google_login(request: Request):
|
|
||||||
auth_url, _, _ = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL)
|
|
||||||
state_token = oauth_generate_state_token()
|
|
||||||
request.session["oauth_state"] = state_token # requires SessionMiddleware
|
|
||||||
return oauth_build_authorization_redirect(
|
|
||||||
auth_url,
|
|
||||||
client_id=GOOGLE_CLIENT_ID,
|
|
||||||
scopes="openid email profile",
|
|
||||||
redirect_uri="https://myapp.com/auth/google/callback",
|
|
||||||
destination="/dashboard",
|
|
||||||
state_token=state_token,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Token exchange and userinfo
|
|
||||||
|
|
||||||
[`oauth_fetch_userinfo()`](../reference/security.md#fastapi_toolsets.security.oauth_fetch_userinfo) performs the two-step exchange: it POSTs the authorization code to the token endpoint, then GETs the userinfo endpoint with the resulting access token.
|
|
||||||
|
|
||||||
On the callback, retrieve the stored token and pass it to [`oauth_decode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_decode_state) to verify the CSRF token before processing the code:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi import HTTPException, Request
|
|
||||||
from fastapi_toolsets.security import oauth_decode_state, oauth_fetch_userinfo
|
|
||||||
|
|
||||||
@app.get("/auth/google/callback")
|
|
||||||
async def google_callback(request: Request, code: str, state: str):
|
|
||||||
# Pop token first — single-use, regardless of whether verification succeeds
|
|
||||||
state_token = request.session.pop("oauth_state", None)
|
|
||||||
if state_token is None:
|
|
||||||
raise HTTPException(status_code=400, detail="missing OAuth state")
|
|
||||||
destination = oauth_decode_state(state, expected_state_token=state_token, fallback="/")
|
|
||||||
if not destination.startswith("/"): # reject absolute URLs to prevent open-redirect
|
|
||||||
destination = "/"
|
|
||||||
|
|
||||||
_, token_url, userinfo_url = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL)
|
|
||||||
userinfo = await oauth_fetch_userinfo(
|
|
||||||
token_url=token_url,
|
|
||||||
userinfo_url=userinfo_url,
|
|
||||||
code=code,
|
|
||||||
client_id=GOOGLE_CLIENT_ID,
|
|
||||||
client_secret=GOOGLE_CLIENT_SECRET,
|
|
||||||
redirect_uri="https://myapp.com/auth/google/callback",
|
|
||||||
required_scopes="openid email profile",
|
|
||||||
)
|
|
||||||
user = await db.upsert_user(email=userinfo["email"])
|
|
||||||
response = RedirectResponse(destination)
|
|
||||||
session_cookie.set_cookie(response, str(user.id))
|
|
||||||
return response
|
|
||||||
```
|
|
||||||
|
|
||||||
Pass `required_scopes` to guard against providers silently granting fewer scopes than requested — `oauth_fetch_userinfo` raises `ValueError` if any are missing.
|
|
||||||
|
|
||||||
### State encoding
|
|
||||||
|
|
||||||
[`oauth_encode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_encode_state) and [`oauth_decode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_decode_state) encode and decode the destination URL together with the CSRF token embedded in the OAuth `state` parameter. `oauth_decode_state` returns `fallback` if `state` is absent, malformed, or the token does not match:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.security import oauth_encode_state, oauth_decode_state
|
|
||||||
|
|
||||||
state_token = oauth_generate_state_token()
|
|
||||||
encoded = oauth_encode_state("/dashboard", state_token)
|
|
||||||
decoded = oauth_decode_state(encoded, expected_state_token=state_token, fallback="/") # "/dashboard"
|
|
||||||
decoded = oauth_decode_state(encoded, expected_state_token="wrong", fallback="/") # "/"
|
|
||||||
decoded = oauth_decode_state(None, expected_state_token=state_token, fallback="/") # "/"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
[:material-api: API Reference](../reference/security.md)
|
|
||||||
+20
-18
@@ -1,46 +1,48 @@
|
|||||||
# `db`
|
# `db`
|
||||||
|
|
||||||
Here's the reference for all database session utilities, transaction helpers, and locking functions.
|
Here's the reference for the `Database` facade, the transaction helper, locking
|
||||||
|
functions, many-to-many helpers, and row-watching utilities.
|
||||||
|
|
||||||
You can import them directly from `fastapi_toolsets.db`:
|
You can import them directly from `fastapi_toolsets.db`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import (
|
from fastapi_toolsets.db import (
|
||||||
|
Database,
|
||||||
LockMode,
|
LockMode,
|
||||||
advisory_lock,
|
advisory_lock,
|
||||||
cleanup_tables,
|
|
||||||
create_database,
|
|
||||||
create_db_dependency,
|
|
||||||
create_db_context,
|
|
||||||
get_transaction,
|
|
||||||
lock_tables,
|
lock_tables,
|
||||||
m2m_add,
|
m2m_add,
|
||||||
m2m_remove,
|
m2m_remove,
|
||||||
m2m_set,
|
m2m_set,
|
||||||
|
transaction,
|
||||||
wait_for_row_change,
|
wait_for_row_change,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.Database
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.transaction
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.LockMode
|
## ::: fastapi_toolsets.db.LockMode
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.create_db_dependency
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.create_db_context
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.get_transaction
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.lock_tables
|
## ::: fastapi_toolsets.db.lock_tables
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.advisory_lock
|
## ::: fastapi_toolsets.db.advisory_lock
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.wait_for_row_change
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.create_database
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.cleanup_tables
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.m2m_add
|
## ::: fastapi_toolsets.db.m2m_add
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.m2m_remove
|
## ::: fastapi_toolsets.db.m2m_remove
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.m2m_set
|
## ::: fastapi_toolsets.db.m2m_set
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.wait_for_row_change
|
||||||
|
|
||||||
|
Admin and test helpers live in `fastapi_toolsets.db.testing`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db.testing import cleanup_tables, create_database
|
||||||
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.testing.create_database
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.testing.cleanup_tables
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
# `security`
|
|
||||||
|
|
||||||
Here's the reference for the authentication helpers provided by the `security` module.
|
|
||||||
|
|
||||||
You can import them directly from `fastapi_toolsets.security`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.security import (
|
|
||||||
AuthSource,
|
|
||||||
BearerTokenAuth,
|
|
||||||
CookieAuth,
|
|
||||||
APIKeyHeaderAuth,
|
|
||||||
MultiAuth,
|
|
||||||
oauth_build_authorization_redirect,
|
|
||||||
oauth_decode_state,
|
|
||||||
oauth_encode_state,
|
|
||||||
oauth_fetch_userinfo,
|
|
||||||
oauth_generate_state_token,
|
|
||||||
oauth_resolve_provider_urls,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.AuthSource
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.BearerTokenAuth
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.CookieAuth
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.APIKeyHeaderAuth
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.MultiAuth
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_resolve_provider_urls
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_fetch_userinfo
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_generate_state_token
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_build_authorization_redirect
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_encode_state
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_decode_state
|
|
||||||
@@ -2,8 +2,10 @@ from fastapi import FastAPI
|
|||||||
|
|
||||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||||
|
|
||||||
|
from .db import db
|
||||||
from .routes import router
|
from .routes import router
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
db.install(app=app)
|
||||||
init_exceptions_handlers(app=app)
|
init_exceptions_handlers(app=app)
|
||||||
app.include_router(router=router)
|
app.include_router(router=router)
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from fastapi_toolsets.db import create_db_context, create_db_dependency
|
from fastapi_toolsets.db import Database
|
||||||
|
|
||||||
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres"
|
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres"
|
||||||
|
|
||||||
engine = create_async_engine(url=DATABASE_URL, future=True)
|
db = Database(url=DATABASE_URL)
|
||||||
async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False)
|
|
||||||
|
|
||||||
get_db = create_db_dependency(session_maker=async_session_maker)
|
get_db = db
|
||||||
get_db_context = create_db_context(session_maker=async_session_maker)
|
|
||||||
|
|
||||||
|
SessionDep = Annotated[AsyncSession, Depends(db)]
|
||||||
SessionDep = Annotated[AsyncSession, Depends(get_db)]
|
|
||||||
|
|||||||
+2
-6
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "4.1.2"
|
version = "5.0.0b1"
|
||||||
description = "Production-ready utilities for FastAPI applications"
|
description = "Production-ready utilities for FastAPI applications"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -50,17 +50,13 @@ cli = [
|
|||||||
metrics = [
|
metrics = [
|
||||||
"prometheus_client>=0.20.0",
|
"prometheus_client>=0.20.0",
|
||||||
]
|
]
|
||||||
security = [
|
|
||||||
"async-lru>=1.0",
|
|
||||||
"httpx>=0.25.0",
|
|
||||||
]
|
|
||||||
pytest = [
|
pytest = [
|
||||||
"httpx>=0.25.0",
|
"httpx>=0.25.0",
|
||||||
"pytest-xdist>=3.0.0",
|
"pytest-xdist>=3.0.0",
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
]
|
]
|
||||||
all = [
|
all = [
|
||||||
"fastapi-toolsets[cli,metrics,pytest,security]",
|
"fastapi-toolsets[cli,metrics,pytest]",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -7,18 +7,21 @@ Example usage:
|
|||||||
from fastapi import FastAPI, Depends
|
from fastapi import FastAPI, Depends
|
||||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||||
from fastapi_toolsets.crud import CrudFactory
|
from fastapi_toolsets.crud import CrudFactory
|
||||||
from fastapi_toolsets.db import create_db_dependency
|
from fastapi_toolsets.db import Database
|
||||||
from fastapi_toolsets.schemas import Response
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
|
db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
db.install(app)
|
||||||
init_exceptions_handlers(app)
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
UserCrud = CrudFactory(User)
|
UserCrud = CrudFactory(User)
|
||||||
|
|
||||||
@app.get("/users/{user_id}", response_model=Response[dict])
|
@app.get("/users/{user_id}", response_model=Response[dict])
|
||||||
async def get_user(user_id: int, session = Depends(get_db)):
|
async def get_user(user_id: int, session = Depends(db)):
|
||||||
user = await UserCrud.get(session, [User.id == user_id])
|
user = await UserCrud.get(session, [User.id == user_id])
|
||||||
return Response(data={"user": user.username}, message="Success")
|
return Response(data={"user": user.username}, message="Success")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "4.1.2"
|
__version__ = "5.0.0b1"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload
|
|||||||
from sqlalchemy.sql.base import ExecutableOption
|
from sqlalchemy.sql.base import ExecutableOption
|
||||||
from sqlalchemy.sql.roles import WhereHavingRole
|
from sqlalchemy.sql.roles import WhereHavingRole
|
||||||
|
|
||||||
from ..db import get_transaction
|
from ..db import transaction
|
||||||
from ..exceptions import InvalidOrderFieldError, NotFoundError
|
from ..exceptions import InvalidOrderFieldError, NotFoundError
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
CursorPaginatedResponse,
|
CursorPaginatedResponse,
|
||||||
@@ -716,7 +716,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
Returns:
|
Returns:
|
||||||
Created model instance, or ``Response[schema]`` when ``schema`` is given.
|
Created model instance, or ``Response[schema]`` when ``schema`` is given.
|
||||||
"""
|
"""
|
||||||
async with get_transaction(session):
|
async with transaction(session):
|
||||||
m2m_exclude = cls._m2m_schema_fields()
|
m2m_exclude = cls._m2m_schema_fields()
|
||||||
data = (
|
data = (
|
||||||
obj.model_dump(exclude=m2m_exclude) if m2m_exclude else obj.model_dump()
|
obj.model_dump(exclude=m2m_exclude) if m2m_exclude else obj.model_dump()
|
||||||
@@ -1067,7 +1067,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
Raises:
|
Raises:
|
||||||
NotFoundError: If no record found
|
NotFoundError: If no record found
|
||||||
"""
|
"""
|
||||||
async with get_transaction(session):
|
async with transaction(session):
|
||||||
m2m_exclude = cls._m2m_schema_fields()
|
m2m_exclude = cls._m2m_schema_fields()
|
||||||
|
|
||||||
# Eagerly load M2M relationships that will be updated so that
|
# Eagerly load M2M relationships that will be updated so that
|
||||||
@@ -1127,7 +1127,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
Returns:
|
Returns:
|
||||||
Model instance
|
Model instance
|
||||||
"""
|
"""
|
||||||
async with get_transaction(session):
|
async with transaction(session):
|
||||||
values = obj.model_dump(exclude_unset=True)
|
values = obj.model_dump(exclude_unset=True)
|
||||||
q = insert(cls.model).values(**values)
|
q = insert(cls.model).values(**values)
|
||||||
if set_:
|
if set_:
|
||||||
@@ -1189,7 +1189,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
Returns:
|
Returns:
|
||||||
``None``, or ``Response[None]`` when ``return_response=True``.
|
``None``, or ``Response[None]`` when ``return_response=True``.
|
||||||
"""
|
"""
|
||||||
async with get_transaction(session):
|
async with transaction(session):
|
||||||
result = await session.execute(select(cls.model).where(and_(*filters)))
|
result = await session.execute(select(cls.model).where(and_(*filters)))
|
||||||
objects = result.scalars().all()
|
objects = result.scalars().all()
|
||||||
for obj in objects:
|
for obj in objects:
|
||||||
|
|||||||
@@ -1,591 +0,0 @@
|
|||||||
"""Database utilities: sessions, transactions, and locks."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from collections.abc import AsyncGenerator, Callable
|
|
||||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Any, TypeVar, cast
|
|
||||||
|
|
||||||
import asyncpg
|
|
||||||
from sqlalchemy import Table, delete, text, tuple_
|
|
||||||
from sqlalchemy import exc as sa_exc
|
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
||||||
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
|
|
||||||
from sqlalchemy.orm.relationships import RelationshipProperty
|
|
||||||
|
|
||||||
from .exceptions import LockTimeoutError, NotFoundError, PoolExhaustedError
|
|
||||||
|
|
||||||
|
|
||||||
def _is_lock_not_available(e: sa_exc.DBAPIError) -> bool:
|
|
||||||
return e.orig is not None and isinstance(
|
|
||||||
e.orig.__cause__, asyncpg.exceptions.LockNotAvailableError
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"LockMode",
|
|
||||||
"advisory_lock",
|
|
||||||
"cleanup_tables",
|
|
||||||
"create_database",
|
|
||||||
"create_db_context",
|
|
||||||
"create_db_dependency",
|
|
||||||
"get_transaction",
|
|
||||||
"lock_tables",
|
|
||||||
"m2m_add",
|
|
||||||
"m2m_remove",
|
|
||||||
"m2m_set",
|
|
||||||
"wait_for_row_change",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
_SessionT = TypeVar("_SessionT", bound=AsyncSession)
|
|
||||||
|
|
||||||
|
|
||||||
def create_db_dependency(
|
|
||||||
session_maker: async_sessionmaker[_SessionT],
|
|
||||||
) -> Callable[[], AsyncGenerator[_SessionT, None]]:
|
|
||||||
"""Create a FastAPI dependency for database sessions.
|
|
||||||
|
|
||||||
Creates a dependency function that yields a session and auto-commits
|
|
||||||
if a transaction is active when the request completes.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session_maker: Async session factory from create_session_factory()
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
An async generator function usable with FastAPI's Depends()
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from fastapi import Depends
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
|
||||||
from fastapi_toolsets.db import create_db_dependency
|
|
||||||
|
|
||||||
engine = create_async_engine("postgresql+asyncpg://...")
|
|
||||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
|
||||||
get_db = create_db_dependency(SessionLocal)
|
|
||||||
|
|
||||||
@app.get("/users")
|
|
||||||
async def list_users(session: AsyncSession = Depends(get_db)):
|
|
||||||
...
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def get_db() -> AsyncGenerator[_SessionT, None]:
|
|
||||||
async with session_maker() as session:
|
|
||||||
try:
|
|
||||||
await session.connection()
|
|
||||||
except sa_exc.TimeoutError as e:
|
|
||||||
raise PoolExhaustedError() from e
|
|
||||||
yield session
|
|
||||||
if session.in_transaction():
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return get_db
|
|
||||||
|
|
||||||
|
|
||||||
def create_db_context(
|
|
||||||
session_maker: async_sessionmaker[_SessionT],
|
|
||||||
) -> Callable[[], AbstractAsyncContextManager[_SessionT]]:
|
|
||||||
"""Create a context manager for database sessions.
|
|
||||||
|
|
||||||
Creates a context manager for use outside of FastAPI request handlers,
|
|
||||||
such as in background tasks, CLI commands, or tests.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session_maker: Async session factory from create_session_factory()
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
An async context manager function
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
|
||||||
from fastapi_toolsets.db import create_db_context
|
|
||||||
|
|
||||||
engine = create_async_engine("postgresql+asyncpg://...")
|
|
||||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
|
||||||
get_db_context = create_db_context(SessionLocal)
|
|
||||||
|
|
||||||
async def background_task():
|
|
||||||
async with get_db_context() as session:
|
|
||||||
user = await UserCrud.get(session, [User.id == 1])
|
|
||||||
...
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
get_db = create_db_dependency(session_maker)
|
|
||||||
return asynccontextmanager(get_db)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def get_transaction(
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> AsyncGenerator[AsyncSession, None]:
|
|
||||||
"""Get a transaction context, handling nested transactions.
|
|
||||||
|
|
||||||
If already in a transaction, creates a savepoint (nested transaction).
|
|
||||||
Otherwise, starts a new transaction.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: AsyncSession instance
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
The session within the transaction context
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
async with get_transaction(session):
|
|
||||||
session.add(model)
|
|
||||||
# Auto-commits on exit, rolls back on exception
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
if session.in_transaction():
|
|
||||||
async with session.begin_nested():
|
|
||||||
yield session
|
|
||||||
else:
|
|
||||||
async with session.begin():
|
|
||||||
yield session
|
|
||||||
|
|
||||||
|
|
||||||
class LockMode(str, Enum):
|
|
||||||
"""PostgreSQL table lock modes.
|
|
||||||
|
|
||||||
See: https://www.postgresql.org/docs/current/explicit-locking.html
|
|
||||||
"""
|
|
||||||
|
|
||||||
ACCESS_SHARE = "ACCESS SHARE"
|
|
||||||
ROW_SHARE = "ROW SHARE"
|
|
||||||
ROW_EXCLUSIVE = "ROW EXCLUSIVE"
|
|
||||||
SHARE_UPDATE_EXCLUSIVE = "SHARE UPDATE EXCLUSIVE"
|
|
||||||
SHARE = "SHARE"
|
|
||||||
SHARE_ROW_EXCLUSIVE = "SHARE ROW EXCLUSIVE"
|
|
||||||
EXCLUSIVE = "EXCLUSIVE"
|
|
||||||
ACCESS_EXCLUSIVE = "ACCESS EXCLUSIVE"
|
|
||||||
|
|
||||||
|
|
||||||
def lock_tables(
|
|
||||||
session_maker: async_sessionmaker[_SessionT],
|
|
||||||
tables: list[type[DeclarativeBase]],
|
|
||||||
*,
|
|
||||||
mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE,
|
|
||||||
timeout: str = "5s",
|
|
||||||
) -> AbstractAsyncContextManager[_SessionT]:
|
|
||||||
"""Lock PostgreSQL tables for the duration of a transaction.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session_maker: Async session factory used to create the dedicated
|
|
||||||
session.
|
|
||||||
tables: List of SQLAlchemy model classes to lock.
|
|
||||||
mode: Lock mode (default: SHARE UPDATE EXCLUSIVE).
|
|
||||||
timeout: Lock timeout (default: "5s").
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
The dedicated session, open within the locked transaction.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
SQLAlchemyError: If the lock cannot be acquired within *timeout*.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.db import lock_tables, LockMode
|
|
||||||
|
|
||||||
async with lock_tables(session_maker, [User, Account]) as session:
|
|
||||||
# Tables are locked; changes are committed when the context exits.
|
|
||||||
user = await UserCrud.get(session, [User.id == 1])
|
|
||||||
user.balance += 100
|
|
||||||
|
|
||||||
# With custom lock mode
|
|
||||||
async with lock_tables(session_maker, [Order], mode=LockMode.EXCLUSIVE) as session:
|
|
||||||
await process_order(session, order_id)
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
table_names = ",".join(table.__tablename__ for table in tables)
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def _lock() -> AsyncGenerator[_SessionT, None]:
|
|
||||||
async with session_maker() as session:
|
|
||||||
try:
|
|
||||||
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
|
|
||||||
await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
|
|
||||||
yield session
|
|
||||||
await session.commit()
|
|
||||||
except sa_exc.TimeoutError as e:
|
|
||||||
await session.rollback()
|
|
||||||
raise PoolExhaustedError(
|
|
||||||
f"Connection pool exhausted while locking '{table_names}'. "
|
|
||||||
) from e
|
|
||||||
except sa_exc.DBAPIError as e:
|
|
||||||
await session.rollback()
|
|
||||||
if _is_lock_not_available(e):
|
|
||||||
raise LockTimeoutError(
|
|
||||||
f"Lock on '{table_names}' could not be acquired within {timeout}."
|
|
||||||
) from e
|
|
||||||
raise # pragma: no cover
|
|
||||||
except BaseException:
|
|
||||||
await session.rollback()
|
|
||||||
raise
|
|
||||||
|
|
||||||
return _lock()
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def advisory_lock(
|
|
||||||
session: AsyncSession,
|
|
||||||
key: int | tuple[int, int],
|
|
||||||
*,
|
|
||||||
shared: bool = False,
|
|
||||||
nowait: bool = False,
|
|
||||||
timeout: str | None = None,
|
|
||||||
) -> AsyncGenerator[bool, None]:
|
|
||||||
"""Acquire a PostgreSQL session-level advisory lock.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: AsyncSession instance.
|
|
||||||
key: Lock key — a single ``int`` (bigint) or a ``(int, int)`` pair for namespacing.
|
|
||||||
shared: Acquire a shared lock (multiple holders allowed). Default is exclusive.
|
|
||||||
nowait: Return ``False`` immediately if the lock is unavailable instead of waiting.
|
|
||||||
timeout: Maximum wait time (e.g. ``"5s"``, ``"500ms"``). Raises ``DBAPIError``
|
|
||||||
if exceeded. Ignored when *nowait* is ``True``.
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
``True`` if the lock was acquired, ``False`` if *nowait* is ``True`` and the lock
|
|
||||||
is already held.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
LockTimeoutError: If *timeout* is set and the lock cannot be acquired in time.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.db import advisory_lock
|
|
||||||
|
|
||||||
async with advisory_lock(session, 42):
|
|
||||||
...
|
|
||||||
|
|
||||||
async with advisory_lock(session, 42, nowait=True) as acquired:
|
|
||||||
if not acquired:
|
|
||||||
raise HTTPException(409, "Resource is locked")
|
|
||||||
|
|
||||||
async with advisory_lock(session, 42, timeout="5s"):
|
|
||||||
...
|
|
||||||
|
|
||||||
async with advisory_lock(session, (1, user_id), shared=True):
|
|
||||||
...
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
suffix = "_shared" if shared else ""
|
|
||||||
acquire_fn = f"{'pg_try_advisory_lock' if nowait else 'pg_advisory_lock'}{suffix}"
|
|
||||||
release_fn = f"pg_advisory_unlock{suffix}"
|
|
||||||
|
|
||||||
if isinstance(key, tuple):
|
|
||||||
k1, k2 = key
|
|
||||||
args = "CAST(:k1 AS integer), CAST(:k2 AS integer)"
|
|
||||||
params: dict[str, int] = {"k1": k1, "k2": k2}
|
|
||||||
else:
|
|
||||||
args = ":k"
|
|
||||||
params = {"k": key}
|
|
||||||
|
|
||||||
acquire_sql = text(f"SELECT {acquire_fn}({args})")
|
|
||||||
release_sql = text(f"SELECT {release_fn}({args})")
|
|
||||||
|
|
||||||
if timeout is not None and not nowait:
|
|
||||||
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await session.execute(acquire_sql, params)
|
|
||||||
except sa_exc.DBAPIError as e:
|
|
||||||
if _is_lock_not_available(e):
|
|
||||||
raise LockTimeoutError(
|
|
||||||
f"Advisory lock {key!r} could not be acquired within {timeout}."
|
|
||||||
) from e
|
|
||||||
raise # pragma: no cover
|
|
||||||
acquired = result.scalar() if nowait else True
|
|
||||||
try:
|
|
||||||
yield acquired
|
|
||||||
finally:
|
|
||||||
if acquired:
|
|
||||||
await session.execute(release_sql, params)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_database(
|
|
||||||
db_name: str,
|
|
||||||
*,
|
|
||||||
server_url: str,
|
|
||||||
) -> None:
|
|
||||||
"""Create a database.
|
|
||||||
|
|
||||||
Connects to *server_url* using ``AUTOCOMMIT`` isolation and issues a
|
|
||||||
``CREATE DATABASE`` statement for *db_name*.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db_name: Name of the database to create.
|
|
||||||
server_url: URL used for server-level DDL (must point to an existing
|
|
||||||
database on the same server).
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.db import create_database
|
|
||||||
|
|
||||||
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
|
|
||||||
await create_database("myapp_test", server_url=SERVER_URL)
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
engine = create_async_engine(server_url, isolation_level="AUTOCOMMIT")
|
|
||||||
try:
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
await conn.execute(text(f"CREATE DATABASE {db_name}"))
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_tables(
|
|
||||||
session: AsyncSession,
|
|
||||||
base: type[DeclarativeBase],
|
|
||||||
) -> None:
|
|
||||||
"""Truncate all tables for fast between-test cleanup.
|
|
||||||
|
|
||||||
Executes a single ``TRUNCATE … RESTART IDENTITY CASCADE`` statement
|
|
||||||
across every table in *base*'s metadata, which is significantly faster
|
|
||||||
than dropping and re-creating tables between tests.
|
|
||||||
|
|
||||||
This is a no-op when the metadata contains no tables.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: An active async database session.
|
|
||||||
base: SQLAlchemy DeclarativeBase class containing model metadata.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
@pytest.fixture
|
|
||||||
async def db_session(worker_db_url):
|
|
||||||
async with create_db_session(worker_db_url, Base) as session:
|
|
||||||
yield session
|
|
||||||
await cleanup_tables(session, Base)
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
tables = base.metadata.sorted_tables
|
|
||||||
if not tables:
|
|
||||||
return
|
|
||||||
|
|
||||||
table_names = ", ".join(f'"{t.name}"' for t in tables)
|
|
||||||
await session.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE"))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
_M = TypeVar("_M", bound=DeclarativeBase)
|
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_row_change(
|
|
||||||
session: AsyncSession,
|
|
||||||
model: type[_M],
|
|
||||||
pk_value: Any,
|
|
||||||
*,
|
|
||||||
columns: list[str] | None = None,
|
|
||||||
interval: float = 0.5,
|
|
||||||
timeout: float | None = None,
|
|
||||||
) -> _M:
|
|
||||||
"""Poll a database row until a change is detected.
|
|
||||||
|
|
||||||
Queries the row every ``interval`` seconds and returns the model instance
|
|
||||||
once a change is detected in any column (or only the specified ``columns``).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: AsyncSession instance
|
|
||||||
model: SQLAlchemy model class
|
|
||||||
pk_value: Primary key value of the row to watch
|
|
||||||
columns: Optional list of column names to watch. If None, all columns
|
|
||||||
are watched.
|
|
||||||
interval: Polling interval in seconds (default: 0.5)
|
|
||||||
timeout: Maximum time to wait in seconds. None means wait forever.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The refreshed model instance with updated values
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
NotFoundError: If the row does not exist or is deleted during polling
|
|
||||||
TimeoutError: If timeout expires before a change is detected
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.db import wait_for_row_change
|
|
||||||
|
|
||||||
# Wait for any column to change
|
|
||||||
updated = await wait_for_row_change(session, User, user_id)
|
|
||||||
|
|
||||||
# Watch specific columns with a timeout
|
|
||||||
updated = await wait_for_row_change(
|
|
||||||
session, User, user_id,
|
|
||||||
columns=["status", "email"],
|
|
||||||
interval=1.0,
|
|
||||||
timeout=30.0,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
instance = await session.get(model, pk_value)
|
|
||||||
if instance is None:
|
|
||||||
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} not found")
|
|
||||||
|
|
||||||
if columns is not None:
|
|
||||||
watch_cols = columns
|
|
||||||
else:
|
|
||||||
watch_cols = [attr.key for attr in model.__mapper__.column_attrs]
|
|
||||||
|
|
||||||
initial = {col: getattr(instance, col) for col in watch_cols}
|
|
||||||
|
|
||||||
elapsed = 0.0
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
elapsed += interval
|
|
||||||
|
|
||||||
if timeout is not None and elapsed >= timeout:
|
|
||||||
raise TimeoutError(
|
|
||||||
f"No change detected on {model.__name__} "
|
|
||||||
f"with pk={pk_value!r} within {timeout}s"
|
|
||||||
)
|
|
||||||
|
|
||||||
session.expunge(instance)
|
|
||||||
instance = await session.get(model, pk_value)
|
|
||||||
|
|
||||||
if instance is None:
|
|
||||||
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} was deleted")
|
|
||||||
|
|
||||||
current = {col: getattr(instance, col) for col in watch_cols}
|
|
||||||
if current != initial:
|
|
||||||
return instance
|
|
||||||
|
|
||||||
|
|
||||||
def _m2m_prop(rel_attr: QueryableAttribute) -> RelationshipProperty: # type: ignore[type-arg]
|
|
||||||
"""Return the validated M2M RelationshipProperty for *rel_attr*.
|
|
||||||
|
|
||||||
Raises TypeError if *rel_attr* is not a Many-to-Many relationship.
|
|
||||||
"""
|
|
||||||
prop = rel_attr.property
|
|
||||||
if not isinstance(prop, RelationshipProperty) or prop.secondary is None:
|
|
||||||
raise TypeError(
|
|
||||||
f"m2m helpers require a Many-to-Many relationship attribute, "
|
|
||||||
f"got {rel_attr!r}. Use a relationship with a secondary table."
|
|
||||||
)
|
|
||||||
return prop
|
|
||||||
|
|
||||||
|
|
||||||
async def m2m_add(
|
|
||||||
session: AsyncSession,
|
|
||||||
instance: DeclarativeBase,
|
|
||||||
rel_attr: QueryableAttribute,
|
|
||||||
*related: DeclarativeBase,
|
|
||||||
ignore_conflicts: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""Insert rows into a Many-to-Many association table without loading the ORM collection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: DB async session.
|
|
||||||
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
|
||||||
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
|
||||||
*related: One or more related instances to associate with ``instance``.
|
|
||||||
ignore_conflicts: When ``True``, silently skip rows that already exist
|
|
||||||
in the association table (``ON CONFLICT DO NOTHING``).
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
|
||||||
"""
|
|
||||||
prop = _m2m_prop(rel_attr)
|
|
||||||
if not related:
|
|
||||||
return
|
|
||||||
|
|
||||||
secondary = cast(Table, prop.secondary)
|
|
||||||
assert secondary is not None # guaranteed by _m2m_prop
|
|
||||||
sync_pairs = prop.secondary_synchronize_pairs
|
|
||||||
assert sync_pairs is not None # set whenever secondary is set
|
|
||||||
|
|
||||||
# synchronize_pairs: [(parent_col, assoc_col), ...]
|
|
||||||
# secondary_synchronize_pairs: [(related_col, assoc_col), ...]
|
|
||||||
rows: list[dict[str, Any]] = []
|
|
||||||
for rel_instance in related:
|
|
||||||
row: dict[str, Any] = {}
|
|
||||||
for parent_col, assoc_col in prop.synchronize_pairs:
|
|
||||||
row[assoc_col.name] = getattr(instance, cast(str, parent_col.key))
|
|
||||||
for related_col, assoc_col in sync_pairs:
|
|
||||||
row[assoc_col.name] = getattr(rel_instance, cast(str, related_col.key))
|
|
||||||
rows.append(row)
|
|
||||||
|
|
||||||
stmt = pg_insert(secondary).values(rows)
|
|
||||||
if ignore_conflicts:
|
|
||||||
stmt = stmt.on_conflict_do_nothing()
|
|
||||||
await session.execute(stmt)
|
|
||||||
|
|
||||||
|
|
||||||
async def m2m_remove(
|
|
||||||
session: AsyncSession,
|
|
||||||
instance: DeclarativeBase,
|
|
||||||
rel_attr: QueryableAttribute,
|
|
||||||
*related: DeclarativeBase,
|
|
||||||
) -> None:
|
|
||||||
"""Remove rows from a Many-to-Many association table without loading the ORM collection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: DB async session.
|
|
||||||
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
|
||||||
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
|
||||||
*related: One or more related instances to disassociate from ``instance``.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
|
||||||
"""
|
|
||||||
prop = _m2m_prop(rel_attr)
|
|
||||||
if not related:
|
|
||||||
return
|
|
||||||
|
|
||||||
secondary = cast(Table, prop.secondary)
|
|
||||||
assert secondary is not None # guaranteed by _m2m_prop
|
|
||||||
related_pairs = prop.secondary_synchronize_pairs
|
|
||||||
assert related_pairs is not None # set whenever secondary is set
|
|
||||||
|
|
||||||
parent_where = [
|
|
||||||
assoc_col == getattr(instance, cast(str, parent_col.key))
|
|
||||||
for parent_col, assoc_col in prop.synchronize_pairs
|
|
||||||
]
|
|
||||||
|
|
||||||
if len(related_pairs) == 1:
|
|
||||||
related_col, assoc_col = related_pairs[0]
|
|
||||||
related_values = [getattr(r, cast(str, related_col.key)) for r in related]
|
|
||||||
related_where = assoc_col.in_(related_values)
|
|
||||||
else:
|
|
||||||
assoc_cols = [ac for _, ac in related_pairs]
|
|
||||||
rel_cols = [rc for rc, _ in related_pairs]
|
|
||||||
related_values_t = [
|
|
||||||
tuple(getattr(r, cast(str, rc.key)) for rc in rel_cols) for r in related
|
|
||||||
]
|
|
||||||
related_where = tuple_(*assoc_cols).in_(related_values_t)
|
|
||||||
|
|
||||||
await session.execute(delete(secondary).where(*parent_where, related_where))
|
|
||||||
|
|
||||||
|
|
||||||
async def m2m_set(
|
|
||||||
session: AsyncSession,
|
|
||||||
instance: DeclarativeBase,
|
|
||||||
rel_attr: QueryableAttribute,
|
|
||||||
*related: DeclarativeBase,
|
|
||||||
) -> None:
|
|
||||||
"""Replace the entire Many-to-Many association set atomically.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: DB async session.
|
|
||||||
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
|
||||||
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
|
||||||
*related: The new complete set of related instances.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
|
||||||
"""
|
|
||||||
prop = _m2m_prop(rel_attr)
|
|
||||||
secondary = cast(Table, prop.secondary)
|
|
||||||
assert secondary is not None # guaranteed by _m2m_prop
|
|
||||||
|
|
||||||
parent_where = [
|
|
||||||
assoc_col == getattr(instance, cast(str, parent_col.key))
|
|
||||||
for parent_col, assoc_col in prop.synchronize_pairs
|
|
||||||
]
|
|
||||||
await session.execute(delete(secondary).where(*parent_where))
|
|
||||||
|
|
||||||
if related:
|
|
||||||
await m2m_add(session, instance, rel_attr, *related)
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Database package: the ``Database`` facade plus PostgreSQL power-tools."""
|
||||||
|
|
||||||
|
from .core import Database, transaction
|
||||||
|
from .locks import LockMode, advisory_lock, lock_tables
|
||||||
|
from .m2m import m2m_add, m2m_remove, m2m_set
|
||||||
|
from .watch import wait_for_row_change
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Database",
|
||||||
|
"LockMode",
|
||||||
|
"advisory_lock",
|
||||||
|
"lock_tables",
|
||||||
|
"m2m_add",
|
||||||
|
"m2m_remove",
|
||||||
|
"m2m_set",
|
||||||
|
"transaction",
|
||||||
|
"wait_for_row_change",
|
||||||
|
]
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"""The ``Database`` facade: session lifecycle, dependency, middleware, transactions."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import PostgresDsn
|
||||||
|
from sqlalchemy import exc as sa_exc
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncEngine,
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||||
|
|
||||||
|
from ..exceptions import PoolExhaustedError
|
||||||
|
from .locks import LockMode, lock_tables
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def transaction(
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Run a block inside a savepoint-aware transaction.
|
||||||
|
|
||||||
|
If *session* is already in a transaction, a nested transaction (savepoint)
|
||||||
|
is opened so the block can roll back independently. Otherwise a top-level
|
||||||
|
transaction is started. Commits on clean exit, rolls back on exception.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: AsyncSession instance.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
The session within the transaction context.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import transaction
|
||||||
|
|
||||||
|
async with transaction(session):
|
||||||
|
session.add(model)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
if session.in_transaction():
|
||||||
|
async with session.begin_nested():
|
||||||
|
yield session
|
||||||
|
else:
|
||||||
|
async with session.begin():
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
class _CommitOnResponseMiddleware:
|
||||||
|
"""Commit the request's DB session before the response is sent."""
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp, *, state_attr: str) -> None:
|
||||||
|
self.app = app
|
||||||
|
self.state_attr = state_attr
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
async def send_wrapper(message: Message) -> None:
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
# ``scope["state"]`` is the same dict ``request.state`` writes
|
||||||
|
# to, so this is the session stashed by the dependency.
|
||||||
|
state = scope.get("state")
|
||||||
|
session = state.get(self.state_attr) if state else None
|
||||||
|
if session is not None and session.in_transaction():
|
||||||
|
await session.commit()
|
||||||
|
await send(message)
|
||||||
|
|
||||||
|
await self.app(scope, receive, send_wrapper)
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
"""One object that owns the engine, sessions, dependency, and middleware.
|
||||||
|
|
||||||
|
Provide exactly one of *url* (the facade builds and disposes the engine) or
|
||||||
|
*engine* (an engine you own, e.g. for Alembic or ``event.listen``, left
|
||||||
|
untouched).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Database connection URL. Accepts a plain string or a Pydantic
|
||||||
|
:class:`~pydantic.PostgresDsn`.
|
||||||
|
engine: An existing :class:`AsyncEngine` to reuse instead of *url*.
|
||||||
|
session_class: Session class for the sessionmaker (e.g. ``EventSession``).
|
||||||
|
expire_on_commit: Expire attributes after commit. Defaults to ``False``.
|
||||||
|
autoflush: Autoflush the session before queries. Defaults to ``True``.
|
||||||
|
connect_args: DBAPI-level connection arguments forwarded to
|
||||||
|
:func:`create_async_engine` (URL mode only).
|
||||||
|
**engine_options: Extra keyword arguments forwarded to
|
||||||
|
:func:`create_async_engine` (URL mode only).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If neither or both of *url* and *engine* are given, or if
|
||||||
|
*connect_args*/*engine_options* are passed together with *engine*.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi import Depends, FastAPI
|
||||||
|
from fastapi_toolsets.db import Database
|
||||||
|
|
||||||
|
db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
db.install(app)
|
||||||
|
|
||||||
|
@app.get("/users/{user_id}")
|
||||||
|
async def get_user(user_id: int, session=Depends(db)):
|
||||||
|
return await UserCrud.get(session, [User.id == user_id])
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: str | PostgresDsn | None = None,
|
||||||
|
*,
|
||||||
|
engine: AsyncEngine | None = None,
|
||||||
|
session_class: type[AsyncSession] = AsyncSession,
|
||||||
|
expire_on_commit: bool = False,
|
||||||
|
autoflush: bool = True,
|
||||||
|
connect_args: dict[str, Any] | None = None,
|
||||||
|
**engine_options: Any,
|
||||||
|
) -> None:
|
||||||
|
if (url is None) == (engine is None):
|
||||||
|
raise TypeError(
|
||||||
|
"Database requires exactly one of 'url' or 'engine' "
|
||||||
|
"(got both or neither)."
|
||||||
|
)
|
||||||
|
if engine is not None and (engine_options or connect_args is not None):
|
||||||
|
raise TypeError(
|
||||||
|
"connect_args/engine_options are only valid in URL mode; "
|
||||||
|
"configure the engine you pass via 'engine=' yourself."
|
||||||
|
)
|
||||||
|
|
||||||
|
if engine is not None:
|
||||||
|
self._owns_engine = False
|
||||||
|
self.engine: AsyncEngine = engine
|
||||||
|
else:
|
||||||
|
assert url is not None # guaranteed by the XOR check above
|
||||||
|
self._owns_engine = True
|
||||||
|
if connect_args is not None:
|
||||||
|
engine_options["connect_args"] = connect_args
|
||||||
|
# ``PostgresDsn`` (and other URL objects) are not str subclasses, so
|
||||||
|
# coerce to the string form SQLAlchemy expects.
|
||||||
|
self.engine = create_async_engine(str(url), **engine_options)
|
||||||
|
self._sessionmaker: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||||
|
self.engine,
|
||||||
|
class_=session_class,
|
||||||
|
expire_on_commit=expire_on_commit,
|
||||||
|
autoflush=autoflush,
|
||||||
|
)
|
||||||
|
# Private, per-instance state attribute; cannot collide with another
|
||||||
|
# Database or be mismatched against the middleware.
|
||||||
|
self._state_attr = f"_ft_db_session_{id(self):x}"
|
||||||
|
self._middleware_installed = False
|
||||||
|
self._disposed = False
|
||||||
|
|
||||||
|
async def _dispose(self) -> None:
|
||||||
|
"""Dispose the engine once, only if we own it (idempotent)."""
|
||||||
|
if self._owns_engine and not self._disposed:
|
||||||
|
self._disposed = True
|
||||||
|
await self.engine.dispose()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(self, app: Any) -> AsyncGenerator[None, None]:
|
||||||
|
"""Dispose the engine on shutdown; use as ``FastAPI(lifespan=db.lifespan)``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: The ASGI application (unused; required by the lifespan protocol).
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Control to the application for its lifetime.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
app = FastAPI(lifespan=db.lifespan)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
await self._dispose()
|
||||||
|
|
||||||
|
def install(self, app: Any) -> None:
|
||||||
|
"""Wire the commit middleware and engine disposal onto *app*.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: The FastAPI/Starlette application to wire.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app):
|
||||||
|
... # your startup
|
||||||
|
yield
|
||||||
|
... # your shutdown
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
db.install(app)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
app.add_middleware(_CommitOnResponseMiddleware, state_attr=self._state_attr)
|
||||||
|
self._middleware_installed = True
|
||||||
|
|
||||||
|
inner_lifespan = app.router.lifespan_context
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _composed(app_: Any) -> AsyncGenerator[None, None]:
|
||||||
|
async with self.lifespan(app_):
|
||||||
|
async with inner_lifespan(app_):
|
||||||
|
yield
|
||||||
|
|
||||||
|
app.router.lifespan_context = _composed
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _open(self) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Open a session and eagerly acquire a connection (fail-fast on pool)."""
|
||||||
|
async with self._sessionmaker() as session:
|
||||||
|
try:
|
||||||
|
await session.connection()
|
||||||
|
except sa_exc.TimeoutError as e:
|
||||||
|
raise PoolExhaustedError() from e
|
||||||
|
yield session
|
||||||
|
|
||||||
|
async def __call__(self, request: Request) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""FastAPI dependency: yield a session and commit once at the right time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming request (injected by FastAPI).
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
An AsyncSession for the duration of the request.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
@app.get("/users/{user_id}")
|
||||||
|
async def get_user(user_id: int, session=Depends(db)):
|
||||||
|
return await UserCrud.get(session, [User.id == user_id])
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
async with self._open() as session:
|
||||||
|
setattr(request.state, self._state_attr, session)
|
||||||
|
yield session
|
||||||
|
if not self._middleware_installed and session.in_transaction():
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def session(self) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Open a session outside request handlers (background tasks, CLI, tests).
|
||||||
|
|
||||||
|
Commits on clean exit, rolls back on exception.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
An AsyncSession ready for database operations.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
async with db.session() as session:
|
||||||
|
user = await UserCrud.get(session, [User.id == 1])
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
async with self._open() as session:
|
||||||
|
yield session
|
||||||
|
if session.in_transaction():
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def begin(self) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Open a session already inside a transaction (sugar for the common case).
|
||||||
|
|
||||||
|
Equivalent to ``session()`` + :func:`transaction`. Commits on clean exit,
|
||||||
|
rolls back on exception.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
An AsyncSession open within a transaction.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
async with db.begin() as session:
|
||||||
|
session.add(User(name="ada"))
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
async with self.session() as session, transaction(session):
|
||||||
|
yield session
|
||||||
|
|
||||||
|
def lock_tables(
|
||||||
|
self,
|
||||||
|
tables: list[type[DeclarativeBase]],
|
||||||
|
*,
|
||||||
|
mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE,
|
||||||
|
timeout: str = "5s",
|
||||||
|
) -> AbstractAsyncContextManager[AsyncSession]:
|
||||||
|
"""Lock PostgreSQL tables for the duration of a dedicated transaction.
|
||||||
|
|
||||||
|
Opens its own session from the facade's sessionmaker, changes are
|
||||||
|
committed when the context exits.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tables: List of SQLAlchemy model classes to lock.
|
||||||
|
mode: Lock mode (default: ``SHARE UPDATE EXCLUSIVE``).
|
||||||
|
timeout: Lock timeout (default: ``"5s"``).
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
The dedicated session, open within the locked transaction.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
LockTimeoutError: If the lock cannot be acquired within *timeout*.
|
||||||
|
PoolExhaustedError: If the connection pool is exhausted.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
async with db.lock_tables([User, Account]) as session:
|
||||||
|
user = await UserCrud.get(session, [User.id == 1])
|
||||||
|
user.balance += 100
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
return lock_tables(self._sessionmaker, tables, mode=mode, timeout=timeout)
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""PostgreSQL locking helpers: table locks and advisory locks."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||||
|
from enum import Enum
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
from sqlalchemy import exc as sa_exc
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from ..exceptions import LockTimeoutError, PoolExhaustedError
|
||||||
|
|
||||||
|
_SessionT = TypeVar("_SessionT", bound=AsyncSession)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_lock_not_available(e: sa_exc.DBAPIError) -> bool:
|
||||||
|
return e.orig is not None and isinstance(
|
||||||
|
e.orig.__cause__, asyncpg.exceptions.LockNotAvailableError
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LockMode(str, Enum):
|
||||||
|
"""PostgreSQL table lock modes.
|
||||||
|
|
||||||
|
See: https://www.postgresql.org/docs/current/explicit-locking.html
|
||||||
|
"""
|
||||||
|
|
||||||
|
ACCESS_SHARE = "ACCESS SHARE"
|
||||||
|
ROW_SHARE = "ROW SHARE"
|
||||||
|
ROW_EXCLUSIVE = "ROW EXCLUSIVE"
|
||||||
|
SHARE_UPDATE_EXCLUSIVE = "SHARE UPDATE EXCLUSIVE"
|
||||||
|
SHARE = "SHARE"
|
||||||
|
SHARE_ROW_EXCLUSIVE = "SHARE ROW EXCLUSIVE"
|
||||||
|
EXCLUSIVE = "EXCLUSIVE"
|
||||||
|
ACCESS_EXCLUSIVE = "ACCESS EXCLUSIVE"
|
||||||
|
|
||||||
|
|
||||||
|
def lock_tables(
|
||||||
|
session_maker: async_sessionmaker[_SessionT],
|
||||||
|
tables: list[type[DeclarativeBase]],
|
||||||
|
*,
|
||||||
|
mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE,
|
||||||
|
timeout: str = "5s",
|
||||||
|
) -> AbstractAsyncContextManager[_SessionT]:
|
||||||
|
"""Lock PostgreSQL tables for the duration of a transaction.
|
||||||
|
|
||||||
|
Prefer the method on a :class:`Database` instance; use this
|
||||||
|
directly only when you manage your own session factory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_maker: Async session factory used to create the dedicated
|
||||||
|
session.
|
||||||
|
tables: List of SQLAlchemy model classes to lock.
|
||||||
|
mode: Lock mode (default: SHARE UPDATE EXCLUSIVE).
|
||||||
|
timeout: Lock timeout (default: "5s").
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
The dedicated session, open within the locked transaction.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
LockTimeoutError: If the lock cannot be acquired within *timeout*.
|
||||||
|
PoolExhaustedError: If the connection pool is exhausted.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import lock_tables
|
||||||
|
|
||||||
|
async with lock_tables(session_maker, [User, Account]) as session:
|
||||||
|
user = await UserCrud.get(session, [User.id == 1])
|
||||||
|
user.balance += 100
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
table_names = ",".join(table.__tablename__ for table in tables)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _lock() -> AsyncGenerator[_SessionT, None]:
|
||||||
|
async with session_maker() as session:
|
||||||
|
try:
|
||||||
|
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
|
||||||
|
await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except sa_exc.TimeoutError as e:
|
||||||
|
await session.rollback()
|
||||||
|
raise PoolExhaustedError(
|
||||||
|
f"Connection pool exhausted while locking '{table_names}'. "
|
||||||
|
) from e
|
||||||
|
except sa_exc.DBAPIError as e:
|
||||||
|
await session.rollback()
|
||||||
|
if _is_lock_not_available(e):
|
||||||
|
raise LockTimeoutError(
|
||||||
|
f"Lock on '{table_names}' could not be acquired within {timeout}."
|
||||||
|
) from e
|
||||||
|
raise # pragma: no cover
|
||||||
|
except BaseException:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
return _lock()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def advisory_lock(
|
||||||
|
session: AsyncSession,
|
||||||
|
key: int | tuple[int, int],
|
||||||
|
*,
|
||||||
|
shared: bool = False,
|
||||||
|
nowait: bool = False,
|
||||||
|
timeout: str | None = None,
|
||||||
|
) -> AsyncGenerator[bool, None]:
|
||||||
|
"""Acquire a PostgreSQL session-level advisory lock.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: AsyncSession instance.
|
||||||
|
key: Lock key, either a single ``int`` (bigint) or a ``(int, int)`` pair for namespacing.
|
||||||
|
shared: Acquire a shared lock (multiple holders allowed). Default is exclusive.
|
||||||
|
nowait: Return ``False`` immediately if the lock is unavailable instead of waiting.
|
||||||
|
timeout: Maximum wait time (e.g. ``"5s"``, ``"500ms"``). Raises ``DBAPIError``
|
||||||
|
if exceeded. Ignored when *nowait* is ``True``.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
``True`` if the lock was acquired, ``False`` if *nowait* is ``True`` and the lock
|
||||||
|
is already held.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
LockTimeoutError: If *timeout* is set and the lock cannot be acquired in time.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import advisory_lock
|
||||||
|
|
||||||
|
async with advisory_lock(session, 42):
|
||||||
|
...
|
||||||
|
|
||||||
|
async with advisory_lock(session, 42, nowait=True) as acquired:
|
||||||
|
if not acquired:
|
||||||
|
raise HTTPException(409, "Resource is locked")
|
||||||
|
|
||||||
|
async with advisory_lock(session, 42, timeout="5s"):
|
||||||
|
...
|
||||||
|
|
||||||
|
async with advisory_lock(session, (1, user_id), shared=True):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
suffix = "_shared" if shared else ""
|
||||||
|
acquire_fn = f"{'pg_try_advisory_lock' if nowait else 'pg_advisory_lock'}{suffix}"
|
||||||
|
release_fn = f"pg_advisory_unlock{suffix}"
|
||||||
|
|
||||||
|
if isinstance(key, tuple):
|
||||||
|
k1, k2 = key
|
||||||
|
args = "CAST(:k1 AS integer), CAST(:k2 AS integer)"
|
||||||
|
params: dict[str, int] = {"k1": k1, "k2": k2}
|
||||||
|
else:
|
||||||
|
args = ":k"
|
||||||
|
params = {"k": key}
|
||||||
|
|
||||||
|
acquire_sql = text(f"SELECT {acquire_fn}({args})")
|
||||||
|
release_sql = text(f"SELECT {release_fn}({args})")
|
||||||
|
|
||||||
|
# Lock management runs raw SQL on the caller's session. Guard it with
|
||||||
|
# ``no_autoflush`` so acquiring or releasing the lock never flushes the
|
||||||
|
# caller's pending ORM changes; SQLAlchemy 2.1 autoflushes on raw
|
||||||
|
# ``text()`` too, where 2.0 did not.
|
||||||
|
try:
|
||||||
|
with session.no_autoflush:
|
||||||
|
if timeout is not None and not nowait:
|
||||||
|
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
|
||||||
|
result = await session.execute(acquire_sql, params)
|
||||||
|
except sa_exc.DBAPIError as e:
|
||||||
|
if _is_lock_not_available(e):
|
||||||
|
raise LockTimeoutError(
|
||||||
|
f"Advisory lock {key!r} could not be acquired within {timeout}."
|
||||||
|
) from e
|
||||||
|
raise # pragma: no cover
|
||||||
|
acquired = result.scalar() if nowait else True
|
||||||
|
try:
|
||||||
|
yield acquired
|
||||||
|
finally:
|
||||||
|
if acquired:
|
||||||
|
with session.no_autoflush:
|
||||||
|
await session.execute(release_sql, params)
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Many-to-Many association-table helpers (direct, without loading collections)."""
|
||||||
|
|
||||||
|
from typing import Any, TypeVar, cast
|
||||||
|
|
||||||
|
from sqlalchemy import ColumnElement, Table, delete, tuple_
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
|
||||||
|
from sqlalchemy.orm.relationships import RelationshipProperty
|
||||||
|
|
||||||
|
_M = TypeVar("_M", bound=DeclarativeBase)
|
||||||
|
|
||||||
|
|
||||||
|
def _m2m_prop(rel_attr: QueryableAttribute) -> tuple[RelationshipProperty, Table]: # type: ignore[type-arg]
|
||||||
|
"""Return the validated M2M RelationshipProperty and its secondary table.
|
||||||
|
|
||||||
|
Raises TypeError if *rel_attr* is not a Many-to-Many relationship.
|
||||||
|
"""
|
||||||
|
prop = rel_attr.property
|
||||||
|
if not isinstance(prop, RelationshipProperty) or prop.secondary is None:
|
||||||
|
raise TypeError(
|
||||||
|
f"m2m helpers require a Many-to-Many relationship attribute, "
|
||||||
|
f"got {rel_attr!r}. Use a relationship with a secondary table."
|
||||||
|
)
|
||||||
|
return prop, cast(Table, prop.secondary)
|
||||||
|
|
||||||
|
|
||||||
|
def _parent_where(
|
||||||
|
prop: RelationshipProperty, # type: ignore[type-arg]
|
||||||
|
instance: DeclarativeBase,
|
||||||
|
) -> list[ColumnElement[bool]]:
|
||||||
|
"""Build the WHERE clauses matching the owner side of *instance*."""
|
||||||
|
return [
|
||||||
|
assoc_col == getattr(instance, cast(str, parent_col.key))
|
||||||
|
for parent_col, assoc_col in prop.synchronize_pairs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def m2m_add(
|
||||||
|
session: AsyncSession,
|
||||||
|
instance: DeclarativeBase,
|
||||||
|
rel_attr: QueryableAttribute,
|
||||||
|
*related: DeclarativeBase,
|
||||||
|
ignore_conflicts: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Insert rows into a Many-to-Many association table without loading the ORM collection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DB async session.
|
||||||
|
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
||||||
|
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
||||||
|
*related: One or more related instances to associate with ``instance``.
|
||||||
|
ignore_conflicts: When ``True``, silently skip rows that already exist
|
||||||
|
in the association table (``ON CONFLICT DO NOTHING``).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import m2m_add, transaction
|
||||||
|
|
||||||
|
async with transaction(session):
|
||||||
|
await m2m_add(session, post, Post.tags, tag1, tag2)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
prop, secondary = _m2m_prop(rel_attr)
|
||||||
|
if not related:
|
||||||
|
return
|
||||||
|
|
||||||
|
sync_pairs = prop.secondary_synchronize_pairs
|
||||||
|
assert sync_pairs is not None # set whenever secondary is set
|
||||||
|
|
||||||
|
# synchronize_pairs: [(parent_col, assoc_col), ...]
|
||||||
|
# secondary_synchronize_pairs: [(related_col, assoc_col), ...]
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for rel_instance in related:
|
||||||
|
row: dict[str, Any] = {}
|
||||||
|
for parent_col, assoc_col in prop.synchronize_pairs:
|
||||||
|
row[assoc_col.name] = getattr(instance, cast(str, parent_col.key))
|
||||||
|
for related_col, assoc_col in sync_pairs:
|
||||||
|
row[assoc_col.name] = getattr(rel_instance, cast(str, related_col.key))
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
stmt = pg_insert(secondary).values(rows)
|
||||||
|
if ignore_conflicts:
|
||||||
|
stmt = stmt.on_conflict_do_nothing()
|
||||||
|
await session.execute(stmt)
|
||||||
|
|
||||||
|
|
||||||
|
async def m2m_remove(
|
||||||
|
session: AsyncSession,
|
||||||
|
instance: DeclarativeBase,
|
||||||
|
rel_attr: QueryableAttribute,
|
||||||
|
*related: DeclarativeBase,
|
||||||
|
) -> None:
|
||||||
|
"""Remove rows from a Many-to-Many association table without loading the ORM collection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DB async session.
|
||||||
|
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
||||||
|
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
||||||
|
*related: One or more related instances to disassociate from ``instance``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import m2m_remove, transaction
|
||||||
|
|
||||||
|
async with transaction(session):
|
||||||
|
await m2m_remove(session, post, Post.tags, tag1)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
prop, secondary = _m2m_prop(rel_attr)
|
||||||
|
if not related:
|
||||||
|
return
|
||||||
|
|
||||||
|
related_pairs = prop.secondary_synchronize_pairs
|
||||||
|
assert related_pairs is not None # set whenever secondary is set
|
||||||
|
|
||||||
|
parent_where = _parent_where(prop, instance)
|
||||||
|
|
||||||
|
if len(related_pairs) == 1:
|
||||||
|
related_col, assoc_col = related_pairs[0]
|
||||||
|
related_values = [getattr(r, cast(str, related_col.key)) for r in related]
|
||||||
|
related_where = assoc_col.in_(related_values)
|
||||||
|
else:
|
||||||
|
assoc_cols = [ac for _, ac in related_pairs]
|
||||||
|
rel_cols = [rc for rc, _ in related_pairs]
|
||||||
|
related_values_t = [
|
||||||
|
tuple(getattr(r, cast(str, rc.key)) for rc in rel_cols) for r in related
|
||||||
|
]
|
||||||
|
related_where = tuple_(*assoc_cols).in_(related_values_t)
|
||||||
|
|
||||||
|
await session.execute(delete(secondary).where(*parent_where, related_where))
|
||||||
|
|
||||||
|
|
||||||
|
async def m2m_set(
|
||||||
|
session: AsyncSession,
|
||||||
|
instance: DeclarativeBase,
|
||||||
|
rel_attr: QueryableAttribute,
|
||||||
|
*related: DeclarativeBase,
|
||||||
|
) -> None:
|
||||||
|
"""Replace the entire Many-to-Many association set atomically.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DB async session.
|
||||||
|
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
||||||
|
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
||||||
|
*related: The new complete set of related instances.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import m2m_set, transaction
|
||||||
|
|
||||||
|
async with transaction(session):
|
||||||
|
await m2m_set(session, post, Post.tags, tag1, tag2) # replaces all
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
prop, secondary = _m2m_prop(rel_attr)
|
||||||
|
|
||||||
|
await session.execute(delete(secondary).where(*_parent_where(prop, instance)))
|
||||||
|
|
||||||
|
if related:
|
||||||
|
await m2m_add(session, instance, rel_attr, *related)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Database admin and test helpers: DDL and truncation."""
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
async def create_database(
|
||||||
|
db_name: str,
|
||||||
|
*,
|
||||||
|
server_url: str,
|
||||||
|
) -> None:
|
||||||
|
"""Create a database.
|
||||||
|
|
||||||
|
Connects to *server_url* using ``AUTOCOMMIT`` isolation and issues a
|
||||||
|
``CREATE DATABASE`` statement for *db_name*.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_name: Name of the database to create.
|
||||||
|
server_url: URL used for server-level DDL (must point to an existing
|
||||||
|
database on the same server).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db.testing import create_database
|
||||||
|
|
||||||
|
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
|
||||||
|
await create_database("myapp_test", server_url=SERVER_URL)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
engine = create_async_engine(server_url, isolation_level="AUTOCOMMIT")
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text(f"CREATE DATABASE {db_name}"))
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_tables(
|
||||||
|
session: AsyncSession,
|
||||||
|
base: type[DeclarativeBase],
|
||||||
|
) -> None:
|
||||||
|
"""Truncate all tables for fast between-test cleanup.
|
||||||
|
|
||||||
|
Executes a single ``TRUNCATE … RESTART IDENTITY CASCADE`` statement
|
||||||
|
across every table in *base*'s metadata.
|
||||||
|
|
||||||
|
This is a no-op when the metadata contains no tables.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: An active async database session.
|
||||||
|
base: SQLAlchemy DeclarativeBase class containing model metadata.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_session(worker_db_url):
|
||||||
|
async with create_db_session(worker_db_url, Base) as session:
|
||||||
|
yield session
|
||||||
|
await cleanup_tables(session, Base)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
tables = base.metadata.sorted_tables
|
||||||
|
if not tables:
|
||||||
|
return
|
||||||
|
|
||||||
|
table_names = ", ".join(f'"{t.name}"' for t in tables)
|
||||||
|
await session.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE"))
|
||||||
|
await session.commit()
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Row-watching helpers: poll a database row until it changes."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from ..exceptions import NotFoundError
|
||||||
|
|
||||||
|
_M = TypeVar("_M", bound=DeclarativeBase)
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_row_change(
|
||||||
|
session: AsyncSession,
|
||||||
|
model: type[_M],
|
||||||
|
pk_value: Any,
|
||||||
|
*,
|
||||||
|
columns: list[str] | None = None,
|
||||||
|
interval: float = 0.5,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> _M:
|
||||||
|
"""Poll a database row until a change is detected.
|
||||||
|
|
||||||
|
Queries the row every ``interval`` seconds and returns the model instance
|
||||||
|
once a change is detected in any column (or only the specified ``columns``).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: AsyncSession instance.
|
||||||
|
model: SQLAlchemy model class.
|
||||||
|
pk_value: Primary key value of the row to watch.
|
||||||
|
columns: Optional list of column names to watch. If None, all columns
|
||||||
|
are watched.
|
||||||
|
interval: Polling interval in seconds (default: 0.5).
|
||||||
|
timeout: Maximum time to wait in seconds. None means wait forever.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The refreshed model instance with updated values.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotFoundError: If the row does not exist or is deleted during polling.
|
||||||
|
TimeoutError: If timeout expires before a change is detected.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import wait_for_row_change
|
||||||
|
|
||||||
|
# Wait for any column to change
|
||||||
|
updated = await wait_for_row_change(session, User, user_id)
|
||||||
|
|
||||||
|
# Watch specific columns with a timeout
|
||||||
|
updated = await wait_for_row_change(
|
||||||
|
session, User, user_id,
|
||||||
|
columns=["status", "email"],
|
||||||
|
interval=1.0,
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _reload() -> _M | None:
|
||||||
|
await session.rollback()
|
||||||
|
return await session.get(model, pk_value, populate_existing=True)
|
||||||
|
|
||||||
|
instance = await _reload()
|
||||||
|
if instance is None:
|
||||||
|
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} not found")
|
||||||
|
|
||||||
|
if columns is not None:
|
||||||
|
watch_cols = columns
|
||||||
|
else:
|
||||||
|
watch_cols = [attr.key for attr in model.__mapper__.column_attrs]
|
||||||
|
|
||||||
|
initial = {col: getattr(instance, col) for col in watch_cols}
|
||||||
|
|
||||||
|
elapsed = 0.0
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
elapsed += interval
|
||||||
|
|
||||||
|
if timeout is not None and elapsed >= timeout:
|
||||||
|
raise TimeoutError(
|
||||||
|
f"No change detected on {model.__name__} "
|
||||||
|
f"with pk={pk_value!r} within {timeout}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
instance = await _reload()
|
||||||
|
|
||||||
|
if instance is None:
|
||||||
|
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} was deleted")
|
||||||
|
|
||||||
|
current = {col: getattr(instance, col) for col in watch_cols}
|
||||||
|
if current != initial:
|
||||||
|
return instance
|
||||||
@@ -9,7 +9,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from ..db import get_transaction
|
from ..db import transaction
|
||||||
from ..logger import get_logger
|
from ..logger import get_logger
|
||||||
from ..types import ModelType
|
from ..types import ModelType
|
||||||
from .enum import LoadStrategy
|
from .enum import LoadStrategy
|
||||||
@@ -229,7 +229,7 @@ async def _load_ordered(
|
|||||||
model_name = type(instances[0]).__name__
|
model_name = type(instances[0]).__name__
|
||||||
loaded: list[DeclarativeBase] = []
|
loaded: list[DeclarativeBase] = []
|
||||||
|
|
||||||
async with get_transaction(session):
|
async with transaction(session):
|
||||||
for model_cls, group in _group_by_type(instances):
|
for model_cls, group in _group_by_type(instances):
|
||||||
match strategy:
|
match strategy:
|
||||||
case LoadStrategy.INSERT:
|
case LoadStrategy.INSERT:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import DeclarativeBase, selectinload
|
from sqlalchemy.orm import DeclarativeBase, selectinload
|
||||||
from sqlalchemy.orm.interfaces import ExecutableOption, ORMOption
|
from sqlalchemy.orm.interfaces import ExecutableOption, ORMOption
|
||||||
|
|
||||||
from ..db import get_transaction
|
from ..db import transaction
|
||||||
from ..fixtures import FixtureRegistry, LoadStrategy
|
from ..fixtures import FixtureRegistry, LoadStrategy
|
||||||
|
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ def _create_fixture_function(
|
|||||||
|
|
||||||
loaded: list[DeclarativeBase] = []
|
loaded: list[DeclarativeBase] = []
|
||||||
|
|
||||||
async with get_transaction(session):
|
async with transaction(session):
|
||||||
for instance in instances:
|
for instance in instances:
|
||||||
if strategy == LoadStrategy.INSERT:
|
if strategy == LoadStrategy.INSERT:
|
||||||
session.add(instance)
|
session.add(instance)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from sqlalchemy.ext.asyncio import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from ..db import cleanup_tables, create_database
|
from ..db.testing import cleanup_tables, create_database
|
||||||
from ..models.watched import EventSession
|
from ..models.watched import EventSession
|
||||||
|
|
||||||
|
|
||||||
@@ -34,12 +34,18 @@ def _get_xdist_worker(default_test_db: str) -> str:
|
|||||||
return os.environ.get("PYTEST_XDIST_WORKER", default_test_db)
|
return os.environ.get("PYTEST_XDIST_WORKER", default_test_db)
|
||||||
|
|
||||||
|
|
||||||
def worker_database_url(database_url: str, default_test_db: str) -> str:
|
def worker_database_url(
|
||||||
|
database_url: str,
|
||||||
|
default_test_db: str,
|
||||||
|
*,
|
||||||
|
prefix: str | None = None,
|
||||||
|
) -> str:
|
||||||
"""Derive a per-worker database URL for pytest-xdist parallel runs.
|
"""Derive a per-worker database URL for pytest-xdist parallel runs.
|
||||||
|
|
||||||
Appends ``_{worker_name}`` to the database name so each xdist worker
|
Sets the database name to the worker name so each xdist worker operates
|
||||||
operates on its own database. When not running under xdist,
|
on its own database. When not running under xdist, *default_test_db* is
|
||||||
``_{default_test_db}`` is appended instead.
|
used instead. When *prefix* is provided, the name becomes
|
||||||
|
``{prefix}_{worker}``.
|
||||||
|
|
||||||
The worker name is read from the ``PYTEST_XDIST_WORKER`` environment
|
The worker name is read from the ``PYTEST_XDIST_WORKER`` environment
|
||||||
variable (set automatically by xdist in each worker process).
|
variable (set automatically by xdist in each worker process).
|
||||||
@@ -48,6 +54,9 @@ def worker_database_url(database_url: str, default_test_db: str) -> str:
|
|||||||
database_url: Original database connection URL.
|
database_url: Original database connection URL.
|
||||||
default_test_db: Suffix appended to the database name when
|
default_test_db: Suffix appended to the database name when
|
||||||
``PYTEST_XDIST_WORKER`` is not set.
|
``PYTEST_XDIST_WORKER`` is not set.
|
||||||
|
prefix: Optional prefix prepended to the worker name
|
||||||
|
(e.g. ``"test"`` → ``"test_gw0"``). Without it, the database
|
||||||
|
name is just the worker name (e.g. ``"gw0"``).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A database URL with a worker- or default-specific database name.
|
A database URL with a worker- or default-specific database name.
|
||||||
@@ -55,7 +64,8 @@ def worker_database_url(database_url: str, default_test_db: str) -> str:
|
|||||||
worker = _get_xdist_worker(default_test_db=default_test_db)
|
worker = _get_xdist_worker(default_test_db=default_test_db)
|
||||||
|
|
||||||
url = make_url(database_url)
|
url = make_url(database_url)
|
||||||
url = url.set(database=f"{url.database}_{worker}")
|
db_name = f"{prefix}_{worker}" if prefix else worker
|
||||||
|
url = url.set(database=db_name)
|
||||||
return url.render_as_string(hide_password=False)
|
return url.render_as_string(hide_password=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -64,6 +74,7 @@ async def create_worker_database(
|
|||||||
database_url: str,
|
database_url: str,
|
||||||
default_test_db: str = "test_db",
|
default_test_db: str = "test_db",
|
||||||
*,
|
*,
|
||||||
|
prefix: str | None = None,
|
||||||
server_url: str | None = None,
|
server_url: str | None = None,
|
||||||
) -> AsyncGenerator[str, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
"""Create and drop a per-worker database for pytest-xdist isolation.
|
"""Create and drop a per-worker database for pytest-xdist isolation.
|
||||||
@@ -80,6 +91,9 @@ async def create_worker_database(
|
|||||||
the worker database name).
|
the worker database name).
|
||||||
default_test_db: Suffix appended to the database name when
|
default_test_db: Suffix appended to the database name when
|
||||||
``PYTEST_XDIST_WORKER`` is not set. Defaults to ``"test_db"``.
|
``PYTEST_XDIST_WORKER`` is not set. Defaults to ``"test_db"``.
|
||||||
|
prefix: Optional prefix prepended to the worker name
|
||||||
|
(e.g. ``prefix="test"`` → ``"test_gw0"``). Without it, the
|
||||||
|
database name is just the worker name (e.g. ``"gw0"``).
|
||||||
server_url: URL used for server-level DDL (must point to an existing
|
server_url: URL used for server-level DDL (must point to an existing
|
||||||
database on the same server). Defaults to *database_url* with the
|
database on the same server). Defaults to *database_url* with the
|
||||||
database omitted, letting asyncpg fall back to the username.
|
database omitted, letting asyncpg fall back to the username.
|
||||||
@@ -107,7 +121,7 @@ async def create_worker_database(
|
|||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
worker_url = worker_database_url(
|
worker_url = worker_database_url(
|
||||||
database_url=database_url, default_test_db=default_test_db
|
database_url=database_url, default_test_db=default_test_db, prefix=prefix
|
||||||
)
|
)
|
||||||
worker_db_name = make_url(worker_url).database
|
worker_db_name = make_url(worker_url).database
|
||||||
assert worker_db_name is not None
|
assert worker_db_name is not None
|
||||||
@@ -125,13 +139,17 @@ async def create_worker_database(
|
|||||||
engine = create_async_engine(_server_url, isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(_server_url, isolation_level="AUTOCOMMIT")
|
||||||
try:
|
try:
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
await conn.execute(text(f"DROP DATABASE IF EXISTS {worker_db_name}"))
|
await conn.execute(
|
||||||
|
text(f"DROP DATABASE IF EXISTS {worker_db_name} WITH (FORCE)")
|
||||||
|
)
|
||||||
await create_database(db_name=worker_db_name, server_url=_server_url)
|
await create_database(db_name=worker_db_name, server_url=_server_url)
|
||||||
|
|
||||||
yield worker_url
|
yield worker_url
|
||||||
|
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
await conn.execute(text(f"DROP DATABASE IF EXISTS {worker_db_name}"))
|
await conn.execute(
|
||||||
|
text(f"DROP DATABASE IF EXISTS {worker_db_name} WITH (FORCE)")
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
"""Authentication helpers for FastAPI using Security()."""
|
|
||||||
|
|
||||||
from .abc import AuthSource
|
|
||||||
from .oauth import (
|
|
||||||
oauth_build_authorization_redirect,
|
|
||||||
oauth_decode_state,
|
|
||||||
oauth_encode_state,
|
|
||||||
oauth_fetch_userinfo,
|
|
||||||
oauth_generate_state_token,
|
|
||||||
oauth_resolve_provider_urls,
|
|
||||||
)
|
|
||||||
from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"APIKeyHeaderAuth",
|
|
||||||
"AuthSource",
|
|
||||||
"BearerTokenAuth",
|
|
||||||
"CookieAuth",
|
|
||||||
"MultiAuth",
|
|
||||||
"oauth_build_authorization_redirect",
|
|
||||||
"oauth_decode_state",
|
|
||||||
"oauth_encode_state",
|
|
||||||
"oauth_fetch_userinfo",
|
|
||||||
"oauth_generate_state_token",
|
|
||||||
"oauth_resolve_provider_urls",
|
|
||||||
]
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""Abstract base class for authentication sources."""
|
|
||||||
|
|
||||||
import functools
|
|
||||||
import inspect
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
from fastapi.security import SecurityScopes
|
|
||||||
|
|
||||||
from fastapi_toolsets.exceptions import UnauthorizedError
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
||||||
"""Wrap *fn* so it can always be awaited, caching the coroutine check at init time."""
|
|
||||||
if inspect.iscoroutinefunction(fn):
|
|
||||||
return fn
|
|
||||||
|
|
||||||
@functools.wraps(fn)
|
|
||||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
return fn(*args, **kwargs)
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
class AuthSource(ABC):
|
|
||||||
"""Abstract base class for authentication sources."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
"""Set up the default FastAPI dependency signature."""
|
|
||||||
source = self
|
|
||||||
|
|
||||||
async def _call(
|
|
||||||
request: Request,
|
|
||||||
security_scopes: SecurityScopes, # noqa: ARG001
|
|
||||||
) -> Any:
|
|
||||||
credential = await source.extract(request)
|
|
||||||
if credential is None:
|
|
||||||
raise UnauthorizedError()
|
|
||||||
return await source.authenticate(credential)
|
|
||||||
|
|
||||||
self._call_fn: Callable[..., Any] = _call
|
|
||||||
self.__signature__ = inspect.signature(_call)
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def extract(self, request: Request) -> str | None:
|
|
||||||
"""Extract the raw credential from the request without validating."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def authenticate(self, credential: str) -> Any:
|
|
||||||
"""Validate a credential and return the authenticated identity."""
|
|
||||||
|
|
||||||
async def __call__(self, **kwargs: Any) -> Any:
|
|
||||||
"""FastAPI dependency dispatch."""
|
|
||||||
return await self._call_fn(**kwargs)
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
"""OAuth 2.0 / OIDC helper utilities."""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import binascii
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import secrets
|
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from async_lru import alru_cache
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
|
|
||||||
|
|
||||||
@alru_cache(maxsize=32)
|
|
||||||
async def oauth_resolve_provider_urls(
|
|
||||||
discovery_url: str,
|
|
||||||
) -> tuple[str, str, str | None]:
|
|
||||||
"""Fetch the OIDC discovery document and return endpoint URLs.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
discovery_url: URL of the provider's ``/.well-known/openid-configuration``.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A ``(authorization_url, token_url, userinfo_url)`` tuple.
|
|
||||||
*userinfo_url* is ``None`` when the provider does not advertise one.
|
|
||||||
"""
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(discovery_url)
|
|
||||||
resp.raise_for_status()
|
|
||||||
cfg = resp.json()
|
|
||||||
return (
|
|
||||||
cfg["authorization_endpoint"],
|
|
||||||
cfg["token_endpoint"],
|
|
||||||
cfg.get("userinfo_endpoint"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def oauth_fetch_userinfo(
|
|
||||||
*,
|
|
||||||
token_url: str,
|
|
||||||
userinfo_url: str,
|
|
||||||
code: str,
|
|
||||||
client_id: str,
|
|
||||||
client_secret: str,
|
|
||||||
redirect_uri: str,
|
|
||||||
required_scopes: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Exchange an authorization code for tokens and return the userinfo payload.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
token_url: Provider's token endpoint.
|
|
||||||
userinfo_url: Provider's userinfo endpoint.
|
|
||||||
code: Authorization code received from the provider's callback.
|
|
||||||
client_id: OAuth application client ID.
|
|
||||||
client_secret: OAuth application client secret.
|
|
||||||
redirect_uri: Redirect URI that was used in the authorization request.
|
|
||||||
required_scopes: Space-separated scopes that must be present in the token
|
|
||||||
response ``scope`` field (RFC 6749 §3.3). Raises ``ValueError`` if
|
|
||||||
the provider granted fewer scopes than requested.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The JSON payload returned by the userinfo endpoint as a plain ``dict``.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the provider granted a different token type than ``bearer``
|
|
||||||
or did not grant all ``required_scopes``.
|
|
||||||
"""
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
token_resp = await client.post(
|
|
||||||
token_url,
|
|
||||||
data={
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"code": code,
|
|
||||||
"client_id": client_id,
|
|
||||||
"client_secret": client_secret,
|
|
||||||
"redirect_uri": redirect_uri,
|
|
||||||
},
|
|
||||||
headers={"Accept": "application/json"},
|
|
||||||
)
|
|
||||||
token_resp.raise_for_status()
|
|
||||||
token_data = token_resp.json()
|
|
||||||
|
|
||||||
if token_data.get("token_type", "bearer").lower() != "bearer":
|
|
||||||
raise ValueError(
|
|
||||||
f"unsupported token_type: {token_data.get('token_type')!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if required_scopes is not None:
|
|
||||||
granted = set(token_data.get("scope", "").split())
|
|
||||||
missing = set(required_scopes.split()) - granted
|
|
||||||
if missing:
|
|
||||||
raise ValueError(f"provider did not grant required scopes: {missing}")
|
|
||||||
|
|
||||||
access_token = token_data["access_token"]
|
|
||||||
|
|
||||||
userinfo_resp = await client.get(
|
|
||||||
userinfo_url,
|
|
||||||
headers={"Authorization": f"Bearer {access_token}"},
|
|
||||||
)
|
|
||||||
userinfo_resp.raise_for_status()
|
|
||||||
return userinfo_resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
def oauth_generate_state_token() -> str:
|
|
||||||
"""Generate a cryptographically random CSRF token for the OAuth ``state`` parameter."""
|
|
||||||
return secrets.token_urlsafe(32)
|
|
||||||
|
|
||||||
|
|
||||||
def oauth_build_authorization_redirect(
|
|
||||||
authorization_url: str,
|
|
||||||
*,
|
|
||||||
client_id: str,
|
|
||||||
scopes: str,
|
|
||||||
redirect_uri: str,
|
|
||||||
destination: str,
|
|
||||||
state_token: str,
|
|
||||||
) -> RedirectResponse:
|
|
||||||
"""Return an OAuth 2.0 authorization ``RedirectResponse``.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
authorization_url: Provider's authorization endpoint.
|
|
||||||
client_id: OAuth application client ID.
|
|
||||||
scopes: Space-separated list of requested scopes.
|
|
||||||
redirect_uri: URI the provider should redirect back to after authorization.
|
|
||||||
destination: URL the user should be sent to after the full OAuth flow
|
|
||||||
completes (embedded in ``state``).
|
|
||||||
state_token: CSRF token generated by :func:`oauth_generate_state_token`.
|
|
||||||
Must be stored server-side (session or signed cookie) and verified via
|
|
||||||
:func:`oauth_decode_state` on the callback endpoint (RFC 6749 §10.12).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A :class:`~fastapi.responses.RedirectResponse` to the provider's
|
|
||||||
authorization page.
|
|
||||||
"""
|
|
||||||
params = urlencode(
|
|
||||||
{
|
|
||||||
"client_id": client_id,
|
|
||||||
"response_type": "code",
|
|
||||||
"scope": scopes,
|
|
||||||
"redirect_uri": redirect_uri,
|
|
||||||
"state": oauth_encode_state(destination, state_token),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return RedirectResponse(f"{authorization_url}?{params}")
|
|
||||||
|
|
||||||
|
|
||||||
def oauth_encode_state(url: str, state_token: str) -> str:
|
|
||||||
"""Encode a destination URL and CSRF token into an OAuth ``state`` parameter.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: Post-login destination URL.
|
|
||||||
state_token: CSRF token from :func:`oauth_generate_state_token`.
|
|
||||||
"""
|
|
||||||
payload = json.dumps({"n": state_token, "d": url}, separators=(",", ":"))
|
|
||||||
return base64.urlsafe_b64encode(payload.encode()).decode()
|
|
||||||
|
|
||||||
|
|
||||||
def oauth_decode_state(
|
|
||||||
state: str | None, *, expected_state_token: str, fallback: str
|
|
||||||
) -> str:
|
|
||||||
"""Decode and CSRF-verify an OAuth ``state`` parameter.
|
|
||||||
|
|
||||||
Uses a constant-time comparison for the CSRF token to prevent timing attacks.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
state: Raw ``state`` query parameter from the provider's callback.
|
|
||||||
expected_state_token: The token stored before the authorization redirect.
|
|
||||||
If it does not match the decoded value, ``fallback`` is returned.
|
|
||||||
fallback: URL to return when ``state`` is absent, malformed, or fails
|
|
||||||
CSRF verification.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The destination URL embedded in ``state``, or ``fallback``.
|
|
||||||
|
|
||||||
Important:
|
|
||||||
**Single-use**: delete the stored token from the session immediately
|
|
||||||
after calling this function — whether it matched or not — so that a
|
|
||||||
captured callback URL cannot be replayed.
|
|
||||||
|
|
||||||
**Open-redirect**: validate the returned URL against a known-good
|
|
||||||
origin or relative-path allowlist before issuing the final redirect.
|
|
||||||
Do not forward arbitrary URLs to ``RedirectResponse``.
|
|
||||||
"""
|
|
||||||
if not state or state == "null": # "null" guards against JS JSON.stringify(null)
|
|
||||||
return fallback
|
|
||||||
try:
|
|
||||||
padded = state + "=" * (-len(state) % 4)
|
|
||||||
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
|
|
||||||
if not isinstance(payload, dict) or not hmac.compare_digest(
|
|
||||||
payload.get("n", "").encode(), expected_state_token.encode()
|
|
||||||
):
|
|
||||||
return fallback
|
|
||||||
return str(payload["d"])
|
|
||||||
except (UnicodeDecodeError, ValueError, binascii.Error, KeyError):
|
|
||||||
return fallback
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"""Built-in authentication source implementations."""
|
|
||||||
|
|
||||||
from .header import APIKeyHeaderAuth
|
|
||||||
from .bearer import BearerTokenAuth
|
|
||||||
from .cookie import CookieAuth
|
|
||||||
from .multi import MultiAuth
|
|
||||||
|
|
||||||
__all__ = ["APIKeyHeaderAuth", "BearerTokenAuth", "CookieAuth", "MultiAuth"]
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
"""Bearer token authentication source."""
|
|
||||||
|
|
||||||
import inspect
|
|
||||||
import secrets
|
|
||||||
from typing import Annotated, Any, Callable
|
|
||||||
|
|
||||||
from fastapi import Depends, Request
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes
|
|
||||||
|
|
||||||
from fastapi_toolsets.exceptions import UnauthorizedError
|
|
||||||
|
|
||||||
from ..abc import AuthSource, _ensure_async
|
|
||||||
|
|
||||||
|
|
||||||
class BearerTokenAuth(AuthSource):
|
|
||||||
"""Bearer token authentication source.
|
|
||||||
|
|
||||||
Wraps :class:`fastapi.security.HTTPBearer` for OpenAPI documentation.
|
|
||||||
The validator is called as ``await validator(credential, **kwargs)``
|
|
||||||
where ``kwargs`` are the extra keyword arguments provided at instantiation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
validator: Sync or async callable that receives the credential and any
|
|
||||||
extra keyword arguments, and returns the authenticated identity
|
|
||||||
(e.g. a ``User`` model). Should raise
|
|
||||||
:class:`~fastapi_toolsets.exceptions.UnauthorizedError` on failure.
|
|
||||||
prefix: Optional token prefix (e.g. ``"user_"``). If set, only tokens
|
|
||||||
whose value starts with this prefix are matched. The prefix is
|
|
||||||
**kept** in the value passed to the validator — store and compare
|
|
||||||
tokens with their prefix included. Use :meth:`generate_token` to
|
|
||||||
create correctly-prefixed tokens. This enables multiple
|
|
||||||
``BearerTokenAuth`` instances in the same app (e.g. ``"user_"``
|
|
||||||
for user tokens, ``"org_"`` for org tokens).
|
|
||||||
**kwargs: Extra keyword arguments forwarded to the validator on every
|
|
||||||
call (e.g. ``role=Role.ADMIN``).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
validator: Callable[..., Any],
|
|
||||||
*,
|
|
||||||
prefix: str | None = None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> None:
|
|
||||||
self._validator = _ensure_async(validator)
|
|
||||||
self._prefix = prefix
|
|
||||||
self._kwargs = kwargs
|
|
||||||
self._scheme = HTTPBearer(auto_error=False)
|
|
||||||
|
|
||||||
async def _call(
|
|
||||||
security_scopes: SecurityScopes, # noqa: ARG001
|
|
||||||
credentials: Annotated[
|
|
||||||
HTTPAuthorizationCredentials | None, Depends(self._scheme)
|
|
||||||
] = None,
|
|
||||||
) -> Any:
|
|
||||||
if credentials is None:
|
|
||||||
raise UnauthorizedError()
|
|
||||||
return await self._validate(credentials.credentials)
|
|
||||||
|
|
||||||
self._call_fn = _call
|
|
||||||
self.__signature__ = inspect.signature(_call)
|
|
||||||
|
|
||||||
async def _validate(self, token: str) -> Any:
|
|
||||||
"""Check prefix and call the validator."""
|
|
||||||
if self._prefix is not None and not token.startswith(self._prefix):
|
|
||||||
raise UnauthorizedError()
|
|
||||||
return await self._validator(token, **self._kwargs)
|
|
||||||
|
|
||||||
async def extract(self, request: Request) -> str | None:
|
|
||||||
"""Extract the raw credential from the request without validating.
|
|
||||||
|
|
||||||
Returns ``None`` if no ``Authorization: Bearer`` header is present,
|
|
||||||
the token is empty, or the token does not match the configured prefix.
|
|
||||||
The prefix is included in the returned value.
|
|
||||||
"""
|
|
||||||
auth = request.headers.get("Authorization", "")
|
|
||||||
if not auth.startswith("Bearer "):
|
|
||||||
return None
|
|
||||||
token = auth[7:]
|
|
||||||
if not token:
|
|
||||||
return None
|
|
||||||
if self._prefix is not None and not token.startswith(self._prefix):
|
|
||||||
return None
|
|
||||||
return token
|
|
||||||
|
|
||||||
async def authenticate(self, credential: str) -> Any:
|
|
||||||
"""Validate a credential and return the identity.
|
|
||||||
|
|
||||||
Calls ``await validator(credential, **kwargs)`` where ``kwargs`` are
|
|
||||||
the extra keyword arguments provided at instantiation.
|
|
||||||
"""
|
|
||||||
return await self._validate(credential)
|
|
||||||
|
|
||||||
def require(self, **kwargs: Any) -> "BearerTokenAuth":
|
|
||||||
"""Return a new instance with additional (or overriding) validator kwargs."""
|
|
||||||
return BearerTokenAuth(
|
|
||||||
self._validator,
|
|
||||||
prefix=self._prefix,
|
|
||||||
**{**self._kwargs, **kwargs},
|
|
||||||
)
|
|
||||||
|
|
||||||
def generate_token(self, nbytes: int = 32) -> str:
|
|
||||||
"""Generate a secure random token for this auth source.
|
|
||||||
|
|
||||||
Returns a URL-safe random token. If a prefix is configured it is
|
|
||||||
prepended — the returned value is what you store in your database
|
|
||||||
and return to the client as-is.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
nbytes: Number of random bytes before base64 encoding. The
|
|
||||||
resulting string is ``ceil(nbytes * 4 / 3)`` characters
|
|
||||||
(43 chars for the default 32 bytes). Defaults to 32.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A ready-to-use token string (e.g. ``"user_Xk3..."``).
|
|
||||||
"""
|
|
||||||
token = secrets.token_urlsafe(nbytes)
|
|
||||||
if self._prefix is not None:
|
|
||||||
return f"{self._prefix}{token}"
|
|
||||||
return token
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
"""Cookie-based authentication source."""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import inspect
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
from typing import Annotated, Any, Callable
|
|
||||||
|
|
||||||
from fastapi import Depends, Request, Response
|
|
||||||
from fastapi.security import APIKeyCookie, SecurityScopes
|
|
||||||
|
|
||||||
from fastapi_toolsets.exceptions import UnauthorizedError
|
|
||||||
|
|
||||||
from ..abc import AuthSource, _ensure_async
|
|
||||||
|
|
||||||
|
|
||||||
class CookieAuth(AuthSource):
|
|
||||||
"""Cookie-based authentication source.
|
|
||||||
|
|
||||||
Wraps :class:`fastapi.security.APIKeyCookie` for OpenAPI documentation.
|
|
||||||
Optionally signs the cookie with HMAC-SHA256 to provide stateless, tamper-
|
|
||||||
proof sessions without any database entry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Cookie name.
|
|
||||||
validator: Sync or async callable that receives the cookie value
|
|
||||||
(plain, after signature verification when ``secret_key`` is set)
|
|
||||||
and any extra keyword arguments, and returns the authenticated
|
|
||||||
identity.
|
|
||||||
secret_key: When provided, the cookie is HMAC-SHA256 signed.
|
|
||||||
:meth:`set_cookie` embeds an expiry and signs the payload;
|
|
||||||
:meth:`extract` verifies the signature and expiry before handing
|
|
||||||
the plain value to the validator. When ``None`` (default), the raw
|
|
||||||
cookie value is passed to the validator as-is.
|
|
||||||
ttl: Cookie lifetime in seconds (default 24 h). Only used when
|
|
||||||
``secret_key`` is set.
|
|
||||||
secure: Set the ``Secure`` flag on the cookie so it is only transmitted
|
|
||||||
over HTTPS (default ``True``). Set to ``False`` only in local
|
|
||||||
development environments where HTTPS is unavailable.
|
|
||||||
**kwargs: Extra keyword arguments forwarded to the validator on every
|
|
||||||
call (e.g. ``role=Role.ADMIN``).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
validator: Callable[..., Any],
|
|
||||||
*,
|
|
||||||
secret_key: str | None = None,
|
|
||||||
ttl: int = 86400,
|
|
||||||
secure: bool = True,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> None:
|
|
||||||
self._name = name
|
|
||||||
self._validator = _ensure_async(validator)
|
|
||||||
self._secret_key = secret_key
|
|
||||||
self._ttl = ttl
|
|
||||||
self._secure = secure
|
|
||||||
self._kwargs = kwargs
|
|
||||||
self._scheme = APIKeyCookie(name=name, auto_error=False)
|
|
||||||
|
|
||||||
async def _call(
|
|
||||||
security_scopes: SecurityScopes, # noqa: ARG001
|
|
||||||
value: Annotated[str | None, Depends(self._scheme)] = None,
|
|
||||||
) -> Any:
|
|
||||||
if value is None:
|
|
||||||
raise UnauthorizedError()
|
|
||||||
plain = self._verify(value)
|
|
||||||
return await self._validator(plain, **self._kwargs)
|
|
||||||
|
|
||||||
self._call_fn = _call
|
|
||||||
self.__signature__ = inspect.signature(_call)
|
|
||||||
|
|
||||||
def _hmac(self, data: str) -> str:
|
|
||||||
if self._secret_key is None:
|
|
||||||
raise RuntimeError("_hmac called without secret_key configured")
|
|
||||||
return hmac.new(
|
|
||||||
self._secret_key.encode(), data.encode(), hashlib.sha256
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
def _sign(self, value: str) -> str:
|
|
||||||
data = base64.urlsafe_b64encode(
|
|
||||||
json.dumps({"v": value, "exp": int(time.time()) + self._ttl}).encode()
|
|
||||||
).decode()
|
|
||||||
return f"{data}.{self._hmac(data)}"
|
|
||||||
|
|
||||||
def _verify(self, cookie_value: str) -> str:
|
|
||||||
"""Return the plain value, verifying HMAC + expiry when signed."""
|
|
||||||
if not self._secret_key:
|
|
||||||
return cookie_value
|
|
||||||
|
|
||||||
try:
|
|
||||||
data, sig = cookie_value.rsplit(".", 1)
|
|
||||||
except ValueError:
|
|
||||||
raise UnauthorizedError()
|
|
||||||
|
|
||||||
if not hmac.compare_digest(self._hmac(data), sig):
|
|
||||||
raise UnauthorizedError()
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = json.loads(base64.urlsafe_b64decode(data))
|
|
||||||
value: str = payload["v"]
|
|
||||||
exp: int = payload["exp"]
|
|
||||||
except Exception:
|
|
||||||
raise UnauthorizedError()
|
|
||||||
|
|
||||||
if exp < int(time.time()):
|
|
||||||
raise UnauthorizedError()
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
async def extract(self, request: Request) -> str | None:
|
|
||||||
return request.cookies.get(self._name)
|
|
||||||
|
|
||||||
async def authenticate(self, credential: str) -> Any:
|
|
||||||
plain = self._verify(credential)
|
|
||||||
return await self._validator(plain, **self._kwargs)
|
|
||||||
|
|
||||||
def require(self, **kwargs: Any) -> "CookieAuth":
|
|
||||||
"""Return a new instance with additional (or overriding) validator kwargs."""
|
|
||||||
return CookieAuth(
|
|
||||||
self._name,
|
|
||||||
self._validator,
|
|
||||||
secret_key=self._secret_key,
|
|
||||||
ttl=self._ttl,
|
|
||||||
secure=self._secure,
|
|
||||||
**{**self._kwargs, **kwargs},
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_cookie(self, response: Response, value: str) -> None:
|
|
||||||
"""Attach the cookie to *response*, signing it when ``secret_key`` is set."""
|
|
||||||
cookie_value = self._sign(value) if self._secret_key else value
|
|
||||||
response.set_cookie(
|
|
||||||
self._name,
|
|
||||||
cookie_value,
|
|
||||||
httponly=True,
|
|
||||||
samesite="lax",
|
|
||||||
secure=self._secure,
|
|
||||||
max_age=self._ttl,
|
|
||||||
)
|
|
||||||
|
|
||||||
def delete_cookie(self, response: Response) -> None:
|
|
||||||
"""Clear the session cookie (logout)."""
|
|
||||||
response.delete_cookie(
|
|
||||||
self._name, httponly=True, samesite="lax", secure=self._secure
|
|
||||||
)
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"""API key header authentication source."""
|
|
||||||
|
|
||||||
import inspect
|
|
||||||
from typing import Annotated, Any, Callable
|
|
||||||
|
|
||||||
from fastapi import Depends, Request
|
|
||||||
from fastapi.security import APIKeyHeader, SecurityScopes
|
|
||||||
|
|
||||||
from fastapi_toolsets.exceptions import UnauthorizedError
|
|
||||||
|
|
||||||
from ..abc import AuthSource, _ensure_async
|
|
||||||
|
|
||||||
|
|
||||||
class APIKeyHeaderAuth(AuthSource):
|
|
||||||
"""API key header authentication source.
|
|
||||||
|
|
||||||
Wraps :class:`fastapi.security.APIKeyHeader` for OpenAPI documentation.
|
|
||||||
The validator is called as ``await validator(api_key, **kwargs)``
|
|
||||||
where ``kwargs`` are the extra keyword arguments provided at instantiation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: HTTP header name that carries the API key (e.g. ``"X-API-Key"``).
|
|
||||||
validator: Sync or async callable that receives the API key and any
|
|
||||||
extra keyword arguments, and returns the authenticated identity.
|
|
||||||
Should raise :class:`~fastapi_toolsets.exceptions.UnauthorizedError`
|
|
||||||
on failure.
|
|
||||||
**kwargs: Extra keyword arguments forwarded to the validator on every
|
|
||||||
call (e.g. ``role=Role.ADMIN``).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
validator: Callable[..., Any],
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> None:
|
|
||||||
self._name = name
|
|
||||||
self._validator = _ensure_async(validator)
|
|
||||||
self._kwargs = kwargs
|
|
||||||
self._scheme = APIKeyHeader(name=name, auto_error=False)
|
|
||||||
|
|
||||||
async def _call(
|
|
||||||
security_scopes: SecurityScopes, # noqa: ARG001
|
|
||||||
api_key: Annotated[str | None, Depends(self._scheme)] = None,
|
|
||||||
) -> Any:
|
|
||||||
if api_key is None:
|
|
||||||
raise UnauthorizedError()
|
|
||||||
return await self._validator(api_key, **self._kwargs)
|
|
||||||
|
|
||||||
self._call_fn = _call
|
|
||||||
self.__signature__ = inspect.signature(_call)
|
|
||||||
|
|
||||||
async def extract(self, request: Request) -> str | None:
|
|
||||||
"""Extract the API key from the configured header."""
|
|
||||||
return request.headers.get(self._name) or None
|
|
||||||
|
|
||||||
async def authenticate(self, credential: str) -> Any:
|
|
||||||
"""Validate a credential and return the identity."""
|
|
||||||
return await self._validator(credential, **self._kwargs)
|
|
||||||
|
|
||||||
def require(self, **kwargs: Any) -> "APIKeyHeaderAuth":
|
|
||||||
"""Return a new instance with additional (or overriding) validator kwargs."""
|
|
||||||
return APIKeyHeaderAuth(
|
|
||||||
self._name,
|
|
||||||
self._validator,
|
|
||||||
**{**self._kwargs, **kwargs},
|
|
||||||
)
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
"""MultiAuth: combine multiple authentication sources into a single callable."""
|
|
||||||
|
|
||||||
import inspect
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
from fastapi.security import SecurityScopes
|
|
||||||
|
|
||||||
from fastapi_toolsets.exceptions import UnauthorizedError
|
|
||||||
|
|
||||||
from ..abc import AuthSource
|
|
||||||
|
|
||||||
|
|
||||||
class MultiAuth:
|
|
||||||
"""Combine multiple authentication sources into a single callable.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
*sources: Auth source instances to try in order.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *sources: AuthSource) -> None:
|
|
||||||
self._sources = sources
|
|
||||||
|
|
||||||
async def _call(
|
|
||||||
request: Request,
|
|
||||||
security_scopes: SecurityScopes, # noqa: ARG001
|
|
||||||
**kwargs: Any, # noqa: ARG001 — absorbs scheme values injected by FastAPI
|
|
||||||
) -> Any:
|
|
||||||
for source in self._sources:
|
|
||||||
credential = await source.extract(request)
|
|
||||||
if credential is not None:
|
|
||||||
return await source.authenticate(credential)
|
|
||||||
raise UnauthorizedError()
|
|
||||||
|
|
||||||
self._call_fn = _call
|
|
||||||
|
|
||||||
# Build a merged signature that includes the security-scheme Depends()
|
|
||||||
# parameters from every source so FastAPI registers them in OpenAPI docs.
|
|
||||||
seen: set[str] = {"request", "security_scopes"}
|
|
||||||
merged: list[inspect.Parameter] = [
|
|
||||||
inspect.Parameter(
|
|
||||||
"request",
|
|
||||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
||||||
annotation=Request,
|
|
||||||
),
|
|
||||||
inspect.Parameter(
|
|
||||||
"security_scopes",
|
|
||||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
||||||
annotation=SecurityScopes,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
for i, source in enumerate(sources):
|
|
||||||
for name, param in inspect.signature(source).parameters.items():
|
|
||||||
if name in seen:
|
|
||||||
continue
|
|
||||||
merged.append(param.replace(name=f"_s{i}_{name}"))
|
|
||||||
seen.add(name)
|
|
||||||
self.__signature__ = inspect.Signature(merged, return_annotation=Any)
|
|
||||||
|
|
||||||
async def __call__(self, **kwargs: Any) -> Any:
|
|
||||||
return await self._call_fn(**kwargs)
|
|
||||||
|
|
||||||
def require(self, **kwargs: Any) -> "MultiAuth":
|
|
||||||
"""Return a new :class:`MultiAuth` with kwargs forwarded to each source."""
|
|
||||||
new_sources = tuple(
|
|
||||||
cast(Any, source).require(**kwargs)
|
|
||||||
if hasattr(source, "require")
|
|
||||||
else source
|
|
||||||
for source in self._sources
|
|
||||||
)
|
|
||||||
return MultiAuth(*new_sources)
|
|
||||||
+699
-169
File diff suppressed because it is too large
Load Diff
@@ -91,13 +91,19 @@ async def seed(session: AsyncSession):
|
|||||||
class TestAppSessionDep:
|
class TestAppSessionDep:
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_get_db_yields_async_session(self):
|
async def test_get_db_yields_async_session(self):
|
||||||
"""get_db yields a real AsyncSession when called directly."""
|
"""The Database dependency yields a real AsyncSession when called directly."""
|
||||||
from docs_src.examples.pagination_search.db import get_db
|
from starlette.requests import Request
|
||||||
|
|
||||||
gen = get_db()
|
from fastapi_toolsets.db import Database
|
||||||
|
|
||||||
|
db = Database(DATABASE_URL)
|
||||||
|
try:
|
||||||
|
gen = db(Request({"type": "http", "headers": []}))
|
||||||
session = await gen.__anext__()
|
session = await gen.__anext__()
|
||||||
assert isinstance(session, AsyncSession)
|
assert isinstance(session, AsyncSession)
|
||||||
await gen.aclose()
|
await gen.aclose()
|
||||||
|
finally:
|
||||||
|
await db.engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
class TestOffsetPagination:
|
class TestOffsetPagination:
|
||||||
|
|||||||
+28
-42
@@ -1506,8 +1506,8 @@ class TestListensFor:
|
|||||||
assert all(e["event"] == "change" for e in _listener_events)
|
assert all(e["event"] == "change" for e in _listener_events)
|
||||||
|
|
||||||
|
|
||||||
class TestEventSessionWithGetTransaction:
|
class TestEventSessionWithTransaction:
|
||||||
"""Verify callbacks fire correctly when using get_transaction / lock_tables."""
|
"""Verify callbacks fire correctly when using transaction / lock_tables."""
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clear_events(self):
|
def clear_events(self):
|
||||||
@@ -1517,10 +1517,10 @@ class TestEventSessionWithGetTransaction:
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_callbacks_fire_after_outer_commit_not_savepoint(self, mixin_session):
|
async def test_callbacks_fire_after_outer_commit_not_savepoint(self, mixin_session):
|
||||||
"""get_transaction creates a savepoint; callbacks fire only on outer commit."""
|
"""transaction creates a savepoint; callbacks fire only on outer commit."""
|
||||||
from fastapi_toolsets.db import get_transaction
|
from fastapi_toolsets.db import transaction
|
||||||
|
|
||||||
async with get_transaction(mixin_session):
|
async with transaction(mixin_session):
|
||||||
obj = WatchedModel(status="active", other="x")
|
obj = WatchedModel(status="active", other="x")
|
||||||
mixin_session.add(obj)
|
mixin_session.add(obj)
|
||||||
|
|
||||||
@@ -1535,14 +1535,14 @@ class TestEventSessionWithGetTransaction:
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_nested_transactions_accumulate_events(self, mixin_session):
|
async def test_nested_transactions_accumulate_events(self, mixin_session):
|
||||||
"""Multiple get_transaction blocks accumulate events for a single commit."""
|
"""Multiple transaction blocks accumulate events for a single commit."""
|
||||||
from fastapi_toolsets.db import get_transaction
|
from fastapi_toolsets.db import transaction
|
||||||
|
|
||||||
async with get_transaction(mixin_session):
|
async with transaction(mixin_session):
|
||||||
obj1 = WatchedModel(status="first", other="x")
|
obj1 = WatchedModel(status="first", other="x")
|
||||||
mixin_session.add(obj1)
|
mixin_session.add(obj1)
|
||||||
|
|
||||||
async with get_transaction(mixin_session):
|
async with transaction(mixin_session):
|
||||||
obj2 = WatchedModel(status="second", other="y")
|
obj2 = WatchedModel(status="second", other="y")
|
||||||
mixin_session.add(obj2)
|
mixin_session.add(obj2)
|
||||||
|
|
||||||
@@ -1556,14 +1556,14 @@ class TestEventSessionWithGetTransaction:
|
|||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_savepoint_rollback_suppresses_events(self, mixin_session):
|
async def test_savepoint_rollback_suppresses_events(self, mixin_session):
|
||||||
"""Objects from a rolled-back savepoint don't fire callbacks."""
|
"""Objects from a rolled-back savepoint don't fire callbacks."""
|
||||||
from fastapi_toolsets.db import get_transaction
|
from fastapi_toolsets.db import transaction
|
||||||
|
|
||||||
survivor = WatchedModel(status="kept", other="x")
|
survivor = WatchedModel(status="kept", other="x")
|
||||||
mixin_session.add(survivor)
|
mixin_session.add(survivor)
|
||||||
await mixin_session.flush()
|
await mixin_session.flush()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with get_transaction(mixin_session):
|
async with transaction(mixin_session):
|
||||||
doomed = WatchedModel(status="doomed", other="y")
|
doomed = WatchedModel(status="doomed", other="y")
|
||||||
mixin_session.add(doomed)
|
mixin_session.add(doomed)
|
||||||
await mixin_session.flush()
|
await mixin_session.flush()
|
||||||
@@ -1590,9 +1590,9 @@ class TestEventSessionWithGetTransaction:
|
|||||||
assert len(creates) == 1
|
assert len(creates) == 1
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_update_inside_get_transaction(self, mixin_session):
|
async def test_update_inside_transaction(self, mixin_session):
|
||||||
"""UPDATE events fire with correct changes after get_transaction commit."""
|
"""UPDATE events fire with correct changes after transaction commit."""
|
||||||
from fastapi_toolsets.db import get_transaction
|
from fastapi_toolsets.db import transaction
|
||||||
|
|
||||||
obj = WatchedModel(status="initial", other="x")
|
obj = WatchedModel(status="initial", other="x")
|
||||||
mixin_session.add(obj)
|
mixin_session.add(obj)
|
||||||
@@ -1600,7 +1600,7 @@ class TestEventSessionWithGetTransaction:
|
|||||||
|
|
||||||
_test_events.clear()
|
_test_events.clear()
|
||||||
|
|
||||||
async with get_transaction(mixin_session):
|
async with transaction(mixin_session):
|
||||||
obj.status = "updated"
|
obj.status = "updated"
|
||||||
|
|
||||||
await mixin_session.commit()
|
await mixin_session.commit()
|
||||||
@@ -1696,7 +1696,7 @@ class TestEventSessionWithNullableFields:
|
|||||||
|
|
||||||
|
|
||||||
class TestEventSessionWithFastAPIDependency:
|
class TestEventSessionWithFastAPIDependency:
|
||||||
"""Verify EventSession works when session comes from create_db_dependency."""
|
"""Verify EventSession works when session comes from the Database dependency."""
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clear_events(self):
|
def clear_events(self):
|
||||||
@@ -1706,31 +1706,24 @@ class TestEventSessionWithFastAPIDependency:
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_create_event_fires_via_dependency(self):
|
async def test_create_event_fires_via_dependency(self):
|
||||||
"""CREATE callback fires when session is provided by create_db_dependency."""
|
"""CREATE callback fires when session is provided by the Database dependency."""
|
||||||
from fastapi import Depends, FastAPI
|
from fastapi import Depends, FastAPI
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||||
AsyncSession,
|
|
||||||
async_sessionmaker,
|
|
||||||
create_async_engine,
|
|
||||||
)
|
|
||||||
|
|
||||||
from fastapi_toolsets.db import create_db_dependency
|
from fastapi_toolsets.db import Database
|
||||||
from fastapi_toolsets.models import EventSession
|
from fastapi_toolsets.models import EventSession
|
||||||
|
|
||||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||||
session_factory = async_sessionmaker(
|
|
||||||
engine, expire_on_commit=False, class_=EventSession
|
|
||||||
)
|
|
||||||
|
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(MixinBase.metadata.create_all)
|
await conn.run_sync(MixinBase.metadata.create_all)
|
||||||
|
|
||||||
get_db = create_db_dependency(session_factory)
|
db = Database(engine=engine, session_class=EventSession)
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
@app.post("/watched")
|
@app.post("/watched")
|
||||||
async def create_watched(session: AsyncSession = Depends(get_db)):
|
async def create_watched(session: AsyncSession = Depends(db)):
|
||||||
obj = WatchedModel(status="from-api", other="x")
|
obj = WatchedModel(status="from-api", other="x")
|
||||||
session.add(obj)
|
session.add(obj)
|
||||||
return {"id": str(obj.id)}
|
return {"id": str(obj.id)}
|
||||||
@@ -1753,40 +1746,33 @@ class TestEventSessionWithFastAPIDependency:
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_update_event_fires_via_dependency(self):
|
async def test_update_event_fires_via_dependency(self):
|
||||||
"""UPDATE callback fires when session is provided by create_db_dependency."""
|
"""UPDATE callback fires when session is provided by the Database dependency."""
|
||||||
from fastapi import Depends, FastAPI
|
from fastapi import Depends, FastAPI
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||||
AsyncSession,
|
|
||||||
async_sessionmaker,
|
|
||||||
create_async_engine,
|
|
||||||
)
|
|
||||||
|
|
||||||
from fastapi_toolsets.db import create_db_dependency
|
from fastapi_toolsets.db import Database
|
||||||
from fastapi_toolsets.models import EventSession
|
from fastapi_toolsets.models import EventSession
|
||||||
|
|
||||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||||
session_factory = async_sessionmaker(
|
|
||||||
engine, expire_on_commit=False, class_=EventSession
|
|
||||||
)
|
|
||||||
|
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(MixinBase.metadata.create_all)
|
await conn.run_sync(MixinBase.metadata.create_all)
|
||||||
|
|
||||||
get_db = create_db_dependency(session_factory)
|
db = Database(engine=engine, session_class=EventSession)
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
# Pre-seed an object.
|
# Pre-seed an object.
|
||||||
async with session_factory() as seed_session:
|
async with db.session() as seed_session:
|
||||||
obj = WatchedModel(status="initial", other="x")
|
obj = WatchedModel(status="initial", other="x")
|
||||||
seed_session.add(obj)
|
seed_session.add(obj)
|
||||||
await seed_session.commit()
|
await seed_session.flush()
|
||||||
obj_id = obj.id
|
obj_id = obj.id
|
||||||
|
|
||||||
_test_events.clear()
|
_test_events.clear()
|
||||||
|
|
||||||
@app.put("/watched/{item_id}")
|
@app.put("/watched/{item_id}")
|
||||||
async def update_watched(item_id: str, session: AsyncSession = Depends(get_db)):
|
async def update_watched(item_id: str, session: AsyncSession = Depends(db)):
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
stmt = select(WatchedModel).where(WatchedModel.id == item_id)
|
stmt = select(WatchedModel).where(WatchedModel.id == item_id)
|
||||||
|
|||||||
+88
-17
@@ -11,7 +11,7 @@ from sqlalchemy.engine import make_url
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from fastapi_toolsets.db import get_transaction
|
from fastapi_toolsets.db import transaction
|
||||||
from fastapi_toolsets.fixtures import Context, FixtureRegistry, LoadStrategy
|
from fastapi_toolsets.fixtures import Context, FixtureRegistry, LoadStrategy
|
||||||
from fastapi_toolsets.pytest import (
|
from fastapi_toolsets.pytest import (
|
||||||
create_async_client,
|
create_async_client,
|
||||||
@@ -387,14 +387,14 @@ class TestCreateDbSession:
|
|||||||
assert session.autoflush is False
|
assert session.autoflush is False
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_get_transaction_commits_visible_to_separate_session(self):
|
async def test_transaction_commits_visible_to_separate_session(self):
|
||||||
"""Data written via get_transaction() is committed and visible to other sessions."""
|
"""Data written via transaction() is committed and visible to other sessions."""
|
||||||
role_id = uuid.uuid4()
|
role_id = uuid.uuid4()
|
||||||
|
|
||||||
async with create_db_session(DATABASE_URL, Base, drop_tables=False) as session:
|
async with create_db_session(DATABASE_URL, Base, drop_tables=False) as session:
|
||||||
# Simulate what _create_fixture_function does: insert via get_transaction
|
# Simulate what _create_fixture_function does: insert via transaction()
|
||||||
# with no explicit commit afterward.
|
# with no explicit commit afterward.
|
||||||
async with get_transaction(session):
|
async with transaction(session):
|
||||||
role = Role(id=role_id, name="visible_to_other_session")
|
role = Role(id=role_id, name="visible_to_other_session")
|
||||||
session.add(role)
|
session.add(role)
|
||||||
|
|
||||||
@@ -409,9 +409,9 @@ class TestCreateDbSession:
|
|||||||
result = await other.execute(select(Role).where(Role.id == role_id))
|
result = await other.execute(select(Role).where(Role.id == role_id))
|
||||||
fetched = result.scalar_one_or_none()
|
fetched = result.scalar_one_or_none()
|
||||||
assert fetched is not None, (
|
assert fetched is not None, (
|
||||||
"Fixture data inserted via get_transaction() must be committed "
|
"Fixture data inserted via transaction() must be committed "
|
||||||
"and visible to a separate session. If create_db_session uses "
|
"and visible to a separate session. If create_db_session uses "
|
||||||
"create_db_context, auto-begin forces get_transaction() into "
|
"db.session(), auto-begin forces transaction() into "
|
||||||
"savepoints instead of real commits."
|
"savepoints instead of real commits."
|
||||||
)
|
)
|
||||||
assert fetched.name == "visible_to_other_session"
|
assert fetched.name == "visible_to_other_session"
|
||||||
@@ -442,21 +442,19 @@ class TestGetXdistWorker:
|
|||||||
class TestWorkerDatabaseUrl:
|
class TestWorkerDatabaseUrl:
|
||||||
"""Tests for worker_database_url helper."""
|
"""Tests for worker_database_url helper."""
|
||||||
|
|
||||||
def test_appends_default_test_db_without_xdist(
|
def test_uses_default_test_db_without_xdist(self, monkeypatch: pytest.MonkeyPatch):
|
||||||
self, monkeypatch: pytest.MonkeyPatch
|
"""default_test_db is used as the database name when not running under xdist."""
|
||||||
):
|
|
||||||
"""default_test_db is appended when not running under xdist."""
|
|
||||||
monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False)
|
monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False)
|
||||||
url = "postgresql+asyncpg://user:pass@localhost:5432/mydb"
|
url = "postgresql+asyncpg://user:pass@localhost:5432/mydb"
|
||||||
result = worker_database_url(url, default_test_db="fallback")
|
result = worker_database_url(url, default_test_db="fallback")
|
||||||
assert make_url(result).database == "mydb_fallback"
|
assert make_url(result).database == "fallback"
|
||||||
|
|
||||||
def test_appends_worker_id_to_database_name(self, monkeypatch: pytest.MonkeyPatch):
|
def test_uses_worker_id_as_database_name(self, monkeypatch: pytest.MonkeyPatch):
|
||||||
"""Worker name is appended to the database name."""
|
"""Worker name is used as the database name."""
|
||||||
monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw0")
|
monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw0")
|
||||||
url = "postgresql+asyncpg://user:pass@localhost:5432/db"
|
url = "postgresql+asyncpg://user:pass@localhost:5432/db"
|
||||||
result = worker_database_url(url, default_test_db="unused")
|
result = worker_database_url(url, default_test_db="unused")
|
||||||
assert make_url(result).database == "db_gw0"
|
assert make_url(result).database == "gw0"
|
||||||
|
|
||||||
def test_preserves_url_components(self, monkeypatch: pytest.MonkeyPatch):
|
def test_preserves_url_components(self, monkeypatch: pytest.MonkeyPatch):
|
||||||
"""Host, port, username, password, and driver are preserved."""
|
"""Host, port, username, password, and driver are preserved."""
|
||||||
@@ -469,7 +467,21 @@ class TestWorkerDatabaseUrl:
|
|||||||
assert result.password == "secret"
|
assert result.password == "secret"
|
||||||
assert result.host == "dbhost"
|
assert result.host == "dbhost"
|
||||||
assert result.port == 6543
|
assert result.port == 6543
|
||||||
assert result.database == "testdb_gw2"
|
assert result.database == "gw2"
|
||||||
|
|
||||||
|
def test_prefix_with_xdist(self, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
"""prefix is prepended to the worker name when running under xdist."""
|
||||||
|
monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw0")
|
||||||
|
url = "postgresql+asyncpg://user:pass@localhost:5432/mydb"
|
||||||
|
result = worker_database_url(url, default_test_db="unused", prefix="myapp")
|
||||||
|
assert make_url(result).database == "myapp_gw0"
|
||||||
|
|
||||||
|
def test_prefix_without_xdist(self, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
"""prefix is prepended to default_test_db when not running under xdist."""
|
||||||
|
monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False)
|
||||||
|
url = "postgresql+asyncpg://user:pass@localhost:5432/mydb"
|
||||||
|
result = worker_database_url(url, default_test_db="test", prefix="myapp")
|
||||||
|
assert make_url(result).database == "myapp_test"
|
||||||
|
|
||||||
|
|
||||||
class TestCreateWorkerDatabase:
|
class TestCreateWorkerDatabase:
|
||||||
@@ -479,7 +491,7 @@ class TestCreateWorkerDatabase:
|
|||||||
async def test_creates_default_db_without_xdist(
|
async def test_creates_default_db_without_xdist(
|
||||||
self, monkeypatch: pytest.MonkeyPatch
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
):
|
):
|
||||||
"""Without xdist, creates a database suffixed with default_test_db."""
|
"""Without xdist, creates a database named after default_test_db."""
|
||||||
monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False)
|
monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False)
|
||||||
default_test_db = "no_xdist_default"
|
default_test_db = "no_xdist_default"
|
||||||
expected_db = make_url(
|
expected_db = make_url(
|
||||||
@@ -626,6 +638,65 @@ class TestCreateWorkerDatabase:
|
|||||||
assert result.scalar() == 1
|
assert result.scalar() == 1
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_drops_database_with_active_connections(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
"""DROP DATABASE succeeds even when a connection is still open to it."""
|
||||||
|
monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw_active_conn")
|
||||||
|
expected_db = make_url(
|
||||||
|
worker_database_url(DATABASE_URL, default_test_db="unused")
|
||||||
|
).database
|
||||||
|
|
||||||
|
lingering_engine = None
|
||||||
|
async with create_worker_database(DATABASE_URL) as url:
|
||||||
|
# Open a connection to the worker DB and intentionally leave it open.
|
||||||
|
lingering_engine = create_async_engine(url)
|
||||||
|
async with lingering_engine.connect():
|
||||||
|
pass # connection returned to pool but engine not disposed
|
||||||
|
|
||||||
|
# If WITH (FORCE) is absent the DROP above would raise; reaching here means it worked.
|
||||||
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
result = await conn.execute(
|
||||||
|
text("SELECT 1 FROM pg_database WHERE datname = :name"),
|
||||||
|
{"name": expected_db},
|
||||||
|
)
|
||||||
|
assert result.scalar() is None
|
||||||
|
await engine.dispose()
|
||||||
|
if lingering_engine:
|
||||||
|
await lingering_engine.dispose()
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_prefix_names_database(self, monkeypatch: pytest.MonkeyPatch):
|
||||||
|
"""prefix is prepended to the worker name in the created database."""
|
||||||
|
monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw_prefix")
|
||||||
|
expected_db = make_url(
|
||||||
|
worker_database_url(DATABASE_URL, default_test_db="unused", prefix="pfx")
|
||||||
|
).database
|
||||||
|
assert expected_db == "pfx_gw_prefix"
|
||||||
|
|
||||||
|
async with create_worker_database(DATABASE_URL, prefix="pfx") as url:
|
||||||
|
assert make_url(url).database == expected_db
|
||||||
|
|
||||||
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
result = await conn.execute(
|
||||||
|
text("SELECT 1 FROM pg_database WHERE datname = :name"),
|
||||||
|
{"name": expected_db},
|
||||||
|
)
|
||||||
|
assert result.scalar() == 1
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
result = await conn.execute(
|
||||||
|
text("SELECT 1 FROM pg_database WHERE datname = :name"),
|
||||||
|
{"name": expected_db},
|
||||||
|
)
|
||||||
|
assert result.scalar() is None
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
class _LocalBase(DeclarativeBase):
|
class _LocalBase(DeclarativeBase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -330,7 +330,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "4.1.2"
|
version = "5.0.0b1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
@@ -341,7 +341,6 @@ dependencies = [
|
|||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
all = [
|
all = [
|
||||||
{ name = "async-lru" },
|
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "prometheus-client" },
|
{ name = "prometheus-client" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
@@ -359,10 +358,6 @@ pytest = [
|
|||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-xdist" },
|
{ name = "pytest-xdist" },
|
||||||
]
|
]
|
||||||
security = [
|
|
||||||
{ name = "async-lru" },
|
|
||||||
{ name = "httpx" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -402,12 +397,10 @@ tests = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "async-lru", marker = "extra == 'security'", specifier = ">=1.0" },
|
|
||||||
{ name = "asyncpg", specifier = ">=0.29.0" },
|
{ name = "asyncpg", specifier = ">=0.29.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.100.0" },
|
{ name = "fastapi", specifier = ">=0.100.0" },
|
||||||
{ name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest", "security"], marker = "extra == 'all'" },
|
{ name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest"], marker = "extra == 'all'" },
|
||||||
{ name = "httpx", marker = "extra == 'pytest'", specifier = ">=0.25.0" },
|
{ name = "httpx", marker = "extra == 'pytest'", specifier = ">=0.25.0" },
|
||||||
{ name = "httpx", marker = "extra == 'security'", specifier = ">=0.25.0" },
|
|
||||||
{ name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" },
|
{ name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.0" },
|
{ name = "pydantic", specifier = ">=2.0" },
|
||||||
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
|
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
|
||||||
@@ -415,7 +408,7 @@ requires-dist = [
|
|||||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
||||||
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" },
|
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["cli", "metrics", "security", "pytest", "all"]
|
provides-extras = ["cli", "metrics", "pytest", "all"]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -1262,15 +1255,15 @@ asyncio = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "starlette"
|
name = "starlette"
|
||||||
version = "1.0.1"
|
version = "1.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+1
-2
@@ -121,7 +121,6 @@ Modules = [
|
|||||||
{Models = "module/models.md"},
|
{Models = "module/models.md"},
|
||||||
{Pytest = "module/pytest.md"},
|
{Pytest = "module/pytest.md"},
|
||||||
{Schemas = "module/schemas.md"},
|
{Schemas = "module/schemas.md"},
|
||||||
{Security = "module/security.md"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[project.nav]]
|
[[project.nav]]
|
||||||
@@ -137,7 +136,6 @@ Reference = [
|
|||||||
{Models = "reference/models.md"},
|
{Models = "reference/models.md"},
|
||||||
{Pytest = "reference/pytest.md"},
|
{Pytest = "reference/pytest.md"},
|
||||||
{Schemas = "reference/schemas.md"},
|
{Schemas = "reference/schemas.md"},
|
||||||
{Security = "reference/security.md"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[project.nav]]
|
[[project.nav]]
|
||||||
@@ -147,6 +145,7 @@ Examples = [
|
|||||||
|
|
||||||
[[project.nav]]
|
[[project.nav]]
|
||||||
Migration = [
|
Migration = [
|
||||||
|
{"v5.0" = "migration/v5.md"},
|
||||||
{"v4.0" = "migration/v4.md"},
|
{"v4.0" = "migration/v4.md"},
|
||||||
{"v3.0" = "migration/v3.md"},
|
{"v3.0" = "migration/v3.md"},
|
||||||
{"v2.0" = "migration/v2.md"},
|
{"v2.0" = "migration/v2.md"},
|
||||||
|
|||||||
Reference in New Issue
Block a user