Compare commits

..
5 Commits
Author SHA1 Message Date
d3vyce 805ef04054 docs: update module and reference 2026-05-03 06:55:39 -04:00
d3vyce a475af6262 fix: cleanup + simplify 2026-05-03 06:44:38 -04:00
d3vyce f7fac10ea9 docs: add authentication example 2026-05-03 06:44:38 -04:00
d3vyce 8e62a2d658 feat(security): add oauth helpers 2026-05-03 06:44:38 -04:00
d3vyce 771b6a973e feat: add security module 2026-05-03 06:44:38 -04:00
33 changed files with 759 additions and 595 deletions
-2
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# Authentication
+1 -1
View File
@@ -130,7 +130,7 @@ Pass `next_cursor` as the `cursor` query parameter on the next request to advanc
!!! info "Added in `v2.3.0`" !!! info "Added in `v2.3.0`"
[`paginate()`](../module/crud.md#unified-endpoint-both-strategies) lets a single endpoint support both strategies via a `pagination_type` query parameter. The `pagination_type` field in the response acts as a discriminator for frontend tooling. [`paginate()`](../module/crud.md#unified-paginate--both-strategies-on-one-endpoint) lets a single endpoint support both strategies via a `pagination_type` query parameter. The `pagination_type` field in the response acts as a discriminator for frontend tooling.
```python title="routes.py:61:79" ```python title="routes.py:61:79"
--8<-- "docs_src/examples/pagination_search/routes.py:61:79" --8<-- "docs_src/examples/pagination_search/routes.py:61:79"
-2
View File
@@ -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
-40
View File
@@ -1,40 +0,0 @@
# Migrating to v4.0
This page covers every breaking change introduced in **v4.0** and the steps required to update your code.
---
## Database
### `lock_tables` now takes a `session_maker` instead of a `session`
The first argument of `lock_tables` changed from an `AsyncSession` instance to an `async_sessionmaker`.
The function creates and manages its own **dedicated session** internally, yielding it to the caller.
=== "Before (`v3`)"
```python
from fastapi_toolsets.db import lock_tables, LockMode
async with lock_tables(session=session, tables=[User, Account]):
user = await UserCrud.get(session, [User.id == 1])
user.balance += 100
# With a custom lock mode
async with lock_tables(session, [Order], mode=LockMode.EXCLUSIVE):
await process_order(session, order_id)
```
=== "Now (`v4`)"
```python
from fastapi_toolsets.db import lock_tables, LockMode
async with lock_tables(session_maker=session_maker, tables=[User, Account]) as session:
user = await UserCrud.get(session, [User.id == 1])
user.balance += 100
# With a custom lock mode
async with lock_tables(session_maker, [Order], mode=LockMode.EXCLUSIVE) as session:
await process_order(session, order_id)
```
+4 -4
View File
@@ -57,12 +57,12 @@ async def create_user_with_role(session=session):
## Table locking ## Table locking
[`lock_tables`](../reference/db.md#fastapi_toolsets.db.lock_tables) acquires PostgreSQL table-level locks before executing critical sections. It opens a **dedicated session** internally and yields it to the caller, so the lock is guaranteed to be released when the context exits: [`lock_tables`](../reference/db.md#fastapi_toolsets.db.lock_tables) acquires PostgreSQL table-level locks before executing critical sections:
```python ```python
from fastapi_toolsets.db import lock_tables, LockMode from fastapi_toolsets.db import lock_tables
async with lock_tables(session_maker=session_maker, tables=[User], mode=LockMode.EXCLUSIVE) as session: async with lock_tables(session=session, tables=[User], mode="EXCLUSIVE"):
# No other transaction can modify User until this block exits # No other transaction can modify User until this block exits
... ...
``` ```
@@ -129,7 +129,7 @@ SQLAlchemy's ORM collection API triggers lazy-loads when you append to a relatio
```python ```python
from fastapi_toolsets.db import lock_tables, m2m_add from fastapi_toolsets.db import lock_tables, m2m_add
async with lock_tables(session_maker, [Tag]) as session: async with lock_tables(session, [Tag]):
tag = await TagCrud.create(session, TagCreate(name="python")) tag = await TagCrud.create(session, TagCreate(name="python"))
await m2m_add(session, post, Post.tags, tag) await m2m_add(session, post, Post.tags, tag)
``` ```
+2 -2
View File
@@ -102,7 +102,7 @@ async def list_events(
#### [`PaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse) #### [`PaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse)
Return type for endpoints that support **both** pagination strategies via a `pagination_type` query parameter (using [`paginate()`](crud.md#unified-endpoint-both-strategies)). Return type for endpoints that support **both** pagination strategies via a `pagination_type` query parameter (using [`paginate()`](crud.md#unified-paginate--both-strategies-on-one-endpoint)).
When used as a return annotation, `PaginatedResponse[T]` automatically expands to `Annotated[Union[CursorPaginatedResponse[T], OffsetPaginatedResponse[T]], Field(discriminator="pagination_type")]`, so FastAPI emits a proper `oneOf` + discriminator in the OpenAPI schema with no extra boilerplate: When used as a return annotation, `PaginatedResponse[T]` automatically expands to `Annotated[Union[CursorPaginatedResponse[T], OffsetPaginatedResponse[T]], Field(discriminator="pagination_type")]`, so FastAPI emits a proper `oneOf` + discriminator in the OpenAPI schema with no extra boilerplate:
@@ -129,7 +129,7 @@ async def list_users(
#### Pagination metadata models #### Pagination metadata models
The optional `filter_attributes` field is populated when `facet_fields` are configured on the CRUD class (see [Filter attributes](crud.md#faceted-search)). It is `None` by default and can be hidden from API responses with `response_model_exclude_none=True`. The optional `filter_attributes` field is populated when `facet_fields` are configured on the CRUD class (see [Filter attributes](crud.md#filter-attributes-facets)). It is `None` by default and can be hidden from API responses with `response_model_exclude_none=True`.
### [`ErrorResponse`](../reference/schemas.md#fastapi_toolsets.schemas.ErrorResponse) ### [`ErrorResponse`](../reference/schemas.md#fastapi_toolsets.schemas.ErrorResponse)
+84 -60
View File
@@ -47,9 +47,12 @@ async def me(user: User = Security(bearer)):
#### Token prefix #### 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. 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`: This lets you deploy multiple `BearerTokenAuth` instances in the same application
and disambiguate them efficiently in `MultiAuth`:
```python ```python
user_bearer = BearerTokenAuth(verify_user, prefix="user_") # matches "Bearer user_..." user_bearer = BearerTokenAuth(verify_user, prefix="user_") # matches "Bearer user_..."
@@ -60,7 +63,9 @@ Use [`generate_token()`](#token-generation) to create correctly-prefixed tokens.
#### Token generation #### 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: `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 ```python
bearer = BearerTokenAuth(verify_token, prefix="user_") bearer = BearerTokenAuth(verify_token, prefix="user_")
@@ -70,23 +75,18 @@ await db.store_token(user_id, token)
return {"access_token": token, "token_type": "bearer"} 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. 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) ### [`CookieAuth`](../reference/security.md#fastapi_toolsets.security.CookieAuth)
Reads a named cookie. Wraps `APIKeyCookie` for OpenAPI. 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 ```python
from fastapi_toolsets.security import CookieAuth from fastapi_toolsets.security import CookieAuth
# Production (HTTPS) — default
cookie_auth = CookieAuth("session", validator=verify_session) cookie_auth = CookieAuth("session", validator=verify_session)
# Local development (HTTP only)
cookie_auth = CookieAuth("session", validator=verify_session, secure=False)
@app.get("/me") @app.get("/me")
async def me(user: User = Security(cookie_auth)): async def me(user: User = Security(cookie_auth)):
return user return user
@@ -94,17 +94,16 @@ async def me(user: User = Security(cookie_auth)):
#### Signed cookies #### 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. 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: Use `set_cookie()` to issue the signed cookie on login and `delete_cookie()` to
clear it on logout:
```python ```python
# Production
cookie_auth = CookieAuth("session", verify_session, secret_key="your-secret") 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") @app.post("/login")
async def login(response: Response): async def login(response: Response):
cookie_auth.set_cookie(response, user_id) cookie_auth.set_cookie(response, user_id)
@@ -120,7 +119,8 @@ async def me(user: User = Security(cookie_auth)):
return user 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). 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) ### [`APIKeyHeaderAuth`](../reference/security.md#fastapi_toolsets.security.APIKeyHeaderAuth)
@@ -136,11 +136,14 @@ async def data(user: User = Security(api_key_auth)):
return user return user
``` ```
The header name is configurable — use any header your API defines (e.g. `"X-API-Key"`, `"Authorization"`, `"X-Service-Token"`). The header name is configurable — use any header your API defines (e.g.
`"X-API-Key"`, `"Authorization"`, `"X-Service-Token"`).
## Typed validator kwargs ## 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. 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 ```python
async def verify_token(token: str, *, role: Role, permission: str) -> User: async def verify_token(token: str, *, role: Role, permission: str) -> User:
@@ -152,11 +155,14 @@ async def verify_token(token: str, *, role: Role, permission: str) -> User:
bearer = BearerTokenAuth(verify_token, role=Role.ADMIN, permission="billing:read") 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=[...])`. Each auth instance is self-contained — create a separate instance per distinct
requirement instead of passing requirements through `Security(scopes=[...])`.
### Using `.require()` inline ### 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: 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 ```python
bearer = BearerTokenAuth(verify_token) bearer = BearerTokenAuth(verify_token)
@@ -174,11 +180,24 @@ async def profile(user: User = Security(bearer.require(role=Role.USER))):
The `prefix` (for `BearerTokenAuth`), cookie name and `secret_key` (for The `prefix` (for `BearerTokenAuth`), cookie name and `secret_key` (for
`CookieAuth`), and header name (for `APIKeyHeaderAuth`) are always preserved. `CookieAuth`), and header name (for `APIKeyHeaderAuth`) are always preserved.
`.require()` instances work transparently inside `MultiAuth`:
```python
multi = MultiAuth(
user_bearer.require(role=Role.USER),
org_bearer.require(role=Role.ADMIN),
)
```
## MultiAuth ## 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. [`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. 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 ```python
from fastapi_toolsets.security import MultiAuth from fastapi_toolsets.security import MultiAuth
@@ -192,7 +211,9 @@ async def data_route(user = Security(multi)):
### Using `.require()` on MultiAuth ### 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: `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 ```python
multi = MultiAuth(bearer, cookie) multi = MultiAuth(bearer, cookie)
@@ -216,7 +237,9 @@ MultiAuth(
### Prefix-based dispatch ### 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: 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 ```python
user_bearer = BearerTokenAuth(verify_user, prefix="user_") user_bearer = BearerTokenAuth(verify_user, prefix="user_")
@@ -228,7 +251,8 @@ multi = MultiAuth(user_bearer, org_bearer)
# "Bearer org_acme" → only verify_org runs, receives "org_acme" # "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: Tokens are stored and compared **with their prefix** — use `generate_token()` on
each source to issue correctly-prefixed tokens:
```python ```python
user_token = user_bearer.generate_token() # "user_..." user_token = user_bearer.generate_token() # "user_..."
@@ -237,7 +261,9 @@ org_token = org_bearer.generate_token() # "org_..."
## Custom auth sources ## 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()`: Subclass [`AuthSource`](../reference/security.md#fastapi_toolsets.security.AuthSource)
to implement any credential extraction strategy. You only need to implement
`extract()` and `authenticate()`:
```python ```python
from fastapi_toolsets.security import AuthSource from fastapi_toolsets.security import AuthSource
@@ -258,11 +284,16 @@ Custom sources work transparently inside `MultiAuth`.
## OAuth 2.0 / OIDC helpers ## 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. 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 ### 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: [`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 ```python
from fastapi_toolsets.security import oauth_resolve_provider_urls from fastapi_toolsets.security import oauth_resolve_provider_urls
@@ -272,51 +303,42 @@ auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls(
) )
``` ```
Returns a `(authorization_url, token_url, userinfo_url)` tuple. `userinfo_url` is `None` when the provider does not advertise one. Returns a `(authorization_url, token_url, userinfo_url)` tuple. `userinfo_url`
is `None` when the provider does not advertise one.
### Authorization redirect ### 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)): [`oauth_build_authorization_redirect()`](../reference/security.md#fastapi_toolsets.security.oauth_build_authorization_redirect)
constructs the redirect to the provider's authorization page. The `destination`
URL (where to send the user after the full flow) is encoded as the `state`
parameter:
```python ```python
from fastapi import Request from fastapi_toolsets.security import oauth_build_authorization_redirect
from fastapi_toolsets.security import oauth_build_authorization_redirect, oauth_generate_state_token
@app.get("/auth/google/login") @app.get("/auth/google/login")
async def google_login(request: Request): async def google_login():
auth_url, _, _ = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL) 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( return oauth_build_authorization_redirect(
auth_url, auth_url,
client_id=GOOGLE_CLIENT_ID, client_id=GOOGLE_CLIENT_ID,
scopes="openid email profile", scopes="openid email profile",
redirect_uri="https://myapp.com/auth/google/callback", redirect_uri="https://myapp.com/auth/google/callback",
destination="/dashboard", destination="/dashboard",
state_token=state_token,
) )
``` ```
### Token exchange and userinfo ### 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. [`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
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: endpoint, then GETs the userinfo endpoint with the resulting access token:
```python ```python
from fastapi import HTTPException, Request from fastapi_toolsets.security import oauth_fetch_userinfo
from fastapi_toolsets.security import oauth_decode_state, oauth_fetch_userinfo
@app.get("/auth/google/callback") @app.get("/auth/google/callback")
async def google_callback(request: Request, code: str, state: str): async def google_callback(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) _, token_url, userinfo_url = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL)
userinfo = await oauth_fetch_userinfo( userinfo = await oauth_fetch_userinfo(
token_url=token_url, token_url=token_url,
@@ -325,28 +347,30 @@ async def google_callback(request: Request, code: str, state: str):
client_id=GOOGLE_CLIENT_ID, client_id=GOOGLE_CLIENT_ID,
client_secret=GOOGLE_CLIENT_SECRET, client_secret=GOOGLE_CLIENT_SECRET,
redirect_uri="https://myapp.com/auth/google/callback", redirect_uri="https://myapp.com/auth/google/callback",
required_scopes="openid email profile",
) )
user = await db.upsert_user(email=userinfo["email"]) user = await db.upsert_user(email=userinfo["email"])
destination = oauth_decode_state(state, fallback="/")
response = RedirectResponse(destination) response = RedirectResponse(destination)
session_cookie.set_cookie(response, str(user.id)) session_cookie.set_cookie(response, str(user.id))
return response 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 ### 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: [`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)
base64url-encode and decode the destination URL embedded in the OAuth `state`
parameter. `oauth_decode_state` handles missing padding and returns the `fallback`
if `state` is absent, `"null"`, or malformed:
```python ```python
from fastapi_toolsets.security import oauth_encode_state, oauth_decode_state from fastapi_toolsets.security import oauth_encode_state, oauth_decode_state
state_token = oauth_generate_state_token() encoded = oauth_encode_state("/dashboard") # e.g. "L2Rhc2hib2FyZA=="
encoded = oauth_encode_state("/dashboard", state_token) decoded = oauth_decode_state(encoded, fallback="/") # "/dashboard"
decoded = oauth_decode_state(encoded, expected_state_token=state_token, fallback="/") # "/dashboard" decoded = oauth_decode_state(None, fallback="/") # "/"
decoded = oauth_decode_state(encoded, expected_state_token="wrong", fallback="/") # "/" decoded = oauth_decode_state("null", fallback="/") # "/"
decoded = oauth_decode_state(None, expected_state_token=state_token, fallback="/") # "/"
``` ```
--- ---
-3
View File
@@ -12,7 +12,6 @@ from fastapi_toolsets.exceptions import (
NotFoundError, NotFoundError,
ConflictError, ConflictError,
NoSearchableFieldsError, NoSearchableFieldsError,
InvalidSearchColumnError,
InvalidFacetFilterError, InvalidFacetFilterError,
InvalidOrderFieldError, InvalidOrderFieldError,
generate_error_responses, generate_error_responses,
@@ -32,8 +31,6 @@ from fastapi_toolsets.exceptions import (
## ::: fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError ## ::: fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError
## ::: fastapi_toolsets.exceptions.exceptions.InvalidSearchColumnError
## ::: fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError ## ::: fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError
## ::: fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError ## ::: fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError
-3
View File
@@ -15,7 +15,6 @@ from fastapi_toolsets.security import (
oauth_decode_state, oauth_decode_state,
oauth_encode_state, oauth_encode_state,
oauth_fetch_userinfo, oauth_fetch_userinfo,
oauth_generate_state_token,
oauth_resolve_provider_urls, oauth_resolve_provider_urls,
) )
``` ```
@@ -34,8 +33,6 @@ from fastapi_toolsets.security import (
## ::: fastapi_toolsets.security.oauth_fetch_userinfo ## ::: fastapi_toolsets.security.oauth_fetch_userinfo
## ::: fastapi_toolsets.security.oauth_generate_state_token
## ::: fastapi_toolsets.security.oauth_build_authorization_redirect ## ::: fastapi_toolsets.security.oauth_build_authorization_redirect
## ::: fastapi_toolsets.security.oauth_encode_state ## ::: fastapi_toolsets.security.oauth_encode_state
+9
View File
@@ -0,0 +1,9 @@
from fastapi import FastAPI
from fastapi_toolsets.exceptions import init_exceptions_handlers
from .routes import router
app = FastAPI()
init_exceptions_handlers(app=app)
app.include_router(router=router)
+9
View File
@@ -0,0 +1,9 @@
from fastapi_toolsets.crud import CrudFactory
from .models import OAuthAccount, OAuthProvider, Team, User, UserToken
TeamCrud = CrudFactory(model=Team)
UserCrud = CrudFactory(model=User)
UserTokenCrud = CrudFactory(model=UserToken)
OAuthProviderCrud = CrudFactory(model=OAuthProvider)
OAuthAccountCrud = CrudFactory(model=OAuthAccount)
+15
View File
@@ -0,0 +1,15 @@
from fastapi import Depends
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from fastapi_toolsets.db import create_db_context, create_db_dependency
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres"
engine = create_async_engine(url=DATABASE_URL, future=True)
async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False)
get_db = create_db_dependency(session_maker=async_session_maker)
get_db_context = create_db_context(session_maker=async_session_maker)
SessionDep = Depends(get_db)
+105
View File
@@ -0,0 +1,105 @@
import enum
from datetime import datetime
from uuid import UUID
from sqlalchemy import (
Boolean,
DateTime,
Enum,
ForeignKey,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from fastapi_toolsets.models import TimestampMixin, UUIDMixin
class Base(DeclarativeBase, UUIDMixin):
type_annotation_map = {
str: String(),
int: Integer(),
UUID: PG_UUID(as_uuid=True),
datetime: DateTime(timezone=True),
}
class UserRole(enum.Enum):
admin = "admin"
moderator = "moderator"
user = "user"
class Team(Base, TimestampMixin):
__tablename__ = "teams"
name: Mapped[str] = mapped_column(String, unique=True, index=True)
users: Mapped[list["User"]] = relationship(back_populates="team")
class User(Base, TimestampMixin):
__tablename__ = "users"
username: Mapped[str] = mapped_column(String, unique=True, index=True)
email: Mapped[str | None] = mapped_column(
String, unique=True, index=True, nullable=True
)
hashed_password: Mapped[str | None] = mapped_column(String, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
role: Mapped[UserRole] = mapped_column(Enum(UserRole), default=UserRole.user)
team_id: Mapped[UUID | None] = mapped_column(ForeignKey("teams.id"), nullable=True)
team: Mapped["Team | None"] = relationship(back_populates="users")
oauth_accounts: Mapped[list["OAuthAccount"]] = relationship(back_populates="user")
tokens: Mapped[list["UserToken"]] = relationship(back_populates="user")
class UserToken(Base, TimestampMixin):
"""API tokens for a user (multiple allowed)."""
__tablename__ = "user_tokens"
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"))
# Store hashed token value
token_hash: Mapped[str] = mapped_column(String, unique=True, index=True)
name: Mapped[str | None] = mapped_column(String, nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
user: Mapped["User"] = relationship(back_populates="tokens")
class OAuthProvider(Base, TimestampMixin):
"""Configurable OAuth2 / OpenID Connect provider."""
__tablename__ = "oauth_providers"
slug: Mapped[str] = mapped_column(String, unique=True, index=True)
name: Mapped[str] = mapped_column(String)
client_id: Mapped[str] = mapped_column(String)
client_secret: Mapped[str] = mapped_column(String)
discovery_url: Mapped[str] = mapped_column(String, nullable=False)
scopes: Mapped[str] = mapped_column(String, default="openid email profile")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
accounts: Mapped[list["OAuthAccount"]] = relationship(back_populates="provider")
class OAuthAccount(Base, TimestampMixin):
"""OAuth2 / OpenID Connect account linked to a user."""
__tablename__ = "oauth_accounts"
__table_args__ = (
UniqueConstraint("provider_id", "subject", name="uq_oauth_provider_subject"),
)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"))
provider_id: Mapped[UUID] = mapped_column(ForeignKey("oauth_providers.id"))
# OAuth `sub` / OpenID subject identifier
subject: Mapped[str] = mapped_column(String)
user: Mapped["User"] = relationship(back_populates="oauth_accounts")
provider: Mapped["OAuthProvider"] = relationship(back_populates="accounts")
+122
View File
@@ -0,0 +1,122 @@
from typing import Annotated
from uuid import UUID
import bcrypt
from fastapi import APIRouter, Form, HTTPException, Response, Security
from fastapi_toolsets.dependencies import PathDependency
from .crud import UserCrud, UserTokenCrud
from .db import SessionDep
from .models import OAuthProvider, User, UserToken
from .schemas import (
ApiTokenCreateRequest,
ApiTokenResponse,
RegisterRequest,
UserCreate,
UserResponse,
)
from .security import auth, cookie_auth, create_api_token
ProviderDep = PathDependency(
model=OAuthProvider,
field=OAuthProvider.slug,
session_dep=SessionDep,
param_name="slug",
)
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
router = APIRouter(prefix="/auth")
@router.post("/register", response_model=UserResponse, status_code=201)
async def register(body: RegisterRequest, session: SessionDep):
existing = await UserCrud.first(
session=session, filters=[User.username == body.username]
)
if existing:
raise HTTPException(status_code=409, detail="Username already taken")
user = await UserCrud.create(
session=session,
obj=UserCreate(
username=body.username,
email=body.email,
hashed_password=hash_password(body.password),
),
)
return user
@router.post("/token", status_code=204)
async def login(
session: SessionDep,
response: Response,
username: Annotated[str, Form()],
password: Annotated[str, Form()],
):
user = await UserCrud.first(session=session, filters=[User.username == username])
if (
not user
or not user.hashed_password
or not verify_password(password, user.hashed_password)
):
raise HTTPException(status_code=401, detail="Invalid credentials")
if not user.is_active:
raise HTTPException(status_code=403, detail="Account disabled")
cookie_auth.set_cookie(response, str(user.id))
@router.post("/logout", status_code=204)
async def logout(response: Response):
cookie_auth.delete_cookie(response)
@router.get("/me", response_model=UserResponse)
async def me(user: User = Security(auth)):
return user
@router.post("/tokens", response_model=ApiTokenResponse, status_code=201)
async def create_token(
body: ApiTokenCreateRequest,
user: User = Security(auth),
):
raw, token_row = await create_api_token(
user.id, name=body.name, expires_at=body.expires_at
)
return ApiTokenResponse(
id=token_row.id,
name=token_row.name,
expires_at=token_row.expires_at,
created_at=token_row.created_at,
token=raw,
)
@router.delete("/tokens/{token_id}", status_code=204)
async def revoke_token(
session: SessionDep,
token_id: UUID,
user: User = Security(auth),
):
if not await UserTokenCrud.first(
session=session,
filters=[UserToken.id == token_id, UserToken.user_id == user.id],
):
raise HTTPException(status_code=404, detail="Token not found")
await UserTokenCrud.delete(
session=session,
filters=[UserToken.id == token_id, UserToken.user_id == user.id],
)
@@ -0,0 +1,64 @@
from datetime import datetime
from uuid import UUID
from pydantic import EmailStr
from fastapi_toolsets.schemas import PydanticBase
class RegisterRequest(PydanticBase):
username: str
password: str
email: EmailStr | None = None
class UserResponse(PydanticBase):
id: UUID
username: str
email: str | None
role: str
is_active: bool
model_config = {"from_attributes": True}
class ApiTokenCreateRequest(PydanticBase):
name: str | None = None
expires_at: datetime | None = None
class ApiTokenResponse(PydanticBase):
id: UUID
name: str | None
expires_at: datetime | None
created_at: datetime
# Only populated on creation
token: str | None = None
model_config = {"from_attributes": True}
class OAuthProviderResponse(PydanticBase):
slug: str
name: str
model_config = {"from_attributes": True}
class UserCreate(PydanticBase):
username: str
email: str | None = None
hashed_password: str | None = None
class UserTokenCreate(PydanticBase):
user_id: UUID
token_hash: str
name: str | None = None
expires_at: datetime | None = None
class OAuthAccountCreate(PydanticBase):
user_id: UUID
provider_id: UUID
subject: str
@@ -0,0 +1,100 @@
import hashlib
from datetime import datetime, timezone
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy.orm import selectinload
from fastapi_toolsets.exceptions import UnauthorizedError
from fastapi_toolsets.security import (
APIKeyHeaderAuth,
BearerTokenAuth,
CookieAuth,
MultiAuth,
)
from .crud import UserCrud, UserTokenCrud
from .db import get_db_context
from .models import User, UserRole, UserToken
from .schemas import UserTokenCreate
SESSION_COOKIE = "session"
SECRET_KEY = "123456789"
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def _verify_token(token: str, role: UserRole | None = None) -> User:
async with get_db_context() as db:
user_token = await UserTokenCrud.first(
session=db,
filters=[UserToken.token_hash == _hash_token(token)],
load_options=[selectinload(UserToken.user)],
)
if user_token is None or not user_token.user.is_active:
raise UnauthorizedError()
if user_token.expires_at and user_token.expires_at < datetime.now(timezone.utc):
raise UnauthorizedError()
user = user_token.user
if role is not None and user.role != role:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return user
async def _verify_cookie(user_id: str, role: UserRole | None = None) -> User:
async with get_db_context() as db:
user = await UserCrud.first(
session=db,
filters=[User.id == UUID(user_id)],
)
if not user or not user.is_active:
raise UnauthorizedError()
if role is not None and user.role != role:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return user
bearer_auth = BearerTokenAuth(
validator=_verify_token,
prefix="ctf_",
)
header_auth = APIKeyHeaderAuth(
name="X-API-Key",
validator=_verify_token,
)
cookie_auth = CookieAuth(
name=SESSION_COOKIE,
validator=_verify_cookie,
secret_key=SECRET_KEY,
)
auth = MultiAuth(bearer_auth, header_auth, cookie_auth)
async def create_api_token(
user_id: UUID,
*,
name: str | None = None,
expires_at: datetime | None = None,
) -> tuple[str, UserToken]:
raw = bearer_auth.generate_token()
async with get_db_context() as db:
token_row = await UserTokenCrud.create(
session=db,
obj=UserTokenCreate(
user_id=user_id,
token_hash=_hash_token(raw),
name=name,
expires_at=expires_at,
),
)
return raw, token_row
+2 -7
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "fastapi-toolsets" name = "fastapi-toolsets"
version = "4.0.0" version = "3.1.1"
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]
@@ -77,7 +73,6 @@ dev = [
"ty>=0.0.1a0", "ty>=0.0.1a0",
] ]
tests = [ tests = [
"async-lru>=1.0",
"coverage>=7.0.0", "coverage>=7.0.0",
"httpx>=0.25.0", "httpx>=0.25.0",
"pytest-anyio>=0.0.0", "pytest-anyio>=0.0.0",
+1 -1
View File
@@ -21,4 +21,4 @@ Example usage:
return Response(data={"user": user.username}, message="Success") return Response(data={"user": user.username}, message="Success")
""" """
__version__ = "4.0.0" __version__ = "3.1.1"
+21 -26
View File
@@ -151,57 +151,52 @@ class LockMode(str, Enum):
ACCESS_EXCLUSIVE = "ACCESS EXCLUSIVE" ACCESS_EXCLUSIVE = "ACCESS EXCLUSIVE"
def lock_tables( @asynccontextmanager
session_maker: async_sessionmaker[_SessionT], async def lock_tables(
session: AsyncSession,
tables: list[type[DeclarativeBase]], tables: list[type[DeclarativeBase]],
*, *,
mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE, mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE,
timeout: str = "5s", timeout: str = "5s",
) -> AbstractAsyncContextManager[_SessionT]: ) -> AsyncGenerator[AsyncSession, None]:
"""Lock PostgreSQL tables for the duration of a transaction. """Lock PostgreSQL tables for the duration of a transaction.
Acquires table-level locks that are held until the transaction ends.
Useful for preventing concurrent modifications during critical operations.
Args: Args:
session_maker: Async session factory used to create the dedicated session: AsyncSession instance
session. tables: List of SQLAlchemy model classes to lock
tables: List of SQLAlchemy model classes to lock. mode: Lock mode (default: SHARE UPDATE EXCLUSIVE)
mode: Lock mode (default: SHARE UPDATE EXCLUSIVE). timeout: Lock timeout (default: "5s")
timeout: Lock timeout (default: "5s").
Yields: Yields:
The dedicated session, open within the locked transaction. The session with locked tables
Raises: Raises:
SQLAlchemyError: If the lock cannot be acquired within *timeout*. SQLAlchemyError: If lock cannot be acquired within timeout
Example: Example:
```python ```python
from fastapi_toolsets.db import lock_tables, LockMode from fastapi_toolsets.db import lock_tables, LockMode
async with lock_tables(session_maker, [User, Account]) as session: async with lock_tables(session, [User, Account]):
# Tables are locked; changes are committed when the context exits. # Tables are locked with SHARE UPDATE EXCLUSIVE mode
user = await UserCrud.get(session, [User.id == 1]) user = await UserCrud.get(session, [User.id == 1])
user.balance += 100 user.balance += 100
# With custom lock mode # With custom lock mode
async with lock_tables(session_maker, [Order], mode=LockMode.EXCLUSIVE) as session: async with lock_tables(session, [Order], mode=LockMode.EXCLUSIVE):
# Exclusive lock - no other transactions can access
await process_order(session, order_id) await process_order(session, order_id)
``` ```
""" """
table_names = ",".join(table.__tablename__ for table in tables) table_names = ",".join(table.__tablename__ for table in tables)
@asynccontextmanager async with get_transaction(session):
async def _lock() -> AsyncGenerator[_SessionT, None]: await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
async with session_maker() as session: await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
try: yield session
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
yield session
await session.commit()
except BaseException:
await session.rollback()
raise
return _lock()
async def create_database( async def create_database(
@@ -6,7 +6,6 @@ from .oauth import (
oauth_decode_state, oauth_decode_state,
oauth_encode_state, oauth_encode_state,
oauth_fetch_userinfo, oauth_fetch_userinfo,
oauth_generate_state_token,
oauth_resolve_provider_urls, oauth_resolve_provider_urls,
) )
from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth
@@ -21,6 +20,5 @@ __all__ = [
"oauth_decode_state", "oauth_decode_state",
"oauth_encode_state", "oauth_encode_state",
"oauth_fetch_userinfo", "oauth_fetch_userinfo",
"oauth_generate_state_token",
"oauth_resolve_provider_urls", "oauth_resolve_provider_urls",
] ]
-2
View File
@@ -1,6 +1,5 @@
"""Abstract base class for authentication sources.""" """Abstract base class for authentication sources."""
import functools
import inspect import inspect
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, Callable from typing import Any, Callable
@@ -16,7 +15,6 @@ def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]:
if inspect.iscoroutinefunction(fn): if inspect.iscoroutinefunction(fn):
return fn return fn
@functools.wraps(fn)
async def wrapper(*args: Any, **kwargs: Any) -> Any: async def wrapper(*args: Any, **kwargs: Any) -> Any:
return fn(*args, **kwargs) return fn(*args, **kwargs)
+30 -87
View File
@@ -1,19 +1,15 @@
"""OAuth 2.0 / OIDC helper utilities.""" """OAuth 2.0 / OIDC helper utilities."""
import base64 import base64
import binascii
import hmac
import json
import secrets
from typing import Any from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
import httpx import httpx
from async_lru import alru_cache
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
_discovery_cache: dict[str, dict] = {}
@alru_cache(maxsize=32)
async def oauth_resolve_provider_urls( async def oauth_resolve_provider_urls(
discovery_url: str, discovery_url: str,
) -> tuple[str, str, str | None]: ) -> tuple[str, str, str | None]:
@@ -26,10 +22,12 @@ async def oauth_resolve_provider_urls(
A ``(authorization_url, token_url, userinfo_url)`` tuple. A ``(authorization_url, token_url, userinfo_url)`` tuple.
*userinfo_url* is ``None`` when the provider does not advertise one. *userinfo_url* is ``None`` when the provider does not advertise one.
""" """
async with httpx.AsyncClient() as client: if discovery_url not in _discovery_cache:
resp = await client.get(discovery_url) async with httpx.AsyncClient() as client:
resp.raise_for_status() resp = await client.get(discovery_url)
cfg = resp.json() resp.raise_for_status()
_discovery_cache[discovery_url] = resp.json()
cfg = _discovery_cache[discovery_url]
return ( return (
cfg["authorization_endpoint"], cfg["authorization_endpoint"],
cfg["token_endpoint"], cfg["token_endpoint"],
@@ -45,10 +43,14 @@ async def oauth_fetch_userinfo(
client_id: str, client_id: str,
client_secret: str, client_secret: str,
redirect_uri: str, redirect_uri: str,
required_scopes: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Exchange an authorization code for tokens and return the userinfo payload. """Exchange an authorization code for tokens and return the userinfo payload.
Performs the two-step OAuth 2.0 / OIDC token exchange:
1. POSTs the authorization *code* to *token_url* to obtain an access token.
2. GETs *userinfo_url* using that access token as a Bearer credential.
Args: Args:
token_url: Provider's token endpoint. token_url: Provider's token endpoint.
userinfo_url: Provider's userinfo endpoint. userinfo_url: Provider's userinfo endpoint.
@@ -56,16 +58,9 @@ async def oauth_fetch_userinfo(
client_id: OAuth application client ID. client_id: OAuth application client ID.
client_secret: OAuth application client secret. client_secret: OAuth application client secret.
redirect_uri: Redirect URI that was used in the authorization request. 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: Returns:
The JSON payload returned by the userinfo endpoint as a plain ``dict``. 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: async with httpx.AsyncClient() as client:
token_resp = await client.post( token_resp = await client.post(
@@ -80,20 +75,7 @@ async def oauth_fetch_userinfo(
headers={"Accept": "application/json"}, headers={"Accept": "application/json"},
) )
token_resp.raise_for_status() token_resp.raise_for_status()
token_data = token_resp.json() access_token = token_resp.json()["access_token"]
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_resp = await client.get(
userinfo_url, userinfo_url,
@@ -103,11 +85,6 @@ async def oauth_fetch_userinfo(
return userinfo_resp.json() 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( def oauth_build_authorization_redirect(
authorization_url: str, authorization_url: str,
*, *,
@@ -115,7 +92,6 @@ def oauth_build_authorization_redirect(
scopes: str, scopes: str,
redirect_uri: str, redirect_uri: str,
destination: str, destination: str,
state_token: str,
) -> RedirectResponse: ) -> RedirectResponse:
"""Return an OAuth 2.0 authorization ``RedirectResponse``. """Return an OAuth 2.0 authorization ``RedirectResponse``.
@@ -125,10 +101,7 @@ def oauth_build_authorization_redirect(
scopes: Space-separated list of requested scopes. scopes: Space-separated list of requested scopes.
redirect_uri: URI the provider should redirect back to after authorization. redirect_uri: URI the provider should redirect back to after authorization.
destination: URL the user should be sent to after the full OAuth flow destination: URL the user should be sent to after the full OAuth flow
completes (embedded in ``state``). completes (encoded as ``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: Returns:
A :class:`~fastapi.responses.RedirectResponse` to the provider's A :class:`~fastapi.responses.RedirectResponse` to the provider's
@@ -140,58 +113,28 @@ def oauth_build_authorization_redirect(
"response_type": "code", "response_type": "code",
"scope": scopes, "scope": scopes,
"redirect_uri": redirect_uri, "redirect_uri": redirect_uri,
"state": oauth_encode_state(destination, state_token), "state": oauth_encode_state(destination),
} }
) )
return RedirectResponse(f"{authorization_url}?{params}") return RedirectResponse(f"{authorization_url}?{params}")
def oauth_encode_state(url: str, state_token: str) -> str: def oauth_encode_state(url: str) -> str:
"""Encode a destination URL and CSRF token into an OAuth ``state`` parameter. """Base64url-encode a URL to embed as an OAuth ``state`` parameter."""
return base64.urlsafe_b64encode(url.encode()).decode()
Args:
url: Post-login destination URL. def oauth_decode_state(state: str | None, *, fallback: str) -> str:
state_token: CSRF token from :func:`oauth_generate_state_token`. """Decode a base64url OAuth ``state`` parameter.
Handles missing padding (some providers strip ``=``).
Returns *fallback* if *state* is absent, the literal string ``"null"``,
or cannot be decoded.
""" """
payload = json.dumps({"n": state_token, "d": url}, separators=(",", ":")) if not state or state == "null":
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 return fallback
try: try:
padded = state + "=" * (-len(state) % 4) padded = state + "=" * (4 - len(state) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8")) return base64.urlsafe_b64decode(padded).decode()
if not isinstance(payload, dict) or not hmac.compare_digest( except Exception:
payload.get("n", "").encode(), expected_state_token.encode()
):
return fallback
return str(payload["d"])
except (UnicodeDecodeError, ValueError, binascii.Error, KeyError):
return fallback return fallback
@@ -4,7 +4,7 @@ import inspect
import secrets import secrets
from typing import Annotated, Any, Callable from typing import Annotated, Any, Callable
from fastapi import Depends, Request from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes
from fastapi_toolsets.exceptions import UnauthorizedError from fastapi_toolsets.exceptions import UnauthorizedError
@@ -66,7 +66,7 @@ class BearerTokenAuth(AuthSource):
raise UnauthorizedError() raise UnauthorizedError()
return await self._validator(token, **self._kwargs) return await self._validator(token, **self._kwargs)
async def extract(self, request: Request) -> str | None: async def extract(self, request: Any) -> str | None:
"""Extract the raw credential from the request without validating. """Extract the raw credential from the request without validating.
Returns ``None`` if no ``Authorization: Bearer`` header is present, Returns ``None`` if no ``Authorization: Bearer`` header is present,
@@ -36,9 +36,6 @@ class CookieAuth(AuthSource):
cookie value is passed to the validator as-is. cookie value is passed to the validator as-is.
ttl: Cookie lifetime in seconds (default 24 h). Only used when ttl: Cookie lifetime in seconds (default 24 h). Only used when
``secret_key`` is set. ``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 **kwargs: Extra keyword arguments forwarded to the validator on every
call (e.g. ``role=Role.ADMIN``). call (e.g. ``role=Role.ADMIN``).
""" """
@@ -50,14 +47,12 @@ class CookieAuth(AuthSource):
*, *,
secret_key: str | None = None, secret_key: str | None = None,
ttl: int = 86400, ttl: int = 86400,
secure: bool = True,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
self._name = name self._name = name
self._validator = _ensure_async(validator) self._validator = _ensure_async(validator)
self._secret_key = secret_key self._secret_key = secret_key
self._ttl = ttl self._ttl = ttl
self._secure = secure
self._kwargs = kwargs self._kwargs = kwargs
self._scheme = APIKeyCookie(name=name, auto_error=False) self._scheme = APIKeyCookie(name=name, auto_error=False)
@@ -125,7 +120,6 @@ class CookieAuth(AuthSource):
self._validator, self._validator,
secret_key=self._secret_key, secret_key=self._secret_key,
ttl=self._ttl, ttl=self._ttl,
secure=self._secure,
**{**self._kwargs, **kwargs}, **{**self._kwargs, **kwargs},
) )
@@ -137,12 +131,9 @@ class CookieAuth(AuthSource):
cookie_value, cookie_value,
httponly=True, httponly=True,
samesite="lax", samesite="lax",
secure=self._secure,
max_age=self._ttl, max_age=self._ttl,
) )
def delete_cookie(self, response: Response) -> None: def delete_cookie(self, response: Response) -> None:
"""Clear the session cookie (logout).""" """Clear the session cookie (logout)."""
response.delete_cookie( response.delete_cookie(self._name, httponly=True, samesite="lax")
self._name, httponly=True, samesite="lax", secure=self._secure
)
+49 -1
View File
@@ -14,8 +14,42 @@ from ..abc import AuthSource
class MultiAuth: class MultiAuth:
"""Combine multiple authentication sources into a single callable. """Combine multiple authentication sources into a single callable.
Sources are tried in order; the first one whose
:meth:`~AuthSource.extract` returns a non-``None`` credential wins.
Its :meth:`~AuthSource.authenticate` is called and the result returned.
If a credential is found but the validator raises, the exception propagates
immediately — the remaining sources are **not** tried. This prevents
silent fallthrough on invalid credentials.
If no source provides a credential,
:class:`~fastapi_toolsets.exceptions.UnauthorizedError` is raised.
The :meth:`~AuthSource.extract` method of each source performs only
string matching (no I/O), so prefix-based dispatch is essentially free.
Any :class:`~AuthSource` subclass — including user-defined ones — can be
passed as a source.
Args: Args:
*sources: Auth source instances to try in order. *sources: Auth source instances to try in order.
Example::
user_bearer = BearerTokenAuth(verify_user, prefix="user_")
org_bearer = BearerTokenAuth(verify_org, prefix="org_")
cookie = CookieAuth("session", verify_session)
multi = MultiAuth(user_bearer, org_bearer, cookie)
@app.get("/data")
async def data_route(user = Security(multi)):
return user
# Apply a shared requirement to all sources at once
@app.get("/admin")
async def admin_route(user = Security(multi.require(role=Role.ADMIN))):
return user
""" """
def __init__(self, *sources: AuthSource) -> None: def __init__(self, *sources: AuthSource) -> None:
@@ -61,7 +95,21 @@ class MultiAuth:
return await self._call_fn(**kwargs) return await self._call_fn(**kwargs)
def require(self, **kwargs: Any) -> "MultiAuth": def require(self, **kwargs: Any) -> "MultiAuth":
"""Return a new :class:`MultiAuth` with kwargs forwarded to each source.""" """Return a new :class:`MultiAuth` with kwargs forwarded to each source.
Calls ``.require(**kwargs)`` on every source that supports it. Sources
that do not implement ``.require()`` (e.g. custom :class:`~AuthSource`
subclasses) are passed through unchanged.
New kwargs are merged over each source's existing kwargs — new values
win on conflict::
multi = MultiAuth(bearer, cookie)
@app.get("/admin")
async def admin(user = Security(multi.require(role=Role.ADMIN))):
return user
"""
new_sources = tuple( new_sources = tuple(
cast(Any, source).require(**kwargs) cast(Any, source).require(**kwargs)
if hasattr(source, "require") if hasattr(source, "require")
-15
View File
@@ -439,21 +439,6 @@ async def engine():
await engine.dispose() await engine.dispose()
@pytest.fixture(scope="function")
async def session_maker(engine):
"""Provide a session factory with tables created and dropped around the test."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False)
try:
yield factory
finally:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
async def db_session(engine): async def db_session(engine):
"""Create a test database session with tables. """Create a test database session with tables.
+64 -52
View File
@@ -116,8 +116,13 @@ class TestCreateDbDependency:
await engine.dispose() await engine.dispose()
@pytest.mark.anyio @pytest.mark.anyio
async def test_data_inside_lock_is_committed(self): async def test_update_after_lock_tables_is_persisted(self):
"""Changes made inside lock_tables are committed when the context exits.""" """Changes made after lock_tables exits (before endpoint returns) are committed.
Regression: without the auto-begin fix, lock_tables would start and commit a
real outer transaction, leaving the session idle. Any modifications after that
point were silently dropped.
"""
engine = create_async_engine(DATABASE_URL, echo=False) engine = create_async_engine(DATABASE_URL, echo=False)
session_factory = async_sessionmaker(engine, expire_on_commit=False) session_factory = async_sessionmaker(engine, expire_on_commit=False)
@@ -125,12 +130,21 @@ class TestCreateDbDependency:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
try: try:
async with lock_tables(session_factory, [Role]) as session: get_db = create_db_dependency(session_factory)
role = Role(name="lock_committed")
session.add(role) async for session in get_db():
async with lock_tables(session, [Role]):
role = Role(name="lock_then_update")
session.add(role)
await session.flush()
# lock_tables has exited — outer transaction must still be open
assert session.in_transaction()
role.name = "updated_after_lock"
async with session_factory() as verify: async with session_factory() as verify:
result = await RoleCrud.first(verify, [Role.name == "lock_committed"]) result = await RoleCrud.first(
verify, [Role.name == "updated_after_lock"]
)
assert result is not None assert result is not None
finally: finally:
async with engine.begin() as conn: async with engine.begin() as conn:
@@ -273,55 +287,54 @@ class TestLockTables:
"""Tests for lock_tables context manager (PostgreSQL-specific).""" """Tests for lock_tables context manager (PostgreSQL-specific)."""
@pytest.mark.anyio @pytest.mark.anyio
async def test_lock_single_table(self, session_maker): async def test_lock_single_table(self, db_session: AsyncSession):
"""Lock a single table; changes inside are committed on context exit.""" """Lock a single table."""
async with lock_tables(session_maker, [Role]) as session: async with lock_tables(db_session, [Role]):
# Inside the lock, we can still perform operations
role = Role(name="locked_role") role = Role(name="locked_role")
session.add(role) db_session.add(role)
await db_session.flush()
async with session_maker() as verify: # After lock is released, verify the data was committed
result = await RoleCrud.first(verify, [Role.name == "locked_role"]) result = await RoleCrud.first(db_session, [Role.name == "locked_role"])
assert result is not None assert result is not None
@pytest.mark.anyio @pytest.mark.anyio
async def test_lock_multiple_tables(self, session_maker): async def test_lock_multiple_tables(self, db_session: AsyncSession):
"""Lock multiple tables.""" """Lock multiple tables."""
async with lock_tables(session_maker, [Role, User]) as session: async with lock_tables(db_session, [Role, User]):
role = Role(name="multi_lock_role") role = Role(name="multi_lock_role")
session.add(role) db_session.add(role)
await db_session.flush()
async with session_maker() as verify: result = await RoleCrud.first(db_session, [Role.name == "multi_lock_role"])
result = await RoleCrud.first(verify, [Role.name == "multi_lock_role"]) assert result is not None
assert result is not None
@pytest.mark.anyio @pytest.mark.anyio
async def test_lock_with_custom_mode(self, session_maker): async def test_lock_with_custom_mode(self, db_session: AsyncSession):
"""Lock with custom lock mode.""" """Lock with custom lock mode."""
async with lock_tables( async with lock_tables(db_session, [Role], mode=LockMode.EXCLUSIVE):
session_maker, [Role], mode=LockMode.EXCLUSIVE
) as session:
role = Role(name="exclusive_lock_role") role = Role(name="exclusive_lock_role")
session.add(role) db_session.add(role)
await db_session.flush()
async with session_maker() as verify: result = await RoleCrud.first(db_session, [Role.name == "exclusive_lock_role"])
result = await RoleCrud.first(verify, [Role.name == "exclusive_lock_role"]) assert result is not None
assert result is not None
@pytest.mark.anyio @pytest.mark.anyio
async def test_lock_rollback_on_exception(self, session_maker): async def test_lock_rollback_on_exception(self, db_session: AsyncSession):
"""Lock context rolls back on exception.""" """Lock context rolls back on exception."""
try: try:
async with lock_tables(session_maker, [Role]) as session: async with lock_tables(db_session, [Role]):
role = Role(name="lock_rollback_role") role = Role(name="lock_rollback_role")
session.add(role) db_session.add(role)
await session.flush() await db_session.flush()
raise ValueError("Simulated error") raise ValueError("Simulated error")
except ValueError: except ValueError:
pass pass
async with session_maker() as verify: result = await RoleCrud.first(db_session, [Role.name == "lock_rollback_role"])
result = await RoleCrud.first(verify, [Role.name == "lock_rollback_role"]) assert result is None
assert result is None
class TestWaitForRowChange: class TestWaitForRowChange:
@@ -630,30 +643,29 @@ class TestM2MAdd:
await m2m_add(db_session, user, User.role, role) await m2m_add(db_session, user, User.role, role)
@pytest.mark.anyio @pytest.mark.anyio
async def test_works_inside_lock_tables(self, session_maker): async def test_works_inside_lock_tables(self, db_session: AsyncSession):
"""m2m_add works correctly inside a lock_tables context.""" """m2m_add works correctly inside a lock_tables nested transaction."""
async with lock_tables(session_maker, [Tag]) as session: user = User(username="m2m_lock_author", email="m2m_lock@test.com")
user = User(username="m2m_lock_author", email="m2m_lock@test.com") db_session.add(user)
session.add(user) await db_session.flush()
await session.flush()
async with lock_tables(db_session, [Tag]):
tag = Tag(name="locked_tag") tag = Tag(name="locked_tag")
session.add(tag) db_session.add(tag)
await session.flush() await db_session.flush()
post = Post(title="Post Lock", author_id=user.id) post = Post(title="Post Lock", author_id=user.id)
session.add(post) db_session.add(post)
await session.flush() await db_session.flush()
await m2m_add(session, post, Post.tags, tag) await m2m_add(db_session, post, Post.tags, tag)
async with session_maker() as verify: result = await db_session.execute(
result = await verify.execute( select(Post).where(Post.id == post.id).options(selectinload(Post.tags))
select(Post).where(Post.id == post.id).options(selectinload(Post.tags)) )
) loaded = result.scalar_one()
loaded = result.scalar_one() assert len(loaded.tags) == 1
assert len(loaded.tags) == 1 assert loaded.tags[0].name == "locked_tag"
assert loaded.tags[0].name == "locked_tag"
class _LocalBase(DeclarativeBase): class _LocalBase(DeclarativeBase):
+6 -23
View File
@@ -7,7 +7,6 @@ from unittest.mock import patch
import pytest import pytest
from sqlalchemy import String from sqlalchemy import String
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
import fastapi_toolsets.models.watched as _watched_module import fastapi_toolsets.models.watched as _watched_module
@@ -21,7 +20,6 @@ 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,
@@ -340,23 +338,6 @@ async def mixin_session_expire():
yield session yield session
@pytest.fixture(scope="function")
async def mixin_session_maker():
"""Provide an EventSession-backed session factory with MixinBase tables."""
engine = create_async_engine(DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(MixinBase.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False, class_=EventSession)
try:
yield factory
finally:
async with engine.begin() as conn:
await conn.run_sync(MixinBase.metadata.drop_all)
await engine.dispose()
class TestUUIDMixin: class TestUUIDMixin:
@pytest.mark.anyio @pytest.mark.anyio
async def test_uuid_generated_by_db(self, mixin_session): async def test_uuid_generated_by_db(self, mixin_session):
@@ -1578,13 +1559,15 @@ class TestEventSessionWithGetTransaction:
assert creates[0]["obj_id"] == survivor.id assert creates[0]["obj_id"] == survivor.id
@pytest.mark.anyio @pytest.mark.anyio
async def test_lock_tables_with_events(self, mixin_session_maker): async def test_lock_tables_with_events(self, mixin_session):
"""Events fire correctly when lock_tables commits on context exit.""" """Events fire correctly after lock_tables context."""
from fastapi_toolsets.db import lock_tables from fastapi_toolsets.db import lock_tables
async with lock_tables(mixin_session_maker, [WatchedModel]) as session: async with lock_tables(mixin_session, [WatchedModel]):
obj = WatchedModel(status="locked", other="x") obj = WatchedModel(status="locked", other="x")
session.add(obj) mixin_session.add(obj)
await mixin_session.commit()
creates = [e for e in _test_events if e["event"] == "create"] creates = [e for e in _test_events if e["event"] == "create"]
assert len(creates) == 1 assert len(creates) == 1
+23 -184
View File
@@ -18,7 +18,6 @@ from fastapi_toolsets.security import (
oauth_decode_state, oauth_decode_state,
oauth_encode_state, oauth_encode_state,
oauth_fetch_userinfo, oauth_fetch_userinfo,
oauth_generate_state_token,
oauth_resolve_provider_urls, oauth_resolve_provider_urls,
) )
@@ -761,10 +760,7 @@ class TestCookieAuthSigned:
"""set_cookie signs the value; the signed cookie is verified on read.""" """set_cookie signs the value; the signed cookie is verified on read."""
from fastapi import Response from fastapi import Response
# secure=False for test client which runs over plain HTTP auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET)
auth = CookieAuth(
"session", cookie_validator, secret_key=self.SECRET, secure=False
)
def setup(app: FastAPI): def setup(app: FastAPI):
@app.get("/login") @app.get("/login")
@@ -782,26 +778,6 @@ class TestCookieAuthSigned:
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"session": VALID_COOKIE} assert response.json() == {"session": VALID_COOKIE}
def test_set_cookie_has_secure_flag_by_default(self):
"""set_cookie includes Secure flag when secure=True (the default)."""
from starlette.responses import Response as StarletteResponse
auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET)
response = StarletteResponse()
auth.set_cookie(response, "value")
assert "secure" in response.headers["set-cookie"].lower()
def test_set_cookie_no_secure_flag_when_disabled(self):
"""set_cookie omits Secure flag when secure=False (local dev)."""
from starlette.responses import Response as StarletteResponse
auth = CookieAuth(
"session", cookie_validator, secret_key=self.SECRET, secure=False
)
response = StarletteResponse()
auth.set_cookie(response, "value")
assert "secure" not in response.headers["set-cookie"].lower()
def test_tampered_signature_returns_401(self): def test_tampered_signature_returns_401(self):
"""A cookie whose HMAC signature has been modified is rejected.""" """A cookie whose HMAC signature has been modified is rejected."""
import base64 as _b64 import base64 as _b64
@@ -1013,64 +989,28 @@ def _make_async_client_mock(get_return=None, post_return=None):
class TestEncodeDecodeOAuthState: class TestEncodeDecodeOAuthState:
def test_encode_returns_base64url_string(self): def test_encode_returns_base64url_string(self):
result = oauth_encode_state("https://example.com/dashboard", "test-state-token") result = oauth_encode_state("https://example.com/dashboard")
assert isinstance(result, str) assert isinstance(result, str)
assert "+" not in result assert "+" not in result
assert "/" not in result assert "/" not in result
def test_round_trip(self): def test_round_trip(self):
url = "https://example.com/after-login?next=/home" url = "https://example.com/after-login?next=/home"
state_token = "test-state-token" assert oauth_decode_state(oauth_encode_state(url), fallback="/") == url
assert (
oauth_decode_state(
oauth_encode_state(url, state_token),
expected_state_token=state_token,
fallback="/",
)
== url
)
def test_decode_none_returns_fallback(self): def test_decode_none_returns_fallback(self):
assert ( assert oauth_decode_state(None, fallback="/home") == "/home"
oauth_decode_state(None, expected_state_token="any", fallback="/home")
== "/home"
)
def test_decode_null_string_returns_fallback(self): def test_decode_null_string_returns_fallback(self):
assert ( assert oauth_decode_state("null", fallback="/home") == "/home"
oauth_decode_state("null", expected_state_token="any", fallback="/home")
== "/home"
)
def test_decode_invalid_base64_returns_fallback(self): def test_decode_invalid_base64_returns_fallback(self):
assert ( assert oauth_decode_state("!!!notbase64!!!", fallback="/home") == "/home"
oauth_decode_state(
"!!!notbase64!!!", expected_state_token="any", fallback="/home"
)
== "/home"
)
def test_decode_handles_missing_padding(self): def test_decode_handles_missing_padding(self):
url = "https://example.com/x" url = "https://example.com/x"
state_token = "test-state-token" encoded = oauth_encode_state(url).rstrip("=")
encoded = oauth_encode_state(url, state_token).rstrip("=") assert oauth_decode_state(encoded, fallback="/") == url
assert (
oauth_decode_state(encoded, expected_state_token=state_token, fallback="/")
== url
)
def test_decode_wrong_state_token_returns_fallback(self):
url = "https://example.com/dashboard"
encoded = oauth_encode_state(url, "correct-token")
assert (
oauth_decode_state(
encoded, expected_state_token="wrong-token", fallback="/"
)
== "/"
)
def test_generate_state_token_is_random(self):
assert oauth_generate_state_token() != oauth_generate_state_token()
class TestBuildAuthorizationRedirect: class TestBuildAuthorizationRedirect:
@@ -1083,19 +1023,16 @@ class TestBuildAuthorizationRedirect:
scopes="openid email", scopes="openid email",
redirect_uri="https://app.example.com/callback", redirect_uri="https://app.example.com/callback",
destination="https://app.example.com/dashboard", destination="https://app.example.com/dashboard",
state_token="test-state-token",
) )
assert isinstance(response, RedirectResponse) assert isinstance(response, RedirectResponse)
def test_redirect_location_contains_all_params(self): def test_redirect_location_contains_all_params(self):
state_token = "test-state-token"
response = oauth_build_authorization_redirect( response = oauth_build_authorization_redirect(
"https://auth.example.com/authorize", "https://auth.example.com/authorize",
client_id="my-client", client_id="my-client",
scopes="openid email", scopes="openid email",
redirect_uri="https://app.example.com/callback", redirect_uri="https://app.example.com/callback",
destination="https://app.example.com/dashboard", destination="https://app.example.com/dashboard",
state_token=state_token,
) )
location = response.headers["location"] location = response.headers["location"]
parsed = urlparse(location) parsed = urlparse(location)
@@ -1109,9 +1046,7 @@ class TestBuildAuthorizationRedirect:
assert params["scope"] == ["openid email"] assert params["scope"] == ["openid email"]
assert params["redirect_uri"] == ["https://app.example.com/callback"] assert params["redirect_uri"] == ["https://app.example.com/callback"]
assert ( assert (
oauth_decode_state( oauth_decode_state(params["state"][0], fallback="")
params["state"][0], expected_state_token=state_token, fallback=""
)
== "https://app.example.com/dashboard" == "https://app.example.com/dashboard"
) )
@@ -1133,11 +1068,11 @@ class TestResolveProviderUrls:
mock_resp.json.return_value = self._discovery() mock_resp.json.return_value = self._discovery()
cm, mock_client = _make_async_client_mock(get_return=mock_resp) cm, mock_client = _make_async_client_mock(get_return=mock_resp)
oauth_resolve_provider_urls.cache_clear() with patch("fastapi_toolsets.security.oauth._discovery_cache", {}):
with patch("httpx.AsyncClient", return_value=cm): with patch("httpx.AsyncClient", return_value=cm):
auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls( auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls(
"https://auth.example.com/.well-known/openid-configuration" "https://auth.example.com/.well-known/openid-configuration"
) )
assert auth_url == "https://auth.example.com/authorize" assert auth_url == "https://auth.example.com/authorize"
assert token_url == "https://auth.example.com/token" assert token_url == "https://auth.example.com/token"
@@ -1150,11 +1085,11 @@ class TestResolveProviderUrls:
mock_resp.json.return_value = self._discovery(userinfo=False) mock_resp.json.return_value = self._discovery(userinfo=False)
cm, mock_client = _make_async_client_mock(get_return=mock_resp) cm, mock_client = _make_async_client_mock(get_return=mock_resp)
oauth_resolve_provider_urls.cache_clear() with patch("fastapi_toolsets.security.oauth._discovery_cache", {}):
with patch("httpx.AsyncClient", return_value=cm): with patch("httpx.AsyncClient", return_value=cm):
_, _, userinfo_url = await oauth_resolve_provider_urls( _, _, userinfo_url = await oauth_resolve_provider_urls(
"https://auth.example.com/.well-known/openid-configuration" "https://auth.example.com/.well-known/openid-configuration"
) )
assert userinfo_url is None assert userinfo_url is None
@@ -1166,10 +1101,10 @@ class TestResolveProviderUrls:
cm, mock_client = _make_async_client_mock(get_return=mock_resp) cm, mock_client = _make_async_client_mock(get_return=mock_resp)
url = "https://auth.example.com/.well-known/openid-configuration" url = "https://auth.example.com/.well-known/openid-configuration"
oauth_resolve_provider_urls.cache_clear() with patch("fastapi_toolsets.security.oauth._discovery_cache", {}):
with patch("httpx.AsyncClient", return_value=cm): with patch("httpx.AsyncClient", return_value=cm):
await oauth_resolve_provider_urls(url) await oauth_resolve_provider_urls(url)
await oauth_resolve_provider_urls(url) await oauth_resolve_provider_urls(url)
assert mock_client.get.call_count == 1 assert mock_client.get.call_count == 1
@@ -1243,99 +1178,3 @@ class TestFetchUserinfo:
"https://auth.example.com/userinfo", "https://auth.example.com/userinfo",
headers={"Authorization": "Bearer tok123"}, headers={"Authorization": "Bearer tok123"},
) )
@pytest.mark.anyio
async def test_raises_on_unsupported_token_type(self):
token_resp = MagicMock()
token_resp.raise_for_status = MagicMock()
token_resp.json.return_value = {"access_token": "tok123", "token_type": "mac"}
cm, _ = _make_async_client_mock(post_return=token_resp, get_return=MagicMock())
with patch("httpx.AsyncClient", return_value=cm):
with pytest.raises(ValueError, match="unsupported token_type"):
await oauth_fetch_userinfo(
token_url="https://auth.example.com/token",
userinfo_url="https://auth.example.com/userinfo",
code="authcode123",
client_id="client-id",
client_secret="client-secret",
redirect_uri="https://app.example.com/callback",
)
@pytest.mark.anyio
async def test_accepts_bearer_token_type_case_insensitive(self):
token_resp = MagicMock()
token_resp.raise_for_status = MagicMock()
token_resp.json.return_value = {
"access_token": "tok123",
"token_type": "Bearer",
}
userinfo_resp = MagicMock()
userinfo_resp.raise_for_status = MagicMock()
userinfo_resp.json.return_value = {"sub": "user-1"}
cm, _ = _make_async_client_mock(
post_return=token_resp, get_return=userinfo_resp
)
with patch("httpx.AsyncClient", return_value=cm):
result = await oauth_fetch_userinfo(
token_url="https://auth.example.com/token",
userinfo_url="https://auth.example.com/userinfo",
code="authcode123",
client_id="client-id",
client_secret="client-secret",
redirect_uri="https://app.example.com/callback",
)
assert result == {"sub": "user-1"}
@pytest.mark.anyio
async def test_raises_when_required_scopes_not_granted(self):
token_resp = MagicMock()
token_resp.raise_for_status = MagicMock()
token_resp.json.return_value = {"access_token": "tok123", "scope": "openid"}
cm, _ = _make_async_client_mock(post_return=token_resp, get_return=MagicMock())
with patch("httpx.AsyncClient", return_value=cm):
with pytest.raises(ValueError, match="required scopes"):
await oauth_fetch_userinfo(
token_url="https://auth.example.com/token",
userinfo_url="https://auth.example.com/userinfo",
code="authcode123",
client_id="client-id",
client_secret="client-secret",
redirect_uri="https://app.example.com/callback",
required_scopes="openid email profile",
)
@pytest.mark.anyio
async def test_passes_when_all_required_scopes_granted(self):
token_resp = MagicMock()
token_resp.raise_for_status = MagicMock()
token_resp.json.return_value = {
"access_token": "tok123",
"scope": "openid email profile",
}
userinfo_resp = MagicMock()
userinfo_resp.raise_for_status = MagicMock()
userinfo_resp.json.return_value = {"sub": "user-1", "email": "a@b.com"}
cm, _ = _make_async_client_mock(
post_return=token_resp, get_return=userinfo_resp
)
with patch("httpx.AsyncClient", return_value=cm):
result = await oauth_fetch_userinfo(
token_url="https://auth.example.com/token",
userinfo_url="https://auth.example.com/userinfo",
code="authcode123",
client_id="client-id",
client_secret="client-secret",
redirect_uri="https://app.example.com/callback",
required_scopes="openid email",
)
assert result["email"] == "a@b.com"
Generated
+44 -65
View File
@@ -33,15 +33,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
] ]
[[package]]
name = "async-lru"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6", size = 16332, upload-time = "2026-03-19T01:04:32.413Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" },
]
[[package]] [[package]]
name = "asyncpg" name = "asyncpg"
version = "0.31.0" version = "0.31.0"
@@ -314,7 +305,7 @@ wheels = [
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.136.1" version = "0.136.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-doc" }, { name = "annotated-doc" },
@@ -323,14 +314,14 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } sdist = { url = "https://files.pythonhosted.org/packages/4e/d9/e66315807e41e69e7f6a1b42a162dada2f249c5f06ad3f1a95f84ab336ef/fastapi-0.136.0.tar.gz", hash = "sha256:cf08e067cc66e106e102d9ba659463abfac245200752f8a5b7b1e813de4ff73e", size = 396607, upload-time = "2026-04-16T11:47:13.623Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, { url = "https://files.pythonhosted.org/packages/26/a3/0bd5f0cdb0bbc92650e8dc457e9250358411ee5d1b65e42b6632387daf81/fastapi-0.136.0-py3-none-any.whl", hash = "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4", size = 117556, upload-time = "2026-04-16T11:47:11.922Z" },
] ]
[[package]] [[package]]
name = "fastapi-toolsets" name = "fastapi-toolsets"
version = "4.0.0" version = "3.1.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "asyncpg" }, { name = "asyncpg" },
@@ -341,7 +332,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,14 +349,9 @@ 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 = [
{ name = "async-lru" },
{ name = "bcrypt" }, { name = "bcrypt" },
{ name = "coverage" }, { name = "coverage" },
{ name = "fastapi-toolsets", extra = ["all"] }, { name = "fastapi-toolsets", extra = ["all"] },
@@ -391,7 +376,6 @@ docs-src = [
{ name = "bcrypt" }, { name = "bcrypt" },
] ]
tests = [ tests = [
{ name = "async-lru" },
{ name = "coverage" }, { name = "coverage" },
{ name = "httpx" }, { name = "httpx" },
{ name = "pytest" }, { name = "pytest" },
@@ -402,12 +386,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,11 +397,10 @@ 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 = [
{ name = "async-lru", specifier = ">=1.0" },
{ name = "bcrypt", specifier = ">=4.0.0" }, { name = "bcrypt", specifier = ">=4.0.0" },
{ name = "coverage", specifier = ">=7.0.0" }, { name = "coverage", specifier = ">=7.0.0" },
{ name = "fastapi-toolsets", extras = ["all"] }, { name = "fastapi-toolsets", extras = ["all"] },
@@ -442,7 +423,6 @@ docs = [
] ]
docs-src = [{ name = "bcrypt", specifier = ">=4.0.0" }] docs-src = [{ name = "bcrypt", specifier = ">=4.0.0" }]
tests = [ tests = [
{ name = "async-lru", specifier = ">=1.0" },
{ name = "coverage", specifier = ">=7.0.0" }, { name = "coverage", specifier = ">=7.0.0" },
{ name = "httpx", specifier = ">=0.25.0" }, { name = "httpx", specifier = ">=0.25.0" },
{ name = "pytest", specifier = ">=8.0.0" }, { name = "pytest", specifier = ">=8.0.0" },
@@ -836,35 +816,35 @@ wheels = [
[[package]] [[package]]
name = "prek" name = "prek"
version = "0.3.13" version = "0.3.11"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3c/59/0a279983f96bd5d538b4975f0a23121082aa3b8560b6649fdf61f8011b07/prek-0.3.13.tar.gz", hash = "sha256:c48586ee3708bfbf3df80121f55583e9a7d0fa166b08172c091fe5971e92a0ac", size = 444848, upload-time = "2026-05-05T18:07:09.076Z" } sdist = { url = "https://files.pythonhosted.org/packages/6c/60/5b980c70525ca5f0d17942d8eae13b399051aa384413366fe5df229712ea/prek-0.3.11.tar.gz", hash = "sha256:c4cf77848009503c58d80ff216e32af45b63ea49652bb5546748c1ebfd4d9847", size = 433440, upload-time = "2026-04-27T04:22:59.923Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/6a/9baa2bda21dccc2927e952416f6cc23a75eb99c9ed18837164ac2e4a5640/prek-0.3.13-py3-none-linux_armv6l.whl", hash = "sha256:b00d38f01235073c35aa5f48df57fefef45a6cec2ae0884d750345a2c7220370", size = 5506622, upload-time = "2026-05-05T18:06:53.091Z" }, { url = "https://files.pythonhosted.org/packages/ee/2a/3392fa7d1fd1ce538915baa7597e7203bbe888367a8b15bfd51ca74d4714/prek-0.3.11-py3-none-linux_armv6l.whl", hash = "sha256:787e605716cfdc86ec01e7c5cf62799f39c28d49de5e37d75595c8e6248cb0f3", size = 5423112, upload-time = "2026-04-27T04:22:52.659Z" },
{ url = "https://files.pythonhosted.org/packages/56/77/d44b5d9bdca0879b865f8e47bf84cf5dc9e8b358d029e6d9b83d8809c116/prek-0.3.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0d89ac712c60e34d1550a606ad5fdfb8ad71d44ced8afa2fa5cbc106be4abd9e", size = 5878743, upload-time = "2026-05-05T18:07:23.164Z" }, { url = "https://files.pythonhosted.org/packages/a9/b0/3fc653b30b70d6c2714fc56bcfe1c2439437fc38f60b72bc300603ace4cd/prek-0.3.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ef1f37187ca52d75ba9c46b53007476c4eab2c3f11bd23defd57a81c62d90442", size = 5801382, upload-time = "2026-04-27T04:23:04.464Z" },
{ url = "https://files.pythonhosted.org/packages/08/cf/19e8525cde8b3aa12858aca434d1fa653ef3b152da5af11eafc857634dc2/prek-0.3.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f9b5265863d18b5be4ea094fdce4fd6ca61a8c89a70ee3d8ee153b3e0ed6b272", size = 5434909, upload-time = "2026-05-05T18:07:25.276Z" }, { url = "https://files.pythonhosted.org/packages/2e/46/39aedc7843c3703f1f43b686622e4f8cd123e03b87a163e5c8f2fbd56cda/prek-0.3.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d0828a1b50447502ea1be3f5a84da474fdca558cd5d76a1a5205169bb808c7", size = 5370817, upload-time = "2026-04-27T04:22:49.277Z" },
{ url = "https://files.pythonhosted.org/packages/7a/9a/e5f97194782de4dab622ce09dafb3ebdd2ee4d354a83ac4def7ebeee236c/prek-0.3.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:64b59a1550780af2bba37297c704b17f81d8e9df6288af1fab4017938e33b1db", size = 5697536, upload-time = "2026-05-05T18:07:05.475Z" }, { url = "https://files.pythonhosted.org/packages/15/83/df5f3aeacbdea96a88c4f06c98d3932469711fed4e3bf5b703dd6507abe7/prek-0.3.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bf49464b526ee36d2130baf60ab9580560bfaa60efd2997328e6d6671e209014", size = 5621405, upload-time = "2026-04-27T04:23:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/c4/8c/e1f548ffc4b227e4c2b5a9b30f5978a7e0e6dad51305b97a2ba5b2a923e7/prek-0.3.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9ce6cd8f114ba9bbdbe97422103fd886101949b1c42e588a7543c4436ead2020", size = 5428160, upload-time = "2026-05-05T18:07:01.489Z" }, { url = "https://files.pythonhosted.org/packages/8f/f2/e32c9720747a327669863a4f92d05b9e6fadb851e903b0d7310a97c956a4/prek-0.3.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac344529f0d34757c7c95f65e66b9f6440a691f826eaf43f503247bd22023558", size = 5339780, upload-time = "2026-04-27T04:22:47.614Z" },
{ url = "https://files.pythonhosted.org/packages/8a/44/abd919b00905a32d21dca2cec32c707860cf217da2431b62dd52684b310e/prek-0.3.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc03e924a24d8d961f56195853c8b206cb196be6db4ad8312125dae847d718ac", size = 5827275, upload-time = "2026-05-05T18:07:17.437Z" }, { url = "https://files.pythonhosted.org/packages/29/2e/0e2f71b63bc2e5372575d5c1574b0666d2f90d30da51ed706a32cbf465a0/prek-0.3.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83672d963f249e2f246d3bec97f8fd2e8032e70da0a7d9acb2fa38af76dd82d2", size = 5735277, upload-time = "2026-04-27T04:23:12.437Z" },
{ url = "https://files.pythonhosted.org/packages/af/ed/cafd2b80d58a83faf8371c6543bd1475a2224242a3294da7f8582f6aa551/prek-0.3.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7ca8c526a23873177fb3b92013500b08ef5f8bedc7263f9f3a44dd2f49645a26", size = 6710293, upload-time = "2026-05-05T18:07:10.663Z" }, { url = "https://files.pythonhosted.org/packages/09/46/88abf51ac88eeff1ad2fe7d1797ca1fea43eb1ac1ddb8331463ac5b27ed2/prek-0.3.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef70957195d2896a30dd849e64f88344df7bb51af9c950cf16bd11519e7424b0", size = 6622420, upload-time = "2026-04-27T04:23:05.982Z" },
{ url = "https://files.pythonhosted.org/packages/35/09/52a4a27596b764173a34d74db09356b30faaacb4a1075b75adbc036a0008/prek-0.3.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5bbb175478438a871e3281d2c3c3f067288af73ad81707a9bdebfd769766c7d", size = 6096556, upload-time = "2026-05-05T18:07:19.46Z" }, { url = "https://files.pythonhosted.org/packages/5d/b6/592028a45b084a68b76c7edef909c789d1c96b26761388f63659beef7166/prek-0.3.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4f0cc07d2cdb5fe2882015d5fdafc9af98b4c560d4caa1ae948caeab4341b79", size = 6020038, upload-time = "2026-04-27T04:22:54.367Z" },
{ url = "https://files.pythonhosted.org/packages/63/60/80f61729ce6498815d46d5580cf76da2c157c9b6494046183682441a0ea3/prek-0.3.13-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a65327a014d838341af757dfc05a706d10e8e33f039bc32bb3dbe2fa21c440c0", size = 5693267, upload-time = "2026-05-05T18:07:03.66Z" }, { url = "https://files.pythonhosted.org/packages/ca/f7/e97f55a1645a2e9becffeee28892ad8bb66cd144dabfa4392ea8e2674bbe/prek-0.3.11-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d7554b436dae2ec97f4351a46817e3561657244307d1c0915f355b859f4fab71", size = 5622539, upload-time = "2026-04-27T04:23:01.314Z" },
{ url = "https://files.pythonhosted.org/packages/36/9d/c7a663fe70676ffab2e0c6c9a71997a3ccd002ed5bc60b7422a937911af0/prek-0.3.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b6a200843a36a5b0c41764ce7639ccb3471d48b097f1c5e3fc8f034219b42626", size = 5532865, upload-time = "2026-05-05T18:07:15.237Z" }, { url = "https://files.pythonhosted.org/packages/41/e2/f3119eef6b621782ad216a86d449609858ea34c57cf4a40fc6dc80556d7e/prek-0.3.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:caba5d635a5b64b7ac64d903f29b043ca5b0d9d9693543a0ef331ade89e6ad3f", size = 5440681, upload-time = "2026-04-27T04:23:07.422Z" },
{ url = "https://files.pythonhosted.org/packages/32/68/506ef5a235536030e16f61e7210474554f6e05f845f27df5877d2dbb1a06/prek-0.3.13-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:bdacaad8f35f343e063d251211fe34db1de9e5cc591795361ad69a6485202258", size = 5395951, upload-time = "2026-05-05T18:06:55.183Z" }, { url = "https://files.pythonhosted.org/packages/04/62/22dd4f59a47654faeebe74651182ecc48d436542646cc92723052dfd9a45/prek-0.3.11-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:c95a63f19dde48e84b70bd63a235670834af15fa4df8b85d8b7894dd5bc419a9", size = 5314773, upload-time = "2026-04-27T04:22:57.912Z" },
{ url = "https://files.pythonhosted.org/packages/3e/00/22d7c6db7f43b58f7d015913c12660c9bbc82751cff6cfd8c31993cf30eb/prek-0.3.13-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f00328f1c520d8fefb910ab0d3c6764ee330d227952baa19b7e3de7242bd8b3b", size = 5681195, upload-time = "2026-05-05T18:07:12.804Z" }, { url = "https://files.pythonhosted.org/packages/bb/94/a8361462acb8d8f5b8505255b95ffbfc2ee0872a79b4e066eb330692f7be/prek-0.3.11-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7353b45f44a386c676fe96ba72a5ee326b676f789339f405cf6f1d69a1707194", size = 5596208, upload-time = "2026-04-27T04:23:09.08Z" },
{ url = "https://files.pythonhosted.org/packages/10/e3/fdf9882238796914ddaf11381a9083b374980156200a953324f6c795f34d/prek-0.3.13-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e5530a867bcf5b172b7513a64e71b06a337d1d184696227ae953845867376b8d", size = 6212085, upload-time = "2026-05-05T18:07:07.213Z" }, { url = "https://files.pythonhosted.org/packages/04/0c/5f065b86bbeb9977074a055d8a05e90c7201f6c4c7032dada61739b5f8cb/prek-0.3.11-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:39f4e86176ccbb70c098df6abbc8e36c1d86cb81281abe92fb79dcd572418214", size = 6132833, upload-time = "2026-04-27T04:22:56.054Z" },
{ url = "https://files.pythonhosted.org/packages/ca/1d/528759931344b5c7103085798f5fa2e86d27d9410b753a6bcbe7726aa8ba/prek-0.3.13-py3-none-win32.whl", hash = "sha256:326fac2bdce00074ce6c5046b861d310638aee2b9de1ed241ba7eb32bdc83898", size = 5199566, upload-time = "2026-05-05T18:07:21.416Z" }, { url = "https://files.pythonhosted.org/packages/19/0c/8ab0ae140201dcee505f58b60abbe56bd05ac96b821a6866f6f90c4d971f/prek-0.3.11-py3-none-win32.whl", hash = "sha256:35d2361049653a3dcf27227b7f1b340c5c42a12c0e0361c4b785921bfd125839", size = 5120856, upload-time = "2026-04-27T04:23:02.752Z" },
{ url = "https://files.pythonhosted.org/packages/6b/d0/8715ee837c73314a02767d20652cc312d1b6ff6733fa00f52de2b648bc3a/prek-0.3.13-py3-none-win_amd64.whl", hash = "sha256:841049f89f5ec9f4035299283d11e566ac5a068e3742ead1055ea04f886831fc", size = 5589599, upload-time = "2026-05-05T18:06:57.28Z" }, { url = "https://files.pythonhosted.org/packages/57/05/9844c1125d3714f6f6c7b475884128a4b0c6c3ee0cd208ead44ca8174687/prek-0.3.11-py3-none-win_amd64.whl", hash = "sha256:a387689cd2e182f92dbb681151ee5a04f494fe97e95d6d783875da90b950e6d5", size = 5510916, upload-time = "2026-04-27T04:22:45.704Z" },
{ url = "https://files.pythonhosted.org/packages/ff/cf/0af0b15be0ebd82f7e50adee149b05a73533d78cb1b97cb889f0647ebffe/prek-0.3.13-py3-none-win_arm64.whl", hash = "sha256:a9fd74e0aec550c6b8d41076fdcdd6ff121cd7d94d743c1338bd794784e3c775", size = 5419029, upload-time = "2026-05-05T18:06:59.645Z" }, { url = "https://files.pythonhosted.org/packages/ff/13/24b0288c553dc8d61f44c4d0746fe9bb1e1bd29d1e70571658536e4c0f72/prek-0.3.11-py3-none-win_arm64.whl", hash = "sha256:e4a8f900378a6657c7eb2fc4b12fa5c934edf209d0a24544539842479ec16e0b", size = 5345988, upload-time = "2026-04-27T04:22:50.918Z" },
] ]
[[package]] [[package]]
name = "prometheus-client" name = "prometheus-client"
version = "0.25.0" version = "0.24.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" },
] ]
[[package]] [[package]]
@@ -1353,7 +1333,7 @@ wheels = [
[[package]] [[package]]
name = "typer" name = "typer"
version = "0.25.1" version = "0.25.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-doc" }, { name = "annotated-doc" },
@@ -1361,9 +1341,9 @@ dependencies = [
{ name = "rich" }, { name = "rich" },
{ name = "shellingham" }, { name = "shellingham" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } sdist = { url = "https://files.pythonhosted.org/packages/7b/27/ede8cec7596e0041ba7e7b80b47d132562f56ff454313a16f6084e555c9f/typer-0.25.0.tar.gz", hash = "sha256:123eaf9f19bb40fd268310e12a542c0c6b4fab9c98d9d23342a01ff95e3ce930", size = 120150, upload-time = "2026-04-26T08:46:14.767Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, { url = "https://files.pythonhosted.org/packages/9a/72/193d4e586ec5a4db834a36bbeb47641a62f951f114ffd0fe5b1b46e8d56f/typer-0.25.0-py3-none-any.whl", hash = "sha256:ac01b48823d3db9a83c9e164338057eadbb1c9957a2a6b4eeb486669c560b5dc", size = 55993, upload-time = "2026-04-26T08:46:15.889Z" },
] ]
[[package]] [[package]]
@@ -1425,30 +1405,29 @@ wheels = [
[[package]] [[package]]
name = "zensical" name = "zensical"
version = "0.0.40" version = "0.0.37"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
{ name = "deepmerge" }, { name = "deepmerge" },
{ name = "jinja2" },
{ name = "markdown" }, { name = "markdown" },
{ name = "pygments" }, { name = "pygments" },
{ name = "pymdown-extensions" }, { name = "pymdown-extensions" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "tomli" }, { name = "tomli" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ba/a6/88062f7e235f58a5f05d82005fc35d9dbaed27c024fe9ffae5bce7f33661/zensical-0.0.40.tar.gz", hash = "sha256:5c294751977a664614cb84e987186ad8e282af77ce0d0d800fe48ee57791279d", size = 3920555, upload-time = "2026-05-04T16:19:07.962Z" } sdist = { url = "https://files.pythonhosted.org/packages/7c/57/f499eb86c487953866ab7ff364096b92d9a61fd68d38ca73f740bc42b78e/zensical-0.0.37.tar.gz", hash = "sha256:e43a59e939fbddb50d218aa5b643c24d0e7259155e9e36fb791e5f865af588d5", size = 3903829, upload-time = "2026-04-27T07:59:14.885Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/c4/3066f4442923ca1e49269147b70ca7c84467524e8f5228724693b9ac85c2/zensical-0.0.40-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b65a7143c9c6a460880bf3e65b777952bd2dcede9dd17a6c6bac9b4a0686ad9b", size = 12691533, upload-time = "2026-05-04T16:18:31.72Z" }, { url = "https://files.pythonhosted.org/packages/66/fb/63175f1d6785616541a29b30a3a749a04a1c334d431bbae2635399612705/zensical-0.0.37-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f75668ec86f4da8302862a272a6f02a32b7384e5518842960de592e0e7b522c7", size = 12509324, upload-time = "2026-04-27T07:58:41.711Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cb/03e961cbd01620ea91aeb835b0b4e8848c7bcdf5a799a620fb3e57bfc277/zensical-0.0.40-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:045bdcb6d00a11ddcab7d379d0d986cdf78dba8e9287d8e628ef11958241507d", size = 12556486, upload-time = "2026-05-04T16:18:35.278Z" }, { url = "https://files.pythonhosted.org/packages/34/19/6c981a60ad0758b4b8601589c5016b5bf8724b066346c933af9600e3a3c0/zensical-0.0.37-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d853c285ff942883a6f93e665e12af2f4d83b682bbff16db02ce7f058f70d7a4", size = 12383773, upload-time = "2026-04-27T07:58:44.745Z" },
{ url = "https://files.pythonhosted.org/packages/60/76/7dde50220808bdc5f5e63b97866a684418410b3cae9d00cdae1d449bcc20/zensical-0.0.40-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d48ec476c2e8ce3f8585a1278083aabc35ec80361f2c4fc4a53b9a525778f7fc", size = 12935602, upload-time = "2026-05-04T16:18:38.308Z" }, { url = "https://files.pythonhosted.org/packages/11/b0/5fd6d066ca8d9fea9b4ccafddc4b26f938c584864c648d6be29e5515787b/zensical-0.0.37-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bfd5d4afa750c785a2671f586b6b82f18c906c066990ab374f76ef7d359221ce", size = 12779684, upload-time = "2026-04-27T07:58:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/51/55/6c8ef951c390b42249738f4338498e7a1fd64ff09e44d7cc19f5c948c45b/zensical-0.0.40-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48c38e0ae314c25f2e5e64210bbad9be6e970f2d40fe9da106586ad90ce5e85e", size = 12904314, upload-time = "2026-05-04T16:18:41.007Z" }, { url = "https://files.pythonhosted.org/packages/ed/9f/30daa249ab326d059311c51e802957a1e0bdd0505ae7d8d30c19fb00672c/zensical-0.0.37-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48c3e7e33b15d2602de4670171ab119e2e7500b34aa7a1eb1d8abb26a3725240", size = 12726437, upload-time = "2026-04-27T07:58:50.028Z" },
{ url = "https://files.pythonhosted.org/packages/f4/ae/95008f5dc2ee441efcdc2fab36ff29ce24d7477e53390fc340c8add39342/zensical-0.0.40-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f25f62dcd61f6306cab890dfa34c81d2709f5db290b4c3f2675343771db28c90", size = 13269946, upload-time = "2026-05-04T16:18:44.387Z" }, { url = "https://files.pythonhosted.org/packages/4e/3c/9c1a1de28fa807b81746f58eef5f29cf353873fb7fcc2b6af51645fd4569/zensical-0.0.37-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:649c39cec8b3805b0e4abb8484f1ef85582792cea2f7376b33aa1263fe87ad95", size = 13073605, upload-time = "2026-04-27T07:58:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/b9/96/cdbb2bf04255ccaaa07861bdda1ee8dd1630d2233fc2f09636abbd5e084c/zensical-0.0.40-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:168fe3489dd93ae92978b4db11d9300c63e10d382b81634232c2872ce9e746c2", size = 12974962, upload-time = "2026-05-04T16:18:47.462Z" }, { url = "https://files.pythonhosted.org/packages/06/31/de06d2481581891839f9e2a1395fe32bfb9c9eb98cd1d635c560fb759ac6/zensical-0.0.37-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4e23ef4245b07bde6d6d8dd2ab902f34f6d3cd459fd136f6d0345cb87ccbd6c", size = 12804476, upload-time = "2026-04-27T07:58:55.478Z" },
{ url = "https://files.pythonhosted.org/packages/6f/ce/66e86f89fc15bbe667794ba67d7efc8fa72fe7a1be19e1efb4246ff55442/zensical-0.0.40-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8652ba203bd588ebf2d66bda4457a4a7d8e193c886960859c75081c0e3b946de", size = 13111599, upload-time = "2026-05-04T16:18:50.14Z" }, { url = "https://files.pythonhosted.org/packages/54/d0/5bbc27820d6cd622ddd2de5df3743a619003dcec01d51365e06ad623d984/zensical-0.0.37-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:21c6b629bc46062f8cb53e5ad575720df384e4b7db34770f3aeb3f097c3eb4cc", size = 12954884, upload-time = "2026-04-27T07:58:58.108Z" },
{ url = "https://files.pythonhosted.org/packages/87/76/3d71ebdabb02d79a5c523b5e646141c362c9559947078c8d56a9f3bd7a30/zensical-0.0.40-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9ffa6cf208b7ab6b771703be827d4d8c7f07f173abeffb35a8015a0b832b2a40", size = 13175406, upload-time = "2026-05-04T16:18:53.209Z" }, { url = "https://files.pythonhosted.org/packages/df/9f/e5c7624ccfe792254f22f8dbc7e2e4c30bf3ed14ad830e838b0009b42a58/zensical-0.0.37-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:a5f95ebe531264b36cacabf208f3ea7cb7e9d874de857bb6bd3328cdabcf9257", size = 12997404, upload-time = "2026-04-27T07:59:00.488Z" },
{ url = "https://files.pythonhosted.org/packages/e2/6a/2bb5f730786d590f02cb0fef796c148d5ac0d5c1556f2d78c987ad4e1346/zensical-0.0.40-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:7101ba0c739c78bc3a57d22130b59b9e6fdf96c21c8a6b4244070de6b34527d4", size = 13324783, upload-time = "2026-05-04T16:18:56.41Z" }, { url = "https://files.pythonhosted.org/packages/d1/7a/70fe375ce974cdfd4eb42df53340c5b2e790f6a1d20a224f6ab5283c0a32/zensical-0.0.37-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:96375cd6338c2db731ce671e41afc108194552f5ceb75edd14a54eea48adfb89", size = 13139582, upload-time = "2026-04-27T07:59:03.452Z" },
{ url = "https://files.pythonhosted.org/packages/2f/8c/1d2ba1454360ee948dd0f0807b048c076d9578d0d9ebba2a438ecfa9f82f/zensical-0.0.40-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:39bf728a68a5418feeda8f3385cd1063fdb8d896a6812c3dede4267b2868df12", size = 13260045, upload-time = "2026-05-04T16:18:59.244Z" }, { url = "https://files.pythonhosted.org/packages/07/49/0a7b27c89d52d0116ac466845f88495acb0b925405527df0079b2ad56508/zensical-0.0.37-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7be0976869317a2e1672f426853183c32a8193f211347d560d6037358a52fe7b", size = 13082714, upload-time = "2026-04-27T07:59:06.375Z" },
{ url = "https://files.pythonhosted.org/packages/6c/61/efd51c5c5e15cfd5498d59df250f60294cc44d36d8ce4dc2a76fa3669c2f/zensical-0.0.40-cp310-abi3-win32.whl", hash = "sha256:bc750c3ba8d11833d9b9ac8fc14adc3435225b6d17314a21a91eb60209511ca5", size = 12244913, upload-time = "2026-05-04T16:19:02.219Z" }, { url = "https://files.pythonhosted.org/packages/ea/25/81c3f1de09abf571a7d6fdab3c836e37cc5a093596939d8d3af745251ae7/zensical-0.0.37-cp310-abi3-win32.whl", hash = "sha256:e1295f63230621ccb01ac7cd6be24f0ff079a12e95d9bdde631a13331c7ba67f", size = 12093854, upload-time = "2026-04-27T07:59:09.244Z" },
{ url = "https://files.pythonhosted.org/packages/fe/9e/f3f2118fbcfd1c2dc705491c8864c596b1a748b67ffe2a024e512b9201ab/zensical-0.0.40-cp310-abi3-win_amd64.whl", hash = "sha256:c5c86ac468df2dfe515ff54ffa97725c38226f1e5c970059b7e88078abab89ab", size = 12475762, upload-time = "2026-05-04T16:19:05.025Z" }, { url = "https://files.pythonhosted.org/packages/a0/77/419377bee6b91746c5c3de19712eeba6c367c0b14b77c6b9c94e6bf2c3a2/zensical-0.0.37-cp310-abi3-win_amd64.whl", hash = "sha256:c015451bd9af60ee0fb01b95d0b17b751a0790fec58c3e0efd11ee4286ad2724", size = 12317524, upload-time = "2026-04-27T07:59:11.929Z" },
] ]
-1
View File
@@ -147,7 +147,6 @@ Examples = [
[[project.nav]] [[project.nav]]
Migration = [ Migration = [
{"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"},
] ]