mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-05 16:14:08 +00:00
Compare commits
26
Commits
v4.1.2
...
c4ab6ef87c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4ab6ef87c
|
||
|
|
c0d5bf8944
|
||
|
|
3eed5f01d4 | ||
|
|
61dc4cf9b0 | ||
|
|
9eb7d006db | ||
|
|
31f8ad96f7 | ||
|
|
953d58e5cc | ||
|
|
498e75c624 | ||
|
|
541bfeb1a4 | ||
|
|
c16909f585 | ||
|
|
c256e58d64 | ||
|
|
040c023b8a | ||
|
|
06d49d5dc4
|
||
|
|
de06839a16 | ||
|
|
ff367e4281 | ||
|
|
9cb4c1474f | ||
|
|
44ba5bdd4b | ||
|
|
fe2c0f3eff | ||
|
|
70e0b3b9d5 | ||
|
|
1e021005bc | ||
|
|
025f1907fd
|
||
|
|
9698a0743b | ||
|
|
22f307d0fc | ||
|
|
2641881df5
|
||
|
|
49b579bcec | ||
|
|
4bb4287922 |
@@ -11,7 +11,7 @@ jobs:
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
name: Lint (Ruff)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
name: Type Check (ty)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: matrix.python-version == '3.14'
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
report_type: coverage
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
|
||||
- name: Upload test results to Codecov
|
||||
if: matrix.python-version == '3.14'
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
report_type: test_results
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ Install only the extras you need:
|
||||
```bash
|
||||
uv add "fastapi-toolsets[cli]"
|
||||
uv add "fastapi-toolsets[metrics]"
|
||||
uv add "fastapi-toolsets[security]"
|
||||
uv add "fastapi-toolsets[pytest]"
|
||||
```
|
||||
|
||||
@@ -57,7 +56,6 @@ uv add "fastapi-toolsets[all]"
|
||||
|
||||
### 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
|
||||
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
||||
- **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
|
||||
uv add "fastapi-toolsets[cli]"
|
||||
uv add "fastapi-toolsets[metrics]"
|
||||
uv add "fastapi-toolsets[security]"
|
||||
uv add "fastapi-toolsets[pytest]"
|
||||
```
|
||||
|
||||
@@ -57,7 +56,6 @@ uv add "fastapi-toolsets[all]"
|
||||
|
||||
### 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
|
||||
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
||||
- **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
|
||||
|
||||
# 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)
|
||||
```
|
||||
|
||||
@@ -35,6 +35,6 @@ The function creates and manages its own **dedicated session** internally, yield
|
||||
user.balance += 100
|
||||
|
||||
# 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)
|
||||
```
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
## Fixtures
|
||||
|
||||
### `get_obj_by_attr` / `get_field_by_attr` are now `FixtureRegistry` methods
|
||||
|
||||
=== "Before (`v4`)"
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.fixtures import get_obj_by_attr, get_field_by_attr
|
||||
|
||||
@fixtures.register(depends_on=["roles"])
|
||||
def users():
|
||||
admin_role = get_obj_by_attr(roles, "name", "admin")
|
||||
return [User(id=1, username="alice", role_id=admin_role.id)]
|
||||
```
|
||||
|
||||
=== "Now (`v5`)"
|
||||
|
||||
```python
|
||||
@fixtures.register(depends_on=["roles"])
|
||||
def users():
|
||||
admin_role = fixtures.obj("roles", "name", "admin")
|
||||
return [User(id=1, username="alice", role_id=admin_role.id)]
|
||||
```
|
||||
|
||||
## 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 ...`.
|
||||
+15
-1
@@ -24,7 +24,7 @@ Configure the CLI in your `pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[tool.fastapi-toolsets]
|
||||
cli = "myapp.cli:cli" # Custom Typer app
|
||||
custom_cli = "myapp.cli:cli" # Custom Typer app
|
||||
fixtures = "myapp.fixtures:registry" # FixtureRegistry instance
|
||||
db_context = "myapp.db:db_context" # Async context manager for sessions
|
||||
```
|
||||
@@ -68,6 +68,20 @@ manager fixtures --help
|
||||
╰──────────────────────────────────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
### `fixtures load`
|
||||
|
||||
```bash
|
||||
manager fixtures load [CONTEXTS]... [--strategy merge|insert|skip_existing] [--dry-run]
|
||||
```
|
||||
|
||||
`CONTEXTS` defaults to `Context.BASE` when omitted, and can also be set via the `FIXTURES_CONTEXT` environment variable, handy for CI/deploy scripts that shouldn't need an explicit argument per environment:
|
||||
|
||||
```bash
|
||||
FIXTURES_CONTEXT=testing manager fixtures load
|
||||
```
|
||||
|
||||
An explicit CLI argument always takes precedence over the environment variable.
|
||||
|
||||
## Custom CLI
|
||||
|
||||
You can extend the CLI by providing your own Typer app. The `manager` entry point will merge your app's commands with the built-in ones:
|
||||
|
||||
+1
-1
@@ -167,7 +167,7 @@ user = await UserCrud.update(session, UserUpdate(credits=10), [User.id == user_i
|
||||
```
|
||||
|
||||
!!! 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
|
||||
`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
|
||||
|
||||
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
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
from fastapi_toolsets.db import create_db_dependency
|
||||
from fastapi import Depends, FastAPI
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
engine = create_async_engine(url="postgresql+asyncpg://...", future=True)
|
||||
session_maker = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||
from fastapi_toolsets.db import Database
|
||||
|
||||
get_db = create_db_dependency(session_maker=session_maker)
|
||||
db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(session: AsyncSession = Depends(get_db)):
|
||||
app = FastAPI()
|
||||
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
|
||||
|
||||
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
|
||||
from fastapi_toolsets.db import create_db_context
|
||||
|
||||
db_context = create_db_context(session_maker=session_maker)
|
||||
|
||||
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
|
||||
from fastapi_toolsets.db import get_transaction
|
||||
from fastapi_toolsets.db import transaction
|
||||
|
||||
async def create_user_with_role(session=session):
|
||||
async with get_transaction(session=session):
|
||||
async def create_user_with_role(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
|
||||
|
||||
[`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
|
||||
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
|
||||
...
|
||||
```
|
||||
|
||||
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
|
||||
async with lock_tables(session_maker, [Order], timeout="2s") as session:
|
||||
async with db.lock_tables([Order], timeout="2s") as session:
|
||||
...
|
||||
```
|
||||
|
||||
## 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
|
||||
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):
|
||||
...
|
||||
|
||||
# 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:
|
||||
if not acquired:
|
||||
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"):
|
||||
...
|
||||
|
||||
# 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):
|
||||
...
|
||||
|
||||
@@ -106,11 +166,11 @@ async with advisory_lock(session=session, key=(1, user_id)):
|
||||
```
|
||||
|
||||
!!! 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
|
||||
|
||||
[`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
|
||||
from fastapi_toolsets.db import wait_for_row_change
|
||||
@@ -120,7 +180,7 @@ await wait_for_row_change(
|
||||
session=session,
|
||||
model=Order,
|
||||
pk_value=order_id,
|
||||
columns=[Order.status],
|
||||
columns=["status"],
|
||||
interval=1.0,
|
||||
timeout=30.0,
|
||||
)
|
||||
@@ -128,28 +188,24 @@ await wait_for_row_change(
|
||||
|
||||
## Creating a database
|
||||
|
||||
!!! info "Added in `v2.1`"
|
||||
|
||||
[`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:
|
||||
[`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:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import create_database
|
||||
from fastapi_toolsets.db.testing import create_database
|
||||
|
||||
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
|
||||
|
||||
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
|
||||
|
||||
!!! info "Added in `v2.1`"
|
||||
|
||||
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables) truncates all tables:
|
||||
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.testing.cleanup_tables) (in `fastapi_toolsets.db.testing`) truncates all tables:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import cleanup_tables
|
||||
from fastapi_toolsets.db.testing import cleanup_tables
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def clean(db_session):
|
||||
@@ -159,50 +215,50 @@ async def clean(db_session):
|
||||
|
||||
## 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
|
||||
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"))
|
||||
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
|
||||
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:
|
||||
|
||||
```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)
|
||||
```
|
||||
|
||||
### `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
|
||||
from fastapi_toolsets.db import get_transaction, m2m_set
|
||||
from fastapi_toolsets.db import m2m_set, transaction
|
||||
|
||||
# Replace all tags
|
||||
async with get_transaction(session):
|
||||
async with transaction(session):
|
||||
await m2m_set(session, post, Post.tags, tag_a, tag_b)
|
||||
|
||||
# Clear all tags
|
||||
async with get_transaction(session):
|
||||
async with transaction(session):
|
||||
await m2m_set(session, post, Post.tags)
|
||||
```
|
||||
|
||||
|
||||
+23
-7
@@ -65,6 +65,13 @@ Both functions return a `dict[str, list[...]]` mapping each fixture name to the
|
||||
|
||||
A fixture with no `contexts` defined takes `Context.BASE` by default.
|
||||
|
||||
`Context.BASE` fixtures are always included alongside whatever context you load or list — there's no way to load a non-base context in isolation:
|
||||
|
||||
```python
|
||||
# also loads any Context.BASE fixtures, even though only TESTING is requested
|
||||
await load_fixtures_by_context(session, fixtures, Context.TESTING)
|
||||
```
|
||||
|
||||
### Custom contexts
|
||||
|
||||
Plain strings and any `Enum` subclass are accepted wherever a `Context` enum is expected.
|
||||
@@ -80,6 +87,7 @@ class AppContext(str, Enum):
|
||||
def staging_data():
|
||||
return [Config(key="feature_x", enabled=True)]
|
||||
|
||||
# loads staging_data plus any Context.BASE fixtures
|
||||
await load_fixtures_by_context(session, fixtures, AppContext.STAGING)
|
||||
```
|
||||
|
||||
@@ -108,8 +116,8 @@ def users():
|
||||
def users():
|
||||
return [User(id=2, username="tester")]
|
||||
|
||||
# loads both admin and tester
|
||||
await load_fixtures_by_context(session, fixtures, Context.BASE, Context.TESTING)
|
||||
# loads both admin and tester (Context.BASE is included automatically)
|
||||
await load_fixtures_by_context(session, fixtures, Context.TESTING)
|
||||
```
|
||||
|
||||
Registering two variants with overlapping context sets raises `ValueError`.
|
||||
@@ -147,18 +155,26 @@ Fixtures with the same name are allowed as long as their context sets do not ove
|
||||
|
||||
## Looking up fixture instances
|
||||
|
||||
[`get_obj_by_attr`](../reference/fixtures.md#fastapi_toolsets.fixtures.utils.get_obj_by_attr) retrieves a specific instance from a fixture function by attribute value — useful when building cross-fixture `depends_on` relationships:
|
||||
[`FixtureRegistry.obj`](../reference/fixtures.md#fastapi_toolsets.fixtures.registry.FixtureRegistry.obj) retrieves a specific instance from a registered fixture by attribute value, looked up by name on the registry — useful when building cross-fixture `depends_on` relationships:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.fixtures import get_obj_by_attr
|
||||
|
||||
@fixtures.register(depends_on=["roles"])
|
||||
def users():
|
||||
admin_role = get_obj_by_attr(roles, "name", "admin")
|
||||
admin_role = fixtures.obj("roles", "name", "admin")
|
||||
return [User(id=1, username="alice", role_id=admin_role.id)]
|
||||
```
|
||||
|
||||
Raises `StopIteration` if no matching instance is found.
|
||||
Looking the fixture up by name (instead of importing the `roles` function directly) means fixture modules never need to import each other, which avoids circular imports in larger projects split across multiple files — the same reason `depends_on` takes fixture names rather than the functions themselves. The registry passed in must be the one that actually contains the fixture by load time; with a single shared registry this is automatic, but if you merge registries with `include_registry`, call `obj`/`field` on the merged registry.
|
||||
|
||||
[`FixtureRegistry.field`](../reference/fixtures.md#fastapi_toolsets.fixtures.registry.FixtureRegistry.field) is shorthand for pulling a single attribute (`id` by default):
|
||||
|
||||
```python
|
||||
@fixtures.register(depends_on=["roles"])
|
||||
def users():
|
||||
return [User(id=1, username="alice", role_id=fixtures.field("roles", "name", "admin"))]
|
||||
```
|
||||
|
||||
Both raise `StopIteration` if no matching instance is found, and `KeyError` if the fixture name isn't registered.
|
||||
|
||||
## Pytest integration
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=EventSe
|
||||
```
|
||||
|
||||
!!! 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
|
||||
when the outermost `commit()` is called.
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ async with create_db_session(
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -97,16 +97,20 @@ Use [`worker_database_url`](../reference/pytest.md#fastapi_toolsets.pytest.utils
|
||||
from fastapi_toolsets.pytest import worker_database_url
|
||||
|
||||
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_test" otherwise
|
||||
```
|
||||
|
||||
## 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
|
||||
from fastapi_toolsets.db import cleanup_tables
|
||||
from fastapi_toolsets.pytest import cleanup_tables
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
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`
|
||||
|
||||
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`:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import (
|
||||
Database,
|
||||
LockMode,
|
||||
advisory_lock,
|
||||
cleanup_tables,
|
||||
create_database,
|
||||
create_db_dependency,
|
||||
create_db_context,
|
||||
get_transaction,
|
||||
lock_tables,
|
||||
m2m_add,
|
||||
m2m_remove,
|
||||
m2m_set,
|
||||
transaction,
|
||||
wait_for_row_change,
|
||||
)
|
||||
```
|
||||
|
||||
## ::: fastapi_toolsets.db.Database
|
||||
|
||||
## ::: fastapi_toolsets.db.transaction
|
||||
|
||||
## ::: 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.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_remove
|
||||
|
||||
## ::: 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
|
||||
|
||||
@@ -12,7 +12,6 @@ from fastapi_toolsets.fixtures import (
|
||||
FixtureRegistry,
|
||||
load_fixtures,
|
||||
load_fixtures_by_context,
|
||||
get_obj_by_attr,
|
||||
)
|
||||
```
|
||||
|
||||
@@ -27,5 +26,3 @@ from fastapi_toolsets.fixtures import (
|
||||
## ::: fastapi_toolsets.fixtures.utils.load_fixtures
|
||||
|
||||
## ::: fastapi_toolsets.fixtures.utils.load_fixtures_by_context
|
||||
|
||||
## ::: fastapi_toolsets.fixtures.utils.get_obj_by_attr
|
||||
|
||||
@@ -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 .db import db
|
||||
from .routes import router
|
||||
|
||||
app = FastAPI()
|
||||
db.install(app=app)
|
||||
init_exceptions_handlers(app=app)
|
||||
app.include_router(router=router)
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
from typing import Annotated
|
||||
|
||||
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"
|
||||
|
||||
engine = create_async_engine(url=DATABASE_URL, future=True)
|
||||
async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||
db = Database(url=DATABASE_URL)
|
||||
|
||||
get_db = create_db_dependency(session_maker=async_session_maker)
|
||||
get_db_context = create_db_context(session_maker=async_session_maker)
|
||||
get_db = db
|
||||
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(db)]
|
||||
|
||||
+2
-6
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "fastapi-toolsets"
|
||||
version = "4.1.2"
|
||||
version = "5.0.0b2"
|
||||
description = "Production-ready utilities for FastAPI applications"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@@ -50,17 +50,13 @@ cli = [
|
||||
metrics = [
|
||||
"prometheus_client>=0.20.0",
|
||||
]
|
||||
security = [
|
||||
"async-lru>=1.0",
|
||||
"httpx>=0.25.0",
|
||||
]
|
||||
pytest = [
|
||||
"httpx>=0.25.0",
|
||||
"pytest-xdist>=3.0.0",
|
||||
"pytest>=8.0.0",
|
||||
]
|
||||
all = [
|
||||
"fastapi-toolsets[cli,metrics,pytest,security]",
|
||||
"fastapi-toolsets[cli,metrics,pytest]",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -7,18 +7,21 @@ Example usage:
|
||||
from fastapi import FastAPI, Depends
|
||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||
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
|
||||
|
||||
db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
|
||||
|
||||
app = FastAPI()
|
||||
db.install(app)
|
||||
init_exceptions_handlers(app)
|
||||
|
||||
UserCrud = CrudFactory(User)
|
||||
|
||||
@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])
|
||||
return Response(data={"user": user.username}, message="Success")
|
||||
"""
|
||||
|
||||
__version__ = "4.1.2"
|
||||
__version__ = "5.0.0b2"
|
||||
|
||||
@@ -6,7 +6,8 @@ import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from ...fixtures import Context, LoadStrategy, load_fixtures_by_context
|
||||
from ...fixtures import Context, LoadStrategy
|
||||
from ...logger import get_logger
|
||||
from ..config import get_db_context, get_fixtures_registry
|
||||
from ..utils import async_command
|
||||
|
||||
@@ -16,13 +17,14 @@ fixture_cli = typer.Typer(
|
||||
no_args_is_help=True,
|
||||
)
|
||||
console = Console()
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
@fixture_cli.command("list")
|
||||
def list_fixtures(
|
||||
ctx: typer.Context,
|
||||
context: Annotated[
|
||||
Context | None,
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--context",
|
||||
"-c",
|
||||
@@ -32,10 +34,10 @@ def list_fixtures(
|
||||
) -> None:
|
||||
"""List all registered fixtures."""
|
||||
registry = get_fixtures_registry()
|
||||
fixtures = registry.get_by_context(context.value) if context else registry.get_all()
|
||||
fixtures = registry.get_by_context(context) if context else registry.get_all()
|
||||
|
||||
if not fixtures:
|
||||
print("No fixtures found.")
|
||||
logger.info("No fixtures found.")
|
||||
return
|
||||
|
||||
table = Table("Name", "Contexts", "Dependencies")
|
||||
@@ -46,7 +48,7 @@ def list_fixtures(
|
||||
table.add_row(fixture.name, contexts, deps)
|
||||
|
||||
console.print(table)
|
||||
print(f"\nTotal: {len(fixtures)} fixture(s)")
|
||||
logger.info("Total: %d fixture(s)", len(fixtures))
|
||||
|
||||
|
||||
@fixture_cli.command("load")
|
||||
@@ -54,8 +56,8 @@ def list_fixtures(
|
||||
async def load(
|
||||
ctx: typer.Context,
|
||||
contexts: Annotated[
|
||||
list[Context] | None,
|
||||
typer.Argument(help="Contexts to load."),
|
||||
list[str] | None,
|
||||
typer.Argument(help="Contexts to load.", envvar="FIXTURES_CONTEXT"),
|
||||
] = None,
|
||||
strategy: Annotated[
|
||||
LoadStrategy,
|
||||
@@ -69,26 +71,27 @@ async def load(
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Load fixtures into the database."""
|
||||
from ...fixtures import load_fixtures_by_context
|
||||
|
||||
registry = get_fixtures_registry()
|
||||
db_context = get_db_context()
|
||||
|
||||
context_list = list(contexts) if contexts else [Context.BASE]
|
||||
context_list = contexts or [Context.BASE.value]
|
||||
|
||||
ordered = registry.resolve_context_dependencies(*context_list)
|
||||
|
||||
if not ordered:
|
||||
print("No fixtures to load for the specified context(s).")
|
||||
logger.info("No fixtures to load for the specified context(s).")
|
||||
return
|
||||
|
||||
print(f"\nFixtures to load ({strategy.value} strategy):")
|
||||
for name in ordered:
|
||||
fixture = registry.get(name)
|
||||
instances = list(fixture.func())
|
||||
model_name = type(instances[0]).__name__ if instances else "?"
|
||||
print(f" - {name}: {len(instances)} {model_name}(s)")
|
||||
|
||||
if dry_run:
|
||||
print("\n[Dry run - no changes made]")
|
||||
logger.info("Fixtures to load (%s strategy):", strategy.value)
|
||||
for name in ordered:
|
||||
variants = registry.get_load_variants(name, *context_list)
|
||||
instances = [inst for v in variants for inst in v.func()]
|
||||
model_name = type(instances[0]).__name__ if instances else "?"
|
||||
logger.info(" - %s: %d %s(s)", name, len(instances), model_name)
|
||||
logger.info("[Dry run - no changes made]")
|
||||
return
|
||||
|
||||
async with db_context() as session:
|
||||
@@ -97,4 +100,4 @@ async def load(
|
||||
)
|
||||
|
||||
total = sum(len(items) for items in result.values())
|
||||
print(f"\nLoaded {total} record(s) successfully.")
|
||||
logger.info("Loaded %d record(s) successfully.", total)
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any, Literal, overload
|
||||
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
|
||||
|
||||
import typer
|
||||
|
||||
@@ -13,6 +13,8 @@ from .pyproject import find_pyproject, load_pyproject
|
||||
if TYPE_CHECKING:
|
||||
from ..fixtures import FixtureRegistry
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def _ensure_project_in_path():
|
||||
"""Add project root to sys.path if not installed in editable mode."""
|
||||
@@ -88,19 +90,39 @@ def get_config_value(key: str, required: bool = False) -> Any | None:
|
||||
return value
|
||||
|
||||
|
||||
@overload
|
||||
def _import_typed(
|
||||
key: str, expected_type: type[T], *, required: Literal[True]
|
||||
) -> T: ... # pragma: no cover
|
||||
@overload
|
||||
def _import_typed(
|
||||
key: str, expected_type: type[T], *, required: bool
|
||||
) -> T | None: ... # pragma: no cover
|
||||
def _import_typed(key: str, expected_type: type[T], *, required: bool) -> T | None:
|
||||
"""Import a config value by key and validate its type.
|
||||
|
||||
Raises:
|
||||
typer.BadParameter: If required and missing, or if the imported
|
||||
value isn't an instance of *expected_type*.
|
||||
"""
|
||||
import_path = get_config_value(key, required=required)
|
||||
if not import_path:
|
||||
return None
|
||||
|
||||
obj = import_from_string(import_path)
|
||||
if not isinstance(obj, expected_type):
|
||||
raise typer.BadParameter(
|
||||
f"'{key}' must be a {expected_type.__name__} instance, got {type(obj).__name__}"
|
||||
)
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
def get_fixtures_registry() -> FixtureRegistry:
|
||||
"""Import and return the fixtures registry from config."""
|
||||
from ..fixtures import FixtureRegistry
|
||||
|
||||
import_path = get_config_value("fixtures", required=True)
|
||||
registry = import_from_string(import_path)
|
||||
|
||||
if not isinstance(registry, FixtureRegistry):
|
||||
raise typer.BadParameter(
|
||||
f"'fixtures' must be a FixtureRegistry instance, got {type(registry).__name__}"
|
||||
)
|
||||
|
||||
return registry
|
||||
return _import_typed("fixtures", FixtureRegistry, required=True)
|
||||
|
||||
|
||||
def get_db_context() -> Any:
|
||||
@@ -111,15 +133,4 @@ def get_db_context() -> Any:
|
||||
|
||||
def get_custom_cli() -> typer.Typer | None:
|
||||
"""Import and return the custom CLI Typer instance from config."""
|
||||
import_path = get_config_value("custom_cli")
|
||||
if not import_path:
|
||||
return None
|
||||
|
||||
custom = import_from_string(import_path)
|
||||
|
||||
if not isinstance(custom, typer.Typer):
|
||||
raise typer.BadParameter(
|
||||
f"'custom_cli' must be a Typer instance, got {type(custom).__name__}"
|
||||
)
|
||||
|
||||
return custom
|
||||
return _import_typed("custom_cli", typer.Typer, required=False)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""CLI utility functions."""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any, ParamSpec, TypeVar
|
||||
@@ -24,6 +23,8 @@ def async_command(func: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]:
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||
import asyncio
|
||||
|
||||
return asyncio.run(func(*args, **kwargs))
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -22,7 +22,7 @@ from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload
|
||||
from sqlalchemy.sql.base import ExecutableOption
|
||||
from sqlalchemy.sql.roles import WhereHavingRole
|
||||
|
||||
from ..db import get_transaction
|
||||
from ..db import transaction
|
||||
from ..exceptions import InvalidOrderFieldError, NotFoundError
|
||||
from ..schemas import (
|
||||
CursorPaginatedResponse,
|
||||
@@ -190,7 +190,8 @@ class AsyncCrud(Generic[ModelType]):
|
||||
"""Re-query instance by PK with default_load_options applied."""
|
||||
mapper = cls.model.__mapper__
|
||||
pk_filters = [
|
||||
getattr(cls.model, col.key) == getattr(instance, col.key)
|
||||
getattr(cls.model, cast(str, col.key))
|
||||
== getattr(instance, cast(str, col.key))
|
||||
for col in mapper.primary_key
|
||||
]
|
||||
return await cls.get(session, filters=pk_filters)
|
||||
@@ -716,7 +717,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
Returns:
|
||||
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()
|
||||
data = (
|
||||
obj.model_dump(exclude=m2m_exclude) if m2m_exclude else obj.model_dump()
|
||||
@@ -1067,7 +1068,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
Raises:
|
||||
NotFoundError: If no record found
|
||||
"""
|
||||
async with get_transaction(session):
|
||||
async with transaction(session):
|
||||
m2m_exclude = cls._m2m_schema_fields()
|
||||
|
||||
# Eagerly load M2M relationships that will be updated so that
|
||||
@@ -1127,7 +1128,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
Returns:
|
||||
Model instance
|
||||
"""
|
||||
async with get_transaction(session):
|
||||
async with transaction(session):
|
||||
values = obj.model_dump(exclude_unset=True)
|
||||
q = insert(cls.model).values(**values)
|
||||
if set_:
|
||||
@@ -1189,7 +1190,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
Returns:
|
||||
``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)))
|
||||
objects = result.scalars().all()
|
||||
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,106 @@
|
||||
"""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,
|
||||
)
|
||||
```
|
||||
"""
|
||||
bind = getattr(session, "bind", None)
|
||||
if bind is None:
|
||||
raise TypeError(
|
||||
"wait_for_row_change requires a session bound to an engine "
|
||||
"(session.bind is None)"
|
||||
)
|
||||
watcher = AsyncSession(bind=bind)
|
||||
try:
|
||||
|
||||
async def _reload() -> _M | None:
|
||||
await watcher.rollback()
|
||||
return await watcher.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
|
||||
finally:
|
||||
await watcher.close()
|
||||
@@ -1,21 +1,34 @@
|
||||
"""Fixture system for seeding databases with dependency resolution."""
|
||||
|
||||
from .enum import LoadStrategy
|
||||
from .registry import Context, FixtureRegistry
|
||||
from .utils import (
|
||||
get_field_by_attr,
|
||||
get_obj_by_attr,
|
||||
load_fixtures,
|
||||
load_fixtures_by_context,
|
||||
)
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .enum import Context, LoadStrategy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .registry import FixtureRegistry
|
||||
from .utils import load_fixtures, load_fixtures_by_context
|
||||
|
||||
__all__ = [
|
||||
"Context",
|
||||
"FixtureRegistry",
|
||||
"LoadStrategy",
|
||||
"get_field_by_attr",
|
||||
"get_obj_by_attr",
|
||||
"load_fixtures",
|
||||
"load_fixtures_by_context",
|
||||
"register_fixtures",
|
||||
]
|
||||
|
||||
_LAZY = {
|
||||
"FixtureRegistry": ".registry",
|
||||
"load_fixtures": ".utils",
|
||||
"load_fixtures_by_context": ".utils",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
module_name = _LAZY.get(name)
|
||||
if module_name is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
import importlib
|
||||
|
||||
module = importlib.import_module(module_name, __name__)
|
||||
return getattr(module, name)
|
||||
|
||||
@@ -7,11 +7,8 @@ from typing import Any, cast
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from ..logger import get_logger
|
||||
from .enum import Context
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
def _normalize_contexts(
|
||||
contexts: list[str | Enum] | tuple[str | Enum, ...],
|
||||
@@ -20,6 +17,11 @@ def _normalize_contexts(
|
||||
return [c.value if isinstance(c, Enum) else c for c in contexts]
|
||||
|
||||
|
||||
def _context_filter_values(contexts: tuple[str | Enum, ...]) -> set[str]:
|
||||
"""Normalize *contexts* for filtering, always including Context.BASE."""
|
||||
return set(_normalize_contexts(contexts)) | {Context.BASE.value}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fixture:
|
||||
"""A fixture definition with metadata."""
|
||||
@@ -70,8 +72,6 @@ class FixtureRegistry:
|
||||
@fixtures.register(contexts=[Context.TESTING])
|
||||
def users():
|
||||
return [User(id=2, username="tester")]
|
||||
# load_fixtures_by_context(..., Context.BASE, Context.TESTING)
|
||||
# → loads both User(admin) and User(tester) under the "users" name
|
||||
```
|
||||
"""
|
||||
|
||||
@@ -189,9 +189,7 @@ class FixtureRegistry:
|
||||
ValueError: If the fixture has multiple context variants — use
|
||||
:meth:`get_variants` in that case.
|
||||
"""
|
||||
if name not in self._fixtures:
|
||||
raise KeyError(f"Fixture '{name}' not found")
|
||||
variants = self._fixtures[name]
|
||||
variants = self.get_variants(name)
|
||||
if len(variants) > 1:
|
||||
raise ValueError(
|
||||
f"Fixture '{name}' has {len(variants)} context variants. "
|
||||
@@ -205,8 +203,9 @@ class FixtureRegistry:
|
||||
Args:
|
||||
name: Fixture name.
|
||||
*contexts: If given, only return variants whose context set
|
||||
intersects with these values. Both :class:`Context` enum
|
||||
values and plain strings are accepted.
|
||||
intersects with these values (:class:`Context.BASE` variants
|
||||
are always included). Both :class:`Context` enum values and
|
||||
plain strings are accepted.
|
||||
|
||||
Returns:
|
||||
List of matching :class:`Fixture` objects (may be empty when a
|
||||
@@ -220,16 +219,89 @@ class FixtureRegistry:
|
||||
variants = self._fixtures[name]
|
||||
if not contexts:
|
||||
return list(variants)
|
||||
context_values = set(_normalize_contexts(contexts))
|
||||
context_values = _context_filter_values(contexts)
|
||||
return [v for v in variants if set(v.contexts) & context_values]
|
||||
|
||||
def get_load_variants(self, name: str, *contexts: str | Enum) -> list[Fixture]:
|
||||
"""Return variants for *name* filtered by *contexts*.
|
||||
|
||||
Raises:
|
||||
KeyError: If no fixture with *name* is registered.
|
||||
"""
|
||||
variants = self.get_variants(name, *contexts)
|
||||
if contexts and not variants:
|
||||
return self.get_variants(name)
|
||||
return variants
|
||||
|
||||
def get_all(self) -> list[Fixture]:
|
||||
"""Get all registered fixtures (all variants of all names)."""
|
||||
return [f for variants in self._fixtures.values() for f in variants]
|
||||
|
||||
def get_dependencies(self, name: str) -> list[str]:
|
||||
"""Get the union of ``depends_on`` across all variants of *name*.
|
||||
|
||||
Raises:
|
||||
KeyError: If no fixture named *name* is registered.
|
||||
"""
|
||||
variants = self._fixtures.get(name)
|
||||
if variants is None:
|
||||
raise KeyError(f"Fixture '{name}' not found")
|
||||
|
||||
seen: set[str] = set()
|
||||
deps: list[str] = []
|
||||
for variant in variants:
|
||||
for dep in variant.depends_on:
|
||||
if dep not in seen:
|
||||
deps.append(dep)
|
||||
seen.add(dep)
|
||||
return deps
|
||||
|
||||
def obj(self, name: str, attr_name: str, value: Any) -> DeclarativeBase:
|
||||
"""Get a model instance from a registered fixture by attribute value.
|
||||
|
||||
Args:
|
||||
name: Fixture name to look up.
|
||||
attr_name: Name of the attribute to match against.
|
||||
value: Value to match.
|
||||
|
||||
Returns:
|
||||
The first model instance where the attribute matches the given value.
|
||||
|
||||
Raises:
|
||||
KeyError: If no fixture named *name* is registered.
|
||||
StopIteration: If no matching object is found.
|
||||
"""
|
||||
instances = (
|
||||
obj for variant in self.get_variants(name) for obj in variant.func()
|
||||
)
|
||||
try:
|
||||
return next(obj for obj in instances if getattr(obj, attr_name) == value)
|
||||
except StopIteration:
|
||||
raise StopIteration(
|
||||
f"No object with {attr_name}={value} found in fixture '{name}'"
|
||||
) from None
|
||||
|
||||
def field(self, name: str, attr_name: str, value: Any, *, field: str = "id") -> Any:
|
||||
"""Get a single field value from a fixture object matched by an attribute.
|
||||
|
||||
Args:
|
||||
name: Fixture name to look up.
|
||||
attr_name: Name of the attribute to match against.
|
||||
value: Value to match.
|
||||
field: Attribute name to return from the matched object (default: ``"id"``).
|
||||
|
||||
Returns:
|
||||
The value of ``field`` on the first matching model instance.
|
||||
|
||||
Raises:
|
||||
KeyError: If no fixture named *name* is registered.
|
||||
StopIteration: If no matching object is found.
|
||||
"""
|
||||
return getattr(self.obj(name, attr_name, value), field)
|
||||
|
||||
def get_by_context(self, *contexts: str | Enum) -> list[Fixture]:
|
||||
"""Get fixtures for specific contexts."""
|
||||
context_values = set(_normalize_contexts(contexts))
|
||||
context_values = _context_filter_values(contexts)
|
||||
return [
|
||||
f
|
||||
for variants in self._fixtures.values()
|
||||
@@ -254,7 +326,6 @@ class FixtureRegistry:
|
||||
ValueError: If circular dependency detected
|
||||
"""
|
||||
resolved: list[str] = []
|
||||
seen: set[str] = set()
|
||||
visiting: set[str] = set()
|
||||
|
||||
def visit(name: str) -> None:
|
||||
@@ -264,25 +335,11 @@ class FixtureRegistry:
|
||||
raise ValueError(f"Circular dependency detected: {name}")
|
||||
|
||||
visiting.add(name)
|
||||
variants = self._fixtures.get(name)
|
||||
if variants is None:
|
||||
raise KeyError(f"Fixture '{name}' not found")
|
||||
|
||||
# Union of depends_on across all variants, preserving first-seen order.
|
||||
seen_deps: set[str] = set()
|
||||
all_deps: list[str] = []
|
||||
for variant in variants:
|
||||
for dep in variant.depends_on:
|
||||
if dep not in seen_deps:
|
||||
all_deps.append(dep)
|
||||
seen_deps.add(dep)
|
||||
|
||||
for dep in all_deps:
|
||||
for dep in self.get_dependencies(name):
|
||||
visit(dep)
|
||||
|
||||
visiting.remove(name)
|
||||
resolved.append(name)
|
||||
seen.add(name)
|
||||
|
||||
for name in names:
|
||||
visit(name)
|
||||
@@ -303,9 +360,4 @@ class FixtureRegistry:
|
||||
# appear multiple times if it has variants in different contexts).
|
||||
names = list(dict.fromkeys(f.name for f in context_fixtures))
|
||||
|
||||
all_deps: set[str] = set()
|
||||
for name in names:
|
||||
deps = self.resolve_dependencies(name)
|
||||
all_deps.update(deps)
|
||||
|
||||
return self.resolve_dependencies(*all_deps)
|
||||
return self.resolve_dependencies(*names)
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"""Fixture loading utilities for database seeding."""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from collections.abc import Iterator
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from sqlalchemy import Table, select
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.orm import DeclarativeBase, selectinload
|
||||
from sqlalchemy.orm.interfaces import ExecutableOption, ORMOption
|
||||
|
||||
from ..db import get_transaction
|
||||
from ..db import transaction
|
||||
from ..logger import get_logger
|
||||
from ..types import ModelType
|
||||
from .enum import LoadStrategy
|
||||
from .registry import FixtureRegistry, _normalize_contexts
|
||||
|
||||
@@ -93,17 +94,42 @@ def _group_by_column_set(
|
||||
return list(groups.values())
|
||||
|
||||
|
||||
def _grouped_table_dicts(
|
||||
model_cls: type[DeclarativeBase], instances: list[DeclarativeBase]
|
||||
) -> Iterator[
|
||||
tuple[type[DeclarativeBase], list[dict[str, Any]], list[DeclarativeBase]]
|
||||
]:
|
||||
"""Yield (cls, group_dicts, group_instances) per table in the inheritance
|
||||
chain and per column-set group, skipping empty groups.
|
||||
"""
|
||||
for cls in _get_table_chain(model_cls):
|
||||
dicts = [_instance_to_dict_for_cls(i, cls) for i in instances]
|
||||
for group_dicts, group_instances in _group_by_column_set(dicts, instances):
|
||||
if group_dicts and group_dicts[0]: # pragma: no branch
|
||||
yield cls, group_dicts, group_instances
|
||||
|
||||
|
||||
async def _batch_insert(
|
||||
session: AsyncSession,
|
||||
model_cls: type[DeclarativeBase],
|
||||
instances: list[DeclarativeBase],
|
||||
) -> None:
|
||||
"""INSERT all instances — raises on conflict (no duplicate handling)."""
|
||||
for cls in _get_table_chain(model_cls):
|
||||
dicts = [_instance_to_dict_for_cls(i, cls) for i in instances]
|
||||
for group_dicts, _ in _group_by_column_set(dicts, instances):
|
||||
if group_dicts and group_dicts[0]: # pragma: no branch
|
||||
await session.execute(pg_insert(cls).values(group_dicts))
|
||||
"""INSERT all instances, raises on conflict."""
|
||||
for cls, group_dicts, group_instances in _grouped_table_dicts(model_cls, instances):
|
||||
table = cast(Table, cls.__table__)
|
||||
missing_pk_cols = [
|
||||
col for col in table.primary_key.columns if col.key not in group_dicts[0]
|
||||
]
|
||||
if not missing_pk_cols:
|
||||
await session.execute(pg_insert(table), group_dicts)
|
||||
continue
|
||||
stmt = pg_insert(table).returning(
|
||||
*missing_pk_cols, sort_by_parameter_order=True
|
||||
)
|
||||
result = await session.execute(stmt, group_dicts)
|
||||
for inst, row in zip(group_instances, result):
|
||||
for col, val in zip(missing_pk_cols, row):
|
||||
setattr(inst, col.key, val)
|
||||
|
||||
|
||||
async def _batch_merge(
|
||||
@@ -112,17 +138,14 @@ async def _batch_merge(
|
||||
instances: list[DeclarativeBase],
|
||||
) -> None:
|
||||
"""UPSERT: insert new rows, update existing ones with the provided values."""
|
||||
for cls in _get_table_chain(model_cls):
|
||||
pk_names = [col.name for col in cls.__table__.primary_key]
|
||||
for cls, group_dicts, _ in _grouped_table_dicts(model_cls, instances):
|
||||
table = cast(Table, cls.__table__)
|
||||
pk_names = [col.name for col in table.primary_key]
|
||||
pk_names_set = set(pk_names)
|
||||
own_col_keys = {col.key for col in cls.__table__.columns}
|
||||
own_col_keys = {col.key for col in table.columns}
|
||||
non_pk_cols = [k for k in own_col_keys if k not in pk_names_set]
|
||||
|
||||
dicts = [_instance_to_dict_for_cls(i, cls) for i in instances]
|
||||
for group_dicts, _ in _group_by_column_set(dicts, instances):
|
||||
if not group_dicts or not group_dicts[0]: # pragma: no cover
|
||||
continue
|
||||
stmt = pg_insert(cls).values(group_dicts)
|
||||
stmt = pg_insert(table).values(group_dicts)
|
||||
|
||||
inserted_keys = set(group_dicts[0])
|
||||
update_cols = [col for col in non_pk_cols if col in inserted_keys]
|
||||
@@ -169,8 +192,14 @@ async def _batch_skip_existing(
|
||||
loaded = list(no_pk)
|
||||
if no_pk:
|
||||
no_pk_dicts = [_instance_to_dict(i) for i in no_pk]
|
||||
for group_dicts, _ in _group_by_column_set(no_pk_dicts, no_pk):
|
||||
await session.execute(pg_insert(model_cls).values(group_dicts))
|
||||
for group_dicts, group_instances in _group_by_column_set(no_pk_dicts, no_pk):
|
||||
stmt = pg_insert(cast(Table, model_cls.__table__)).returning(
|
||||
*mapper.primary_key, sort_by_parameter_order=True
|
||||
)
|
||||
result = await session.execute(stmt, group_dicts)
|
||||
for inst, row in zip(group_instances, result):
|
||||
for col, val in zip(mapper.primary_key, row):
|
||||
setattr(inst, cast(str, col.key), val)
|
||||
|
||||
if with_pk_pairs:
|
||||
with_pk = [i for i, _ in with_pk_pairs]
|
||||
@@ -196,6 +225,65 @@ async def _batch_skip_existing(
|
||||
return loaded
|
||||
|
||||
|
||||
def _relationship_load_options(model: type[DeclarativeBase]) -> list[ExecutableOption]:
|
||||
"""Build selectinload options for all direct relationships on a model."""
|
||||
return [
|
||||
selectinload(getattr(model, rel.key)) for rel in model.__mapper__.relationships
|
||||
]
|
||||
|
||||
|
||||
async def _reload_with_relationships(
|
||||
session: AsyncSession,
|
||||
instances: list[DeclarativeBase],
|
||||
load_options: list[ExecutableOption],
|
||||
) -> list[DeclarativeBase]:
|
||||
"""Reload instances in a single bulk query with relationship eager-loading."""
|
||||
model = type(instances[0])
|
||||
mapper = model.__mapper__
|
||||
pk_cols = mapper.primary_key
|
||||
|
||||
if len(pk_cols) == 1:
|
||||
pk_key = cast(str, pk_cols[0].key)
|
||||
pk_attr = getattr(model, pk_key)
|
||||
pks = [getattr(inst, pk_key) for inst in instances]
|
||||
result = await session.execute(
|
||||
select(model).where(pk_attr.in_(pks)).options(*load_options)
|
||||
)
|
||||
by_pk = {getattr(row, pk_key): row for row in result.unique().scalars()}
|
||||
return [by_pk[pk] for pk in pks]
|
||||
|
||||
# Composite PK: fall back to per-instance reload
|
||||
reloaded: list[DeclarativeBase] = []
|
||||
for instance in instances:
|
||||
pk = _get_primary_key(instance)
|
||||
refreshed = await session.get(
|
||||
model,
|
||||
pk,
|
||||
options=cast(list[ORMOption], load_options),
|
||||
populate_existing=True,
|
||||
)
|
||||
if refreshed is not None: # pragma: no branch
|
||||
reloaded.append(refreshed)
|
||||
return reloaded
|
||||
|
||||
|
||||
async def _refresh_loaded(
|
||||
session: AsyncSession, instances: list[DeclarativeBase]
|
||||
) -> list[DeclarativeBase]:
|
||||
"""Re-select freshly written rows, eager-loading relationships."""
|
||||
if not instances:
|
||||
return []
|
||||
refreshed: list[DeclarativeBase | None] = [None] * len(instances)
|
||||
for model_cls, group in _group_by_type(instances):
|
||||
positions = [i for i, inst in enumerate(instances) if type(inst) is model_cls]
|
||||
load_options = _relationship_load_options(model_cls)
|
||||
for pos, new in zip(
|
||||
positions, await _reload_with_relationships(session, group, load_options)
|
||||
):
|
||||
refreshed[pos] = new
|
||||
return cast(list[DeclarativeBase], refreshed)
|
||||
|
||||
|
||||
async def _load_ordered(
|
||||
session: AsyncSession,
|
||||
registry: FixtureRegistry,
|
||||
@@ -208,14 +296,11 @@ async def _load_ordered(
|
||||
|
||||
for name in ordered_names:
|
||||
variants = (
|
||||
registry.get_variants(name, *contexts)
|
||||
registry.get_load_variants(name, *contexts)
|
||||
if contexts is not None
|
||||
else registry.get_variants(name)
|
||||
)
|
||||
|
||||
if contexts is not None and not variants:
|
||||
variants = registry.get_variants(name)
|
||||
|
||||
if not variants: # pragma: no cover
|
||||
results[name] = []
|
||||
continue
|
||||
@@ -229,7 +314,7 @@ async def _load_ordered(
|
||||
model_name = type(instances[0]).__name__
|
||||
loaded: list[DeclarativeBase] = []
|
||||
|
||||
async with get_transaction(session):
|
||||
async with transaction(session):
|
||||
for model_cls, group in _group_by_type(instances):
|
||||
match strategy:
|
||||
case LoadStrategy.INSERT:
|
||||
@@ -244,8 +329,10 @@ async def _load_ordered(
|
||||
case _: # pragma: no cover
|
||||
pass
|
||||
|
||||
loaded = await _refresh_loaded(session, loaded)
|
||||
|
||||
results[name] = loaded
|
||||
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
|
||||
logger.info("Loaded fixture '%s': %d %s(s)", name, len(loaded), model_name)
|
||||
|
||||
return results
|
||||
|
||||
@@ -264,56 +351,6 @@ def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_obj_by_attr(
|
||||
fixtures: Callable[[], Sequence[ModelType]], attr_name: str, value: Any
|
||||
) -> ModelType:
|
||||
"""Get a SQLAlchemy model instance by matching an attribute value.
|
||||
|
||||
Args:
|
||||
fixtures: A fixture function registered via ``@registry.register``
|
||||
that returns a sequence of SQLAlchemy model instances.
|
||||
attr_name: Name of the attribute to match against.
|
||||
value: Value to match.
|
||||
|
||||
Returns:
|
||||
The first model instance where the attribute matches the given value.
|
||||
|
||||
Raises:
|
||||
StopIteration: If no matching object is found in the fixture group.
|
||||
"""
|
||||
try:
|
||||
return next(obj for obj in fixtures() if getattr(obj, attr_name) == value)
|
||||
except StopIteration:
|
||||
raise StopIteration(
|
||||
f"No object with {attr_name}={value} found in fixture '{getattr(fixtures, '__name__', repr(fixtures))}'"
|
||||
) from None
|
||||
|
||||
|
||||
def get_field_by_attr(
|
||||
fixtures: Callable[[], Sequence[ModelType]],
|
||||
attr_name: str,
|
||||
value: Any,
|
||||
*,
|
||||
field: str = "id",
|
||||
) -> Any:
|
||||
"""Get a single field value from a fixture object matched by an attribute.
|
||||
|
||||
Args:
|
||||
fixtures: A fixture function registered via ``@registry.register``
|
||||
that returns a sequence of SQLAlchemy model instances.
|
||||
attr_name: Name of the attribute to match against.
|
||||
value: Value to match.
|
||||
field: Attribute name to return from the matched object (default: ``"id"``).
|
||||
|
||||
Returns:
|
||||
The value of ``field`` on the first matching model instance.
|
||||
|
||||
Raises:
|
||||
StopIteration: If no matching object is found in the fixture group.
|
||||
"""
|
||||
return getattr(get_obj_by_attr(fixtures, attr_name, value), field)
|
||||
|
||||
|
||||
async def load_fixtures(
|
||||
session: AsyncSession,
|
||||
registry: FixtureRegistry,
|
||||
@@ -348,8 +385,8 @@ async def load_fixtures_by_context(
|
||||
Args:
|
||||
session: Database session
|
||||
registry: Fixture registry
|
||||
*contexts: Contexts to load (e.g., ``Context.BASE``, ``Context.TESTING``,
|
||||
or plain strings for custom contexts)
|
||||
*contexts: Contexts to load (e.g., ``Context.TESTING``, or plain
|
||||
strings for custom contexts)
|
||||
strategy: How to handle existing records
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -204,6 +204,11 @@ async def _invoke_callback(
|
||||
await result
|
||||
|
||||
|
||||
async def _reload_if_present(session: AsyncSession, obj: Any, state: Any) -> None:
|
||||
"""Re-populate *obj* from the DB if its row still exists."""
|
||||
await session.get(type(obj), state.key[1], populate_existing=True)
|
||||
|
||||
|
||||
class EventSession(AsyncSession):
|
||||
"""AsyncSession subclass that dispatches lifecycle callbacks after commit."""
|
||||
|
||||
@@ -253,7 +258,7 @@ class EventSession(AsyncSession):
|
||||
state is None or state.detached or state.transient
|
||||
): # pragma: no cover
|
||||
continue
|
||||
await self.refresh(obj)
|
||||
await _reload_if_present(self, obj, state)
|
||||
for handler in _get_handlers(type(obj), ModelEvent.CREATE):
|
||||
await _invoke_callback(handler, obj, ModelEvent.CREATE, None)
|
||||
except Exception as exc:
|
||||
@@ -277,7 +282,7 @@ class EventSession(AsyncSession):
|
||||
state is None or state.detached or state.transient
|
||||
): # pragma: no cover
|
||||
continue
|
||||
await self.refresh(obj)
|
||||
await _reload_if_present(self, obj, state)
|
||||
for handler in _get_handlers(type(obj), ModelEvent.UPDATE):
|
||||
await _invoke_callback(handler, obj, ModelEvent.UPDATE, changes)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
"""Pytest plugin for using FixtureRegistry fixtures in tests."""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import Any, cast
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase, selectinload
|
||||
from sqlalchemy.orm.interfaces import ExecutableOption, ORMOption
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from ..db import get_transaction
|
||||
from ..fixtures import FixtureRegistry, LoadStrategy
|
||||
from ..fixtures.utils import _get_primary_key, _load_ordered, _refresh_loaded
|
||||
|
||||
|
||||
def register_fixtures(
|
||||
@@ -57,7 +55,7 @@ def register_fixtures(
|
||||
|
||||
# Build list of pytest fixture dependencies
|
||||
pytest_deps = [session_fixture]
|
||||
for dep in fixture.depends_on:
|
||||
for dep in registry.get_dependencies(fixture.name):
|
||||
pytest_deps.append(f"{prefix}{dep}")
|
||||
|
||||
# Create the fixture function
|
||||
@@ -83,56 +81,38 @@ def _create_fixture_function(
|
||||
fixture_name: str,
|
||||
dependencies: list[str],
|
||||
strategy: LoadStrategy,
|
||||
) -> Callable[..., Any]:
|
||||
) -> Any:
|
||||
"""Create a fixture function with the correct signature.
|
||||
|
||||
The function signature must include all dependencies as parameters
|
||||
for pytest to resolve them correctly.
|
||||
for pytest (and pytest-anyio's fixture chaining) to resolve them
|
||||
correctly — dynamic resolution via ``request.getfixturevalue`` deadlocks
|
||||
when called from inside an already-running async fixture.
|
||||
"""
|
||||
# Get the fixture definition
|
||||
fixture_def = registry.get(fixture_name)
|
||||
|
||||
# Build the function dynamically with correct parameters
|
||||
# We need the session as first param, then all dependencies
|
||||
async def fixture_func(**kwargs: Any) -> Sequence[DeclarativeBase]:
|
||||
# Get session from kwargs (first dependency)
|
||||
session: AsyncSession = kwargs[dependencies[0]]
|
||||
result = (await _load_ordered(session, registry, [fixture_name], strategy))[
|
||||
fixture_name
|
||||
]
|
||||
|
||||
# Load the fixture data
|
||||
instances = list(fixture_def.func())
|
||||
if strategy is LoadStrategy.SKIP_EXISTING:
|
||||
# _load_ordered only returns newly-inserted rows for this
|
||||
# strategy (the CLI seeding contract). A test fixture should
|
||||
# still hand back the full, usable set including rows that
|
||||
# were already present, so top up with those.
|
||||
declared = list(fixture_def.func())
|
||||
result_pks = {_get_primary_key(r) for r in result}
|
||||
missing = [
|
||||
d
|
||||
for d in declared
|
||||
if (pk := _get_primary_key(d)) is not None and pk not in result_pks
|
||||
]
|
||||
if missing:
|
||||
result = result + await _refresh_loaded(session, missing)
|
||||
|
||||
if not instances:
|
||||
return []
|
||||
|
||||
loaded: list[DeclarativeBase] = []
|
||||
|
||||
async with get_transaction(session):
|
||||
for instance in instances:
|
||||
if strategy == LoadStrategy.INSERT:
|
||||
session.add(instance)
|
||||
loaded.append(instance)
|
||||
elif strategy == LoadStrategy.MERGE:
|
||||
merged = await session.merge(instance)
|
||||
loaded.append(merged)
|
||||
elif strategy == LoadStrategy.SKIP_EXISTING: # pragma: no branch
|
||||
pk = _get_primary_key(instance)
|
||||
if pk is not None:
|
||||
existing = await session.get(type(instance), pk)
|
||||
if existing is None:
|
||||
session.add(instance)
|
||||
loaded.append(instance)
|
||||
else:
|
||||
loaded.append(existing)
|
||||
else:
|
||||
session.add(instance)
|
||||
loaded.append(instance)
|
||||
|
||||
if loaded: # pragma: no branch
|
||||
load_options = _relationship_load_options(type(loaded[0]))
|
||||
if load_options:
|
||||
return await _reload_with_relationships(session, loaded, load_options)
|
||||
|
||||
return loaded
|
||||
return result
|
||||
|
||||
# Update function signature to include dependencies
|
||||
# This is needed for pytest to inject the right fixtures
|
||||
@@ -146,65 +126,3 @@ def _create_fixture_function(
|
||||
created_func.__doc__ = f"Load {fixture_name} fixture data."
|
||||
|
||||
return created_func
|
||||
|
||||
|
||||
def _relationship_load_options(model: type[DeclarativeBase]) -> list[ExecutableOption]:
|
||||
"""Build selectinload options for all direct relationships on a model."""
|
||||
return [
|
||||
selectinload(getattr(model, rel.key)) for rel in model.__mapper__.relationships
|
||||
]
|
||||
|
||||
|
||||
async def _reload_with_relationships(
|
||||
session: AsyncSession,
|
||||
instances: list[DeclarativeBase],
|
||||
load_options: list[ExecutableOption],
|
||||
) -> list[DeclarativeBase]:
|
||||
"""Reload instances in a single bulk query with relationship eager-loading.
|
||||
|
||||
Uses one SELECT … WHERE pk IN (…) so selectinload can batch all relationship
|
||||
queries — 1 + N_relationships round-trips regardless of how many instances
|
||||
there are, instead of one session.get() per instance.
|
||||
|
||||
Preserves the original insertion order.
|
||||
"""
|
||||
model = type(instances[0])
|
||||
mapper = model.__mapper__
|
||||
pk_cols = mapper.primary_key
|
||||
|
||||
if len(pk_cols) == 1:
|
||||
pk_attr = getattr(model, pk_cols[0].key)
|
||||
pks = [getattr(inst, pk_cols[0].key) for inst in instances]
|
||||
result = await session.execute(
|
||||
select(model).where(pk_attr.in_(pks)).options(*load_options)
|
||||
)
|
||||
by_pk = {getattr(row, pk_cols[0].key): row for row in result.unique().scalars()}
|
||||
return [by_pk[pk] for pk in pks]
|
||||
|
||||
# Composite PK: fall back to per-instance reload
|
||||
reloaded: list[DeclarativeBase] = []
|
||||
for instance in instances:
|
||||
pk = _get_primary_key(instance)
|
||||
refreshed = await session.get(
|
||||
model,
|
||||
pk,
|
||||
options=cast(list[ORMOption], load_options),
|
||||
populate_existing=True,
|
||||
)
|
||||
if refreshed is not None: # pragma: no branch
|
||||
reloaded.append(refreshed)
|
||||
return reloaded
|
||||
|
||||
|
||||
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
||||
"""Get the primary key value of a model instance."""
|
||||
mapper = instance.__class__.__mapper__
|
||||
pk_cols = mapper.primary_key
|
||||
|
||||
if len(pk_cols) == 1:
|
||||
return getattr(instance, pk_cols[0].name, None)
|
||||
|
||||
pk_values = tuple(getattr(instance, col.name, None) for col in pk_cols)
|
||||
if all(v is not None for v in pk_values):
|
||||
return pk_values
|
||||
return None
|
||||
|
||||
@@ -15,7 +15,7 @@ from sqlalchemy.ext.asyncio import (
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -34,12 +34,18 @@ def _get_xdist_worker(default_test_db: str) -> str:
|
||||
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.
|
||||
|
||||
Appends ``_{worker_name}`` to the database name so each xdist worker
|
||||
operates on its own database. When not running under xdist,
|
||||
``_{default_test_db}`` is appended instead.
|
||||
Sets the database name to the worker name so each xdist worker operates
|
||||
on its own database. When not running under xdist, *default_test_db* is
|
||||
used instead. When *prefix* is provided, the name becomes
|
||||
``{prefix}_{worker}``.
|
||||
|
||||
The worker name is read from the ``PYTEST_XDIST_WORKER`` environment
|
||||
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.
|
||||
default_test_db: Suffix appended to the database name when
|
||||
``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:
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -64,6 +74,7 @@ async def create_worker_database(
|
||||
database_url: str,
|
||||
default_test_db: str = "test_db",
|
||||
*,
|
||||
prefix: str | None = None,
|
||||
server_url: str | None = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Create and drop a per-worker database for pytest-xdist isolation.
|
||||
@@ -80,6 +91,9 @@ async def create_worker_database(
|
||||
the worker database name).
|
||||
default_test_db: Suffix appended to the database name when
|
||||
``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
|
||||
database on the same server). Defaults to *database_url* with the
|
||||
database omitted, letting asyncpg fall back to the username.
|
||||
@@ -107,7 +121,7 @@ async def create_worker_database(
|
||||
```
|
||||
"""
|
||||
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
|
||||
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")
|
||||
try:
|
||||
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)
|
||||
|
||||
yield worker_url
|
||||
|
||||
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:
|
||||
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)
|
||||
+26
-1
@@ -277,6 +277,10 @@ class TestFixturesCli:
|
||||
'@registry.register(depends_on=["roles"], contexts=[Context.TESTING])\n'
|
||||
"def users():\n"
|
||||
' return [{"id": 1, "name": "alice", "role_id": 1}]\n'
|
||||
"\n"
|
||||
'@registry.register(contexts=["staging"])\n'
|
||||
"def staging_only():\n"
|
||||
' return [{"id": 3, "name": "staging-user"}]\n'
|
||||
)
|
||||
|
||||
# Create db module
|
||||
@@ -316,7 +320,7 @@ class TestFixturesCli:
|
||||
assert result.exit_code == 0
|
||||
assert "roles" in result.output
|
||||
assert "users" in result.output
|
||||
assert "Total: 2 fixture(s)" in result.output
|
||||
assert "Total: 3 fixture(s)" in result.output
|
||||
|
||||
def test_fixtures_list_with_context(self, cli_env):
|
||||
"""fixtures list --context filters by context."""
|
||||
@@ -338,6 +342,27 @@ class TestFixturesCli:
|
||||
assert "roles" in result.output
|
||||
assert "[Dry run - no changes made]" in result.output
|
||||
|
||||
def test_fixtures_list_with_custom_context(self, cli_env):
|
||||
"""fixtures list --context accepts contexts outside the Context enum, and
|
||||
always includes base fixtures alongside the requested context."""
|
||||
tmp_path, cli = cli_env
|
||||
result = runner.invoke(cli, ["fixtures", "list", "--context", "staging"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "staging_only" in result.output
|
||||
assert "roles" in result.output
|
||||
assert "Total: 2 fixture(s)" in result.output
|
||||
|
||||
def test_fixtures_load_custom_context_dry_run(self, cli_env):
|
||||
"""fixtures load accepts a custom context argument outside the Context enum,
|
||||
and always loads base fixtures alongside it."""
|
||||
tmp_path, cli = cli_env
|
||||
result = runner.invoke(cli, ["fixtures", "load", "staging", "--dry-run"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "staging_only" in result.output
|
||||
assert "roles" in result.output
|
||||
|
||||
def test_fixtures_load_invalid_strategy(self, cli_env):
|
||||
"""fixtures load with invalid strategy shows error."""
|
||||
tmp_path, cli = cli_env
|
||||
|
||||
+743
-169
File diff suppressed because it is too large
Load Diff
@@ -91,13 +91,19 @@ async def seed(session: AsyncSession):
|
||||
class TestAppSessionDep:
|
||||
@pytest.mark.anyio
|
||||
async def test_get_db_yields_async_session(self):
|
||||
"""get_db yields a real AsyncSession when called directly."""
|
||||
from docs_src.examples.pagination_search.db import get_db
|
||||
"""The Database dependency yields a real AsyncSession when called directly."""
|
||||
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__()
|
||||
assert isinstance(session, AsyncSession)
|
||||
await gen.aclose()
|
||||
finally:
|
||||
await db.engine.dispose()
|
||||
|
||||
|
||||
class TestOffsetPagination:
|
||||
|
||||
+103
-22
@@ -2,6 +2,7 @@
|
||||
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -10,8 +11,6 @@ from fastapi_toolsets.fixtures import (
|
||||
Context,
|
||||
FixtureRegistry,
|
||||
LoadStrategy,
|
||||
get_field_by_attr,
|
||||
get_obj_by_attr,
|
||||
load_fixtures,
|
||||
load_fixtures_by_context,
|
||||
)
|
||||
@@ -267,7 +266,34 @@ class TestFixtureRegistry:
|
||||
|
||||
testing_fixtures = registry.get_by_context(Context.TESTING)
|
||||
names = {f.name for f in testing_fixtures}
|
||||
assert names == {"test_data"}
|
||||
assert names == {"test_data", "base_data"}
|
||||
|
||||
def test_get_by_context_always_includes_base(self):
|
||||
"""Context.BASE fixtures load even for a fully custom context."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(contexts=[Context.BASE])
|
||||
def base_data():
|
||||
return []
|
||||
|
||||
@registry.register(contexts=["staging"])
|
||||
def staging_data():
|
||||
return []
|
||||
|
||||
names = {f.name for f in registry.get_by_context("staging")}
|
||||
assert names == {"staging_data", "base_data"}
|
||||
|
||||
def test_get_load_variants_falls_back_to_all_when_context_has_no_match(self):
|
||||
"""get_load_variants returns every variant if none match the requested
|
||||
context (and none are Context.BASE either)."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(contexts=["staging"])
|
||||
def env_data():
|
||||
return []
|
||||
|
||||
variants = registry.get_load_variants("env_data", "production")
|
||||
assert [v.contexts for v in variants] == [["staging"]]
|
||||
|
||||
|
||||
class TestIncludeRegistry:
|
||||
@@ -812,6 +838,45 @@ class TestLoadFixtures:
|
||||
db_session, registry, "int_roles", strategy=LoadStrategy.SKIP_EXISTING
|
||||
)
|
||||
assert len(result["int_roles"]) == 1
|
||||
# The generated autoincrement PK must be written back onto the
|
||||
# returned instance, not just visible via a fresh DB query.
|
||||
assert cast(IntRole, result["int_roles"][0]).id is not None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_insert_refreshes_autoincrement_pk_on_returned_instance(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""INSERT strategy writes the generated PK back onto the returned instance."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register
|
||||
def int_roles():
|
||||
return [IntRole(name="auto")]
|
||||
|
||||
result = await load_fixtures(
|
||||
db_session, registry, "int_roles", strategy=LoadStrategy.INSERT
|
||||
)
|
||||
assert cast(IntRole, result["int_roles"][0]).id is not None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_merge_refreshes_server_default_on_returned_instance(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""MERGE strategy refreshes the returned instance with server-generated values."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register
|
||||
def challenges():
|
||||
return [
|
||||
Challenge(id=uuid.uuid4(), title="Solo", challenge_type="challenge")
|
||||
]
|
||||
|
||||
result = await load_fixtures(
|
||||
db_session, registry, "challenges", strategy=LoadStrategy.MERGE
|
||||
)
|
||||
# `points` has a column default of 0 applied by the DB, never set on
|
||||
# the in-memory instance — the returned object must reflect it.
|
||||
assert cast(Challenge, result["challenges"][0]).points == 0
|
||||
|
||||
|
||||
class TestLoadFixturesByContext:
|
||||
@@ -891,8 +956,8 @@ class TestLoadFixturesByContext:
|
||||
assert await UserCrud.count(db_session) == 1
|
||||
|
||||
|
||||
class TestGetObjByAttr:
|
||||
"""Tests for get_obj_by_attr helper function."""
|
||||
class TestRegistryObj:
|
||||
"""Tests for FixtureRegistry.obj."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures for each test."""
|
||||
@@ -934,23 +999,20 @@ class TestGetObjByAttr:
|
||||
),
|
||||
]
|
||||
|
||||
self.roles = roles
|
||||
self.users = users
|
||||
|
||||
def test_get_by_id(self):
|
||||
"""Get an object by its id attribute."""
|
||||
role = get_obj_by_attr(self.roles, "id", self.role_id_1)
|
||||
assert role.name == "admin"
|
||||
role = self.registry.obj("roles", "id", self.role_id_1)
|
||||
assert cast(Role, role).name == "admin"
|
||||
|
||||
def test_get_user_by_username(self):
|
||||
"""Get a user by username."""
|
||||
user = get_obj_by_attr(self.users, "username", "bob")
|
||||
user = cast(User, self.registry.obj("users", "username", "bob"))
|
||||
assert user.id == self.user_id_2
|
||||
assert user.email == "bob@example.com"
|
||||
|
||||
def test_returns_first_match(self):
|
||||
"""Returns the first matching object when multiple could match."""
|
||||
user = get_obj_by_attr(self.users, "role_id", self.role_id_1)
|
||||
user = cast(User, self.registry.obj("users", "role_id", self.role_id_1))
|
||||
assert user.username == "alice"
|
||||
|
||||
def test_no_match_raises_stop_iteration(self):
|
||||
@@ -959,16 +1021,37 @@ class TestGetObjByAttr:
|
||||
StopIteration,
|
||||
match="No object with name=nonexistent found in fixture 'roles'",
|
||||
):
|
||||
get_obj_by_attr(self.roles, "name", "nonexistent")
|
||||
self.registry.obj("roles", "name", "nonexistent")
|
||||
|
||||
def test_no_match_on_wrong_value_type(self):
|
||||
"""Raises StopIteration when value type doesn't match."""
|
||||
with pytest.raises(StopIteration):
|
||||
get_obj_by_attr(self.roles, "id", "not-a-uuid")
|
||||
self.registry.obj("roles", "id", "not-a-uuid")
|
||||
|
||||
def test_unknown_fixture_raises_key_error(self):
|
||||
"""Raises KeyError when the fixture name isn't registered."""
|
||||
with pytest.raises(KeyError):
|
||||
self.registry.obj("unknown", "id", self.role_id_1)
|
||||
|
||||
def test_searches_across_context_variants(self):
|
||||
"""obj() finds matches across all context variants of a fixture name, not just one."""
|
||||
registry = FixtureRegistry()
|
||||
tester_id = uuid.uuid4()
|
||||
|
||||
@registry.register(contexts=[Context.BASE])
|
||||
def variant_users() -> list[User]:
|
||||
return [User(id=uuid.uuid4(), username="admin", email="admin@x.com")]
|
||||
|
||||
@registry.register(contexts=[Context.TESTING])
|
||||
def variant_users() -> list[User]: # noqa: F811
|
||||
return [User(id=tester_id, username="tester", email="tester@x.com")]
|
||||
|
||||
user = cast(User, registry.obj("variant_users", "username", "tester"))
|
||||
assert user.id == tester_id
|
||||
|
||||
|
||||
class TestGetFieldByAttr:
|
||||
"""Tests for get_field_by_attr helper function."""
|
||||
class TestRegistryField:
|
||||
"""Tests for FixtureRegistry.field."""
|
||||
|
||||
def setup_method(self):
|
||||
self.registry = FixtureRegistry()
|
||||
@@ -984,22 +1067,20 @@ class TestGetFieldByAttr:
|
||||
Role(id=role_id_2, name="user"),
|
||||
]
|
||||
|
||||
self.roles = roles
|
||||
|
||||
def test_returns_id_by_default(self):
|
||||
"""Returns the id field when no field is specified."""
|
||||
result = get_field_by_attr(self.roles, "name", "admin")
|
||||
result = self.registry.field("roles", "name", "admin")
|
||||
assert result == self.role_id_1
|
||||
|
||||
def test_returns_specified_field(self):
|
||||
"""Returns the requested field instead of id."""
|
||||
result = get_field_by_attr(self.roles, "id", self.role_id_2, field="name")
|
||||
result = self.registry.field("roles", "id", self.role_id_2, field="name")
|
||||
assert result == "user"
|
||||
|
||||
def test_no_match_raises_stop_iteration(self):
|
||||
"""Propagates StopIteration from get_obj_by_attr when no match found."""
|
||||
"""Propagates StopIteration from obj() when no match found."""
|
||||
with pytest.raises(StopIteration, match="No object with name=missing"):
|
||||
get_field_by_attr(self.roles, "name", "missing")
|
||||
self.registry.field("roles", "name", "missing")
|
||||
|
||||
|
||||
class TestGetPrimaryKey:
|
||||
|
||||
+80
-43
@@ -21,12 +21,12 @@ from fastapi_toolsets.models import (
|
||||
listens_for,
|
||||
)
|
||||
from fastapi_toolsets.models.watched import (
|
||||
EventSession,
|
||||
_EVENT_HANDLERS,
|
||||
_SESSION_CREATES,
|
||||
_SESSION_DELETES,
|
||||
_SESSION_UPDATES,
|
||||
_WATCHED_MODELS,
|
||||
EventSession,
|
||||
_after_flush,
|
||||
_after_rollback,
|
||||
_get_watched_fields,
|
||||
@@ -1001,6 +1001,57 @@ class TestEventCallbacks:
|
||||
|
||||
assert _test_events == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_survives_row_deleted_before_reload(self, mixin_session):
|
||||
"""A row deleted by another transaction right after commit still fires CREATE."""
|
||||
keep = WatchedModel(status="active", other="x")
|
||||
doomed = WatchedModel(status="active", other="x")
|
||||
mixin_session.add_all([keep, doomed])
|
||||
await mixin_session.flush()
|
||||
doomed_id = doomed.id
|
||||
|
||||
raced = {"done": False}
|
||||
|
||||
async def kill_doomed_row_once():
|
||||
if raced["done"]:
|
||||
return
|
||||
raced["done"] = True
|
||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||
async with async_sessionmaker(engine)() as other:
|
||||
row = await other.get(WatchedModel, doomed_id)
|
||||
await other.delete(row)
|
||||
await other.commit()
|
||||
await engine.dispose()
|
||||
|
||||
real_get = mixin_session.get
|
||||
real_refresh = mixin_session.refresh
|
||||
|
||||
def _matches_doomed(pk):
|
||||
return pk == doomed_id or (isinstance(pk, tuple) and pk[0] == doomed_id)
|
||||
|
||||
async def racing_get(model, pk, *args, **kwargs):
|
||||
if _matches_doomed(pk):
|
||||
await kill_doomed_row_once()
|
||||
return await real_get(model, pk, *args, **kwargs)
|
||||
|
||||
async def racing_refresh(obj, *args, **kwargs):
|
||||
if getattr(obj, "id", None) == doomed_id:
|
||||
await kill_doomed_row_once()
|
||||
return await real_refresh(obj, *args, **kwargs)
|
||||
|
||||
# Patch both possible reload mechanisms (session.get / session.refresh)
|
||||
# so this test still exercises the race regardless of which one
|
||||
# EventSession.commit() uses internally to pick up server defaults.
|
||||
mixin_session.get = racing_get
|
||||
mixin_session.refresh = racing_refresh
|
||||
with patch.object(_watched_module._logger, "error") as mock_error:
|
||||
await mixin_session.commit()
|
||||
mock_error.assert_not_called()
|
||||
|
||||
assert raced["done"]
|
||||
created_ids = {e["obj_id"] for e in _test_events if e["event"] == "create"}
|
||||
assert created_ids == {keep.id, doomed_id}
|
||||
|
||||
|
||||
class TestTransientObject:
|
||||
"""Create + delete within the same transaction should fire no events."""
|
||||
@@ -1506,8 +1557,8 @@ class TestListensFor:
|
||||
assert all(e["event"] == "change" for e in _listener_events)
|
||||
|
||||
|
||||
class TestEventSessionWithGetTransaction:
|
||||
"""Verify callbacks fire correctly when using get_transaction / lock_tables."""
|
||||
class TestEventSessionWithTransaction:
|
||||
"""Verify callbacks fire correctly when using transaction / lock_tables."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_events(self):
|
||||
@@ -1517,10 +1568,10 @@ class TestEventSessionWithGetTransaction:
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_callbacks_fire_after_outer_commit_not_savepoint(self, mixin_session):
|
||||
"""get_transaction creates a savepoint; callbacks fire only on outer commit."""
|
||||
from fastapi_toolsets.db import get_transaction
|
||||
"""transaction creates a savepoint; callbacks fire only on outer commit."""
|
||||
from fastapi_toolsets.db import transaction
|
||||
|
||||
async with get_transaction(mixin_session):
|
||||
async with transaction(mixin_session):
|
||||
obj = WatchedModel(status="active", other="x")
|
||||
mixin_session.add(obj)
|
||||
|
||||
@@ -1535,14 +1586,14 @@ class TestEventSessionWithGetTransaction:
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nested_transactions_accumulate_events(self, mixin_session):
|
||||
"""Multiple get_transaction blocks accumulate events for a single commit."""
|
||||
from fastapi_toolsets.db import get_transaction
|
||||
"""Multiple transaction blocks accumulate events for a single commit."""
|
||||
from fastapi_toolsets.db import transaction
|
||||
|
||||
async with get_transaction(mixin_session):
|
||||
async with transaction(mixin_session):
|
||||
obj1 = WatchedModel(status="first", other="x")
|
||||
mixin_session.add(obj1)
|
||||
|
||||
async with get_transaction(mixin_session):
|
||||
async with transaction(mixin_session):
|
||||
obj2 = WatchedModel(status="second", other="y")
|
||||
mixin_session.add(obj2)
|
||||
|
||||
@@ -1556,14 +1607,14 @@ class TestEventSessionWithGetTransaction:
|
||||
@pytest.mark.anyio
|
||||
async def test_savepoint_rollback_suppresses_events(self, mixin_session):
|
||||
"""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")
|
||||
mixin_session.add(survivor)
|
||||
await mixin_session.flush()
|
||||
|
||||
try:
|
||||
async with get_transaction(mixin_session):
|
||||
async with transaction(mixin_session):
|
||||
doomed = WatchedModel(status="doomed", other="y")
|
||||
mixin_session.add(doomed)
|
||||
await mixin_session.flush()
|
||||
@@ -1590,9 +1641,9 @@ class TestEventSessionWithGetTransaction:
|
||||
assert len(creates) == 1
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_inside_get_transaction(self, mixin_session):
|
||||
"""UPDATE events fire with correct changes after get_transaction commit."""
|
||||
from fastapi_toolsets.db import get_transaction
|
||||
async def test_update_inside_transaction(self, mixin_session):
|
||||
"""UPDATE events fire with correct changes after transaction commit."""
|
||||
from fastapi_toolsets.db import transaction
|
||||
|
||||
obj = WatchedModel(status="initial", other="x")
|
||||
mixin_session.add(obj)
|
||||
@@ -1600,7 +1651,7 @@ class TestEventSessionWithGetTransaction:
|
||||
|
||||
_test_events.clear()
|
||||
|
||||
async with get_transaction(mixin_session):
|
||||
async with transaction(mixin_session):
|
||||
obj.status = "updated"
|
||||
|
||||
await mixin_session.commit()
|
||||
@@ -1696,7 +1747,7 @@ class TestEventSessionWithNullableFields:
|
||||
|
||||
|
||||
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)
|
||||
def clear_events(self):
|
||||
@@ -1706,31 +1757,24 @@ class TestEventSessionWithFastAPIDependency:
|
||||
|
||||
@pytest.mark.anyio
|
||||
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 httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
|
||||
from fastapi_toolsets.db import create_db_dependency
|
||||
from fastapi_toolsets.db import Database
|
||||
from fastapi_toolsets.models import EventSession
|
||||
|
||||
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:
|
||||
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.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")
|
||||
session.add(obj)
|
||||
return {"id": str(obj.id)}
|
||||
@@ -1753,40 +1797,33 @@ class TestEventSessionWithFastAPIDependency:
|
||||
|
||||
@pytest.mark.anyio
|
||||
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 httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
|
||||
from fastapi_toolsets.db import create_db_dependency
|
||||
from fastapi_toolsets.db import Database
|
||||
from fastapi_toolsets.models import EventSession
|
||||
|
||||
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:
|
||||
await conn.run_sync(MixinBase.metadata.create_all)
|
||||
|
||||
get_db = create_db_dependency(session_factory)
|
||||
db = Database(engine=engine, session_class=EventSession)
|
||||
app = FastAPI()
|
||||
|
||||
# Pre-seed an object.
|
||||
async with session_factory() as seed_session:
|
||||
async with db.session() as seed_session:
|
||||
obj = WatchedModel(status="initial", other="x")
|
||||
seed_session.add(obj)
|
||||
await seed_session.commit()
|
||||
await seed_session.flush()
|
||||
obj_id = obj.id
|
||||
|
||||
_test_events.clear()
|
||||
|
||||
@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
|
||||
|
||||
stmt = select(WatchedModel).where(WatchedModel.id == item_id)
|
||||
|
||||
+89
-18
@@ -11,7 +11,7 @@ from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
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.pytest import (
|
||||
create_async_client,
|
||||
@@ -20,7 +20,7 @@ from fastapi_toolsets.pytest import (
|
||||
register_fixtures,
|
||||
worker_database_url,
|
||||
)
|
||||
from fastapi_toolsets.pytest.plugin import (
|
||||
from fastapi_toolsets.fixtures.utils import (
|
||||
_get_primary_key,
|
||||
_relationship_load_options,
|
||||
_reload_with_relationships,
|
||||
@@ -387,14 +387,14 @@ class TestCreateDbSession:
|
||||
assert session.autoflush is False
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_transaction_commits_visible_to_separate_session(self):
|
||||
"""Data written via get_transaction() is committed and visible to other sessions."""
|
||||
async def test_transaction_commits_visible_to_separate_session(self):
|
||||
"""Data written via transaction() is committed and visible to other sessions."""
|
||||
role_id = uuid.uuid4()
|
||||
|
||||
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.
|
||||
async with get_transaction(session):
|
||||
async with transaction(session):
|
||||
role = Role(id=role_id, name="visible_to_other_session")
|
||||
session.add(role)
|
||||
|
||||
@@ -409,9 +409,9 @@ class TestCreateDbSession:
|
||||
result = await other.execute(select(Role).where(Role.id == role_id))
|
||||
fetched = result.scalar_one_or_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 "
|
||||
"create_db_context, auto-begin forces get_transaction() into "
|
||||
"db.session(), auto-begin forces transaction() into "
|
||||
"savepoints instead of real commits."
|
||||
)
|
||||
assert fetched.name == "visible_to_other_session"
|
||||
@@ -442,21 +442,19 @@ class TestGetXdistWorker:
|
||||
class TestWorkerDatabaseUrl:
|
||||
"""Tests for worker_database_url helper."""
|
||||
|
||||
def test_appends_default_test_db_without_xdist(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""default_test_db is appended when not running under xdist."""
|
||||
def test_uses_default_test_db_without_xdist(self, monkeypatch: pytest.MonkeyPatch):
|
||||
"""default_test_db is used as the database name 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="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):
|
||||
"""Worker name is appended to the database name."""
|
||||
def test_uses_worker_id_as_database_name(self, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Worker name is used as the database name."""
|
||||
monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw0")
|
||||
url = "postgresql+asyncpg://user:pass@localhost:5432/db"
|
||||
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):
|
||||
"""Host, port, username, password, and driver are preserved."""
|
||||
@@ -469,7 +467,21 @@ class TestWorkerDatabaseUrl:
|
||||
assert result.password == "secret"
|
||||
assert result.host == "dbhost"
|
||||
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:
|
||||
@@ -479,7 +491,7 @@ class TestCreateWorkerDatabase:
|
||||
async def test_creates_default_db_without_xdist(
|
||||
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)
|
||||
default_test_db = "no_xdist_default"
|
||||
expected_db = make_url(
|
||||
@@ -626,6 +638,65 @@ class TestCreateWorkerDatabase:
|
||||
assert result.scalar() == 1
|
||||
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):
|
||||
pass
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -330,7 +330,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi-toolsets"
|
||||
version = "4.1.2"
|
||||
version = "5.0.0b2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "asyncpg" },
|
||||
@@ -341,7 +341,6 @@ dependencies = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
all = [
|
||||
{ name = "async-lru" },
|
||||
{ name = "httpx" },
|
||||
{ name = "prometheus-client" },
|
||||
{ name = "pytest" },
|
||||
@@ -359,10 +358,6 @@ pytest = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-xdist" },
|
||||
]
|
||||
security = [
|
||||
{ name = "async-lru" },
|
||||
{ name = "httpx" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
@@ -402,12 +397,10 @@ tests = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "async-lru", marker = "extra == 'security'", specifier = ">=1.0" },
|
||||
{ name = "asyncpg", specifier = ">=0.29.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 == 'security'", specifier = ">=0.25.0" },
|
||||
{ name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0" },
|
||||
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
|
||||
@@ -415,7 +408,7 @@ requires-dist = [
|
||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.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]
|
||||
dev = [
|
||||
@@ -786,16 +779,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mkdocstrings-python"
|
||||
version = "2.0.3"
|
||||
version = "2.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "griffelib" },
|
||||
{ name = "mkdocs-autorefs" },
|
||||
{ name = "mkdocstrings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/b6/e858701499d57eee8b3fd8e78168083956c6683ddbe727b46758b19e1119/mkdocstrings_python-2.0.5.tar.gz", hash = "sha256:3a4d92556ad39637e88af94a5374213af9a8e3040c3824ceaed04b486c017594", size = 199578, upload-time = "2026-06-19T10:41:08.868Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl", hash = "sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d", size = 104951, upload-time = "2026-06-19T10:41:07.378Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -836,26 +829,26 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prek"
|
||||
version = "0.4.3"
|
||||
version = "0.4.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/3b/a0ae60bbd4c4735f20aeddfbd3c50fb669cd8e99c078a3ed75a6a4a5c6d7/prek-0.4.3.tar.gz", hash = "sha256:e486307ea649e7300b3535fac52fe0ba0b80aebe23143b662659d16e6a7c8b47", size = 461800, upload-time = "2026-05-27T03:18:58.045Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/46/e436a6eb9fdb4d3fd08d0ab7fdba19fe03a9e994ec810de57869b853bd8e/prek-0.4.8.tar.gz", hash = "sha256:d15d8bef72ab7b02c7dc01458ac9e05b3131534492b5ce9bb11c4f6f636fa868", size = 494570, upload-time = "2026-07-04T12:05:10.941Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/be/980a0512f7eec3469dd40574f4e35d9ce7b67b358fea58888d13a0625b0d/prek-0.4.3-py3-none-linux_armv6l.whl", hash = "sha256:c67109de8d9766c2afd6e7e64feb9e1a0d3eceb3b4123280c28344660c1a97cd", size = 5541730, upload-time = "2026-05-27T03:19:09.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/55/937d707cc01d311e5c856c7019bc7db2c5e1835728396bb1ea32a7ecfdfd/prek-0.4.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b43a85f5ddf7827a75491e79ca068a49c5e4efde8dbac844ecb89622a78458e4", size = 5906762, upload-time = "2026-05-27T03:19:21.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/6a/9a99ac481eb148dba55652df88b029ab6c1f90384bd51996026cdab2dafb/prek-0.4.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e99ee90a7b6e84dabef891ff7521eb59dae38953467bdb482f004ea522d3a64c", size = 5461541, upload-time = "2026-05-27T03:18:55.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/8d/9056b02a100cc18b101fc05ecc82635889f5f8cb1cce5d70b027e517a6d9/prek-0.4.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:f514ec0d95cd4578d74d4601058bd259f5baf91c937f2aaae942d4b070b8077f", size = 5720501, upload-time = "2026-05-27T03:18:52.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/ea/efbe4523e53022d94272ddfdd3a198ace7de004dd8830a69318085a10393/prek-0.4.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03a4ac3c3023a76faa52ad7775720599b10241930be8902c471085b22572b4b0", size = 5452412, upload-time = "2026-05-27T03:19:17.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/d6/8a48b2c6a5117110d688c2d8ca2526264ad9f0d3baed4587038ee85e4c2d/prek-0.4.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40173425ab82bf0a7267d672b3e3aae9dd425eaee3a3641c6a5f040da3ff95e4", size = 5849515, upload-time = "2026-05-27T03:19:05.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/66/ccce7a1b6c6b610a22b54092d523ea7d35709e42864dace3734c05dd5f98/prek-0.4.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b8d99ee3277f8f3a3453a953120ee5c6c52f7ad89e459a25425cf62135f47b1", size = 6743978, upload-time = "2026-05-27T03:19:07.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/f8/7a441d780c42e858ad677c82bb54eb3f01b424b710a8db5b9a8782305326/prek-0.4.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08595fe96d24c1fe13486b00d55ce73a7b37040a16e82365942606594c67a6b", size = 6108774, upload-time = "2026-05-27T03:19:03.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/38/fbb1afe14c7536109c68a1d9ca602f152f1929972d006c517c3b92140192/prek-0.4.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:8607d636ef9232675507d97d252e1dcca5628bff79cb069fa945fff09d7bbb43", size = 5723165, upload-time = "2026-05-27T03:18:59.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b8/edafebce2bbd85f9e9de2781c225d690eb2b9897a06b224f5c24658fe398/prek-0.4.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:89484765304a779780f83489eb3aed5de5366f47fce7713fa5a917ebc281baa0", size = 5560557, upload-time = "2026-05-27T03:18:49.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/2e/a85a40458ac50c452cae2ddd2eed0b70107fd2b4074d7a5003088ac508f1/prek-0.4.3-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:2d2b0c12e3d1c6d90646f9faa2d4c66f9861f3c6e577d7dbd25e733ed095ac56", size = 5417874, upload-time = "2026-05-27T03:19:01.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/be/106fb026646e1da65da6d2a5f3cfbda817e68a72429645351b7033c0b2b5/prek-0.4.3-py3-none-musllinux_1_1_i686.whl", hash = "sha256:ca6802eaf191acb6166e9e013dd277ea193ba27c1dca896ab7debf6dca758b6d", size = 5710013, upload-time = "2026-05-27T03:18:54.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/c4/edfff5f7d9b6c9e5860dfe05c9488e1b96de990b652db2e379d45af8ad2e/prek-0.4.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a46862d81078d2c8caa286c392f965ed72fb72eb1fed171910ba54fe8d546ed0", size = 6230160, upload-time = "2026-05-27T03:19:15.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/d2/70adf26d5da0b7a66d8e284a661feddd5e8c69784b82084f40485fa321e4/prek-0.4.3-py3-none-win32.whl", hash = "sha256:f78e343584cfff106fc3c361109b87949ad8028dc5aa667e0fccd26db8170d7d", size = 5226844, upload-time = "2026-05-27T03:19:13.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/5d/9f21aca8ccee6978db831dbf36c2e17461692c75dd291c9b3d170e39a82a/prek-0.4.3-py3-none-win_amd64.whl", hash = "sha256:798d04437d30d6b4e6c1d520fe6ca800c340c9246f0dc8900d8b365df54b71b6", size = 5616068, upload-time = "2026-05-27T03:19:19.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/ef/cad8f9c66bcc199e22d1ad82a50032067a4c8b4182306d3472ff99f64aa3/prek-0.4.3-py3-none-win_arm64.whl", hash = "sha256:70d9da5fc14ef41565ff7ba9f476fb53166bf719a954339b2e9f42ed494a2f71", size = 5448057, upload-time = "2026-05-27T03:19:11.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/78/b4149c8913ced2e42debb49e261c4788a1ce431e84226921c2e1a7ea8545/prek-0.4.8-py3-none-linux_armv6l.whl", hash = "sha256:1f8f8cdc65836b571824c965daebb81b449f7e4a43894c58621f5708d5a185ed", size = 5668955, upload-time = "2026-07-04T12:04:41.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/5f/7f54a0087b6b2f1751aeb41266d9c15e66fd0055492814798ab818cd0414/prek-0.4.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bce1798e96d9e3a6e6abf435da7107e81452f69edb3ca7c6f90a457355ea46e2", size = 6030947, upload-time = "2026-07-04T12:04:43.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/d6/f2829fc3902920c36b764a386fa303e71a8219dac25cb3827c575e84199a/prek-0.4.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ab3a52db17254d701c3cebb7eea58c8230aa7c1959aacfd5b5f25de18edb15d1", size = 5572593, upload-time = "2026-07-04T12:04:45.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/8c/c5589955bcd5e3e33b67d8bc3110818cecac82a38fd6bc8b5dfdc5de421c/prek-0.4.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:b3fcfd620523bbc3f51a21d7cd63449f659b9e2cf3582de12dd5949e23227b8f", size = 5847150, upload-time = "2026-07-04T12:04:47.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/9d/1f2dc91bdb79d2c4714b27eac9477a51490fba5b4731330dbbebc76bd345/prek-0.4.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42e65bc8425e9d7f1691a13ca1da2e07807d1ba76c35740833354b945131689e", size = 5573738, upload-time = "2026-07-04T12:04:49.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/29/69a7b58e16ecbc5f3989bf4b028018d11a82dcdd320b93d6588d72f32aa7/prek-0.4.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f578492a8e0c9bc6b4bf6dfbba8716f647d4cd0769bf10ad6cf336e3096fd392", size = 5981054, upload-time = "2026-07-04T12:04:50.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/cc/9b9850a60c22ed18c7755ebd2d72c6eefb37fac58149d09f6adc4691c2cf/prek-0.4.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4335f9d5beb123a3884a7fe34f57c9f0828f4fbb7666beab4298833459b104f", size = 6751350, upload-time = "2026-07-04T12:04:52.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/e5/c425aa7272b430630119e6757def3a2007555ba8cbeb2630e0448e7a8b7f/prek-0.4.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18a8747df9c602e052881d3efb14dd7f7d62a59bd7277ae5171c9e7661d59d84", size = 6243881, upload-time = "2026-07-04T12:04:54.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/da/accd3ad07fd2891d3c2777eb42435439fdf11982c51d60f087c0b6b6e102/prek-0.4.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4db639db481d5f854eff9b3d2108889e613b8c15868bcf6bdd777c7cee577436", size = 5848846, upload-time = "2026-07-04T12:04:56.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/00/3477704635249f21f5f98ce444cd7690c2aa9dc8d146a045db88ef2cd8c5/prek-0.4.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c3890a6f92316d2cf44eb50584e8d2b23a596dd70487022e61186a71a2ac0900", size = 5713942, upload-time = "2026-07-04T12:04:58.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/e6/3ca4fabaebeadc976d9a92d1d9130674265355ea3b728418bad61583b097/prek-0.4.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:fc7e15c24c591a37c6ffce5b25a021b16c299ac2649f183d812b67d665cd6551", size = 5554725, upload-time = "2026-07-04T12:04:59.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/46/2ab6aaaeff0cedb8955b2e4032071c8712382bdd423bb849718c3720180d/prek-0.4.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:36fe721704ff0c7624c1167639e23a5fe658bfd38c314f487219c9afd1eeb733", size = 5838595, upload-time = "2026-07-04T12:05:01.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8b/91398f2b6cd1629d5d8ca8c85b08eca500814a374313b0193f4aaf6ab6c4/prek-0.4.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:162e544abc394a8124f3a4ad68efee116bad09440e679dbd1675177335c2a432", size = 6357222, upload-time = "2026-07-04T12:05:03.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/2a/ce5cbfaad36866134a21754640a05ecdba641fcd7ad15aa74cf3443f34f6/prek-0.4.8-py3-none-win32.whl", hash = "sha256:2602e46c8c5da7dfa69f60fcf88c2b57132ac623f49fb08bfb3094298c5f07e3", size = 5354388, upload-time = "2026-07-04T12:05:05.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/03/3bc908bc5f7e430315553e47dfa055f19923a3888f9afe4da19f244b5cbf/prek-0.4.8-py3-none-win_amd64.whl", hash = "sha256:7cb22da60bee41b89c4978c0bea7126a3c0ccc003dae6748cf29b53947815edc", size = 5748221, upload-time = "2026-07-04T12:05:07.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/a7/4295e6d5f5028171dfeb115ad38ab76bf3fe0c8df91b70d73c79aa760a94/prek-0.4.8-py3-none-win_arm64.whl", hash = "sha256:da70057f577b15d4bd121bf9dd29ee205fd4b4d75a0cafba062e84d7e8b4378b", size = 5574425, upload-time = "2026-07-04T12:05:09.595Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1165,27 +1158,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.15"
|
||||
version = "0.15.21"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1262,15 +1255,15 @@ asyncio = [
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.0.1"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
@@ -1329,27 +1322,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.44"
|
||||
version = "0.0.58"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/f4/fbb120226e4f239652525a664bad976a23fea58c646d1323f2296fee8a61/ty-0.0.44.tar.gz", hash = "sha256:5886229830ab77022842a1c55d2ef57405621a91fc465969fa6d538661898173", size = 5803665, upload-time = "2026-06-05T03:33:48.612Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/09/4c/26c90732658903aeb1d289208f7b7b492fa21029e0c4d6c51bdd6f8f5e51/ty-0.0.58.tar.gz", hash = "sha256:8f22484174e65c630660a454bf81b80cae7a3a7e70479f19c170d6cd87949258", size = 6133665, upload-time = "2026-07-10T03:09:30.542Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/c6/b5b8c4762efb4d85401652658786506867553ecfc2beac3bcf361a15937f/ty-0.0.44-py3-none-linux_armv6l.whl", hash = "sha256:272d31e7ad49b1dc5e8465a9fe700354e14c755b40d9c75f08f031d786903df3", size = 11607267, upload-time = "2026-06-05T03:33:27.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/5c/f4b405570737f44ab0fc4214117fe43353f8f0825a1823d9e99e9c8e57be/ty-0.0.44-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b92c4ddd7a3daf2049715edec9dc70cf6fd31a5a318ee647258f90dd75495eed", size = 11382826, upload-time = "2026-06-05T03:33:54.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/aa/fb9835aa492b148d7754cb4c3db07f31a7e2e09f0d8e0e8e297f01125dd2/ty-0.0.44-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4d42cfd84a690f6654b2a4f0515027c21b692cf2512d32e6433f754893a95609", size = 10809741, upload-time = "2026-06-05T03:33:33.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/f5/0b20ba6b66837a5a37bab7f74ac0732c66e766b5f0b2d55b30816b15f348/ty-0.0.44-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc47ae87e4cb7db2a9166bb23b78a905c3626e523296ec5bccf36b5e89bda6b", size = 11318153, upload-time = "2026-06-05T03:34:09.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/bb/b82ea730774a4f950f06d355fbc120d51eac7da23b57fc79ef6ff7c79cbb/ty-0.0.44-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46d867e80f16f421ac72c9a85240dbf050d62d9b3fbd10a8b5b082fb21679e0b", size = 11403108, upload-time = "2026-06-05T03:33:57.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/41/e2c83856165291049c702eda4e2ef3d3ebd875e8a0a77b8cc4ef3156aa1c/ty-0.0.44-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:411f5de0f96a4e4e5cccc3e0d55954c41f6a99ee6ca1fe5a7226cbc68406e053", size = 11944815, upload-time = "2026-06-05T03:34:15.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/95/1fa6a101eb9d5bec042b87e5ca9c8fc349b75961beca6306f95af5cd5539/ty-0.0.44-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b15f01ecb4e2b46c05a1769293f9d32c3d4a1e4e7dfccf37c604d705dc3e3f4", size = 12476121, upload-time = "2026-06-05T03:33:51.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/6a/da4b45b1229d39207c6140681c2aaf4f5691bcb1dc830b84450ca25c8f57/ty-0.0.44-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:edd32b7467af509c99c0244c2226a4e4c03400699003ec33373282ab931654d9", size = 12091340, upload-time = "2026-06-05T03:33:36.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/c7/e1c9260ea5188195962ff1214ace418b5d69187e8fa7c0a1ec4994b8071b/ty-0.0.44-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:503a585f4007387c3afc58bae23a7ca1b9f236cbdb1a881dc36110655ceb1937", size = 11986201, upload-time = "2026-06-05T03:34:00.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/f9/312bb112da9b1a7da295bb0426be85e72ad48da4e4266c36d77256b4058d/ty-0.0.44-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d28bcfa83243d77c2316944e8cf197f73597bf17d1ddc047d0b10a762531252", size = 12168475, upload-time = "2026-06-05T03:33:30.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/de/64978d603f6c3e5dd7cb97eca2214567d8ad0c85fa4a7435b7852ae4b779/ty-0.0.44-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:56fd2dd0192def189715b25f5338f6222fb827884dc34111e50aa1c4e061cee5", size = 11292937, upload-time = "2026-06-05T03:34:06.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/63/a625d8a3c71dcaa01988d330f849c465fe72ead4b0bbab44fe4bd6e672b5/ty-0.0.44-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7f8d990489032de1984e73c159f3e760d754cf83a602b874827d943821f63595", size = 11421560, upload-time = "2026-06-05T03:33:23.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/96/61aeba0e629b0c91bd316ff94d00e38817ec493ae4316f39508988daa287/ty-0.0.44-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f61ffe72996a755432922fe90b28db593f572eb5cbf48e3ef4e67b282533d1b0", size = 11580282, upload-time = "2026-06-05T03:34:03.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/f7/256e1538ce21cab67b381201444c42454de69d310059c4929d92a0ee9c48/ty-0.0.44-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2b237a143bac4f30cec9257d45f01e72da97030a80a09a2b69cfef065f09c37f", size = 12085723, upload-time = "2026-06-05T03:33:45.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/76/ec3957c10872643a98db7a7895101ad89c5b7cba4bc6c4aebbbfc91756cc/ty-0.0.44-py3-none-win32.whl", hash = "sha256:6a24586c65419223ac5bab4822d49ab493a5d19ea2a897514284c232b9d6166a", size = 10892978, upload-time = "2026-06-05T03:34:12.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/7d/ba24050432196e7d7f03945e5c379951593c48e04e5c5d5275cfc4624791/ty-0.0.44-py3-none-win_amd64.whl", hash = "sha256:8cccb27e348c89a9733fbad1b2efadfbad79b107c7e52adb52dfd8a70156a38d", size = 11987058, upload-time = "2026-06-05T03:33:42.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/34/16ec3f1fec75292d9c56a8b5fef037ceaba85a5c30562206c1a245a00a67/ty-0.0.44-py3-none-win_arm64.whl", hash = "sha256:58049504e7a12bf1957f24a5384a332c94d5590127083a80db5e5a1bed34190b", size = 11329961, upload-time = "2026-06-05T03:33:39.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/e1/5d1aa2a75829459834689f080e4be7a9d8828ce14b939ebed69161a35811/ty-0.0.58-py3-none-linux_armv6l.whl", hash = "sha256:47412850b6fbef61c42f244f6a51aa2f2c9e91f08cfbafd2d1e3730d2419d317", size = 11706915, upload-time = "2026-07-10T03:08:51.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/e1/929eda9cc72a9afe39a03c76f946a503508d37343cc8ff2e64226afda105/ty-0.0.58-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:79deb7bb4e5b3a1eee6ab9abc724d6ce3559d4977982707f310a139ee11fc703", size = 11532079, upload-time = "2026-07-10T03:08:53.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/43/ebc58b3fc7d86a7abba2829f1674f7d4ae3a08f9794c1f31b707950c871f/ty-0.0.58-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a28af3187e661708a386d44a4fc32896a5f589fb07b734a11ab2f516e7572b7", size = 11092983, upload-time = "2026-07-10T03:08:56.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/6e/9547dbb8e51e47749cfb721a02b4fc862f9a932fa0f66a34a4d6dc429bb1/ty-0.0.58-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21a6977e34bc362fb378add46e59d5d56331c1c36727e6904767217ca8479718", size = 11490492, upload-time = "2026-07-10T03:08:58.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/76/9f83b51b5e7795d6c8d76b4bb1bb7cebf4c10d609e846c02ab138e404556/ty-0.0.58-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3ac23e6bf6105ceca46632debf1b10b98125aaf60202aeae02f6abfeea242b3d", size = 11503696, upload-time = "2026-07-10T03:09:00.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/9d/9fd48a0696c680f74e50f31cd54e524eeb58c97ea9bb1c3b8e04230ba215/ty-0.0.58-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb6df6a8c6a21894807a49851370ec7fc64aa910296c78ada31db0ef19359112", size = 12158653, upload-time = "2026-07-10T03:09:02.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/ea/b5de845d2d8edae04d901c7585af7f04a057e18b26311f7fa4ca62b2da30/ty-0.0.58-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9df5847eebc026cde088b44420a03f7c6c169a7db747176bdf0a656eb1144713", size = 12723019, upload-time = "2026-07-10T03:09:05.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/1c/54083b23eeff1e101f50b6df6a2c7f1e14b31abe0577c91bcae9e2c9395d/ty-0.0.58-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53a331a7f1f85872c810676a0f16096ef98c1b95c8c9a573fe7fde64d0a93e7c", size = 12275715, upload-time = "2026-07-10T03:09:07.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/88/16925434b06faa49d36aa7e7508a6821ec6feacb429ca2fd80a3d52716b4/ty-0.0.58-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb0b05cd479fdcedc2e6781d376ee1a33569f37ae7f58357004635f615c4374c", size = 12033075, upload-time = "2026-07-10T03:09:10.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/2b/b55708dd483982ae03d14da3667e3f0346cd384e11cca3dd3674a8b598c1/ty-0.0.58-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a0012786077e5becbb6add9fe51eda1d4d36d249afd3ae6cd141d554af15ae3", size = 12367729, upload-time = "2026-07-10T03:09:12.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/a3/99ad66652956408f7e9ac3db6b4a416199d773b6c073cf95b0eea126d340/ty-0.0.58-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4ede96d7f6d149156da254e784b062b817736b68d4c6d660555a3d02d96966fb", size = 11439798, upload-time = "2026-07-10T03:09:14.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/23/344ceed4fe02ed498711e1f4a47b6e311fb1e9c4fecc19d31894be7a3472/ty-0.0.58-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:47608e58f73901b989402e6f283249cda4c2314282ec368aa74ab61761c62bd5", size = 11512695, upload-time = "2026-07-10T03:09:16.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/cf/3801831812c468f3fd0b3043a80f557a9aa90e6c27375763d7c3121e03f2/ty-0.0.58-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9757de17cc17e4c6bc26e18d4e26dea52ffee53d41a10d9087c6465e6ab12e2b", size = 11812253, upload-time = "2026-07-10T03:09:19.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/80/bce4f245787b77d1ec9feec7d9161eade5e01a77dbc132e016b24df83b0d/ty-0.0.58-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f3776d54c1d935fcb8f814bd58efba402c86c555f93e1144c59d087e7aa8b906", size = 12123918, upload-time = "2026-07-10T03:09:21.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/00/bab3d6268e7e88c792bf7cdde81bd11b31aa587eaba75196502b729747c8/ty-0.0.58-py3-none-win32.whl", hash = "sha256:8f50ec0ac3b42baa4c75895dc367071dc86b00ab440d29fdc72c633286a94815", size = 11230897, upload-time = "2026-07-10T03:09:23.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/68/d9504c895864aaa84a840dce6ac7f8e681f6938be1882c8d4f60832dbe57/ty-0.0.58-py3-none-win_amd64.whl", hash = "sha256:de8847b3a65475ae4773bddd3126bfcf29f017e88967c4dcc9c75d743a4d3e5c", size = 12299376, upload-time = "2026-07-10T03:09:26.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/04/c9847cb680b5fe8e1f7d7b483edd5cedcc29394496e7b8ed40d96be796ba/ty-0.0.58-py3-none-win_arm64.whl", hash = "sha256:7334bb38789878f60677f2eb9c1de4bfdf4583e2443790989c77da9b10fe0989", size = 11710495, upload-time = "2026-07-10T03:09:28.478Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1426,7 +1419,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "zensical"
|
||||
version = "0.0.43"
|
||||
version = "0.0.50"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1438,18 +1431,18 @@ dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
{ name = "tomli" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d4/85/ec45162e7824a8f879d887ef0774ee65926bf7d1064e2eebccc7eaee3378/zensical-0.0.43.tar.gz", hash = "sha256:dc2d3804ff562795c1024130e0c3ce79736467930729dda314f096d0e35b98c8", size = 3932396, upload-time = "2026-05-19T09:44:07.418Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/79/f7959b13c766a831f1248779bb718555f70f40c5b8e68db7de1de9936662/zensical-0.0.50.tar.gz", hash = "sha256:7040e52ebe5e6a275e4edeb351bf2bc314d007f3fb5750f178a38d840723e69c", size = 3979246, upload-time = "2026-07-09T13:52:11.908Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c2/55e0709607ae41c266987c3b91a1a9702b37fbbef0d07eddfe5e25c2d823/zensical-0.0.43-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:17c335362b6bac3a50178181694a964f6d9f0c516fc532129ba5a0a5c4103fb6", size = 12706531, upload-time = "2026-05-19T09:43:32.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/64/ce8627bc5ea30556162b29b041fe97d6a6aef2a87b51f12def628e4fa608/zensical-0.0.43-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8fe97f185194215f6193af45a17d2b30ebd72c8113e3650f2d7d6767b9c2206", size = 12563012, upload-time = "2026-05-19T09:43:35.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/d1/533bc9454f0e06b3d9d8bd2e7ac405308c3d4dee6572acab98f0ed6d1c07/zensical-0.0.43-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c4c85978c765b3e7f347e8102dfe1373d4bbe4229d7008b6bdbf352f1fbcd7f", size = 12947599, upload-time = "2026-05-19T09:43:38.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/a0/94f47d6fb592997be7ab9526938c929f0199adf2637c3c2b2b9b2101b28e/zensical-0.0.43-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90d7c06ffd07b2bdf78bef041d541baba8a3ea51fd2dd84dbdbc5b0229076524", size = 12904911, upload-time = "2026-05-19T09:43:42.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/fb/1db3ad9a86ff772f74a8bc60ad5b447aa02a158e70f94adacf50bdd5c40f/zensical-0.0.43-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:60022f4a6b95e46ec0023f51052fcd491743b3ebd08c0066b22a5cf1e741fecd", size = 13269386, upload-time = "2026-05-19T09:43:45.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/ee/b24fd0f94885519d851c35615b086d069a1077b0198021a56755395a4633/zensical-0.0.43-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e278eb948a0b7545d50609d713c7c27e366dade4523ff73a311a5d5f136518a", size = 12999364, upload-time = "2026-05-19T09:43:48.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/78/401ccd7afd9d2690f81b5319b7f1eed05108154ce20e4207053914518c1c/zensical-0.0.43-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b85e5ab99fbda13823e67c43a4be6e5ebda6600602969c6575e143f20ac203fd", size = 13124392, upload-time = "2026-05-19T09:43:50.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/b3/9af6eba5826b0ef143fc8308bd1e219e221441e307a958e39f824ba9ab53/zensical-0.0.43-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:751385accc92cccfd4560dabed7c423870686ef6ede244a67e5c96286af25e8f", size = 13177538, upload-time = "2026-05-19T09:43:53.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/6b/cd090bd6659d32692487206469988ee84d41aa6de4cdf9e380f847da90e2/zensical-0.0.43-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:dd3ff5bfa6e65cf3d2550dc639c3da2a3bfa11087b83d57e06623c4c1607d583", size = 13327086, upload-time = "2026-05-19T09:43:56.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/5b/ac2555354b5a53cb9c2c942811905c47be0b9f5603d3c1328ee8564333eb/zensical-0.0.43-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85055a115b12f49c6ab194dcf04f966fc06b690ed6a8ddddd819929fc5f340e6", size = 13284645, upload-time = "2026-05-19T09:43:59.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/c6/1688ec6e5be15e3ab367d7804753291bfbdff3109b06e20c19ce30a7129c/zensical-0.0.43-cp310-abi3-win32.whl", hash = "sha256:8a75ddd4bb3cd3c4a8e71d2ebae44c5611fd636c1d355c6124dd96e2f9c52838", size = 12256740, upload-time = "2026-05-19T09:44:02.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/a8/d967e70eac810a7e9eb8c5150d6d02848a1f42260f42977c71debed3cb02/zensical-0.0.43-cp310-abi3-win_amd64.whl", hash = "sha256:03a9d1744a6394ad66c355d6f1de04cfd92efa525b0b94bf6dbf6971c5cd2c6b", size = 12496166, upload-time = "2026-05-19T09:44:04.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/d2/e126d56642a5fd437eeb5a067b7c0d1f766c08e22d6a708bb923986f1d44/zensical-0.0.50-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:26dda9dbacab84db2743726f83202798e1893fbfddecb6a845812d7a331043ad", size = 12817706, upload-time = "2026-07-09T13:51:29.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9b/e1a22fb4ea8fc3e8377d7b394ee06cf0349e7f9a64ecb4c9873dee79b102/zensical-0.0.50-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:376887def6470c57509b48519870f74e2b987e30509cf5c819fcf372771f9403", size = 12691061, upload-time = "2026-07-09T13:51:33.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/f5/16c087635680efb39e1d7b7aa3c2cca548305aa29ad51dee04f37b32b65f/zensical-0.0.50-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bbf3034a2cb1a9d2a72a5ee7047688717c93da0d8a90d25dd871db8d5f33a6d", size = 13129505, upload-time = "2026-07-09T13:51:36.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/50d02402e53aa01a5c5c116a3522c7bb18d6ae4da964d8b236173b6fa086/zensical-0.0.50-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26fe4167663d544ca385ef420a49d3b738c7606a79d7a62a6bedcbf74cd383e7", size = 13055300, upload-time = "2026-07-09T13:51:39.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/0f/45f4f3e56a6483ea9f88478b20d6b434718e7c46579239589202e5135ec1/zensical-0.0.50-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8813d1f8895b19520d4a982d6bfcfd06372d73d96e6a63f0d2cf8f3b6036d5a1", size = 13445447, upload-time = "2026-07-09T13:51:43.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/9b/1f5065c664afde6a63e0ada8c14eae75f9e6960df597a5ca28c07340f938/zensical-0.0.50-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a64f957f94f7d8e8847a7f1e8205509ba4b188c93c157c5e7be49bcb8556a127", size = 13098775, upload-time = "2026-07-09T13:51:46.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/65/1e42e18d8f9a0cb9fcc8b871852810c7fe196237b79aaa831e863325091e/zensical-0.0.50-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:781d333bcb42c6a6b92360e05eadc944e2674794e1a95537971de1bf64cb9f89", size = 13305922, upload-time = "2026-07-09T13:51:51.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/36/9cace194ba13aef3bdde8ca2d327f3a05cdf5c456e94f6d896852e2cbd38/zensical-0.0.50-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5d0f86eec76c23efb04566cd444bf025845ac8a02e75c8a6b4af13a39a2a9955", size = 13325488, upload-time = "2026-07-09T13:51:54.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/80/2891a9316e486794dae43151561b49f3313499cfb2c95c7886abad7bcf2d/zensical-0.0.50-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:392a7299a3c4d1ac7ff288bbed181d77a9fd23e6614180476e01630aaee0d67e", size = 13496866, upload-time = "2026-07-09T13:51:58.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/09/fbd3af58081a0d399fa913ef1f2fd6a007f2f06f7026c8d6a6096e34b996/zensical-0.0.50-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cac3448d8c8a76dfb43e55908b4ca9ad17596aee89a9287a184c1a0ba7dce2a2", size = 13437565, upload-time = "2026-07-09T13:52:01.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/79/709311ff202326260ff223fb6e9ddefa3100474c428e3c70a04036cefe0e/zensical-0.0.50-cp310-abi3-win32.whl", hash = "sha256:32b244f96a930f68e049b2e03d50790c120ef701b6277ddd854905a33040c75f", size = 12371966, upload-time = "2026-07-09T13:52:05.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/e1/4babb30544d7465c95a6a93abc0680b1c51752411a225c2b7f2cb44e80c7/zensical-0.0.50-cp310-abi3-win_amd64.whl", hash = "sha256:2f054769bf96ae46353de3eca2463ad4db6df1da9fde515c6d7fc54c3608e615", size = 12604832, upload-time = "2026-07-09T13:52:09.013Z" },
|
||||
]
|
||||
|
||||
+1
-2
@@ -121,7 +121,6 @@ Modules = [
|
||||
{Models = "module/models.md"},
|
||||
{Pytest = "module/pytest.md"},
|
||||
{Schemas = "module/schemas.md"},
|
||||
{Security = "module/security.md"},
|
||||
]
|
||||
|
||||
[[project.nav]]
|
||||
@@ -137,7 +136,6 @@ Reference = [
|
||||
{Models = "reference/models.md"},
|
||||
{Pytest = "reference/pytest.md"},
|
||||
{Schemas = "reference/schemas.md"},
|
||||
{Security = "reference/security.md"},
|
||||
]
|
||||
|
||||
[[project.nav]]
|
||||
@@ -147,6 +145,7 @@ Examples = [
|
||||
|
||||
[[project.nav]]
|
||||
Migration = [
|
||||
{"v5.0" = "migration/v5.md"},
|
||||
{"v4.0" = "migration/v4.md"},
|
||||
{"v3.0" = "migration/v3.md"},
|
||||
{"v2.0" = "migration/v2.md"},
|
||||
|
||||
Reference in New Issue
Block a user