mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-05 16:14:08 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efaa8828ea
|
||
|
|
5ef71af418
|
||
|
|
06d49d5dc4
|
||
|
|
de06839a16 | ||
|
|
ff367e4281 | ||
|
|
9cb4c1474f | ||
|
|
44ba5bdd4b | ||
|
|
fe2c0f3eff | ||
|
|
70e0b3b9d5 | ||
|
|
1e021005bc |
@@ -31,7 +31,6 @@ Install only the extras you need:
|
|||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]"
|
uv add "fastapi-toolsets[cli]"
|
||||||
uv add "fastapi-toolsets[metrics]"
|
uv add "fastapi-toolsets[metrics]"
|
||||||
uv add "fastapi-toolsets[security]"
|
|
||||||
uv add "fastapi-toolsets[pytest]"
|
uv add "fastapi-toolsets[pytest]"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,7 +56,6 @@ uv add "fastapi-toolsets[all]"
|
|||||||
|
|
||||||
### Optional
|
### Optional
|
||||||
|
|
||||||
- **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers
|
|
||||||
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
||||||
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
||||||
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ Install only the extras you need:
|
|||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]"
|
uv add "fastapi-toolsets[cli]"
|
||||||
uv add "fastapi-toolsets[metrics]"
|
uv add "fastapi-toolsets[metrics]"
|
||||||
uv add "fastapi-toolsets[security]"
|
|
||||||
uv add "fastapi-toolsets[pytest]"
|
uv add "fastapi-toolsets[pytest]"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,7 +56,6 @@ uv add "fastapi-toolsets[all]"
|
|||||||
|
|
||||||
### Optional
|
### Optional
|
||||||
|
|
||||||
- **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers
|
|
||||||
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
||||||
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
||||||
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ The function creates and manages its own **dedicated session** internally, yield
|
|||||||
user.balance += 100
|
user.balance += 100
|
||||||
|
|
||||||
# With a custom lock mode
|
# With a custom lock mode
|
||||||
async with lock_tables(session, [Order], mode=LockMode.EXCLUSIVE):
|
async with lock_tables(session=session, tables=[Order], mode=LockMode.EXCLUSIVE):
|
||||||
await process_order(session, order_id)
|
await process_order(session, order_id)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -35,6 +35,6 @@ The function creates and manages its own **dedicated session** internally, yield
|
|||||||
user.balance += 100
|
user.balance += 100
|
||||||
|
|
||||||
# With a custom lock mode
|
# With a custom lock mode
|
||||||
async with lock_tables(session_maker, [Order], mode=LockMode.EXCLUSIVE) as session:
|
async with lock_tables(session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE) as session:
|
||||||
await process_order(session, order_id)
|
await process_order(session, order_id)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,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 ...`.
|
||||||
+1
-1
@@ -24,7 +24,7 @@ Configure the CLI in your `pyproject.toml`:
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[tool.fastapi-toolsets]
|
[tool.fastapi-toolsets]
|
||||||
cli = "myapp.cli:cli" # Custom Typer app
|
custom_cli = "myapp.cli:cli" # Custom Typer app
|
||||||
fixtures = "myapp.fixtures:registry" # FixtureRegistry instance
|
fixtures = "myapp.fixtures:registry" # FixtureRegistry instance
|
||||||
db_context = "myapp.db:db_context" # Async context manager for sessions
|
db_context = "myapp.db:db_context" # Async context manager for sessions
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -31,6 +31,25 @@ 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 `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
|
## 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.
|
[`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.
|
||||||
|
|||||||
+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.
|
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
|
### Custom contexts
|
||||||
|
|
||||||
Plain strings and any `Enum` subclass are accepted wherever a `Context` enum is expected.
|
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():
|
def staging_data():
|
||||||
return [Config(key="feature_x", enabled=True)]
|
return [Config(key="feature_x", enabled=True)]
|
||||||
|
|
||||||
|
# loads staging_data plus any Context.BASE fixtures
|
||||||
await load_fixtures_by_context(session, fixtures, AppContext.STAGING)
|
await load_fixtures_by_context(session, fixtures, AppContext.STAGING)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -108,8 +116,8 @@ def users():
|
|||||||
def users():
|
def users():
|
||||||
return [User(id=2, username="tester")]
|
return [User(id=2, username="tester")]
|
||||||
|
|
||||||
# loads both admin and tester
|
# loads both admin and tester (Context.BASE is included automatically)
|
||||||
await load_fixtures_by_context(session, fixtures, Context.BASE, Context.TESTING)
|
await load_fixtures_by_context(session, fixtures, Context.TESTING)
|
||||||
```
|
```
|
||||||
|
|
||||||
Registering two variants with overlapping context sets raises `ValueError`.
|
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
|
## 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
|
```python
|
||||||
from fastapi_toolsets.fixtures import get_obj_by_attr
|
|
||||||
|
|
||||||
@fixtures.register(depends_on=["roles"])
|
@fixtures.register(depends_on=["roles"])
|
||||||
def users():
|
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)]
|
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
|
## Pytest integration
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -12,7 +12,6 @@ from fastapi_toolsets.fixtures import (
|
|||||||
FixtureRegistry,
|
FixtureRegistry,
|
||||||
load_fixtures,
|
load_fixtures,
|
||||||
load_fixtures_by_context,
|
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
|
||||||
|
|
||||||
## ::: fastapi_toolsets.fixtures.utils.load_fixtures_by_context
|
## ::: 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
-6
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "5.0.0b1"
|
version = "5.0.0b2"
|
||||||
description = "Production-ready utilities for FastAPI applications"
|
description = "Production-ready utilities for FastAPI applications"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -50,17 +50,13 @@ cli = [
|
|||||||
metrics = [
|
metrics = [
|
||||||
"prometheus_client>=0.20.0",
|
"prometheus_client>=0.20.0",
|
||||||
]
|
]
|
||||||
security = [
|
|
||||||
"async-lru>=1.0",
|
|
||||||
"httpx>=0.25.0",
|
|
||||||
]
|
|
||||||
pytest = [
|
pytest = [
|
||||||
"httpx>=0.25.0",
|
"httpx>=0.25.0",
|
||||||
"pytest-xdist>=3.0.0",
|
"pytest-xdist>=3.0.0",
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
]
|
]
|
||||||
all = [
|
all = [
|
||||||
"fastapi-toolsets[cli,metrics,pytest,security]",
|
"fastapi-toolsets[cli,metrics,pytest]",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -24,4 +24,4 @@ Example usage:
|
|||||||
return Response(data={"user": user.username}, message="Success")
|
return Response(data={"user": user.username}, message="Success")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "5.0.0b1"
|
__version__ = "5.0.0b2"
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import typer
|
|||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
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 ..config import get_db_context, get_fixtures_registry
|
||||||
from ..utils import async_command
|
from ..utils import async_command
|
||||||
|
|
||||||
@@ -16,13 +17,14 @@ fixture_cli = typer.Typer(
|
|||||||
no_args_is_help=True,
|
no_args_is_help=True,
|
||||||
)
|
)
|
||||||
console = Console()
|
console = Console()
|
||||||
|
logger = get_logger()
|
||||||
|
|
||||||
|
|
||||||
@fixture_cli.command("list")
|
@fixture_cli.command("list")
|
||||||
def list_fixtures(
|
def list_fixtures(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
context: Annotated[
|
context: Annotated[
|
||||||
Context | None,
|
str | None,
|
||||||
typer.Option(
|
typer.Option(
|
||||||
"--context",
|
"--context",
|
||||||
"-c",
|
"-c",
|
||||||
@@ -32,10 +34,10 @@ def list_fixtures(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""List all registered fixtures."""
|
"""List all registered fixtures."""
|
||||||
registry = get_fixtures_registry()
|
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:
|
if not fixtures:
|
||||||
print("No fixtures found.")
|
logger.info("No fixtures found.")
|
||||||
return
|
return
|
||||||
|
|
||||||
table = Table("Name", "Contexts", "Dependencies")
|
table = Table("Name", "Contexts", "Dependencies")
|
||||||
@@ -46,7 +48,7 @@ def list_fixtures(
|
|||||||
table.add_row(fixture.name, contexts, deps)
|
table.add_row(fixture.name, contexts, deps)
|
||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
print(f"\nTotal: {len(fixtures)} fixture(s)")
|
logger.info("Total: %d fixture(s)", len(fixtures))
|
||||||
|
|
||||||
|
|
||||||
@fixture_cli.command("load")
|
@fixture_cli.command("load")
|
||||||
@@ -54,7 +56,7 @@ def list_fixtures(
|
|||||||
async def load(
|
async def load(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
contexts: Annotated[
|
contexts: Annotated[
|
||||||
list[Context] | None,
|
list[str] | None,
|
||||||
typer.Argument(help="Contexts to load."),
|
typer.Argument(help="Contexts to load."),
|
||||||
] = None,
|
] = None,
|
||||||
strategy: Annotated[
|
strategy: Annotated[
|
||||||
@@ -69,26 +71,27 @@ async def load(
|
|||||||
] = False,
|
] = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Load fixtures into the database."""
|
"""Load fixtures into the database."""
|
||||||
|
from ...fixtures import load_fixtures_by_context
|
||||||
|
|
||||||
registry = get_fixtures_registry()
|
registry = get_fixtures_registry()
|
||||||
db_context = get_db_context()
|
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)
|
ordered = registry.resolve_context_dependencies(*context_list)
|
||||||
|
|
||||||
if not ordered:
|
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
|
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:
|
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
|
return
|
||||||
|
|
||||||
async with db_context() as session:
|
async with db_context() as session:
|
||||||
@@ -97,4 +100,4 @@ async def load(
|
|||||||
)
|
)
|
||||||
|
|
||||||
total = sum(len(items) for items in result.values())
|
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 importlib
|
||||||
import sys
|
import sys
|
||||||
from typing import TYPE_CHECKING, Any, Literal, overload
|
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
@@ -13,6 +13,8 @@ from .pyproject import find_pyproject, load_pyproject
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..fixtures import FixtureRegistry
|
from ..fixtures import FixtureRegistry
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
def _ensure_project_in_path():
|
def _ensure_project_in_path():
|
||||||
"""Add project root to sys.path if not installed in editable mode."""
|
"""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
|
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:
|
def get_fixtures_registry() -> FixtureRegistry:
|
||||||
"""Import and return the fixtures registry from config."""
|
"""Import and return the fixtures registry from config."""
|
||||||
from ..fixtures import FixtureRegistry
|
from ..fixtures import FixtureRegistry
|
||||||
|
|
||||||
import_path = get_config_value("fixtures", required=True)
|
return _import_typed("fixtures", FixtureRegistry, 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
|
|
||||||
|
|
||||||
|
|
||||||
def get_db_context() -> Any:
|
def get_db_context() -> Any:
|
||||||
@@ -111,15 +133,4 @@ def get_db_context() -> Any:
|
|||||||
|
|
||||||
def get_custom_cli() -> typer.Typer | None:
|
def get_custom_cli() -> typer.Typer | None:
|
||||||
"""Import and return the custom CLI Typer instance from config."""
|
"""Import and return the custom CLI Typer instance from config."""
|
||||||
import_path = get_config_value("custom_cli")
|
return _import_typed("custom_cli", typer.Typer, required=False)
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""CLI utility functions."""
|
"""CLI utility functions."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import functools
|
import functools
|
||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
from typing import Any, ParamSpec, TypeVar
|
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)
|
@functools.wraps(func)
|
||||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
return asyncio.run(func(*args, **kwargs))
|
return asyncio.run(func(*args, **kwargs))
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator
|
|||||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import PostgresDsn
|
||||||
from sqlalchemy import exc as sa_exc
|
from sqlalchemy import exc as sa_exc
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncEngine,
|
AsyncEngine,
|
||||||
@@ -84,18 +85,20 @@ class Database:
|
|||||||
untouched).
|
untouched).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: Database connection URL (e.g. ``"postgresql+asyncpg://..."``).
|
url: Database connection URL. Accepts a plain string or a Pydantic
|
||||||
|
:class:`~pydantic.PostgresDsn`.
|
||||||
engine: An existing :class:`AsyncEngine` to reuse instead of *url*.
|
engine: An existing :class:`AsyncEngine` to reuse instead of *url*.
|
||||||
session_class: Session class for the sessionmaker (e.g. ``EventSession``).
|
session_class: Session class for the sessionmaker (e.g. ``EventSession``).
|
||||||
expire_on_commit: Expire attributes after commit. Defaults to ``False``.
|
expire_on_commit: Expire attributes after commit. Defaults to ``False``.
|
||||||
autoflush: Autoflush the session before queries. Defaults to ``True``.
|
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
|
**engine_options: Extra keyword arguments forwarded to
|
||||||
:func:`create_async_engine` (URL mode only, e.g. ``pool_size``,
|
:func:`create_async_engine` (URL mode only).
|
||||||
``echo``, ``connect_args``).
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TypeError: If neither or both of *url* and *engine* are given, or if
|
TypeError: If neither or both of *url* and *engine* are given, or if
|
||||||
*engine_options* are passed together with *engine*.
|
*connect_args*/*engine_options* are passed together with *engine*.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
@@ -115,12 +118,13 @@ class Database:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
url: str | None = None,
|
url: str | PostgresDsn | None = None,
|
||||||
*,
|
*,
|
||||||
engine: AsyncEngine | None = None,
|
engine: AsyncEngine | None = None,
|
||||||
session_class: type[AsyncSession] = AsyncSession,
|
session_class: type[AsyncSession] = AsyncSession,
|
||||||
expire_on_commit: bool = False,
|
expire_on_commit: bool = False,
|
||||||
autoflush: bool = True,
|
autoflush: bool = True,
|
||||||
|
connect_args: dict[str, Any] | None = None,
|
||||||
**engine_options: Any,
|
**engine_options: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
if (url is None) == (engine is None):
|
if (url is None) == (engine is None):
|
||||||
@@ -128,10 +132,10 @@ class Database:
|
|||||||
"Database requires exactly one of 'url' or 'engine' "
|
"Database requires exactly one of 'url' or 'engine' "
|
||||||
"(got both or neither)."
|
"(got both or neither)."
|
||||||
)
|
)
|
||||||
if engine is not None and engine_options:
|
if engine is not None and (engine_options or connect_args is not None):
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
"engine_options are only valid in URL mode; configure the "
|
"connect_args/engine_options are only valid in URL mode; "
|
||||||
"engine you pass via 'engine=' yourself."
|
"configure the engine you pass via 'engine=' yourself."
|
||||||
)
|
)
|
||||||
|
|
||||||
if engine is not None:
|
if engine is not None:
|
||||||
@@ -140,7 +144,11 @@ class Database:
|
|||||||
else:
|
else:
|
||||||
assert url is not None # guaranteed by the XOR check above
|
assert url is not None # guaranteed by the XOR check above
|
||||||
self._owns_engine = True
|
self._owns_engine = True
|
||||||
self.engine = create_async_engine(url, **engine_options)
|
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._sessionmaker: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||||
self.engine,
|
self.engine,
|
||||||
class_=session_class,
|
class_=session_class,
|
||||||
|
|||||||
@@ -57,34 +57,50 @@ async def wait_for_row_change(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
instance = await session.get(model, pk_value)
|
bind = getattr(session, "bind", None)
|
||||||
if instance is None:
|
if bind is None:
|
||||||
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} not found")
|
raise TypeError(
|
||||||
|
"wait_for_row_change requires a session bound to an engine "
|
||||||
|
"(session.bind is None)"
|
||||||
|
)
|
||||||
|
watcher = AsyncSession(bind=bind)
|
||||||
|
try:
|
||||||
|
|
||||||
if columns is not None:
|
async def _reload() -> _M | None:
|
||||||
watch_cols = columns
|
await watcher.rollback()
|
||||||
else:
|
return await watcher.get(model, pk_value, populate_existing=True)
|
||||||
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)
|
|
||||||
|
|
||||||
|
instance = await _reload()
|
||||||
if instance is None:
|
if instance is None:
|
||||||
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} was deleted")
|
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} not found")
|
||||||
|
|
||||||
current = {col: getattr(instance, col) for col in watch_cols}
|
if columns is not None:
|
||||||
if current != initial:
|
watch_cols = columns
|
||||||
return instance
|
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,28 @@
|
|||||||
"""Fixture system for seeding databases with dependency resolution."""
|
"""Fixture system for seeding databases with dependency resolution."""
|
||||||
|
|
||||||
from .enum import LoadStrategy
|
from .enum import Context, LoadStrategy
|
||||||
from .registry import Context, FixtureRegistry
|
|
||||||
from .utils import (
|
|
||||||
get_field_by_attr,
|
|
||||||
get_obj_by_attr,
|
|
||||||
load_fixtures,
|
|
||||||
load_fixtures_by_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Context",
|
"Context",
|
||||||
"FixtureRegistry",
|
"FixtureRegistry",
|
||||||
"LoadStrategy",
|
"LoadStrategy",
|
||||||
"get_field_by_attr",
|
|
||||||
"get_obj_by_attr",
|
|
||||||
"load_fixtures",
|
"load_fixtures",
|
||||||
"load_fixtures_by_context",
|
"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 sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from ..logger import get_logger
|
|
||||||
from .enum import Context
|
from .enum import Context
|
||||||
|
|
||||||
logger = get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_contexts(
|
def _normalize_contexts(
|
||||||
contexts: list[str | Enum] | tuple[str | Enum, ...],
|
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]
|
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
|
@dataclass
|
||||||
class Fixture:
|
class Fixture:
|
||||||
"""A fixture definition with metadata."""
|
"""A fixture definition with metadata."""
|
||||||
@@ -70,8 +72,6 @@ class FixtureRegistry:
|
|||||||
@fixtures.register(contexts=[Context.TESTING])
|
@fixtures.register(contexts=[Context.TESTING])
|
||||||
def users():
|
def users():
|
||||||
return [User(id=2, username="tester")]
|
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
|
ValueError: If the fixture has multiple context variants — use
|
||||||
:meth:`get_variants` in that case.
|
:meth:`get_variants` in that case.
|
||||||
"""
|
"""
|
||||||
if name not in self._fixtures:
|
variants = self.get_variants(name)
|
||||||
raise KeyError(f"Fixture '{name}' not found")
|
|
||||||
variants = self._fixtures[name]
|
|
||||||
if len(variants) > 1:
|
if len(variants) > 1:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Fixture '{name}' has {len(variants)} context variants. "
|
f"Fixture '{name}' has {len(variants)} context variants. "
|
||||||
@@ -205,8 +203,9 @@ class FixtureRegistry:
|
|||||||
Args:
|
Args:
|
||||||
name: Fixture name.
|
name: Fixture name.
|
||||||
*contexts: If given, only return variants whose context set
|
*contexts: If given, only return variants whose context set
|
||||||
intersects with these values. Both :class:`Context` enum
|
intersects with these values (:class:`Context.BASE` variants
|
||||||
values and plain strings are accepted.
|
are always included). Both :class:`Context` enum values and
|
||||||
|
plain strings are accepted.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of matching :class:`Fixture` objects (may be empty when a
|
List of matching :class:`Fixture` objects (may be empty when a
|
||||||
@@ -220,16 +219,89 @@ class FixtureRegistry:
|
|||||||
variants = self._fixtures[name]
|
variants = self._fixtures[name]
|
||||||
if not contexts:
|
if not contexts:
|
||||||
return list(variants)
|
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]
|
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]:
|
def get_all(self) -> list[Fixture]:
|
||||||
"""Get all registered fixtures (all variants of all names)."""
|
"""Get all registered fixtures (all variants of all names)."""
|
||||||
return [f for variants in self._fixtures.values() for f in variants]
|
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]:
|
def get_by_context(self, *contexts: str | Enum) -> list[Fixture]:
|
||||||
"""Get fixtures for specific contexts."""
|
"""Get fixtures for specific contexts."""
|
||||||
context_values = set(_normalize_contexts(contexts))
|
context_values = _context_filter_values(contexts)
|
||||||
return [
|
return [
|
||||||
f
|
f
|
||||||
for variants in self._fixtures.values()
|
for variants in self._fixtures.values()
|
||||||
@@ -254,7 +326,6 @@ class FixtureRegistry:
|
|||||||
ValueError: If circular dependency detected
|
ValueError: If circular dependency detected
|
||||||
"""
|
"""
|
||||||
resolved: list[str] = []
|
resolved: list[str] = []
|
||||||
seen: set[str] = set()
|
|
||||||
visiting: set[str] = set()
|
visiting: set[str] = set()
|
||||||
|
|
||||||
def visit(name: str) -> None:
|
def visit(name: str) -> None:
|
||||||
@@ -264,25 +335,11 @@ class FixtureRegistry:
|
|||||||
raise ValueError(f"Circular dependency detected: {name}")
|
raise ValueError(f"Circular dependency detected: {name}")
|
||||||
|
|
||||||
visiting.add(name)
|
visiting.add(name)
|
||||||
variants = self._fixtures.get(name)
|
for dep in self.get_dependencies(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:
|
|
||||||
visit(dep)
|
visit(dep)
|
||||||
|
|
||||||
visiting.remove(name)
|
visiting.remove(name)
|
||||||
resolved.append(name)
|
resolved.append(name)
|
||||||
seen.add(name)
|
|
||||||
|
|
||||||
for name in names:
|
for name in names:
|
||||||
visit(name)
|
visit(name)
|
||||||
@@ -303,9 +360,4 @@ class FixtureRegistry:
|
|||||||
# appear multiple times if it has variants in different contexts).
|
# appear multiple times if it has variants in different contexts).
|
||||||
names = list(dict.fromkeys(f.name for f in context_fixtures))
|
names = list(dict.fromkeys(f.name for f in context_fixtures))
|
||||||
|
|
||||||
all_deps: set[str] = set()
|
return self.resolve_dependencies(*names)
|
||||||
for name in names:
|
|
||||||
deps = self.resolve_dependencies(name)
|
|
||||||
all_deps.update(deps)
|
|
||||||
|
|
||||||
return self.resolve_dependencies(*all_deps)
|
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
"""Fixture loading utilities for database seeding."""
|
"""Fixture loading utilities for database seeding."""
|
||||||
|
|
||||||
from collections.abc import Callable, Sequence
|
from collections.abc import Iterator
|
||||||
from enum import Enum
|
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 import inspect as sa_inspect
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase, selectinload
|
||||||
|
from sqlalchemy.orm.interfaces import ExecutableOption, ORMOption
|
||||||
|
|
||||||
from ..db import transaction
|
from ..db import transaction
|
||||||
from ..logger import get_logger
|
from ..logger import get_logger
|
||||||
from ..types import ModelType
|
|
||||||
from .enum import LoadStrategy
|
from .enum import LoadStrategy
|
||||||
from .registry import FixtureRegistry, _normalize_contexts
|
from .registry import FixtureRegistry, _normalize_contexts
|
||||||
|
|
||||||
@@ -93,17 +94,42 @@ def _group_by_column_set(
|
|||||||
return list(groups.values())
|
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(
|
async def _batch_insert(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
model_cls: type[DeclarativeBase],
|
model_cls: type[DeclarativeBase],
|
||||||
instances: list[DeclarativeBase],
|
instances: list[DeclarativeBase],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""INSERT all instances — raises on conflict (no duplicate handling)."""
|
"""INSERT all instances, raises on conflict."""
|
||||||
for cls in _get_table_chain(model_cls):
|
for cls, group_dicts, group_instances in _grouped_table_dicts(model_cls, instances):
|
||||||
dicts = [_instance_to_dict_for_cls(i, cls) for i in instances]
|
table = cast(Table, cls.__table__)
|
||||||
for group_dicts, _ in _group_by_column_set(dicts, instances):
|
missing_pk_cols = [
|
||||||
if group_dicts and group_dicts[0]: # pragma: no branch
|
col for col in table.primary_key.columns if col.key not in group_dicts[0]
|
||||||
await session.execute(pg_insert(cls).values(group_dicts))
|
]
|
||||||
|
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(
|
async def _batch_merge(
|
||||||
@@ -112,30 +138,26 @@ async def _batch_merge(
|
|||||||
instances: list[DeclarativeBase],
|
instances: list[DeclarativeBase],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""UPSERT: insert new rows, update existing ones with the provided values."""
|
"""UPSERT: insert new rows, update existing ones with the provided values."""
|
||||||
for cls in _get_table_chain(model_cls):
|
for cls, group_dicts, _ in _grouped_table_dicts(model_cls, instances):
|
||||||
pk_names = [col.name for col in cls.__table__.primary_key]
|
pk_names = [col.name for col in cls.__table__.primary_key]
|
||||||
pk_names_set = set(pk_names)
|
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 cls.__table__.columns}
|
||||||
non_pk_cols = [k for k in own_col_keys if k not in pk_names_set]
|
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]
|
stmt = pg_insert(cls).values(group_dicts)
|
||||||
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)
|
|
||||||
|
|
||||||
inserted_keys = set(group_dicts[0])
|
inserted_keys = set(group_dicts[0])
|
||||||
update_cols = [col for col in non_pk_cols if col in inserted_keys]
|
update_cols = [col for col in non_pk_cols if col in inserted_keys]
|
||||||
|
|
||||||
if update_cols:
|
if update_cols:
|
||||||
stmt = stmt.on_conflict_do_update(
|
stmt = stmt.on_conflict_do_update(
|
||||||
index_elements=pk_names,
|
index_elements=pk_names,
|
||||||
set_={col: stmt.excluded[col] for col in update_cols},
|
set_={col: stmt.excluded[col] for col in update_cols},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
stmt = stmt.on_conflict_do_nothing(index_elements=pk_names)
|
stmt = stmt.on_conflict_do_nothing(index_elements=pk_names)
|
||||||
|
|
||||||
await session.execute(stmt)
|
await session.execute(stmt)
|
||||||
|
|
||||||
|
|
||||||
async def _batch_skip_existing(
|
async def _batch_skip_existing(
|
||||||
@@ -169,8 +191,14 @@ async def _batch_skip_existing(
|
|||||||
loaded = list(no_pk)
|
loaded = list(no_pk)
|
||||||
if no_pk:
|
if no_pk:
|
||||||
no_pk_dicts = [_instance_to_dict(i) for i in 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):
|
for group_dicts, group_instances in _group_by_column_set(no_pk_dicts, no_pk):
|
||||||
await session.execute(pg_insert(model_cls).values(group_dicts))
|
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, col.key, val)
|
||||||
|
|
||||||
if with_pk_pairs:
|
if with_pk_pairs:
|
||||||
with_pk = [i for i, _ in with_pk_pairs]
|
with_pk = [i for i, _ in with_pk_pairs]
|
||||||
@@ -196,6 +224,64 @@ async def _batch_skip_existing(
|
|||||||
return loaded
|
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_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
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
async def _load_ordered(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
registry: FixtureRegistry,
|
registry: FixtureRegistry,
|
||||||
@@ -208,14 +294,11 @@ async def _load_ordered(
|
|||||||
|
|
||||||
for name in ordered_names:
|
for name in ordered_names:
|
||||||
variants = (
|
variants = (
|
||||||
registry.get_variants(name, *contexts)
|
registry.get_load_variants(name, *contexts)
|
||||||
if contexts is not None
|
if contexts is not None
|
||||||
else registry.get_variants(name)
|
else registry.get_variants(name)
|
||||||
)
|
)
|
||||||
|
|
||||||
if contexts is not None and not variants:
|
|
||||||
variants = registry.get_variants(name)
|
|
||||||
|
|
||||||
if not variants: # pragma: no cover
|
if not variants: # pragma: no cover
|
||||||
results[name] = []
|
results[name] = []
|
||||||
continue
|
continue
|
||||||
@@ -244,8 +327,10 @@ async def _load_ordered(
|
|||||||
case _: # pragma: no cover
|
case _: # pragma: no cover
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
loaded = await _refresh_loaded(session, loaded)
|
||||||
|
|
||||||
results[name] = 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
|
return results
|
||||||
|
|
||||||
@@ -264,56 +349,6 @@ def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
|||||||
return 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(
|
async def load_fixtures(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
registry: FixtureRegistry,
|
registry: FixtureRegistry,
|
||||||
@@ -348,8 +383,8 @@ async def load_fixtures_by_context(
|
|||||||
Args:
|
Args:
|
||||||
session: Database session
|
session: Database session
|
||||||
registry: Fixture registry
|
registry: Fixture registry
|
||||||
*contexts: Contexts to load (e.g., ``Context.BASE``, ``Context.TESTING``,
|
*contexts: Contexts to load (e.g., ``Context.TESTING``, or plain
|
||||||
or plain strings for custom contexts)
|
strings for custom contexts)
|
||||||
strategy: How to handle existing records
|
strategy: How to handle existing records
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
@@ -204,6 +204,11 @@ async def _invoke_callback(
|
|||||||
await result
|
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):
|
class EventSession(AsyncSession):
|
||||||
"""AsyncSession subclass that dispatches lifecycle callbacks after commit."""
|
"""AsyncSession subclass that dispatches lifecycle callbacks after commit."""
|
||||||
|
|
||||||
@@ -253,7 +258,7 @@ class EventSession(AsyncSession):
|
|||||||
state is None or state.detached or state.transient
|
state is None or state.detached or state.transient
|
||||||
): # pragma: no cover
|
): # pragma: no cover
|
||||||
continue
|
continue
|
||||||
await self.refresh(obj)
|
await _reload_if_present(self, obj, state)
|
||||||
for handler in _get_handlers(type(obj), ModelEvent.CREATE):
|
for handler in _get_handlers(type(obj), ModelEvent.CREATE):
|
||||||
await _invoke_callback(handler, obj, ModelEvent.CREATE, None)
|
await _invoke_callback(handler, obj, ModelEvent.CREATE, None)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -277,7 +282,7 @@ class EventSession(AsyncSession):
|
|||||||
state is None or state.detached or state.transient
|
state is None or state.detached or state.transient
|
||||||
): # pragma: no cover
|
): # pragma: no cover
|
||||||
continue
|
continue
|
||||||
await self.refresh(obj)
|
await _reload_if_present(self, obj, state)
|
||||||
for handler in _get_handlers(type(obj), ModelEvent.UPDATE):
|
for handler in _get_handlers(type(obj), ModelEvent.UPDATE):
|
||||||
await _invoke_callback(handler, obj, ModelEvent.UPDATE, changes)
|
await _invoke_callback(handler, obj, ModelEvent.UPDATE, changes)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
"""Pytest plugin for using FixtureRegistry fixtures in tests."""
|
"""Pytest plugin for using FixtureRegistry fixtures in tests."""
|
||||||
|
|
||||||
from collections.abc import Callable, Sequence
|
from collections.abc import Sequence
|
||||||
from typing import Any, cast
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase, selectinload
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from sqlalchemy.orm.interfaces import ExecutableOption, ORMOption
|
|
||||||
|
|
||||||
from ..db import transaction
|
|
||||||
from ..fixtures import FixtureRegistry, LoadStrategy
|
from ..fixtures import FixtureRegistry, LoadStrategy
|
||||||
|
from ..fixtures.utils import _get_primary_key, _load_ordered, _refresh_loaded
|
||||||
|
|
||||||
|
|
||||||
def register_fixtures(
|
def register_fixtures(
|
||||||
@@ -57,7 +55,7 @@ def register_fixtures(
|
|||||||
|
|
||||||
# Build list of pytest fixture dependencies
|
# Build list of pytest fixture dependencies
|
||||||
pytest_deps = [session_fixture]
|
pytest_deps = [session_fixture]
|
||||||
for dep in fixture.depends_on:
|
for dep in registry.get_dependencies(fixture.name):
|
||||||
pytest_deps.append(f"{prefix}{dep}")
|
pytest_deps.append(f"{prefix}{dep}")
|
||||||
|
|
||||||
# Create the fixture function
|
# Create the fixture function
|
||||||
@@ -83,56 +81,38 @@ def _create_fixture_function(
|
|||||||
fixture_name: str,
|
fixture_name: str,
|
||||||
dependencies: list[str],
|
dependencies: list[str],
|
||||||
strategy: LoadStrategy,
|
strategy: LoadStrategy,
|
||||||
) -> Callable[..., Any]:
|
) -> Any:
|
||||||
"""Create a fixture function with the correct signature.
|
"""Create a fixture function with the correct signature.
|
||||||
|
|
||||||
The function signature must include all dependencies as parameters
|
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)
|
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]:
|
async def fixture_func(**kwargs: Any) -> Sequence[DeclarativeBase]:
|
||||||
# Get session from kwargs (first dependency)
|
|
||||||
session: AsyncSession = kwargs[dependencies[0]]
|
session: AsyncSession = kwargs[dependencies[0]]
|
||||||
|
result = (await _load_ordered(session, registry, [fixture_name], strategy))[
|
||||||
|
fixture_name
|
||||||
|
]
|
||||||
|
|
||||||
# Load the fixture data
|
if strategy is LoadStrategy.SKIP_EXISTING:
|
||||||
instances = list(fixture_def.func())
|
# _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 result
|
||||||
return []
|
|
||||||
|
|
||||||
loaded: list[DeclarativeBase] = []
|
|
||||||
|
|
||||||
async with 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
|
|
||||||
|
|
||||||
# Update function signature to include dependencies
|
# Update function signature to include dependencies
|
||||||
# This is needed for pytest to inject the right fixtures
|
# 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."
|
created_func.__doc__ = f"Load {fixture_name} fixture data."
|
||||||
|
|
||||||
return created_func
|
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
|
|
||||||
|
|||||||
@@ -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'
|
'@registry.register(depends_on=["roles"], contexts=[Context.TESTING])\n'
|
||||||
"def users():\n"
|
"def users():\n"
|
||||||
' return [{"id": 1, "name": "alice", "role_id": 1}]\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
|
# Create db module
|
||||||
@@ -316,7 +320,7 @@ class TestFixturesCli:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "roles" in result.output
|
assert "roles" in result.output
|
||||||
assert "users" 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):
|
def test_fixtures_list_with_context(self, cli_env):
|
||||||
"""fixtures list --context filters by context."""
|
"""fixtures list --context filters by context."""
|
||||||
@@ -338,6 +342,27 @@ class TestFixturesCli:
|
|||||||
assert "roles" in result.output
|
assert "roles" in result.output
|
||||||
assert "[Dry run - no changes made]" 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):
|
def test_fixtures_load_invalid_strategy(self, cli_env):
|
||||||
"""fixtures load with invalid strategy shows error."""
|
"""fixtures load with invalid strategy shows error."""
|
||||||
tmp_path, cli = cli_env
|
tmp_path, cli = cli_env
|
||||||
|
|||||||
+133
-1
@@ -3,12 +3,13 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import Depends, FastAPI
|
from fastapi import Depends, FastAPI
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from pydantic import PostgresDsn
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column,
|
Column,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
@@ -94,6 +95,33 @@ class TestDatabaseConstruction:
|
|||||||
with pytest.raises(TypeError):
|
with pytest.raises(TypeError):
|
||||||
Database(engine=engine, pool_size=5)
|
Database(engine=engine, pool_size=5)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_connect_args_with_engine_raises(self, engine):
|
||||||
|
"""connect_args are rejected in engine= mode."""
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
Database(engine=engine, connect_args={"server_settings": {}})
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_accepts_postgres_dsn(self):
|
||||||
|
"""A Pydantic PostgresDsn is coerced to a string URL."""
|
||||||
|
dsn = PostgresDsn(DATABASE_URL)
|
||||||
|
db = Database(dsn)
|
||||||
|
try:
|
||||||
|
assert db._owns_engine is True
|
||||||
|
assert str(db.engine.url) == str(create_async_engine(DATABASE_URL).url)
|
||||||
|
finally:
|
||||||
|
await db.engine.dispose()
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_connect_args_forwarded_to_engine(self):
|
||||||
|
"""connect_args are forwarded to create_async_engine in URL mode."""
|
||||||
|
connect_args = {"server_settings": {"application_name": "ft_test"}}
|
||||||
|
with patch("fastapi_toolsets.db.core.create_async_engine") as mocked:
|
||||||
|
mocked.return_value = MagicMock()
|
||||||
|
Database(DATABASE_URL, connect_args=connect_args)
|
||||||
|
_, kwargs = mocked.call_args
|
||||||
|
assert kwargs["connect_args"] == connect_args
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_url_mode_owns_engine(self):
|
async def test_url_mode_owns_engine(self):
|
||||||
"""URL mode builds and owns the engine."""
|
"""URL mode builds and owns the engine."""
|
||||||
@@ -661,6 +689,13 @@ class TestWaitForRowChange:
|
|||||||
with pytest.raises(NotFoundError, match="not found"):
|
with pytest.raises(NotFoundError, match="not found"):
|
||||||
await wait_for_row_change(db_session, Role, fake_id, interval=0.05)
|
await wait_for_row_change(db_session, Role, fake_id, interval=0.05)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_unbound_session_raises_type_error(self):
|
||||||
|
"""Raises TypeError when the session has no bind to open a watcher on."""
|
||||||
|
unbound = AsyncSession()
|
||||||
|
with pytest.raises(TypeError, match="requires a session bound to an engine"):
|
||||||
|
await wait_for_row_change(unbound, Role, uuid.uuid4())
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_timeout_raises(self, db_session: AsyncSession):
|
async def test_timeout_raises(self, db_session: AsyncSession):
|
||||||
"""Raises TimeoutError when no change is detected within timeout."""
|
"""Raises TimeoutError when no change is detected within timeout."""
|
||||||
@@ -673,6 +708,66 @@ class TestWaitForRowChange:
|
|||||||
db_session, Role, role.id, interval=0.05, timeout=0.2
|
db_session, Role, role.id, interval=0.05, timeout=0.2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_detects_update_under_repeatable_read(self, engine):
|
||||||
|
"""Detects external commits even when the watcher pins a snapshot."""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
rr_engine = engine.execution_options(isolation_level="REPEATABLE READ")
|
||||||
|
factory = async_sessionmaker(rr_engine, expire_on_commit=False)
|
||||||
|
try:
|
||||||
|
async with factory() as setup:
|
||||||
|
role = Role(name="rr_role")
|
||||||
|
setup.add(role)
|
||||||
|
await setup.commit()
|
||||||
|
role_id = role.id
|
||||||
|
|
||||||
|
async def update_later():
|
||||||
|
await asyncio.sleep(0.15)
|
||||||
|
async with factory() as other:
|
||||||
|
r = await other.get(Role, role_id)
|
||||||
|
assert r is not None
|
||||||
|
r.name = "rr_updated"
|
||||||
|
await other.commit()
|
||||||
|
|
||||||
|
watcher = factory()
|
||||||
|
try:
|
||||||
|
# Pin a snapshot before the update lands.
|
||||||
|
await watcher.get(Role, role_id)
|
||||||
|
update_task = asyncio.create_task(update_later())
|
||||||
|
result = await wait_for_row_change(
|
||||||
|
watcher, Role, role_id, interval=0.05, timeout=2.0
|
||||||
|
)
|
||||||
|
await update_task
|
||||||
|
assert result.name == "rr_updated"
|
||||||
|
finally:
|
||||||
|
await watcher.close()
|
||||||
|
finally:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_stale_then_deleted_instance_raises_not_found(
|
||||||
|
self, db_session: AsyncSession, engine
|
||||||
|
):
|
||||||
|
"""A stale expired instance in the identity map yields NotFoundError."""
|
||||||
|
role = Role(name="stale_role")
|
||||||
|
db_session.add(role)
|
||||||
|
await db_session.commit()
|
||||||
|
role_id = role.id
|
||||||
|
|
||||||
|
# db_session still holds `role`; delete it from another committed session.
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with factory() as other:
|
||||||
|
r = await other.get(Role, role_id)
|
||||||
|
await other.delete(r)
|
||||||
|
await other.commit()
|
||||||
|
|
||||||
|
with pytest.raises(NotFoundError):
|
||||||
|
await wait_for_row_change(
|
||||||
|
db_session, Role, role_id, interval=0.05, timeout=0.5
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_deleted_row_raises(self, db_session: AsyncSession, engine):
|
async def test_deleted_row_raises(self, db_session: AsyncSession, engine):
|
||||||
"""Raises NotFoundError when the row is deleted during polling."""
|
"""Raises NotFoundError when the row is deleted during polling."""
|
||||||
@@ -693,6 +788,43 @@ class TestWaitForRowChange:
|
|||||||
await wait_for_row_change(db_session, Role, role.id, interval=0.05)
|
await wait_for_row_change(db_session, Role, role.id, interval=0.05)
|
||||||
await delete_task
|
await delete_task
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_does_not_disturb_ambient_transaction(
|
||||||
|
self, db_session: AsyncSession, engine
|
||||||
|
):
|
||||||
|
"""A read-only ambient transaction around the call survives untouched."""
|
||||||
|
role = Role(name="ambient_role")
|
||||||
|
db_session.add(role)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
async def update_later():
|
||||||
|
await asyncio.sleep(0.15)
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with factory() as other:
|
||||||
|
r = await other.get(Role, role.id)
|
||||||
|
assert r is not None
|
||||||
|
r.name = "ambient_updated"
|
||||||
|
await other.commit()
|
||||||
|
|
||||||
|
update_task = asyncio.create_task(update_later())
|
||||||
|
async with transaction(db_session):
|
||||||
|
# A read before the watch, establishing an ambient transaction
|
||||||
|
# that must remain usable once wait_for_row_change returns.
|
||||||
|
await db_session.get(Role, role.id)
|
||||||
|
result = await wait_for_row_change(
|
||||||
|
db_session, Role, role.id, interval=0.05, timeout=2.0
|
||||||
|
)
|
||||||
|
await update_task
|
||||||
|
assert result.name == "ambient_updated"
|
||||||
|
# The ambient transaction must still be open and usable here.
|
||||||
|
assert db_session.in_transaction()
|
||||||
|
other_role = Role(name="added_within_ambient_tx")
|
||||||
|
db_session.add(other_role)
|
||||||
|
|
||||||
|
# transaction() committed cleanly on exit; the write above landed.
|
||||||
|
check = await db_session.get(Role, other_role.id)
|
||||||
|
assert check is not None
|
||||||
|
|
||||||
|
|
||||||
class TestCreateDatabase:
|
class TestCreateDatabase:
|
||||||
"""Tests for create_database."""
|
"""Tests for create_database."""
|
||||||
|
|||||||
+103
-22
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -10,8 +11,6 @@ from fastapi_toolsets.fixtures import (
|
|||||||
Context,
|
Context,
|
||||||
FixtureRegistry,
|
FixtureRegistry,
|
||||||
LoadStrategy,
|
LoadStrategy,
|
||||||
get_field_by_attr,
|
|
||||||
get_obj_by_attr,
|
|
||||||
load_fixtures,
|
load_fixtures,
|
||||||
load_fixtures_by_context,
|
load_fixtures_by_context,
|
||||||
)
|
)
|
||||||
@@ -267,7 +266,34 @@ class TestFixtureRegistry:
|
|||||||
|
|
||||||
testing_fixtures = registry.get_by_context(Context.TESTING)
|
testing_fixtures = registry.get_by_context(Context.TESTING)
|
||||||
names = {f.name for f in testing_fixtures}
|
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:
|
class TestIncludeRegistry:
|
||||||
@@ -812,6 +838,45 @@ class TestLoadFixtures:
|
|||||||
db_session, registry, "int_roles", strategy=LoadStrategy.SKIP_EXISTING
|
db_session, registry, "int_roles", strategy=LoadStrategy.SKIP_EXISTING
|
||||||
)
|
)
|
||||||
assert len(result["int_roles"]) == 1
|
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:
|
class TestLoadFixturesByContext:
|
||||||
@@ -891,8 +956,8 @@ class TestLoadFixturesByContext:
|
|||||||
assert await UserCrud.count(db_session) == 1
|
assert await UserCrud.count(db_session) == 1
|
||||||
|
|
||||||
|
|
||||||
class TestGetObjByAttr:
|
class TestRegistryObj:
|
||||||
"""Tests for get_obj_by_attr helper function."""
|
"""Tests for FixtureRegistry.obj."""
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
"""Set up test fixtures for each test."""
|
"""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):
|
def test_get_by_id(self):
|
||||||
"""Get an object by its id attribute."""
|
"""Get an object by its id attribute."""
|
||||||
role = get_obj_by_attr(self.roles, "id", self.role_id_1)
|
role = self.registry.obj("roles", "id", self.role_id_1)
|
||||||
assert role.name == "admin"
|
assert cast(Role, role).name == "admin"
|
||||||
|
|
||||||
def test_get_user_by_username(self):
|
def test_get_user_by_username(self):
|
||||||
"""Get a user by username."""
|
"""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.id == self.user_id_2
|
||||||
assert user.email == "bob@example.com"
|
assert user.email == "bob@example.com"
|
||||||
|
|
||||||
def test_returns_first_match(self):
|
def test_returns_first_match(self):
|
||||||
"""Returns the first matching object when multiple could match."""
|
"""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"
|
assert user.username == "alice"
|
||||||
|
|
||||||
def test_no_match_raises_stop_iteration(self):
|
def test_no_match_raises_stop_iteration(self):
|
||||||
@@ -959,16 +1021,37 @@ class TestGetObjByAttr:
|
|||||||
StopIteration,
|
StopIteration,
|
||||||
match="No object with name=nonexistent found in fixture 'roles'",
|
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):
|
def test_no_match_on_wrong_value_type(self):
|
||||||
"""Raises StopIteration when value type doesn't match."""
|
"""Raises StopIteration when value type doesn't match."""
|
||||||
with pytest.raises(StopIteration):
|
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:
|
class TestRegistryField:
|
||||||
"""Tests for get_field_by_attr helper function."""
|
"""Tests for FixtureRegistry.field."""
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
self.registry = FixtureRegistry()
|
self.registry = FixtureRegistry()
|
||||||
@@ -984,22 +1067,20 @@ class TestGetFieldByAttr:
|
|||||||
Role(id=role_id_2, name="user"),
|
Role(id=role_id_2, name="user"),
|
||||||
]
|
]
|
||||||
|
|
||||||
self.roles = roles
|
|
||||||
|
|
||||||
def test_returns_id_by_default(self):
|
def test_returns_id_by_default(self):
|
||||||
"""Returns the id field when no field is specified."""
|
"""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
|
assert result == self.role_id_1
|
||||||
|
|
||||||
def test_returns_specified_field(self):
|
def test_returns_specified_field(self):
|
||||||
"""Returns the requested field instead of id."""
|
"""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"
|
assert result == "user"
|
||||||
|
|
||||||
def test_no_match_raises_stop_iteration(self):
|
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"):
|
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:
|
class TestGetPrimaryKey:
|
||||||
|
|||||||
+52
-1
@@ -21,12 +21,12 @@ from fastapi_toolsets.models import (
|
|||||||
listens_for,
|
listens_for,
|
||||||
)
|
)
|
||||||
from fastapi_toolsets.models.watched import (
|
from fastapi_toolsets.models.watched import (
|
||||||
EventSession,
|
|
||||||
_EVENT_HANDLERS,
|
_EVENT_HANDLERS,
|
||||||
_SESSION_CREATES,
|
_SESSION_CREATES,
|
||||||
_SESSION_DELETES,
|
_SESSION_DELETES,
|
||||||
_SESSION_UPDATES,
|
_SESSION_UPDATES,
|
||||||
_WATCHED_MODELS,
|
_WATCHED_MODELS,
|
||||||
|
EventSession,
|
||||||
_after_flush,
|
_after_flush,
|
||||||
_after_rollback,
|
_after_rollback,
|
||||||
_get_watched_fields,
|
_get_watched_fields,
|
||||||
@@ -1001,6 +1001,57 @@ class TestEventCallbacks:
|
|||||||
|
|
||||||
assert _test_events == []
|
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:
|
class TestTransientObject:
|
||||||
"""Create + delete within the same transaction should fire no events."""
|
"""Create + delete within the same transaction should fire no events."""
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from fastapi_toolsets.pytest import (
|
|||||||
register_fixtures,
|
register_fixtures,
|
||||||
worker_database_url,
|
worker_database_url,
|
||||||
)
|
)
|
||||||
from fastapi_toolsets.pytest.plugin import (
|
from fastapi_toolsets.fixtures.utils import (
|
||||||
_get_primary_key,
|
_get_primary_key,
|
||||||
_relationship_load_options,
|
_relationship_load_options,
|
||||||
_reload_with_relationships,
|
_reload_with_relationships,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -330,7 +330,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "5.0.0b1"
|
version = "5.0.0b2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
@@ -341,7 +341,6 @@ dependencies = [
|
|||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
all = [
|
all = [
|
||||||
{ name = "async-lru" },
|
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "prometheus-client" },
|
{ name = "prometheus-client" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
@@ -359,10 +358,6 @@ pytest = [
|
|||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-xdist" },
|
{ name = "pytest-xdist" },
|
||||||
]
|
]
|
||||||
security = [
|
|
||||||
{ name = "async-lru" },
|
|
||||||
{ name = "httpx" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -402,12 +397,10 @@ tests = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "async-lru", marker = "extra == 'security'", specifier = ">=1.0" },
|
|
||||||
{ name = "asyncpg", specifier = ">=0.29.0" },
|
{ name = "asyncpg", specifier = ">=0.29.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.100.0" },
|
{ name = "fastapi", specifier = ">=0.100.0" },
|
||||||
{ name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest", "security"], marker = "extra == 'all'" },
|
{ name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest"], marker = "extra == 'all'" },
|
||||||
{ name = "httpx", marker = "extra == 'pytest'", specifier = ">=0.25.0" },
|
{ name = "httpx", marker = "extra == 'pytest'", specifier = ">=0.25.0" },
|
||||||
{ name = "httpx", marker = "extra == 'security'", specifier = ">=0.25.0" },
|
|
||||||
{ name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" },
|
{ name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.0" },
|
{ name = "pydantic", specifier = ">=2.0" },
|
||||||
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
|
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
|
||||||
@@ -415,7 +408,7 @@ requires-dist = [
|
|||||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
||||||
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" },
|
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["cli", "metrics", "security", "pytest", "all"]
|
provides-extras = ["cli", "metrics", "pytest", "all"]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
+1
-2
@@ -121,7 +121,6 @@ Modules = [
|
|||||||
{Models = "module/models.md"},
|
{Models = "module/models.md"},
|
||||||
{Pytest = "module/pytest.md"},
|
{Pytest = "module/pytest.md"},
|
||||||
{Schemas = "module/schemas.md"},
|
{Schemas = "module/schemas.md"},
|
||||||
{Security = "module/security.md"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[project.nav]]
|
[[project.nav]]
|
||||||
@@ -137,7 +136,6 @@ Reference = [
|
|||||||
{Models = "reference/models.md"},
|
{Models = "reference/models.md"},
|
||||||
{Pytest = "reference/pytest.md"},
|
{Pytest = "reference/pytest.md"},
|
||||||
{Schemas = "reference/schemas.md"},
|
{Schemas = "reference/schemas.md"},
|
||||||
{Security = "reference/security.md"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[project.nav]]
|
[[project.nav]]
|
||||||
@@ -147,6 +145,7 @@ Examples = [
|
|||||||
|
|
||||||
[[project.nav]]
|
[[project.nav]]
|
||||||
Migration = [
|
Migration = [
|
||||||
|
{"v5.0" = "migration/v5.md"},
|
||||||
{"v4.0" = "migration/v4.md"},
|
{"v4.0" = "migration/v4.md"},
|
||||||
{"v3.0" = "migration/v3.md"},
|
{"v3.0" = "migration/v3.md"},
|
||||||
{"v2.0" = "migration/v2.md"},
|
{"v2.0" = "migration/v2.md"},
|
||||||
|
|||||||
Reference in New Issue
Block a user