mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-04-16 06:36:26 +02:00
Compare commits
87 Commits
v1.3.0
...
f1bd4e42d7
| Author | SHA1 | Date | |
|---|---|---|---|
| f1bd4e42d7 | |||
| 4c8448629d | |||
| e3c3f5f8a3 | |||
| c785b4c1ef | |||
|
9b74f162ab
|
|||
|
|
ab125c6ea1 | ||
|
|
e388e26858 | ||
|
|
04da241294 | ||
|
|
bbe63edc46 | ||
|
|
0b17c77dee | ||
|
|
bce71bfd42 | ||
|
|
2f1eb4d468 | ||
|
|
1f06eab11d | ||
|
|
fac9aa6f60 | ||
|
|
f310466697 | ||
|
|
32059dcb02 | ||
|
|
f027981e80 | ||
|
|
5c1487c24a | ||
|
|
ebaa61525f | ||
|
|
4829cfba73 | ||
|
|
9ca2da4213 | ||
|
|
0b3f097012 | ||
|
|
1890d696bf | ||
|
|
104285c6e5 | ||
|
|
f5afbbe37f | ||
|
|
f4698bea8a | ||
|
|
5215b921ae | ||
|
9dad59e25d
|
|||
|
|
29326ab532 | ||
|
|
04afef7e33 | ||
|
|
666c621fda | ||
|
460b760fa4
|
|||
|
|
65d0b0e0b1 | ||
|
|
2d49cd32db | ||
|
|
a5dd756d87 | ||
|
|
781cfb66c9 | ||
|
|
91b84f8146 | ||
|
|
396e381ac3 | ||
|
|
b4eb4c1ca9 | ||
|
c90717754f
|
|||
|
337985ef38
|
|||
|
|
b5e6dfe6fe | ||
|
|
6681b7ade7 | ||
|
|
6981c33dc8 | ||
|
|
0c7a99039c | ||
|
|
bcb5b0bfda | ||
|
100e1c1aa9
|
|||
|
|
db6c7a565f | ||
|
|
768e405554 | ||
|
|
f0223ebde4 | ||
|
|
f8c9bf69fe | ||
|
6d6fae5538
|
|||
|
|
fc9cd1f034 | ||
|
|
f82225f995 | ||
|
|
e62612a93a | ||
|
|
56f0ea291e | ||
|
|
ee896009ee | ||
|
|
65bf928e12 | ||
|
|
2e9c6c0c90 | ||
|
2c494fcd17
|
|||
|
|
fd7269a372 | ||
|
|
c863744012 | ||
|
|
aedcbf4e04 | ||
|
|
19c013bdec | ||
|
|
81407c3038 | ||
|
|
0fb00d44da | ||
|
|
19232d3436 | ||
|
1eafcb3873
|
|||
|
|
0d67fbb58d | ||
|
|
a59f098930 | ||
|
|
96e34ba8af | ||
|
|
26d649791f | ||
|
dde5183e68
|
|||
|
|
e4250a9910 | ||
|
|
4800941934 | ||
|
0cc21d2012
|
|||
|
|
a3245d50f0 | ||
|
|
baebf022f6 | ||
|
|
96d445e3f3 | ||
|
|
80306e1af3 | ||
|
|
fd999b63f1 | ||
|
|
c0f352b914 | ||
|
c4c760484b
|
|||
|
|
432e0722e0 | ||
|
|
e732e54518 | ||
|
|
05b5a2c876 | ||
|
|
4a020c56d1 |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -93,7 +93,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
if: matrix.python-version == '3.14'
|
if: matrix.python-version == '3.14'
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v6
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
report_type: coverage
|
report_type: coverage
|
||||||
@@ -102,7 +102,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload test results to Codecov
|
- name: Upload test results to Codecov
|
||||||
if: matrix.python-version == '3.14'
|
if: matrix.python-version == '3.14'
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v6
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
report_type: test_results
|
report_type: test_results
|
||||||
|
|||||||
43
.github/workflows/docs.yml
vendored
43
.github/workflows/docs.yml
vendored
@@ -5,20 +5,15 @@ on:
|
|||||||
types: [published]
|
types: [published]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/configure-pages@v5
|
|
||||||
|
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v7
|
||||||
@@ -28,11 +23,31 @@ jobs:
|
|||||||
|
|
||||||
- run: uv sync --group dev
|
- run: uv sync --group dev
|
||||||
|
|
||||||
- run: uv run zensical build --clean
|
- name: Configure git
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
- uses: actions/upload-pages-artifact@v4
|
- name: Deploy documentation
|
||||||
with:
|
run: |
|
||||||
path: site
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
|
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||||
|
DEPLOY_VERSION="v$(echo "$VERSION" | cut -d. -f1-2)"
|
||||||
|
|
||||||
- uses: actions/deploy-pages@v4
|
# On new major: keep only the latest feature version of the previous major
|
||||||
id: deployment
|
PREV_MAJOR=$((MAJOR - 1))
|
||||||
|
OLD_FEATURE_VERSIONS=$(uv run mike list 2>/dev/null | grep -oE "^v${PREV_MAJOR}\.[0-9]+" || true)
|
||||||
|
|
||||||
|
if [ -n "$OLD_FEATURE_VERSIONS" ]; then
|
||||||
|
LATEST_PREV=$(echo "$OLD_FEATURE_VERSIONS" | sort -t. -k2 -n | tail -1)
|
||||||
|
echo "$OLD_FEATURE_VERSIONS" | while read -r OLD_V; do
|
||||||
|
if [ "$OLD_V" != "$LATEST_PREV" ]; then
|
||||||
|
echo "Deleting $OLD_V"
|
||||||
|
uv run mike delete "$OLD_V"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
uv run mike deploy --update-aliases "$DEPLOY_VERSION" stable
|
||||||
|
uv run mike set-default stable
|
||||||
|
git push origin gh-pages
|
||||||
|
|||||||
34
.pre-commit-config.yaml
Normal file
34
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# See https://pre-commit.com for more information
|
||||||
|
# See https://pre-commit.com/hooks.html for more hooks
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v6.0.0
|
||||||
|
hooks:
|
||||||
|
- id: check-added-large-files
|
||||||
|
args: ["--maxkb=750"]
|
||||||
|
exclude: ^uv.lock$
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: trailing-whitespace
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: local-ruff-check
|
||||||
|
name: ruff check
|
||||||
|
entry: uv run ruff check --force-exclude --fix --exit-non-zero-on-fix .
|
||||||
|
require_serial: true
|
||||||
|
language: unsupported
|
||||||
|
types: [python]
|
||||||
|
|
||||||
|
- id: local-ruff-format
|
||||||
|
name: ruff format
|
||||||
|
entry: uv run ruff format --force-exclude --exit-non-zero-on-format .
|
||||||
|
require_serial: true
|
||||||
|
language: unsupported
|
||||||
|
types: [python]
|
||||||
|
|
||||||
|
- id: local-ty
|
||||||
|
name: ty check
|
||||||
|
entry: uv run ty check
|
||||||
|
require_serial: true
|
||||||
|
language: unsupported
|
||||||
|
pass_filenames: false
|
||||||
12
README.md
12
README.md
@@ -20,7 +20,7 @@ A modular collection of production-ready utilities for FastAPI. Install only wha
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
The base package includes the core modules (CRUD, database, schemas, exceptions, fixtures, dependencies, logging):
|
The base package includes the core modules (CRUD, database, schemas, exceptions, fixtures, dependencies, model mixins, logging):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv add fastapi-toolsets
|
uv add fastapi-toolsets
|
||||||
@@ -29,9 +29,9 @@ uv add fastapi-toolsets
|
|||||||
Install only the extras you need:
|
Install only the extras you need:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]" # CLI (typer)
|
uv add "fastapi-toolsets[cli]"
|
||||||
uv add "fastapi-toolsets[metrics]" # Prometheus metrics (prometheus_client)
|
uv add "fastapi-toolsets[metrics]"
|
||||||
uv add "fastapi-toolsets[pytest]" # Pytest helpers (httpx, pytest-xdist)
|
uv add "fastapi-toolsets[pytest]"
|
||||||
```
|
```
|
||||||
|
|
||||||
Or install everything:
|
Or install everything:
|
||||||
@@ -48,7 +48,9 @@ uv add "fastapi-toolsets[all]"
|
|||||||
- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection
|
- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection
|
||||||
- **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters
|
- **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters
|
||||||
- **Fixtures**: Fixture system with dependency management, context support, and pytest integration
|
- **Fixtures**: Fixture system with dependency management, context support, and pytest integration
|
||||||
- **Standardized API Responses**: Consistent response format with `Response`, `PaginatedResponse`, and `PydanticBase`
|
- **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `UUIDv7Mixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`)
|
||||||
|
- **Lifecycle Events**: Post-commit event system (`EventSession`, `listens_for`) that dispatches async/sync callbacks for insert, update, and delete operations
|
||||||
|
- **Standardized API Responses**: Consistent response format with `Response`, `ErrorResponse`, `PaginatedResponse`, `CursorPaginatedResponse` and `OffsetPaginatedResponse`.
|
||||||
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
||||||
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
|
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
|
||||||
|
|
||||||
|
|||||||
1
docs/examples/authentication.md
Normal file
1
docs/examples/authentication.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Authentication
|
||||||
@@ -42,12 +42,17 @@ Declare `searchable_fields`, `facet_fields`, and `order_fields` once on [`CrudFa
|
|||||||
|
|
||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
|
```python title="routes.py:1:16"
|
||||||
|
--8<-- "docs_src/examples/pagination_search/routes.py:1:16"
|
||||||
|
```
|
||||||
|
|
||||||
### Offset pagination
|
### Offset pagination
|
||||||
|
|
||||||
Best for admin panels or any UI that needs a total item count and numbered pages.
|
Best for admin panels or any UI that needs a total item count and numbered pages.
|
||||||
|
|
||||||
```python title="routes.py:1:36"
|
```python title="routes.py:19:37"
|
||||||
--8<-- "docs_src/examples/pagination_search/routes.py:1:36"
|
--8<-- "docs_src/examples/pagination_search/routes.py:19:37"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example request**
|
**Example request**
|
||||||
@@ -61,11 +66,13 @@ GET /articles/offset?page=2&items_per_page=10&search=fastapi&status=published&or
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "SUCCESS",
|
"status": "SUCCESS",
|
||||||
|
"pagination_type": "offset",
|
||||||
"data": [
|
"data": [
|
||||||
{ "id": "3f47ac69-...", "title": "FastAPI tips", "status": "published", ... }
|
{ "id": "3f47ac69-...", "title": "FastAPI tips", "status": "published", ... }
|
||||||
],
|
],
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"total_count": 42,
|
"total_count": 42,
|
||||||
|
"pages": 5,
|
||||||
"page": 2,
|
"page": 2,
|
||||||
"items_per_page": 10,
|
"items_per_page": 10,
|
||||||
"has_more": true
|
"has_more": true
|
||||||
@@ -79,12 +86,14 @@ GET /articles/offset?page=2&items_per_page=10&search=fastapi&status=published&or
|
|||||||
|
|
||||||
`filter_attributes` always reflects the values visible **after** applying the active filters. Use it to populate filter dropdowns on the client.
|
`filter_attributes` always reflects the values visible **after** applying the active filters. Use it to populate filter dropdowns on the client.
|
||||||
|
|
||||||
|
To skip the `COUNT(*)` query for better performance on large tables, pass `include_total=False`. `pagination.total_count` will be `null` in the response, while `has_more` remains accurate.
|
||||||
|
|
||||||
### Cursor pagination
|
### Cursor pagination
|
||||||
|
|
||||||
Best for feeds, infinite scroll, or any high-throughput API where offset performance degrades.
|
Best for feeds, infinite scroll, or any high-throughput API where offset performance degrades.
|
||||||
|
|
||||||
```python title="routes.py:39:59"
|
```python title="routes.py:40:58"
|
||||||
--8<-- "docs_src/examples/pagination_search/routes.py:39:59"
|
--8<-- "docs_src/examples/pagination_search/routes.py:40:58"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example request**
|
**Example request**
|
||||||
@@ -98,6 +107,7 @@ GET /articles/cursor?items_per_page=10&status=published&order_by=created_at&orde
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "SUCCESS",
|
"status": "SUCCESS",
|
||||||
|
"pagination_type": "cursor",
|
||||||
"data": [
|
"data": [
|
||||||
{ "id": "3f47ac69-...", "title": "FastAPI tips", "status": "published", ... }
|
{ "id": "3f47ac69-...", "title": "FastAPI tips", "status": "published", ... }
|
||||||
],
|
],
|
||||||
@@ -116,6 +126,47 @@ GET /articles/cursor?items_per_page=10&status=published&order_by=created_at&orde
|
|||||||
|
|
||||||
Pass `next_cursor` as the `cursor` query parameter on the next request to advance to the next page.
|
Pass `next_cursor` as the `cursor` query parameter on the next request to advance to the next page.
|
||||||
|
|
||||||
|
### Unified endpoint (both strategies)
|
||||||
|
|
||||||
|
!!! info "Added in `v2.3.0`"
|
||||||
|
|
||||||
|
[`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"
|
||||||
|
--8<-- "docs_src/examples/pagination_search/routes.py:61:79"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Offset request** (default)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /articles/?pagination_type=offset&page=1&items_per_page=10
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"pagination_type": "offset",
|
||||||
|
"data": ["..."],
|
||||||
|
"pagination": { "total_count": 42, "pages": 5, "page": 1, "items_per_page": 10, "has_more": true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cursor request**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /articles/?pagination_type=cursor&items_per_page=10
|
||||||
|
GET /articles/?pagination_type=cursor&items_per_page=10&cursor=eyJ2YWx1ZSI6...
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"pagination_type": "cursor",
|
||||||
|
"data": ["..."],
|
||||||
|
"pagination": { "next_cursor": "eyJ2YWx1ZSI6...", "prev_cursor": null, "items_per_page": 10, "has_more": true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Search behaviour
|
## Search behaviour
|
||||||
|
|
||||||
Both endpoints inherit the same `searchable_fields` declared on `ArticleCrud`:
|
Both endpoints inherit the same `searchable_fields` declared on `ArticleCrud`:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ A modular collection of production-ready utilities for FastAPI. Install only wha
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
The base package includes the core modules (CRUD, database, schemas, exceptions, fixtures, dependencies, logging):
|
The base package includes the core modules (CRUD, database, schemas, exceptions, fixtures, dependencies, model mixins, logging):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv add fastapi-toolsets
|
uv add fastapi-toolsets
|
||||||
@@ -29,9 +29,9 @@ uv add fastapi-toolsets
|
|||||||
Install only the extras you need:
|
Install only the extras you need:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]" # CLI (typer)
|
uv add "fastapi-toolsets[cli]"
|
||||||
uv add "fastapi-toolsets[metrics]" # Prometheus metrics (prometheus_client)
|
uv add "fastapi-toolsets[metrics]"
|
||||||
uv add "fastapi-toolsets[pytest]" # Pytest helpers (httpx, pytest-xdist)
|
uv add "fastapi-toolsets[pytest]"
|
||||||
```
|
```
|
||||||
|
|
||||||
Or install everything:
|
Or install everything:
|
||||||
@@ -44,11 +44,13 @@ uv add "fastapi-toolsets[all]"
|
|||||||
|
|
||||||
### Core
|
### Core
|
||||||
|
|
||||||
- **CRUD**: Generic async CRUD operations with `CrudFactory`, built-in full-text/faceted search and offset/cursor pagination.
|
- **CRUD**: Generic async CRUD operations with `CrudFactory`, built-in full-text/faceted search and Offset/Cursor pagination.
|
||||||
- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection
|
- **Database**: Session management, transaction helpers, table locking, and polling-based row change detection
|
||||||
- **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters
|
- **Dependencies**: FastAPI dependency factories (`PathDependency`, `BodyDependency`) for automatic DB lookups from path or body parameters
|
||||||
- **Fixtures**: Fixture system with dependency management, context support, and pytest integration
|
- **Fixtures**: Fixture system with dependency management, context support, and pytest integration
|
||||||
- **Standardized API Responses**: Consistent response format with `Response`, `PaginatedResponse`, and `PydanticBase`
|
- **Model Mixins**: SQLAlchemy mixins for common column patterns (`UUIDMixin`, `UUIDv7Mixin`, `CreatedAtMixin`, `UpdatedAtMixin`, `TimestampMixin`).
|
||||||
|
- **Lifecycle Events**: Post-commit event system (`EventSession`, `listens_for`) that dispatches async/sync callbacks for insert, update, and delete operations.
|
||||||
|
- **Standardized API Responses**: Consistent response format with `Response`, `ErrorResponse`, `PaginatedResponse`, `CursorPaginatedResponse` and `OffsetPaginatedResponse`.
|
||||||
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
- **Exception Handling**: Structured error responses with automatic OpenAPI documentation
|
||||||
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
|
- **Logging**: Logging configuration with uvicorn integration via `configure_logging` and `get_logger`
|
||||||
|
|
||||||
|
|||||||
137
docs/migration/v2.md
Normal file
137
docs/migration/v2.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# Migrating to v2.0
|
||||||
|
|
||||||
|
This page covers every breaking change introduced in **v2.0** and the steps required to update your code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRUD
|
||||||
|
|
||||||
|
### `schema` is now required in `offset_paginate()` and `cursor_paginate()`
|
||||||
|
|
||||||
|
Calls that omit `schema` will now raise a `TypeError` at runtime.
|
||||||
|
|
||||||
|
Previously `schema` was optional; omitting it returned raw SQLAlchemy model instances inside the response. It is now a required keyword argument and the response always contains serialized schema instances.
|
||||||
|
|
||||||
|
=== "Before (`v1`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
# schema omitted — returned raw model instances
|
||||||
|
result = await UserCrud.offset_paginate(session=session, page=1)
|
||||||
|
result = await UserCrud.cursor_paginate(session=session, cursor=token)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v2`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
result = await UserCrud.offset_paginate(session=session, page=1, schema=UserRead)
|
||||||
|
result = await UserCrud.cursor_paginate(session=session, cursor=token, schema=UserRead)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `as_response` removed from `create()`, `get()`, and `update()`
|
||||||
|
|
||||||
|
Passing `as_response` to these methods will raise a `TypeError` at runtime.
|
||||||
|
|
||||||
|
The `as_response=True` shorthand is replaced by passing a `schema` directly. The return value is a `Response[schema]` when `schema` is provided, or the raw model instance when it is not.
|
||||||
|
|
||||||
|
=== "Before (`v1`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
user = await UserCrud.create(session=session, obj=data, as_response=True)
|
||||||
|
user = await UserCrud.get(session=session, filters=filters, as_response=True)
|
||||||
|
user = await UserCrud.update(session=session, obj=data, filters, as_response=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v2`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
user = await UserCrud.create(session=session, obj=data, schema=UserRead)
|
||||||
|
user = await UserCrud.get(session=session, filters=filters, schema=UserRead)
|
||||||
|
user = await UserCrud.update(session=session, obj=data, filters, schema=UserRead)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `delete()`: `as_response` renamed and return type changed
|
||||||
|
|
||||||
|
`as_response` is gone, and the plain (non-response) call no longer returns `True`.
|
||||||
|
|
||||||
|
Two changes were made to `delete()`:
|
||||||
|
|
||||||
|
1. The `as_response` parameter is renamed to `return_response`.
|
||||||
|
2. When called without `return_response=True`, the method now returns `None` on success instead of `True`.
|
||||||
|
|
||||||
|
=== "Before (`v1`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
ok = await UserCrud.delete(session=session, filters=filters)
|
||||||
|
if ok: # True on success
|
||||||
|
...
|
||||||
|
|
||||||
|
response = await UserCrud.delete(session=session, filters=filters, as_response=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v2`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
await UserCrud.delete(session=session, filters=filters) # returns None
|
||||||
|
|
||||||
|
response = await UserCrud.delete(session=session, filters=filters, return_response=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `paginate()` alias removed
|
||||||
|
|
||||||
|
Any call to `crud.paginate(...)` will raise `AttributeError` at runtime.
|
||||||
|
|
||||||
|
The `paginate` shorthand was an alias for `offset_paginate`. It has been removed; call `offset_paginate` directly.
|
||||||
|
|
||||||
|
=== "Before (`v1`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
result = await UserCrud.paginate(session=session, page=2, items_per_page=20, schema=UserRead)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v2`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
result = await UserCrud.offset_paginate(session=session, page=2, items_per_page=20, schema=UserRead)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exceptions
|
||||||
|
|
||||||
|
### Missing `api_error` raises `TypeError` at class definition time
|
||||||
|
|
||||||
|
Unfinished or stub exception subclasses that previously compiled fine will now fail on import.
|
||||||
|
|
||||||
|
In `v1`, a subclass without `api_error` would only fail when the exception was raised. In `v2`, `__init_subclass__` validates this at class definition time.
|
||||||
|
|
||||||
|
=== "Before (`v1`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyError(ApiException):
|
||||||
|
pass # fine until raised
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v2`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyError(ApiException):
|
||||||
|
pass # TypeError: MyError must define an 'api_error' class attribute.
|
||||||
|
```
|
||||||
|
|
||||||
|
For shared base classes that are not meant to be raised directly, use `abstract=True`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BillingError(ApiException, abstract=True):
|
||||||
|
"""Base for all billing-related errors — not raised directly."""
|
||||||
|
|
||||||
|
class PaymentRequiredError(BillingError):
|
||||||
|
api_error = ApiError(code=402, msg="Payment Required", desc="...", err_code="BILLING-402")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schemas
|
||||||
|
|
||||||
|
### `Pagination` alias removed
|
||||||
|
|
||||||
|
`Pagination` was already deprecated in `v1` and is fully removed in `v2`, you now need to use [`OffsetPagination`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPagination) or [`CursorPagination`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPagination).
|
||||||
180
docs/migration/v3.md
Normal file
180
docs/migration/v3.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# Migrating to v3.0
|
||||||
|
|
||||||
|
This page covers every breaking change introduced in **v3.0** and the steps required to update your code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRUD
|
||||||
|
|
||||||
|
### Facet keys now always use the full relationship chain
|
||||||
|
|
||||||
|
In `v2`, relationship facet fields used only the terminal column key (e.g. `"name"` for `Role.name`) and only prepended the relationship name when two facet fields shared the same column key. In `v3`, facet keys **always** include the full relationship chain joined by `__`, regardless of collisions.
|
||||||
|
|
||||||
|
=== "Before (`v2`)"
|
||||||
|
|
||||||
|
```
|
||||||
|
User.status -> status
|
||||||
|
(User.role, Role.name) -> name
|
||||||
|
(User.role, Role.permission, Permission.name) -> name
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v3`)"
|
||||||
|
|
||||||
|
```
|
||||||
|
User.status -> status
|
||||||
|
(User.role, Role.name) -> role__name
|
||||||
|
(User.role, Role.permission, Permission.name) -> role__permission__name
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `*_params` dependencies consolidated into per-paginate methods
|
||||||
|
|
||||||
|
The six individual dependency methods (`offset_params`, `cursor_params`, `paginate_params`, `filter_params`, `search_params`, `order_params`) have been **removed** and replaced by three consolidated methods that bundle pagination, search, filter, and order into a single `Depends()` call.
|
||||||
|
|
||||||
|
| Removed | Replacement |
|
||||||
|
|---|---|
|
||||||
|
| `offset_params()` + `filter_params()` + `search_params()` + `order_params()` | `offset_paginate_params()` |
|
||||||
|
| `cursor_params()` + `filter_params()` + `search_params()` + `order_params()` | `cursor_paginate_params()` |
|
||||||
|
| `paginate_params()` + `filter_params()` + `search_params()` + `order_params()` | `paginate_params()` |
|
||||||
|
|
||||||
|
Each new method accepts `search`, `filter`, and `order` boolean toggles (all `True` by default) to disable features you don't need.
|
||||||
|
|
||||||
|
=== "Before (`v2`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.crud import OrderByClause
|
||||||
|
|
||||||
|
@router.get("/offset")
|
||||||
|
async def list_articles_offset(
|
||||||
|
session: SessionDep,
|
||||||
|
params: Annotated[dict, Depends(ArticleCrud.offset_params(default_page_size=20))],
|
||||||
|
filter_by: Annotated[dict, Depends(ArticleCrud.filter_params())],
|
||||||
|
order_by: Annotated[OrderByClause | None, Depends(ArticleCrud.order_params(default_field=Article.created_at))],
|
||||||
|
search: str | None = None,
|
||||||
|
) -> OffsetPaginatedResponse[ArticleRead]:
|
||||||
|
return await ArticleCrud.offset_paginate(
|
||||||
|
session=session,
|
||||||
|
**params,
|
||||||
|
search=search,
|
||||||
|
filter_by=filter_by or None,
|
||||||
|
order_by=order_by,
|
||||||
|
schema=ArticleRead,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v3`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get("/offset")
|
||||||
|
async def list_articles_offset(
|
||||||
|
session: SessionDep,
|
||||||
|
params: Annotated[
|
||||||
|
dict,
|
||||||
|
Depends(
|
||||||
|
ArticleCrud.offset_paginate_params(
|
||||||
|
default_page_size=20,
|
||||||
|
default_order_field=Article.created_at,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
) -> OffsetPaginatedResponse[ArticleRead]:
|
||||||
|
return await ArticleCrud.offset_paginate(session=session, **params, schema=ArticleRead)
|
||||||
|
```
|
||||||
|
|
||||||
|
The same pattern applies to `cursor_paginate_params()` and `paginate_params()`. To disable a feature, pass the toggle:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# No search or ordering, only pagination + filtering
|
||||||
|
ArticleCrud.offset_paginate_params(search=False, order=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
The lifecycle event system has been rewritten. Callbacks are now registered with a module-level [`listens_for`](../reference/models.md#fastapi_toolsets.models.listens_for) decorator and dispatched by [`EventSession`](../reference/models.md#fastapi_toolsets.models.EventSession), replacing the mixin-based approach from `v2`.
|
||||||
|
|
||||||
|
### `WatchedFieldsMixin` and `@watch` removed
|
||||||
|
|
||||||
|
Importing `WatchedFieldsMixin` or `watch` will raise `ImportError`.
|
||||||
|
|
||||||
|
Model method callbacks (`on_create`, `on_delete`, `on_update`) and the `@watch` decorator are replaced by:
|
||||||
|
|
||||||
|
1. **`__watched_fields__`** — a plain class attribute to restrict which field changes trigger `UPDATE` events (replaces `@watch`).
|
||||||
|
2. **`@listens_for`** — a module-level decorator to register callbacks for one or more [`ModelEvent`](../reference/models.md#fastapi_toolsets.models.ModelEvent) types (replaces `on_create` / `on_delete` / `on_update` methods).
|
||||||
|
|
||||||
|
=== "Before (`v2`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.models import WatchedFieldsMixin, watch
|
||||||
|
|
||||||
|
@watch("status")
|
||||||
|
class Order(Base, UUIDMixin, WatchedFieldsMixin):
|
||||||
|
__tablename__ = "orders"
|
||||||
|
|
||||||
|
status: Mapped[str]
|
||||||
|
|
||||||
|
async def on_create(self):
|
||||||
|
await notify_new_order(self.id)
|
||||||
|
|
||||||
|
async def on_update(self, changes):
|
||||||
|
if "status" in changes:
|
||||||
|
await notify_status_change(self.id, changes["status"])
|
||||||
|
|
||||||
|
async def on_delete(self):
|
||||||
|
await notify_order_cancelled(self.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v3`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for
|
||||||
|
|
||||||
|
class Order(Base, UUIDMixin):
|
||||||
|
__tablename__ = "orders"
|
||||||
|
__watched_fields__ = ("status",)
|
||||||
|
|
||||||
|
status: Mapped[str]
|
||||||
|
|
||||||
|
@listens_for(Order, [ModelEvent.CREATE])
|
||||||
|
async def on_order_created(order: Order, event_type: ModelEvent, changes: None):
|
||||||
|
await notify_new_order(order.id)
|
||||||
|
|
||||||
|
@listens_for(Order, [ModelEvent.UPDATE])
|
||||||
|
async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict):
|
||||||
|
if "status" in changes:
|
||||||
|
await notify_status_change(order.id, changes["status"])
|
||||||
|
|
||||||
|
@listens_for(Order, [ModelEvent.DELETE])
|
||||||
|
async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None):
|
||||||
|
await notify_order_cancelled(order.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `EventSession` now required
|
||||||
|
|
||||||
|
Without `EventSession`, lifecycle callbacks will silently stop firing.
|
||||||
|
|
||||||
|
Callbacks are now dispatched inside `EventSession.commit()` rather than via background tasks. Pass it as the session class when creating your session factory:
|
||||||
|
|
||||||
|
=== "Before (`v2`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
engine = create_async_engine("postgresql+asyncpg://...")
|
||||||
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Now (`v3`)"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from fastapi_toolsets.models import EventSession
|
||||||
|
|
||||||
|
engine = create_async_engine("postgresql+asyncpg://...")
|
||||||
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=EventSession)
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
If you use `create_db_session` from `fastapi_toolsets.pytest`, the session already uses `EventSession` — no changes needed in tests.
|
||||||
@@ -7,10 +7,12 @@ Generic async CRUD operations for SQLAlchemy models with search, pagination, and
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The `crud` module provides [`AsyncCrud`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud), an abstract base class with a full suite of async database operations, and [`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory), a convenience function to instantiate it for a given model.
|
The `crud` module provides [`AsyncCrud`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud), a base class with a full suite of async database operations, and [`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory), a convenience function to instantiate it for a given model.
|
||||||
|
|
||||||
## Creating a CRUD class
|
## Creating a CRUD class
|
||||||
|
|
||||||
|
### Factory style
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.crud import CrudFactory
|
from fastapi_toolsets.crud import CrudFactory
|
||||||
from myapp.models import User
|
from myapp.models import User
|
||||||
@@ -18,10 +20,70 @@ from myapp.models import User
|
|||||||
UserCrud = CrudFactory(model=User)
|
UserCrud = CrudFactory(model=User)
|
||||||
```
|
```
|
||||||
|
|
||||||
[`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory) dynamically creates a class named `AsyncUserCrud` with `User` as its model.
|
[`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory) dynamically creates a class named `AsyncUserCrud` with `User` as its model. This is the most concise option for straightforward CRUD with no custom logic.
|
||||||
|
|
||||||
|
### Subclass style
|
||||||
|
|
||||||
|
!!! info "Added in `v2.3.0`"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.crud.factory import AsyncCrud
|
||||||
|
from myapp.models import User
|
||||||
|
|
||||||
|
class UserCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
searchable_fields = [User.username, User.email]
|
||||||
|
default_load_options = [selectinload(User.role)]
|
||||||
|
```
|
||||||
|
|
||||||
|
Subclassing [`AsyncCrud`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud) directly is the preferred style when you need to add custom methods or when the configuration is complex enough to benefit from a named class body.
|
||||||
|
|
||||||
|
### Adding custom methods
|
||||||
|
|
||||||
|
```python
|
||||||
|
class UserCrud(AsyncCrud[User]):
|
||||||
|
model = User
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_active(cls, session: AsyncSession) -> list[User]:
|
||||||
|
return await cls.get_multi(session, filters=[User.is_active == True])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sharing a custom base across multiple models
|
||||||
|
|
||||||
|
Define a generic base class with the shared methods, then subclass it for each model:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Generic, TypeVar
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from fastapi_toolsets.crud.factory import AsyncCrud
|
||||||
|
|
||||||
|
T = TypeVar("T", bound=DeclarativeBase)
|
||||||
|
|
||||||
|
class AuditedCrud(AsyncCrud[T], Generic[T]):
|
||||||
|
"""Base CRUD with custom function"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_active(cls, session: AsyncSession):
|
||||||
|
return await cls.get_multi(session, filters=[cls.model.is_active == True])
|
||||||
|
|
||||||
|
|
||||||
|
class UserCrud(AuditedCrud[User]):
|
||||||
|
model = User
|
||||||
|
searchable_fields = [User.username, User.email]
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also use the factory shorthand with the same base by passing `base_class`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
UserCrud = CrudFactory(User, base_class=AuditedCrud)
|
||||||
|
```
|
||||||
|
|
||||||
## Basic operations
|
## Basic operations
|
||||||
|
|
||||||
|
!!! info "`get_or_none` added in `v2.2`"
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Create
|
# Create
|
||||||
user = await UserCrud.create(session=session, obj=UserCreateSchema(username="alice"))
|
user = await UserCrud.create(session=session, obj=UserCreateSchema(username="alice"))
|
||||||
@@ -29,6 +91,9 @@ user = await UserCrud.create(session=session, obj=UserCreateSchema(username="ali
|
|||||||
# Get one (raises NotFoundError if not found)
|
# Get one (raises NotFoundError if not found)
|
||||||
user = await UserCrud.get(session=session, filters=[User.id == user_id])
|
user = await UserCrud.get(session=session, filters=[User.id == user_id])
|
||||||
|
|
||||||
|
# Get one or None (never raises)
|
||||||
|
user = await UserCrud.get_or_none(session=session, filters=[User.id == user_id])
|
||||||
|
|
||||||
# Get first or None
|
# Get first or None
|
||||||
user = await UserCrud.first(session=session, filters=[User.email == email])
|
user = await UserCrud.first(session=session, filters=[User.email == email])
|
||||||
|
|
||||||
@@ -46,48 +111,75 @@ count = await UserCrud.count(session=session, filters=[User.is_active == True])
|
|||||||
exists = await UserCrud.exists(session=session, filters=[User.email == email])
|
exists = await UserCrud.exists(session=session, filters=[User.email == email])
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Fetching a single record
|
||||||
|
|
||||||
|
Three methods fetch a single record — choose based on how you want to handle the "not found" case and whether you need strict uniqueness:
|
||||||
|
|
||||||
|
| Method | Not found | Multiple results |
|
||||||
|
|---|---|---|
|
||||||
|
| `get` | raises `NotFoundError` | raises `MultipleResultsFound` |
|
||||||
|
| `get_or_none` | returns `None` | raises `MultipleResultsFound` |
|
||||||
|
| `first` | returns `None` | returns the first match silently |
|
||||||
|
|
||||||
|
Use `get` when the record must exist (e.g. a detail endpoint that should return 404):
|
||||||
|
|
||||||
|
```python
|
||||||
|
user = await UserCrud.get(session=session, filters=[User.id == user_id])
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `get_or_none` when the record may not exist but you still want strict uniqueness enforcement:
|
||||||
|
|
||||||
|
```python
|
||||||
|
user = await UserCrud.get_or_none(session=session, filters=[User.email == email])
|
||||||
|
if user is None:
|
||||||
|
... # handle missing case without catching an exception
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `first` when you only care about any one match and don't need uniqueness:
|
||||||
|
|
||||||
|
```python
|
||||||
|
user = await UserCrud.first(session=session, filters=[User.is_active == True])
|
||||||
|
```
|
||||||
|
|
||||||
## Pagination
|
## Pagination
|
||||||
|
|
||||||
!!! info "Added in `v1.1` (only offset_pagination via `paginate` if `<v1.1`)"
|
!!! info "Added in `v1.1` (only offset_pagination via `paginate` if `<v1.1`)"
|
||||||
|
|
||||||
Two pagination strategies are available. Both return a [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse) but differ in how they navigate through results.
|
Three pagination methods are available. All return a typed response whose `pagination_type` field tells clients which strategy was used.
|
||||||
|
|
||||||
| | `offset_paginate` | `cursor_paginate` |
|
| | `offset_paginate` | `cursor_paginate` | `paginate` |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| Total count | Yes | No |
|
| Return type | `OffsetPaginatedResponse` | `CursorPaginatedResponse` | either, based on `pagination_type` param |
|
||||||
| Jump to arbitrary page | Yes | No |
|
| Total count | Yes | No | / |
|
||||||
| Performance on deep pages | Degrades | Constant |
|
| Jump to arbitrary page | Yes | No | / |
|
||||||
| Stable under concurrent inserts | No | Yes |
|
| Performance on deep pages | Degrades | Constant | / |
|
||||||
| Search compatible | Yes | Yes |
|
| Stable under concurrent inserts | No | Yes | / |
|
||||||
| Use case | Admin panels, numbered pagination | Feeds, APIs, infinite scroll |
|
| Use case | Admin panels, numbered pagination | Feeds, APIs, infinite scroll | single endpoint, both strategies |
|
||||||
|
|
||||||
### Offset pagination
|
### Offset pagination
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@router.get(
|
from typing import Annotated
|
||||||
"",
|
from fastapi import Depends
|
||||||
response_model=PaginatedResponse[User],
|
|
||||||
)
|
@router.get("")
|
||||||
async def get_users(
|
async def get_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
items_per_page: int = 50,
|
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
|
||||||
page: int = 1,
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
):
|
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||||
return await crud.UserCrud.offset_paginate(
|
|
||||||
session=session,
|
|
||||||
items_per_page=items_per_page,
|
|
||||||
page=page,
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) method returns a [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse) whose `pagination` field is an [`OffsetPagination`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPagination) object:
|
The [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) method returns an [`OffsetPaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPaginatedResponse):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "SUCCESS",
|
"status": "SUCCESS",
|
||||||
|
"pagination_type": "offset",
|
||||||
"data": ["..."],
|
"data": ["..."],
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"total_count": 100,
|
"total_count": 100,
|
||||||
|
"pages": 5,
|
||||||
"page": 1,
|
"page": 1,
|
||||||
"items_per_page": 20,
|
"items_per_page": 20,
|
||||||
"has_more": true
|
"has_more": true
|
||||||
@@ -95,33 +187,38 @@ The [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.Async
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! warning "Deprecated: `paginate`"
|
#### Skipping the COUNT query
|
||||||
The `paginate` function is a backward-compatible alias for `offset_paginate`. This function is **deprecated** and will be removed in **v2.0**.
|
|
||||||
|
!!! info "Added in `v2.4.1`"
|
||||||
|
|
||||||
|
By default `offset_paginate` runs two queries: one for the page items and one `COUNT(*)` for `total_count`. On large tables the `COUNT` can be expensive. Pass `include_total=False` to `offset_paginate_params()` to skip it:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get("")
|
||||||
|
async def get_users(
|
||||||
|
session: SessionDep,
|
||||||
|
params: Annotated[dict, Depends(UserCrud.offset_paginate_params(include_total=False))],
|
||||||
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
|
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||||
|
```
|
||||||
|
|
||||||
### Cursor pagination
|
### Cursor pagination
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@router.get(
|
@router.get("")
|
||||||
"",
|
|
||||||
response_model=PaginatedResponse[UserRead],
|
|
||||||
)
|
|
||||||
async def list_users(
|
async def list_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
cursor: str | None = None,
|
params: Annotated[dict, Depends(UserCrud.cursor_paginate_params())],
|
||||||
items_per_page: int = 20,
|
) -> CursorPaginatedResponse[UserRead]:
|
||||||
):
|
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
|
||||||
return await UserCrud.cursor_paginate(
|
|
||||||
session=session,
|
|
||||||
cursor=cursor,
|
|
||||||
items_per_page=items_per_page,
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) method returns a [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse) whose `pagination` field is a [`CursorPagination`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPagination) object:
|
The [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) method returns a [`CursorPaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPaginatedResponse):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "SUCCESS",
|
"status": "SUCCESS",
|
||||||
|
"pagination_type": "cursor",
|
||||||
"data": ["..."],
|
"data": ["..."],
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next_cursor": "eyJ2YWx1ZSI6ICIzZjQ3YWM2OS0uLi4ifQ==",
|
"next_cursor": "eyJ2YWx1ZSI6ICIzZjQ3YWM2OS0uLi4ifQ==",
|
||||||
@@ -148,7 +245,7 @@ The cursor column is set once on [`CrudFactory`](../reference/crud.md#fastapi_to
|
|||||||
!!! note
|
!!! note
|
||||||
`cursor_column` is required. Calling [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) on a CRUD class that has no `cursor_column` configured raises a `ValueError`.
|
`cursor_column` is required. Calling [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate) on a CRUD class that has no `cursor_column` configured raises a `ValueError`.
|
||||||
|
|
||||||
The cursor value is base64-encoded when returned to the client and decoded back to the correct Python type on the next request. The following SQLAlchemy column types are supported:
|
The cursor value is URL-safe base64-encoded (no padding) when returned to the client and decoded back to the correct Python type on the next request. The following SQLAlchemy column types are supported:
|
||||||
|
|
||||||
| SQLAlchemy type | Python type |
|
| SQLAlchemy type | Python type |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -166,6 +263,28 @@ PostCrud = CrudFactory(model=Post, cursor_column=Post.id)
|
|||||||
PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at)
|
PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Unified endpoint (both strategies)
|
||||||
|
|
||||||
|
!!! info "Added in `v2.3.0`"
|
||||||
|
|
||||||
|
[`paginate()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.paginate) dispatches to `offset_paginate` or `cursor_paginate` based on a `pagination_type` query parameter, letting you expose **one endpoint** that supports both strategies. The `pagination_type` field in the response tells clients which strategy was used, enabling frontend discriminated-union typing.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.schemas import PaginatedResponse
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_users(
|
||||||
|
session: SessionDep,
|
||||||
|
params: Annotated[dict, Depends(UserCrud.paginate_params())],
|
||||||
|
) -> PaginatedResponse[UserRead]:
|
||||||
|
return await UserCrud.paginate(session, **params, schema=UserRead)
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /users?pagination_type=offset&page=2&items_per_page=10
|
||||||
|
GET /users?pagination_type=cursor&cursor=eyJ2YWx1ZSI6...&items_per_page=10
|
||||||
|
```
|
||||||
|
|
||||||
## Search
|
## Search
|
||||||
|
|
||||||
Two search strategies are available, both compatible with [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate).
|
Two search strategies are available, both compatible with [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate).
|
||||||
@@ -180,6 +299,9 @@ Two search strategies are available, both compatible with [`offset_paginate`](..
|
|||||||
|
|
||||||
### Full-text search
|
### Full-text search
|
||||||
|
|
||||||
|
!!! info "Added in `v2.2.1`"
|
||||||
|
The model's primary key is always included in `searchable_fields` automatically, so searching by ID works out of the box without any configuration. When no `searchable_fields` are declared, only the primary key is searched.
|
||||||
|
|
||||||
Declare `searchable_fields` on the CRUD class. Relationship traversal is supported via tuples:
|
Declare `searchable_fields` on the CRUD class. Relationship traversal is supported via tuples:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -205,41 +327,21 @@ result = await UserCrud.offset_paginate(
|
|||||||
This allows searching with both [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate):
|
This allows searching with both [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@router.get(
|
@router.get("")
|
||||||
"",
|
|
||||||
response_model=PaginatedResponse[User],
|
|
||||||
)
|
|
||||||
async def get_users(
|
async def get_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
items_per_page: int = 50,
|
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
|
||||||
page: int = 1,
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
search: str | None = None,
|
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||||
):
|
|
||||||
return await crud.UserCrud.offset_paginate(
|
|
||||||
session=session,
|
|
||||||
items_per_page=items_per_page,
|
|
||||||
page=page,
|
|
||||||
search=search,
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@router.get(
|
@router.get("")
|
||||||
"",
|
|
||||||
response_model=PaginatedResponse[User],
|
|
||||||
)
|
|
||||||
async def get_users(
|
async def get_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
cursor: str | None = None,
|
params: Annotated[dict, Depends(UserCrud.cursor_paginate_params())],
|
||||||
items_per_page: int = 50,
|
) -> CursorPaginatedResponse[UserRead]:
|
||||||
search: str | None = None,
|
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
|
||||||
):
|
|
||||||
return await crud.UserCrud.cursor_paginate(
|
|
||||||
session=session,
|
|
||||||
items_per_page=items_per_page,
|
|
||||||
cursor=cursor,
|
|
||||||
search=search,
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Faceted search
|
### Faceted search
|
||||||
@@ -280,7 +382,7 @@ The distinct values are returned in the `filter_attributes` field of [`Paginated
|
|||||||
"filter_attributes": {
|
"filter_attributes": {
|
||||||
"status": ["active", "inactive"],
|
"status": ["active", "inactive"],
|
||||||
"country": ["DE", "FR", "US"],
|
"country": ["DE", "FR", "US"],
|
||||||
"name": ["admin", "editor", "viewer"]
|
"role__name": ["admin", "editor", "viewer"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -288,11 +390,11 @@ The distinct values are returned in the `filter_attributes` field of [`Paginated
|
|||||||
Use `filter_by` to pass the client's chosen filter values directly — no need to build SQLAlchemy conditions by hand. Any unknown key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError).
|
Use `filter_by` to pass the client's chosen filter values directly — no need to build SQLAlchemy conditions by hand. Any unknown key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError).
|
||||||
|
|
||||||
!!! info "The keys in `filter_by` are the same keys the client received in `filter_attributes`."
|
!!! info "The keys in `filter_by` are the same keys the client received in `filter_attributes`."
|
||||||
Keys are normally the terminal `column.key` (e.g. `"name"` for `Role.name`). When two facet fields share the same column key (e.g. `(Build.project, Project.name)` and `(Build.os, Os.name)`), the relationship name is prepended automatically: `"project__name"` and `"os__name"`.
|
Keys use `__` as a separator for the full relationship chain. A direct column `User.status` produces `"status"`. A relationship tuple `(User.role, Role.name)` produces `"role__name"`. A deeper chain `(User.role, Role.permission, Permission.name)` produces `"role__permission__name"`.
|
||||||
|
|
||||||
`filter_by` and `filters` can be combined — both are applied with AND logic.
|
`filter_by` and `filters` can be combined — both are applied with AND logic.
|
||||||
|
|
||||||
Use [`filter_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.filter_params) to generate a dict with the facet filter values from the query parameters:
|
Facet filtering is built into the consolidated params dependencies. When `filter=True` (the default), facet fields are exposed as query parameters and collected into `filter_by` automatically:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
@@ -307,22 +409,21 @@ UserCrud = CrudFactory(
|
|||||||
@router.get("", response_model_exclude_none=True)
|
@router.get("", response_model_exclude_none=True)
|
||||||
async def list_users(
|
async def list_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
page: int = 1,
|
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
|
||||||
filter_by: Annotated[dict[str, list[str]], Depends(UserCrud.filter_params())],
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
) -> PaginatedResponse[UserRead]:
|
|
||||||
return await UserCrud.offset_paginate(
|
return await UserCrud.offset_paginate(
|
||||||
session=session,
|
session=session,
|
||||||
page=page,
|
**params,
|
||||||
filter_by=filter_by,
|
schema=UserRead,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
Both single-value and multi-value query parameters work:
|
Both single-value and multi-value query parameters work:
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /users?status=active → filter_by={"status": ["active"]}
|
GET /users?status=active → filter_by={"status": ["active"]}
|
||||||
GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]}
|
GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]}
|
||||||
GET /users?role=admin&role=editor → filter_by={"role": ["admin", "editor"]} (IN clause)
|
GET /users?role__name=admin&role__name=editor → filter_by={"role__name": ["admin", "editor"]} (IN clause)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sorting
|
## Sorting
|
||||||
@@ -341,20 +442,21 @@ UserCrud = CrudFactory(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
Call [`order_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.order_params) to generate a FastAPI dependency that maps the query parameters to an [`OrderByClause`](../reference/crud.md#fastapi_toolsets.crud.factory.OrderByClause) expression:
|
Ordering is built into the consolidated params dependencies. When `order=True` (the default), `order_by` and `order` query parameters are exposed and resolved into an `OrderByClause` automatically:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from fastapi_toolsets.crud import OrderByClause
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_users(
|
async def list_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
order_by: Annotated[OrderByClause | None, Depends(UserCrud.order_params())],
|
params: Annotated[dict, Depends(UserCrud.offset_paginate_params(
|
||||||
) -> PaginatedResponse[UserRead]:
|
default_order_field=User.created_at,
|
||||||
return await UserCrud.offset_paginate(session=session, order_by=order_by)
|
))],
|
||||||
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
|
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||||
```
|
```
|
||||||
|
|
||||||
The dependency adds two query parameters to the endpoint:
|
The dependency adds two query parameters to the endpoint:
|
||||||
@@ -371,10 +473,10 @@ GET /users?order_by=name&order=desc → ORDER BY users.name DESC
|
|||||||
|
|
||||||
An unknown `order_by` value raises [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) (HTTP 422).
|
An unknown `order_by` value raises [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) (HTTP 422).
|
||||||
|
|
||||||
You can also pass `order_fields` directly to `order_params()` to override the class-level defaults without modifying them:
|
You can also pass `order_fields` directly to override the class-level defaults:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
UserOrderParams = UserCrud.order_params(order_fields=[User.name])
|
params = UserCrud.offset_paginate_params(order_fields=[User.name])
|
||||||
```
|
```
|
||||||
|
|
||||||
## Relationship loading
|
## Relationship loading
|
||||||
@@ -461,19 +563,15 @@ async def get_user(session: SessionDep, uuid: UUID) -> Response[UserRead]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_users(session: SessionDep, page: int = 1) -> PaginatedResponse[UserRead]:
|
async def list_users(
|
||||||
return await crud.UserCrud.offset_paginate(
|
session: SessionDep,
|
||||||
session=session,
|
params: Annotated[dict, Depends(crud.UserCrud.offset_paginate_params())],
|
||||||
page=page,
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
schema=UserRead,
|
return await crud.UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The schema must have `from_attributes=True` (or inherit from [`PydanticBase`](../reference/schemas.md#fastapi_toolsets.schemas.PydanticBase)) so it can be built from SQLAlchemy model instances.
|
The schema must have `from_attributes=True` (or inherit from [`PydanticBase`](../reference/schemas.md#fastapi_toolsets.schemas.PydanticBase)) so it can be built from SQLAlchemy model instances.
|
||||||
|
|
||||||
!!! warning "Deprecated: `as_response`"
|
|
||||||
The `as_response=True` parameter is **deprecated** and will be removed in **v2.0**. Replace it with `schema=YourSchema`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[:material-api: API Reference](../reference/crud.md)
|
[:material-api: API Reference](../reference/crud.md)
|
||||||
|
|||||||
@@ -87,6 +87,37 @@ await wait_for_row_change(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Creating a database
|
||||||
|
|
||||||
|
!!! info "Added in `v2.1`"
|
||||||
|
|
||||||
|
[`create_database`](../reference/db.md#fastapi_toolsets.db.create_database) creates a database at a given URL. It connects to *server_url* and issues a `CREATE DATABASE` statement:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import create_database
|
||||||
|
|
||||||
|
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
|
||||||
|
|
||||||
|
await create_database(db_name="myapp_test", server_url=SERVER_URL)
|
||||||
|
```
|
||||||
|
|
||||||
|
For test isolation with automatic cleanup, use [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_worker_database) from the `pytest` module instead — it handles drop-before, create, and drop-after automatically.
|
||||||
|
|
||||||
|
## Cleaning up tables
|
||||||
|
|
||||||
|
!!! info "Added in `v2.1`"
|
||||||
|
|
||||||
|
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables) truncates all tables:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import cleanup_tables
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def clean(db_session):
|
||||||
|
yield
|
||||||
|
await cleanup_tables(session=db_session, base=Base)
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[:material-api: API Reference](../reference/db.md)
|
[:material-api: API Reference](../reference/db.md)
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ The `dependencies` module provides two factory functions that create FastAPI dep
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.dependencies import PathDependency
|
from fastapi_toolsets.dependencies import PathDependency
|
||||||
|
|
||||||
|
# Plain callable
|
||||||
UserDep = PathDependency(model=User, field=User.id, session_dep=get_db)
|
UserDep = PathDependency(model=User, field=User.id, session_dep=get_db)
|
||||||
|
|
||||||
|
# Annotated
|
||||||
|
SessionDep = Annotated[AsyncSession, Depends(get_db)]
|
||||||
|
UserDep = PathDependency(model=User, field=User.id, session_dep=SessionDep)
|
||||||
|
|
||||||
@router.get("/users/{user_id}")
|
@router.get("/users/{user_id}")
|
||||||
async def get_user(user: User = UserDep):
|
async def get_user(user: User = UserDep):
|
||||||
return user
|
return user
|
||||||
@@ -37,8 +42,14 @@ async def get_user(user: User = UserDep):
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.dependencies import BodyDependency
|
from fastapi_toolsets.dependencies import BodyDependency
|
||||||
|
|
||||||
|
# Plain callable
|
||||||
RoleDep = BodyDependency(model=Role, field=Role.id, session_dep=get_db, body_field="role_id")
|
RoleDep = BodyDependency(model=Role, field=Role.id, session_dep=get_db, body_field="role_id")
|
||||||
|
|
||||||
|
# Annotated
|
||||||
|
SessionDep = Annotated[AsyncSession, Depends(get_db)]
|
||||||
|
RoleDep = BodyDependency(model=Role, field=Role.id, session_dep=SessionDep, body_field="role_id")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users")
|
@router.post("/users")
|
||||||
async def create_user(body: UserCreateSchema, role: Role = RoleDep):
|
async def create_user(body: UserCreateSchema, role: Role = RoleDep):
|
||||||
user = User(username=body.username, role=role)
|
user = User(username=body.username, role=role)
|
||||||
|
|||||||
@@ -21,30 +21,37 @@ init_exceptions_handlers(app=app)
|
|||||||
This registers handlers for:
|
This registers handlers for:
|
||||||
|
|
||||||
- [`ApiException`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ApiException) — all custom exceptions below
|
- [`ApiException`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ApiException) — all custom exceptions below
|
||||||
|
- `HTTPException` — Starlette/FastAPI HTTP errors
|
||||||
- `RequestValidationError` — Pydantic request validation (422)
|
- `RequestValidationError` — Pydantic request validation (422)
|
||||||
- `ResponseValidationError` — Pydantic response validation (422)
|
- `ResponseValidationError` — Pydantic response validation (422)
|
||||||
- `Exception` — unhandled errors (500)
|
- `Exception` — unhandled errors (500)
|
||||||
|
|
||||||
|
It also patches `app.openapi()` to replace the default Pydantic 422 schema with a structured example matching the `ErrorResponse` format.
|
||||||
|
|
||||||
## Built-in exceptions
|
## Built-in exceptions
|
||||||
|
|
||||||
| Exception | Status | Default message |
|
| Exception | Status | Default message |
|
||||||
|-----------|--------|-----------------|
|
|-----------|--------|-----------------|
|
||||||
| [`UnauthorizedError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.UnauthorizedError) | 401 | Unauthorized |
|
| [`UnauthorizedError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.UnauthorizedError) | 401 | Unauthorized |
|
||||||
| [`ForbiddenError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ForbiddenError) | 403 | Forbidden |
|
| [`ForbiddenError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ForbiddenError) | 403 | Forbidden |
|
||||||
| [`NotFoundError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.NotFoundError) | 404 | Not found |
|
| [`NotFoundError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.NotFoundError) | 404 | Not Found |
|
||||||
| [`ConflictError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ConflictError) | 409 | Conflict |
|
| [`ConflictError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.ConflictError) | 409 | Conflict |
|
||||||
| [`NoSearchableFieldsError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError) | 400 | No searchable fields |
|
| [`NoSearchableFieldsError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError) | 400 | No Searchable Fields |
|
||||||
| [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError) | 400 | Invalid facet filter |
|
| [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError) | 400 | Invalid Facet Filter |
|
||||||
|
| [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) | 422 | Invalid Order Field |
|
||||||
|
|
||||||
|
### Per-instance overrides
|
||||||
|
|
||||||
|
All built-in exceptions accept optional keyword arguments to customise the response for a specific raise site without changing the class defaults:
|
||||||
|
|
||||||
|
| Argument | Effect |
|
||||||
|
|----------|--------|
|
||||||
|
| `detail` | Overrides both `str(exc)` (log output) and the `message` field in the response body |
|
||||||
|
| `desc` | Overrides the `description` field |
|
||||||
|
| `data` | Overrides the `data` field |
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.exceptions import NotFoundError
|
raise NotFoundError(detail="User 42 not found", desc="No user with that ID exists in the database.")
|
||||||
|
|
||||||
@router.get("/users/{id}")
|
|
||||||
async def get_user(id: int, session: AsyncSession = Depends(get_db)):
|
|
||||||
user = await UserCrud.first(session=session, filters=[User.id == id])
|
|
||||||
if not user:
|
|
||||||
raise NotFoundError
|
|
||||||
return user
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Custom exceptions
|
## Custom exceptions
|
||||||
@@ -58,12 +65,51 @@ from fastapi_toolsets.schemas import ApiError
|
|||||||
class PaymentRequiredError(ApiException):
|
class PaymentRequiredError(ApiException):
|
||||||
api_error = ApiError(
|
api_error = ApiError(
|
||||||
code=402,
|
code=402,
|
||||||
msg="Payment required",
|
msg="Payment Required",
|
||||||
desc="Your subscription has expired.",
|
desc="Your subscription has expired.",
|
||||||
err_code="PAYMENT_REQUIRED",
|
err_code="BILLING-402",
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
Subclasses that do not define `api_error` raise a `TypeError` at **class creation time**, not at raise time.
|
||||||
|
|
||||||
|
### Custom `__init__`
|
||||||
|
|
||||||
|
Override `__init__` to compute `detail`, `desc`, or `data` dynamically, then delegate to `super().__init__()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class OrderValidationError(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=422,
|
||||||
|
msg="Order Validation Failed",
|
||||||
|
desc="One or more order fields are invalid.",
|
||||||
|
err_code="ORDER-422",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *field_errors: str) -> None:
|
||||||
|
super().__init__(
|
||||||
|
f"{len(field_errors)} validation error(s)",
|
||||||
|
desc=", ".join(field_errors),
|
||||||
|
data={"errors": [{"message": e} for e in field_errors]},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Intermediate base classes
|
||||||
|
|
||||||
|
Use `abstract=True` when creating a shared base that is not meant to be raised directly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BillingError(ApiException, abstract=True):
|
||||||
|
"""Base for all billing-related errors."""
|
||||||
|
|
||||||
|
class PaymentRequiredError(BillingError):
|
||||||
|
api_error = ApiError(code=402, msg="Payment Required", desc="...", err_code="BILLING-402")
|
||||||
|
|
||||||
|
class SubscriptionExpiredError(BillingError):
|
||||||
|
api_error = ApiError(code=402, msg="Subscription Expired", desc="...", err_code="BILLING-402-EXP")
|
||||||
|
```
|
||||||
|
|
||||||
## OpenAPI response documentation
|
## OpenAPI response documentation
|
||||||
|
|
||||||
Use [`generate_error_responses`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.generate_error_responses) to add error schemas to your endpoint's OpenAPI spec:
|
Use [`generate_error_responses`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.generate_error_responses) to add error schemas to your endpoint's OpenAPI spec:
|
||||||
@@ -78,8 +124,7 @@ from fastapi_toolsets.exceptions import generate_error_responses, NotFoundError,
|
|||||||
async def get_user(...): ...
|
async def get_user(...): ...
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! info
|
Multiple exceptions sharing the same HTTP status code are grouped under one entry, each appearing as a named example keyed by its `err_code`. This keeps the OpenAPI UI readable when several error variants map to the same status.
|
||||||
The pydantic validation error is automatically added by FastAPI.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -38,18 +38,20 @@ By context with [`load_fixtures_by_context`](../reference/fixtures.md#fastapi_to
|
|||||||
from fastapi_toolsets.fixtures import load_fixtures_by_context
|
from fastapi_toolsets.fixtures import load_fixtures_by_context
|
||||||
|
|
||||||
async with db_context() as session:
|
async with db_context() as session:
|
||||||
await load_fixtures_by_context(session=session, registry=fixtures, context=Context.TESTING)
|
await load_fixtures_by_context(session, fixtures, Context.TESTING)
|
||||||
```
|
```
|
||||||
|
|
||||||
Directly with [`load_fixtures`](../reference/fixtures.md#fastapi_toolsets.fixtures.utils.load_fixtures):
|
Directly by name with [`load_fixtures`](../reference/fixtures.md#fastapi_toolsets.fixtures.utils.load_fixtures):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.fixtures import load_fixtures
|
from fastapi_toolsets.fixtures import load_fixtures
|
||||||
|
|
||||||
async with db_context() as session:
|
async with db_context() as session:
|
||||||
await load_fixtures(session=session, registry=fixtures)
|
await load_fixtures(session, fixtures, "roles", "test_users")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Both functions return a `dict[str, list[...]]` mapping each fixture name to the list of loaded instances.
|
||||||
|
|
||||||
## Contexts
|
## Contexts
|
||||||
|
|
||||||
[`Context`](../reference/fixtures.md#fastapi_toolsets.fixtures.enum.Context) is an enum with predefined values:
|
[`Context`](../reference/fixtures.md#fastapi_toolsets.fixtures.enum.Context) is an enum with predefined values:
|
||||||
@@ -58,10 +60,60 @@ async with db_context() as session:
|
|||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `Context.BASE` | Core data required in all environments |
|
| `Context.BASE` | Core data required in all environments |
|
||||||
| `Context.TESTING` | Data only loaded during tests |
|
| `Context.TESTING` | Data only loaded during tests |
|
||||||
|
| `Context.DEVELOPMENT` | Data only loaded in development |
|
||||||
| `Context.PRODUCTION` | Data only loaded in production |
|
| `Context.PRODUCTION` | Data only loaded in production |
|
||||||
|
|
||||||
A fixture with no `contexts` defined takes `Context.BASE` by default.
|
A fixture with no `contexts` defined takes `Context.BASE` by default.
|
||||||
|
|
||||||
|
### Custom contexts
|
||||||
|
|
||||||
|
Plain strings and any `Enum` subclass are accepted wherever a `Context` enum is expected.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class AppContext(str, Enum):
|
||||||
|
STAGING = "staging"
|
||||||
|
DEMO = "demo"
|
||||||
|
|
||||||
|
@fixtures.register(contexts=[AppContext.STAGING])
|
||||||
|
def staging_data():
|
||||||
|
return [Config(key="feature_x", enabled=True)]
|
||||||
|
|
||||||
|
await load_fixtures_by_context(session, fixtures, AppContext.STAGING)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Default context for a registry
|
||||||
|
|
||||||
|
Pass `contexts` to `FixtureRegistry` to set a default for all fixtures registered in it:
|
||||||
|
|
||||||
|
```python
|
||||||
|
testing_registry = FixtureRegistry(contexts=[Context.TESTING])
|
||||||
|
|
||||||
|
@testing_registry.register # implicitly contexts=[Context.TESTING]
|
||||||
|
def test_orders():
|
||||||
|
return [Order(id=1, total=99)]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Same fixture name, multiple context variants
|
||||||
|
|
||||||
|
The same fixture name may be registered under different (non-overlapping) context sets. When multiple contexts are loaded together, all matching variants are merged:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@fixtures.register(contexts=[Context.BASE])
|
||||||
|
def users():
|
||||||
|
return [User(id=1, username="admin")]
|
||||||
|
|
||||||
|
@fixtures.register(contexts=[Context.TESTING])
|
||||||
|
def users():
|
||||||
|
return [User(id=2, username="tester")]
|
||||||
|
|
||||||
|
# loads both admin and tester
|
||||||
|
await load_fixtures_by_context(session, fixtures, Context.BASE, Context.TESTING)
|
||||||
|
```
|
||||||
|
|
||||||
|
Registering two variants with overlapping context sets raises `ValueError`.
|
||||||
|
|
||||||
## Load strategies
|
## Load strategies
|
||||||
|
|
||||||
[`LoadStrategy`](../reference/fixtures.md#fastapi_toolsets.fixtures.enum.LoadStrategy) controls how the fixture loader handles rows that already exist:
|
[`LoadStrategy`](../reference/fixtures.md#fastapi_toolsets.fixtures.enum.LoadStrategy) controls how the fixture loader handles rows that already exist:
|
||||||
@@ -69,20 +121,44 @@ A fixture with no `contexts` defined takes `Context.BASE` by default.
|
|||||||
| Strategy | Description |
|
| Strategy | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `LoadStrategy.INSERT` | Insert only, fail on duplicates |
|
| `LoadStrategy.INSERT` | Insert only, fail on duplicates |
|
||||||
| `LoadStrategy.UPSERT` | Insert or update on conflict |
|
| `LoadStrategy.MERGE` | Insert or update on conflict (default) |
|
||||||
| `LoadStrategy.SKIP` | Skip rows that already exist |
|
| `LoadStrategy.SKIP_EXISTING` | Skip rows that already exist |
|
||||||
|
|
||||||
|
```python
|
||||||
|
await load_fixtures_by_context(
|
||||||
|
session, fixtures, Context.BASE, strategy=LoadStrategy.SKIP_EXISTING
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
## Merging registries
|
## Merging registries
|
||||||
|
|
||||||
Split fixtures definitions across modules and merge them:
|
Split fixture definitions across modules and merge them:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from myapp.fixtures.dev import dev_fixtures
|
from myapp.fixtures.dev import dev_fixtures
|
||||||
from myapp.fixtures.prod import prod_fixtures
|
from myapp.fixtures.prod import prod_fixtures
|
||||||
|
|
||||||
fixtures = fixturesRegistry()
|
fixtures = FixtureRegistry()
|
||||||
fixtures.include_registry(registry=dev_fixtures)
|
fixtures.include_registry(registry=dev_fixtures)
|
||||||
fixtures.include_registry(registry=prod_fixtures)
|
fixtures.include_registry(registry=prod_fixtures)
|
||||||
|
```
|
||||||
|
|
||||||
|
Fixtures with the same name are allowed as long as their context sets do not overlap. Conflicting contexts raise `ValueError`.
|
||||||
|
|
||||||
|
## Looking up fixture instances
|
||||||
|
|
||||||
|
[`get_obj_by_attr`](../reference/fixtures.md#fastapi_toolsets.fixtures.utils.get_obj_by_attr) retrieves a specific instance from a fixture function by attribute value — useful when building cross-fixture `depends_on` relationships:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.fixtures import get_obj_by_attr
|
||||||
|
|
||||||
|
@fixtures.register(depends_on=["roles"])
|
||||||
|
def users():
|
||||||
|
admin_role = get_obj_by_attr(roles, "name", "admin")
|
||||||
|
return [User(id=1, username="alice", role_id=admin_role.id)]
|
||||||
|
```
|
||||||
|
|
||||||
|
Raises `StopIteration` if no matching instance is found.
|
||||||
|
|
||||||
## Pytest integration
|
## Pytest integration
|
||||||
|
|
||||||
@@ -111,7 +187,6 @@ async def test_user_can_login(fixture_users: list[User], fixture_roles: list[Rol
|
|||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
The load order is resolved automatically from the `depends_on` declarations in your registry. Each generated fixture receives `db_session` as a dependency and returns the list of loaded model instances.
|
The load order is resolved automatically from the `depends_on` declarations in your registry. Each generated fixture receives `db_session` as a dependency and returns the list of loaded model instances.
|
||||||
|
|
||||||
## CLI integration
|
## CLI integration
|
||||||
|
|||||||
@@ -36,7 +36,13 @@ This mounts the `/metrics` endpoint that Prometheus can scrape.
|
|||||||
|
|
||||||
### Providers
|
### Providers
|
||||||
|
|
||||||
Providers are called once at startup and register metrics that are updated externally (e.g. counters, histograms):
|
Providers are called once at startup by `init_metrics`. The return value (the Prometheus metric object) is stored in the registry and can be retrieved later with [`registry.get(name)`](../reference/metrics.md#fastapi_toolsets.metrics.registry.MetricsRegistry.get).
|
||||||
|
|
||||||
|
Use providers when you want **deferred initialization**: the Prometheus metric is not registered with the global `CollectorRegistry` until `init_metrics` runs, not at import time. This is particularly useful for testing — importing the module in a test suite without calling `init_metrics` leaves no metrics registered, avoiding cross-test pollution.
|
||||||
|
|
||||||
|
It is also useful when metrics are defined across multiple modules and merged with `include_registry`: any code that needs a metric can call `metrics.get()` on the shared registry instead of importing the metric directly from its origin module.
|
||||||
|
|
||||||
|
If neither of these applies to you, declaring metrics at module level (e.g. `HTTP_REQUESTS = Counter(...)`) is simpler and equally valid.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from prometheus_client import Counter, Histogram
|
from prometheus_client import Counter, Histogram
|
||||||
@@ -50,15 +56,32 @@ def request_duration():
|
|||||||
return Histogram("request_duration_seconds", "Request duration")
|
return Histogram("request_duration_seconds", "Request duration")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Collectors
|
To use a provider's metric elsewhere (e.g. in a middleware), call `metrics.get()` inside the handler — **not** at module level, as providers are only initialized when `init_metrics` runs:
|
||||||
|
|
||||||
Collectors are called on every scrape. Use them for metrics that reflect current state (e.g. gauges):
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
async def metrics_middleware(request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
metrics.get("http_requests").labels(
|
||||||
|
method=request.method, status=response.status_code
|
||||||
|
).inc()
|
||||||
|
return response
|
||||||
|
```
|
||||||
|
|
||||||
|
### Collectors
|
||||||
|
|
||||||
|
Collectors are called on every scrape. Use them for metrics that reflect current state (e.g. gauges).
|
||||||
|
|
||||||
|
!!! warning "Declare the metric at module level"
|
||||||
|
Do **not** instantiate the Prometheus metric inside the collector function. Doing so recreates it on every scrape, raising `ValueError: Duplicated timeseries in CollectorRegistry`. Declare it once at module level instead:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from prometheus_client import Gauge
|
||||||
|
|
||||||
|
_queue_depth = Gauge("queue_depth", "Current queue depth")
|
||||||
|
|
||||||
@metrics.register(collect=True)
|
@metrics.register(collect=True)
|
||||||
def queue_depth():
|
def collect_queue_depth():
|
||||||
gauge = Gauge("queue_depth", "Current queue depth")
|
_queue_depth.set(get_current_queue_depth())
|
||||||
gauge.set(get_current_queue_depth())
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Merging registries
|
## Merging registries
|
||||||
|
|||||||
234
docs/module/models.md
Normal file
234
docs/module/models.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
# Models
|
||||||
|
|
||||||
|
!!! info "Added in `v2.0`"
|
||||||
|
|
||||||
|
Reusable SQLAlchemy 2.0 mixins for common column patterns, designed to be composed freely on any `DeclarativeBase` model.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `models` module provides mixins that each add a single, well-defined column behaviour. They work with standard SQLAlchemy 2.0 declarative syntax and are fully compatible with `AsyncSession`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.models import UUIDMixin, TimestampMixin
|
||||||
|
|
||||||
|
class Article(Base, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "articles"
|
||||||
|
|
||||||
|
title: Mapped[str]
|
||||||
|
content: Mapped[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
All timestamp columns are timezone-aware (`TIMESTAMPTZ`). All defaults are server-side (`clock_timestamp()`), so they are also applied when inserting rows via raw SQL outside the ORM.
|
||||||
|
|
||||||
|
## Mixins
|
||||||
|
|
||||||
|
### [`UUIDMixin`](../reference/models.md#fastapi_toolsets.models.UUIDMixin)
|
||||||
|
|
||||||
|
Adds a `id: UUID` primary key generated server-side by PostgreSQL using `gen_random_uuid()`. The value is retrieved via `RETURNING` after insert, so it is available on the Python object immediately after `flush()`.
|
||||||
|
|
||||||
|
!!! warning "Requires PostgreSQL 13+"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.models import UUIDMixin
|
||||||
|
|
||||||
|
class User(Base, UUIDMixin):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
username: Mapped[str]
|
||||||
|
|
||||||
|
# id is None before flush
|
||||||
|
user = User(username="alice")
|
||||||
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
print(user.id) # UUID('...')
|
||||||
|
```
|
||||||
|
|
||||||
|
### [`UUIDv7Mixin`](../reference/models.md#fastapi_toolsets.models.UUIDv7Mixin)
|
||||||
|
|
||||||
|
!!! info "Added in `v2.3`"
|
||||||
|
|
||||||
|
Adds a `id: UUID` primary key generated server-side by PostgreSQL using `uuidv7()`. It's a time-ordered UUID format that encodes a millisecond-precision timestamp in the most significant bits, making it naturally sortable and index-friendly.
|
||||||
|
|
||||||
|
!!! warning "Requires PostgreSQL 18+"
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.models import UUIDv7Mixin
|
||||||
|
|
||||||
|
class Event(Base, UUIDv7Mixin):
|
||||||
|
__tablename__ = "events"
|
||||||
|
|
||||||
|
name: Mapped[str]
|
||||||
|
|
||||||
|
# id is None before flush
|
||||||
|
event = Event(name="user.signup")
|
||||||
|
session.add(event)
|
||||||
|
await session.flush()
|
||||||
|
print(event.id) # UUID('019...')
|
||||||
|
```
|
||||||
|
|
||||||
|
### [`CreatedAtMixin`](../reference/models.md#fastapi_toolsets.models.CreatedAtMixin)
|
||||||
|
|
||||||
|
Adds a `created_at: datetime` column set to `clock_timestamp()` on insert. The column has no `onupdate` hook — it is intentionally immutable after the row is created.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.models import UUIDMixin, CreatedAtMixin
|
||||||
|
|
||||||
|
class Order(Base, UUIDMixin, CreatedAtMixin):
|
||||||
|
__tablename__ = "orders"
|
||||||
|
|
||||||
|
total: Mapped[float]
|
||||||
|
```
|
||||||
|
|
||||||
|
### [`UpdatedAtMixin`](../reference/models.md#fastapi_toolsets.models.UpdatedAtMixin)
|
||||||
|
|
||||||
|
Adds an `updated_at: datetime` column set to `clock_timestamp()` on insert and automatically updated to `clock_timestamp()` on every ORM-level update (via SQLAlchemy's `onupdate` hook).
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.models import UUIDMixin, UpdatedAtMixin
|
||||||
|
|
||||||
|
class Post(Base, UUIDMixin, UpdatedAtMixin):
|
||||||
|
__tablename__ = "posts"
|
||||||
|
|
||||||
|
title: Mapped[str]
|
||||||
|
|
||||||
|
post = Post(title="Hello")
|
||||||
|
await session.flush()
|
||||||
|
await session.refresh(post)
|
||||||
|
|
||||||
|
post.title = "Hello World"
|
||||||
|
await session.flush()
|
||||||
|
await session.refresh(post)
|
||||||
|
print(post.updated_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
`updated_at` is updated by SQLAlchemy at ORM flush time. If you update rows via raw SQL (e.g. `UPDATE posts SET ...`), the column will **not** be updated automatically — use a database trigger if you need that guarantee.
|
||||||
|
|
||||||
|
### [`TimestampMixin`](../reference/models.md#fastapi_toolsets.models.TimestampMixin)
|
||||||
|
|
||||||
|
Convenience mixin that combines [`CreatedAtMixin`](../reference/models.md#fastapi_toolsets.models.CreatedAtMixin) and [`UpdatedAtMixin`](../reference/models.md#fastapi_toolsets.models.UpdatedAtMixin). Equivalent to inheriting both.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.models import UUIDMixin, TimestampMixin
|
||||||
|
|
||||||
|
class Article(Base, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "articles"
|
||||||
|
|
||||||
|
title: Mapped[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lifecycle events
|
||||||
|
|
||||||
|
The event system provides lifecycle callbacks that fire **after commit**. If the transaction rolls back, no callback fires.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
Event dispatch requires [`EventSession`](../reference/models.md#fastapi_toolsets.models.EventSession). Pass it as the session class when creating your session factory:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from fastapi_toolsets.models import EventSession
|
||||||
|
|
||||||
|
engine = create_async_engine("postgresql+asyncpg://...")
|
||||||
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=EventSession)
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! info "Callbacks fire on `session.commit()` only — not on savepoints."
|
||||||
|
Savepoints created by [`get_transaction`](db.md) or `begin_nested()` do **not**
|
||||||
|
trigger callbacks. All events accumulated across flushes are dispatched once
|
||||||
|
when the outermost `commit()` is called.
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
Three event types are available, each corresponding to a [`ModelEvent`](../reference/models.md#fastapi_toolsets.models.ModelEvent) value:
|
||||||
|
|
||||||
|
| Event | Trigger |
|
||||||
|
|---|---|
|
||||||
|
| `ModelEvent.CREATE` | After `INSERT` commit |
|
||||||
|
| `ModelEvent.DELETE` | After `DELETE` commit |
|
||||||
|
| `ModelEvent.UPDATE` | After `UPDATE` commit on a watched field |
|
||||||
|
|
||||||
|
!!! warning "Callbacks fire only for ORM-level changes. Rows updated via raw SQL (`UPDATE ... SET ...`) are not detected."
|
||||||
|
|
||||||
|
### Watched fields
|
||||||
|
|
||||||
|
Set `__watched_fields__` on the model to restrict which field changes trigger `UPDATE` events. It must be a `tuple[str, ...]` — any other type raises `TypeError`:
|
||||||
|
|
||||||
|
| Class attribute | `UPDATE` behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `__watched_fields__ = ("status", "role")` | Only fires when `status` or `role` changes |
|
||||||
|
| *(not set)* | Fires when **any** mapped field changes |
|
||||||
|
|
||||||
|
`__watched_fields__` is inherited through the class hierarchy via normal Python MRO. A subclass can override it:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Order(Base, UUIDMixin):
|
||||||
|
__watched_fields__ = ("status",)
|
||||||
|
...
|
||||||
|
|
||||||
|
class UrgentOrder(Order):
|
||||||
|
# inherits __watched_fields__ = ("status",)
|
||||||
|
...
|
||||||
|
|
||||||
|
class PriorityOrder(Order):
|
||||||
|
__watched_fields__ = ("priority",)
|
||||||
|
# overrides parent — UPDATE fires only for priority changes
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registering handlers
|
||||||
|
|
||||||
|
Register handlers with the [`listens_for`](../reference/models.md#fastapi_toolsets.models.listens_for) decorator. Every callback receives three arguments: the model instance, the [`ModelEvent`](../reference/models.md#fastapi_toolsets.models.ModelEvent) that triggered it, and a `changes` dict (`None` for `CREATE` and `DELETE`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for
|
||||||
|
|
||||||
|
class Order(Base, UUIDMixin):
|
||||||
|
__tablename__ = "orders"
|
||||||
|
__watched_fields__ = ("status",)
|
||||||
|
|
||||||
|
status: Mapped[str]
|
||||||
|
|
||||||
|
@listens_for(Order, [ModelEvent.CREATE])
|
||||||
|
async def on_order_created(order: Order, event_type: ModelEvent, changes: None):
|
||||||
|
await notify_new_order(order.id)
|
||||||
|
|
||||||
|
@listens_for(Order, [ModelEvent.DELETE])
|
||||||
|
async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None):
|
||||||
|
await notify_order_cancelled(order.id)
|
||||||
|
|
||||||
|
@listens_for(Order, [ModelEvent.UPDATE])
|
||||||
|
async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict):
|
||||||
|
if "status" in changes:
|
||||||
|
await notify_status_change(order.id, changes["status"])
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple handlers can be registered for the same model and event. Handlers registered on a parent class also fire for subclass instances.
|
||||||
|
|
||||||
|
A single handler can listen for multiple events at once. When `event_types` is omitted, the handler fires for all events:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@listens_for(Order, [ModelEvent.CREATE, ModelEvent.UPDATE])
|
||||||
|
async def on_order_changed(order: Order, event_type: ModelEvent, changes: dict | None):
|
||||||
|
await invalidate_cache(order.id)
|
||||||
|
|
||||||
|
@listens_for(Order) # all events
|
||||||
|
async def on_any_order_event(order: Order, event_type: ModelEvent, changes: dict | None):
|
||||||
|
await audit_log(order.id, event_type)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field changes format
|
||||||
|
|
||||||
|
The `changes` dict maps each watched field that changed to `{"old": ..., "new": ...}`. Only fields that actually changed are included. For `CREATE` and `DELETE` events, `changes` is `None`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# CREATE / DELETE → changes is None
|
||||||
|
# status changed → {"status": {"old": "pending", "new": "shipped"}}
|
||||||
|
# two fields changed → {"status": {...}, "assigned_to": {...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! info "Multiple flushes in one transaction are merged: the earliest `old` and latest `new` are preserved, and `on_update` fires only once per commit."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[:material-api: API Reference](../reference/models.md)
|
||||||
@@ -40,10 +40,10 @@ async def http_client(db_session):
|
|||||||
|
|
||||||
## Database sessions in tests
|
## Database sessions in tests
|
||||||
|
|
||||||
Use [`create_db_session`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_db_session) to create an isolated `AsyncSession` for a test:
|
Use [`create_db_session`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_db_session) to create an isolated `AsyncSession` for a test, combined with [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_worker_database) to set up a per-worker database:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.pytest import create_db_session, create_worker_database
|
from fastapi_toolsets.pytest import create_worker_database, create_db_session
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def worker_db_url():
|
async def worker_db_url():
|
||||||
@@ -64,16 +64,25 @@ async def db_session(worker_db_url):
|
|||||||
!!! info
|
!!! info
|
||||||
In this example, the database is reset between each test using the argument `cleanup=True`.
|
In this example, the database is reset between each test using the argument `cleanup=True`.
|
||||||
|
|
||||||
|
Use [`worker_database_url`](../reference/pytest.md#fastapi_toolsets.pytest.utils.worker_database_url) to derive the per-worker URL manually if needed:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.pytest import worker_database_url
|
||||||
|
|
||||||
|
url = worker_database_url("postgresql+asyncpg://user:pass@localhost/test_db", default_test_db="test")
|
||||||
|
# e.g. "postgresql+asyncpg://user:pass@localhost/test_db_gw0" under xdist
|
||||||
|
```
|
||||||
|
|
||||||
## Parallel testing with pytest-xdist
|
## Parallel testing with pytest-xdist
|
||||||
|
|
||||||
The examples above are already compatible with parallel test execution with `pytest-xdist`.
|
The examples above are already compatible with parallel test execution with `pytest-xdist`.
|
||||||
|
|
||||||
## Cleaning up tables
|
## Cleaning up tables
|
||||||
|
|
||||||
If you want to manually clean up a database you can use [`cleanup_tables`](../reference/pytest.md#fastapi_toolsets.pytest.utils.cleanup_tables), this will truncates all tables between tests for fast isolation:
|
If you want to manually clean up a database you can use [`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables), this will truncate all tables between tests for fast isolation:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.pytest import cleanup_tables
|
from fastapi_toolsets.db import cleanup_tables
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
async def clean(db_session):
|
async def clean(db_session):
|
||||||
|
|||||||
@@ -20,26 +20,115 @@ async def get_user(user: User = UserDep) -> Response[UserSchema]:
|
|||||||
return Response(data=user, message="User retrieved")
|
return Response(data=user, message="User retrieved")
|
||||||
```
|
```
|
||||||
|
|
||||||
### [`PaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse)
|
### Paginated response models
|
||||||
|
|
||||||
Wraps a list of items with pagination metadata and optional facet values.
|
Three classes wrap paginated list results. Pick the one that matches your endpoint's strategy:
|
||||||
|
|
||||||
|
| Class | `pagination` type | `pagination_type` field | Use when |
|
||||||
|
|---|---|---|---|
|
||||||
|
| [`OffsetPaginatedResponse[T]`](#offsetpaginatedresponset) | `OffsetPagination` | `"offset"` (fixed) | endpoint always uses offset |
|
||||||
|
| [`CursorPaginatedResponse[T]`](#cursorpaginatedresponset) | `CursorPagination` | `"cursor"` (fixed) | endpoint always uses cursor |
|
||||||
|
| [`PaginatedResponse[T]`](#paginatedresponset) | `OffsetPagination \| CursorPagination` | — | unified endpoint supporting both strategies |
|
||||||
|
|
||||||
|
#### [`OffsetPaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPaginatedResponse)
|
||||||
|
|
||||||
|
!!! info "Added in `v2.3.0`"
|
||||||
|
|
||||||
|
Use as the return type when the endpoint always uses [`offset_paginate`](crud.md#offset-pagination). The `pagination` field is guaranteed to be an [`OffsetPagination`](../reference/schemas.md#fastapi_toolsets.schemas.OffsetPagination) object; the response always includes a `pagination_type: "offset"` discriminator.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.schemas import PaginatedResponse, Pagination
|
from fastapi_toolsets.schemas import OffsetPaginatedResponse
|
||||||
|
|
||||||
@router.get("/users")
|
@router.get("/users")
|
||||||
async def list_users() -> PaginatedResponse[UserSchema]:
|
async def list_users(
|
||||||
return PaginatedResponse(
|
page: int = 1,
|
||||||
data=users,
|
items_per_page: int = 20,
|
||||||
pagination=Pagination(
|
) -> OffsetPaginatedResponse[UserSchema]:
|
||||||
total_count=100,
|
return await UserCrud.offset_paginate(
|
||||||
items_per_page=10,
|
session, page=page, items_per_page=items_per_page, schema=UserSchema
|
||||||
page=1,
|
|
||||||
has_more=True,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Response shape:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"pagination_type": "offset",
|
||||||
|
"data": ["..."],
|
||||||
|
"pagination": {
|
||||||
|
"total_count": 100,
|
||||||
|
"page": 1,
|
||||||
|
"items_per_page": 20,
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### [`CursorPaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPaginatedResponse)
|
||||||
|
|
||||||
|
!!! info "Added in `v2.3.0`"
|
||||||
|
|
||||||
|
Use as the return type when the endpoint always uses [`cursor_paginate`](crud.md#cursor-pagination). The `pagination` field is guaranteed to be a [`CursorPagination`](../reference/schemas.md#fastapi_toolsets.schemas.CursorPagination) object; the response always includes a `pagination_type: "cursor"` discriminator.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.schemas import CursorPaginatedResponse
|
||||||
|
|
||||||
|
@router.get("/events")
|
||||||
|
async def list_events(
|
||||||
|
cursor: str | None = None,
|
||||||
|
items_per_page: int = 20,
|
||||||
|
) -> CursorPaginatedResponse[EventSchema]:
|
||||||
|
return await EventCrud.cursor_paginate(
|
||||||
|
session, cursor=cursor, items_per_page=items_per_page, schema=EventSchema
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response shape:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"pagination_type": "cursor",
|
||||||
|
"data": ["..."],
|
||||||
|
"pagination": {
|
||||||
|
"next_cursor": "eyJpZCI6IDQyfQ==",
|
||||||
|
"prev_cursor": null,
|
||||||
|
"items_per_page": 20,
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### [`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-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:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.crud import PaginationType
|
||||||
|
from fastapi_toolsets.schemas import PaginatedResponse
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
async def list_users(
|
||||||
|
pagination_type: PaginationType = PaginationType.OFFSET,
|
||||||
|
page: int = 1,
|
||||||
|
cursor: str | None = None,
|
||||||
|
items_per_page: int = 20,
|
||||||
|
) -> PaginatedResponse[UserSchema]:
|
||||||
|
return await UserCrud.paginate(
|
||||||
|
session,
|
||||||
|
pagination_type=pagination_type,
|
||||||
|
page=page,
|
||||||
|
cursor=cursor,
|
||||||
|
items_per_page=items_per_page,
|
||||||
|
schema=UserSchema,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Pagination metadata models
|
||||||
|
|
||||||
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`.
|
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)
|
||||||
|
|||||||
267
docs/module/security.md
Normal file
267
docs/module/security.md
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
Composable authentication helpers for FastAPI that use `Security()` for OpenAPI documentation and accept user-provided validator functions with full type flexibility.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `security` module provides four auth source classes and a `MultiAuth` factory. Each class wraps a FastAPI security scheme for OpenAPI and accepts a validator function called as:
|
||||||
|
|
||||||
|
```python
|
||||||
|
await validator(credential, **kwargs)
|
||||||
|
```
|
||||||
|
|
||||||
|
where `kwargs` are the extra keyword arguments provided at instantiation (roles, permissions, enums, etc.). The validator returns the authenticated identity (e.g. a `User` model) which becomes the route dependency value.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import Security
|
||||||
|
from fastapi_toolsets.security import BearerTokenAuth
|
||||||
|
|
||||||
|
async def verify_token(token: str, *, role: str) -> User:
|
||||||
|
user = await db.get_by_token(token)
|
||||||
|
if not user or user.role != role:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return user
|
||||||
|
|
||||||
|
bearer_admin = BearerTokenAuth(verify_token, role="admin")
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin_route(user: User = Security(bearer_admin)):
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth sources
|
||||||
|
|
||||||
|
### [`BearerTokenAuth`](../reference/security.md#fastapi_toolsets.security.BearerTokenAuth)
|
||||||
|
|
||||||
|
Reads the `Authorization: Bearer <token>` header. Wraps `HTTPBearer` for OpenAPI.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.security import BearerTokenAuth
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(validator=verify_token)
|
||||||
|
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user: User = Security(bearer)):
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Token prefix
|
||||||
|
|
||||||
|
The optional `prefix` parameter restricts a `BearerTokenAuth` instance to tokens
|
||||||
|
that start with a given string. The prefix is **kept** in the value passed to the
|
||||||
|
validator — store and compare tokens with their prefix included.
|
||||||
|
|
||||||
|
This lets you deploy multiple `BearerTokenAuth` instances in the same application
|
||||||
|
and disambiguate them efficiently in `MultiAuth`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
user_bearer = BearerTokenAuth(verify_user, prefix="user_") # matches "Bearer user_..."
|
||||||
|
org_bearer = BearerTokenAuth(verify_org, prefix="org_") # matches "Bearer org_..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Use [`generate_token()`](#token-generation) to create correctly-prefixed tokens.
|
||||||
|
|
||||||
|
#### Token generation
|
||||||
|
|
||||||
|
`BearerTokenAuth.generate_token()` produces a secure random token ready to store
|
||||||
|
in your database and return to the client. If a prefix is configured it is
|
||||||
|
prepended automatically:
|
||||||
|
|
||||||
|
```python
|
||||||
|
bearer = BearerTokenAuth(verify_token, prefix="user_")
|
||||||
|
|
||||||
|
token = bearer.generate_token() # e.g. "user_Xk3mN..."
|
||||||
|
await db.store_token(user_id, token)
|
||||||
|
return {"access_token": token, "token_type": "bearer"}
|
||||||
|
```
|
||||||
|
|
||||||
|
The client sends `Authorization: Bearer user_Xk3mN...` and the validator receives
|
||||||
|
the full token (prefix included) to compare against the stored value.
|
||||||
|
|
||||||
|
### [`CookieAuth`](../reference/security.md#fastapi_toolsets.security.CookieAuth)
|
||||||
|
|
||||||
|
Reads a named cookie. Wraps `APIKeyCookie` for OpenAPI.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.security import CookieAuth
|
||||||
|
|
||||||
|
cookie_auth = CookieAuth("session", validator=verify_session)
|
||||||
|
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user: User = Security(cookie_auth)):
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
### [`OAuth2Auth`](../reference/security.md#fastapi_toolsets.security.OAuth2Auth)
|
||||||
|
|
||||||
|
Reads the `Authorization: Bearer <token>` header and registers the token endpoint
|
||||||
|
in OpenAPI via `OAuth2PasswordBearer`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.security import OAuth2Auth
|
||||||
|
|
||||||
|
oauth2_auth = OAuth2Auth(token_url="/token", validator=verify_token)
|
||||||
|
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user: User = Security(oauth2_auth)):
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
### [`OpenIDAuth`](../reference/security.md#fastapi_toolsets.security.OpenIDAuth)
|
||||||
|
|
||||||
|
Reads the `Authorization: Bearer <token>` header and registers the OpenID Connect
|
||||||
|
discovery URL in OpenAPI via `OpenIdConnect`. Token validation is fully delegated
|
||||||
|
to your validator — use any OIDC / JWT library (`authlib`, `python-jose`, `PyJWT`).
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.security import OpenIDAuth
|
||||||
|
|
||||||
|
async def verify_google_token(token: str, *, audience: str) -> User:
|
||||||
|
payload = jwt.decode(token, google_public_keys, algorithms=["RS256"],
|
||||||
|
audience=audience)
|
||||||
|
return User(email=payload["email"], name=payload["name"])
|
||||||
|
|
||||||
|
google_auth = OpenIDAuth(
|
||||||
|
"https://accounts.google.com/.well-known/openid-configuration",
|
||||||
|
verify_google_token,
|
||||||
|
audience="my-client-id",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/me")
|
||||||
|
async def me(user: User = Security(google_auth)):
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
The discovery URL is used **only for OpenAPI documentation** — no requests are made
|
||||||
|
to it by this class. You are responsible for fetching and caching the provider's
|
||||||
|
public keys in your validator.
|
||||||
|
|
||||||
|
Multiple providers work naturally with `MultiAuth`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
multi = MultiAuth(google_auth, github_auth)
|
||||||
|
|
||||||
|
@app.get("/data")
|
||||||
|
async def data(user: User = Security(multi)):
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typed validator kwargs
|
||||||
|
|
||||||
|
All auth classes forward extra instantiation keyword arguments to the validator.
|
||||||
|
Arguments can be any type — enums, strings, integers, etc. The validator returns
|
||||||
|
the authenticated identity, which FastAPI injects directly into the route handler.
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def verify_token(token: str, *, role: Role, permission: str) -> User:
|
||||||
|
user = await decode_token(token)
|
||||||
|
if user.role != role or permission not in user.permissions:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return user
|
||||||
|
|
||||||
|
bearer = BearerTokenAuth(verify_token, role=Role.ADMIN, permission="billing:read")
|
||||||
|
```
|
||||||
|
|
||||||
|
Each auth instance is self-contained — create a separate instance per distinct
|
||||||
|
requirement instead of passing requirements through `Security(scopes=[...])`.
|
||||||
|
|
||||||
|
### Using `.require()` inline
|
||||||
|
|
||||||
|
If declaring a new top-level variable per role feels verbose, use `.require()` to
|
||||||
|
create a configured clone directly in the route decorator. The original instance
|
||||||
|
is not mutated:
|
||||||
|
|
||||||
|
```python
|
||||||
|
bearer = BearerTokenAuth(verify_token)
|
||||||
|
|
||||||
|
@app.get("/admin/stats")
|
||||||
|
async def admin_stats(user: User = Security(bearer.require(role=Role.ADMIN))):
|
||||||
|
return {"message": f"Hello admin {user.name}"}
|
||||||
|
|
||||||
|
@app.get("/profile")
|
||||||
|
async def profile(user: User = Security(bearer.require(role=Role.USER))):
|
||||||
|
return {"id": user.id, "name": user.name}
|
||||||
|
```
|
||||||
|
|
||||||
|
`.require()` kwargs are merged over existing ones — new values win on conflict.
|
||||||
|
The `prefix` (for `BearerTokenAuth`) and cookie name (for `CookieAuth`) 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`](../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.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.security import MultiAuth
|
||||||
|
|
||||||
|
multi = MultiAuth(user_bearer, org_bearer, cookie_auth)
|
||||||
|
|
||||||
|
@app.get("/data")
|
||||||
|
async def data_route(user = Security(multi)):
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using `.require()` on MultiAuth
|
||||||
|
|
||||||
|
`MultiAuth` also supports `.require()`, which propagates the kwargs to every
|
||||||
|
source that implements it. Sources that do not (e.g. custom `AuthSource`
|
||||||
|
subclasses) are passed through unchanged:
|
||||||
|
|
||||||
|
```python
|
||||||
|
multi = MultiAuth(bearer, cookie)
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user: User = Security(multi.require(role=Role.ADMIN))):
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
This is equivalent to calling `.require()` on each source individually:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# These two are identical
|
||||||
|
multi.require(role=Role.ADMIN)
|
||||||
|
|
||||||
|
MultiAuth(
|
||||||
|
bearer.require(role=Role.ADMIN),
|
||||||
|
cookie.require(role=Role.ADMIN),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prefix-based dispatch
|
||||||
|
|
||||||
|
Because `extract()` is pure string matching (no I/O), prefix-based source
|
||||||
|
selection is essentially free. Only the matching source's validator (which may
|
||||||
|
involve DB or network I/O) is ever called:
|
||||||
|
|
||||||
|
```python
|
||||||
|
user_bearer = BearerTokenAuth(verify_user, prefix="user_")
|
||||||
|
org_bearer = BearerTokenAuth(verify_org, prefix="org_")
|
||||||
|
|
||||||
|
multi = MultiAuth(user_bearer, org_bearer)
|
||||||
|
|
||||||
|
# "Bearer user_alice" → only verify_user runs, receives "user_alice"
|
||||||
|
# "Bearer org_acme" → only verify_org runs, receives "org_acme"
|
||||||
|
```
|
||||||
|
|
||||||
|
Tokens are stored and compared **with their prefix** — use `generate_token()` on
|
||||||
|
each source to issue correctly-prefixed tokens:
|
||||||
|
|
||||||
|
```python
|
||||||
|
user_token = user_bearer.generate_token() # "user_..."
|
||||||
|
org_token = org_bearer.generate_token() # "org_..."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[:material-api: API Reference](../reference/security.md)
|
||||||
@@ -7,6 +7,8 @@ You can import them directly from `fastapi_toolsets.db`:
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import (
|
from fastapi_toolsets.db import (
|
||||||
LockMode,
|
LockMode,
|
||||||
|
cleanup_tables,
|
||||||
|
create_database,
|
||||||
create_db_dependency,
|
create_db_dependency,
|
||||||
create_db_context,
|
create_db_context,
|
||||||
get_transaction,
|
get_transaction,
|
||||||
@@ -26,3 +28,7 @@ from fastapi_toolsets.db import (
|
|||||||
## ::: fastapi_toolsets.db.lock_tables
|
## ::: fastapi_toolsets.db.lock_tables
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.wait_for_row_change
|
## ::: fastapi_toolsets.db.wait_for_row_change
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.create_database
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.cleanup_tables
|
||||||
|
|||||||
34
docs/reference/models.md
Normal file
34
docs/reference/models.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# `models`
|
||||||
|
|
||||||
|
Here's the reference for the SQLAlchemy model mixins provided by the `models` module.
|
||||||
|
|
||||||
|
You can import them directly from `fastapi_toolsets.models`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.models import (
|
||||||
|
EventSession,
|
||||||
|
ModelEvent,
|
||||||
|
UUIDMixin,
|
||||||
|
UUIDv7Mixin,
|
||||||
|
CreatedAtMixin,
|
||||||
|
UpdatedAtMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
listens_for,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.models.EventSession
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.models.ModelEvent
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.models.UUIDMixin
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.models.UUIDv7Mixin
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.models.CreatedAtMixin
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.models.UpdatedAtMixin
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.models.TimestampMixin
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.models.listens_for
|
||||||
@@ -24,5 +24,3 @@ from fastapi_toolsets.pytest import (
|
|||||||
## ::: fastapi_toolsets.pytest.utils.worker_database_url
|
## ::: fastapi_toolsets.pytest.utils.worker_database_url
|
||||||
|
|
||||||
## ::: fastapi_toolsets.pytest.utils.create_worker_database
|
## ::: fastapi_toolsets.pytest.utils.create_worker_database
|
||||||
|
|
||||||
## ::: fastapi_toolsets.pytest.utils.cleanup_tables
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# `schemas` module
|
# `schemas`
|
||||||
|
|
||||||
Here's the reference for all response models and types provided by the `schemas` module.
|
Here's the reference for all response models and types provided by the `schemas` module.
|
||||||
|
|
||||||
@@ -12,8 +12,12 @@ from fastapi_toolsets.schemas import (
|
|||||||
BaseResponse,
|
BaseResponse,
|
||||||
Response,
|
Response,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
Pagination,
|
OffsetPagination,
|
||||||
|
CursorPagination,
|
||||||
|
PaginationType,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
|
OffsetPaginatedResponse,
|
||||||
|
CursorPaginatedResponse,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -29,6 +33,14 @@ from fastapi_toolsets.schemas import (
|
|||||||
|
|
||||||
## ::: fastapi_toolsets.schemas.ErrorResponse
|
## ::: fastapi_toolsets.schemas.ErrorResponse
|
||||||
|
|
||||||
## ::: fastapi_toolsets.schemas.Pagination
|
## ::: fastapi_toolsets.schemas.OffsetPagination
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.schemas.CursorPagination
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.schemas.PaginationType
|
||||||
|
|
||||||
## ::: fastapi_toolsets.schemas.PaginatedResponse
|
## ::: fastapi_toolsets.schemas.PaginatedResponse
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.schemas.OffsetPaginatedResponse
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.schemas.CursorPaginatedResponse
|
||||||
|
|||||||
28
docs/reference/security.md
Normal file
28
docs/reference/security.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# `security`
|
||||||
|
|
||||||
|
Here's the reference for the authentication helpers provided by the `security` module.
|
||||||
|
|
||||||
|
You can import them directly from `fastapi_toolsets.security`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.security import (
|
||||||
|
AuthSource,
|
||||||
|
BearerTokenAuth,
|
||||||
|
CookieAuth,
|
||||||
|
OAuth2Auth,
|
||||||
|
OpenIDAuth,
|
||||||
|
MultiAuth,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.security.AuthSource
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.security.BearerTokenAuth
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.security.CookieAuth
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.security.OAuth2Auth
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.security.OpenIDAuth
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.security.MultiAuth
|
||||||
0
docs_src/examples/authentication/__init__.py
Normal file
0
docs_src/examples/authentication/__init__.py
Normal file
9
docs_src/examples/authentication/app.py
Normal file
9
docs_src/examples/authentication/app.py
Normal 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
docs_src/examples/authentication/crud.py
Normal file
9
docs_src/examples/authentication/crud.py
Normal 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
docs_src/examples/authentication/db.py
Normal file
15
docs_src/examples/authentication/db.py
Normal 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
docs_src/examples/authentication/models.py
Normal file
105
docs_src/examples/authentication/models.py
Normal 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
docs_src/examples/authentication/routes.py
Normal file
122
docs_src/examples/authentication/routes.py
Normal 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],
|
||||||
|
)
|
||||||
64
docs_src/examples/authentication/schemas.py
Normal file
64
docs_src/examples/authentication/schemas.py
Normal file
@@ -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
|
||||||
100
docs_src/examples/authentication/security.py
Normal file
100
docs_src/examples/authentication/security.py
Normal file
@@ -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
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import datetime
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
|
from sqlalchemy import Boolean, ForeignKey, String, Text
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from fastapi_toolsets.models import CreatedAtMixin
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
pass
|
pass
|
||||||
@@ -18,13 +19,10 @@ class Category(Base):
|
|||||||
articles: Mapped[list["Article"]] = relationship(back_populates="category")
|
articles: Mapped[list["Article"]] = relationship(back_populates="category")
|
||||||
|
|
||||||
|
|
||||||
class Article(Base):
|
class Article(Base, CreatedAtMixin):
|
||||||
__tablename__ = "articles"
|
__tablename__ = "articles"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
title: Mapped[str] = mapped_column(String(256))
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
body: Mapped[str] = mapped_column(Text)
|
body: Mapped[str] = mapped_column(Text)
|
||||||
status: Mapped[str] = mapped_column(String(32))
|
status: Mapped[str] = mapped_column(String(32))
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from fastapi_toolsets.crud import OrderByClause
|
from fastapi_toolsets.schemas import (
|
||||||
from fastapi_toolsets.schemas import PaginatedResponse
|
CursorPaginatedResponse,
|
||||||
|
OffsetPaginatedResponse,
|
||||||
|
PaginatedResponse,
|
||||||
|
)
|
||||||
|
|
||||||
from .crud import ArticleCrud
|
from .crud import ArticleCrud
|
||||||
from .db import SessionDep
|
from .db import SessionDep
|
||||||
@@ -16,22 +19,20 @@ router = APIRouter(prefix="/articles")
|
|||||||
@router.get("/offset")
|
@router.get("/offset")
|
||||||
async def list_articles_offset(
|
async def list_articles_offset(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
params: Annotated[
|
||||||
order_by: Annotated[
|
dict,
|
||||||
OrderByClause | None,
|
Depends(
|
||||||
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
ArticleCrud.offset_paginate_params(
|
||||||
|
default_page_size=20,
|
||||||
|
max_page_size=100,
|
||||||
|
default_order_field=Article.created_at,
|
||||||
|
)
|
||||||
|
),
|
||||||
],
|
],
|
||||||
page: int = Query(1, ge=1),
|
) -> OffsetPaginatedResponse[ArticleRead]:
|
||||||
items_per_page: int = Query(20, ge=1, le=100),
|
|
||||||
search: str | None = None,
|
|
||||||
) -> PaginatedResponse[ArticleRead]:
|
|
||||||
return await ArticleCrud.offset_paginate(
|
return await ArticleCrud.offset_paginate(
|
||||||
session=session,
|
session=session,
|
||||||
page=page,
|
**params,
|
||||||
items_per_page=items_per_page,
|
|
||||||
search=search,
|
|
||||||
filter_by=filter_by or None,
|
|
||||||
order_by=order_by,
|
|
||||||
schema=ArticleRead,
|
schema=ArticleRead,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,21 +40,40 @@ async def list_articles_offset(
|
|||||||
@router.get("/cursor")
|
@router.get("/cursor")
|
||||||
async def list_articles_cursor(
|
async def list_articles_cursor(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
|
params: Annotated[
|
||||||
order_by: Annotated[
|
dict,
|
||||||
OrderByClause | None,
|
Depends(
|
||||||
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
ArticleCrud.cursor_paginate_params(
|
||||||
|
default_page_size=20,
|
||||||
|
max_page_size=100,
|
||||||
|
default_order_field=Article.created_at,
|
||||||
|
)
|
||||||
|
),
|
||||||
],
|
],
|
||||||
cursor: str | None = None,
|
) -> CursorPaginatedResponse[ArticleRead]:
|
||||||
items_per_page: int = Query(20, ge=1, le=100),
|
|
||||||
search: str | None = None,
|
|
||||||
) -> PaginatedResponse[ArticleRead]:
|
|
||||||
return await ArticleCrud.cursor_paginate(
|
return await ArticleCrud.cursor_paginate(
|
||||||
session=session,
|
session=session,
|
||||||
cursor=cursor,
|
**params,
|
||||||
items_per_page=items_per_page,
|
schema=ArticleRead,
|
||||||
search=search,
|
)
|
||||||
filter_by=filter_by or None,
|
|
||||||
order_by=order_by,
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_articles(
|
||||||
|
session: SessionDep,
|
||||||
|
params: Annotated[
|
||||||
|
dict,
|
||||||
|
Depends(
|
||||||
|
ArticleCrud.paginate_params(
|
||||||
|
default_page_size=20,
|
||||||
|
max_page_size=100,
|
||||||
|
default_order_field=Article.created_at,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
) -> PaginatedResponse[ArticleRead]:
|
||||||
|
return await ArticleCrud.paginate(
|
||||||
|
session,
|
||||||
|
**params,
|
||||||
schema=ArticleRead,
|
schema=ArticleRead,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "1.3.0"
|
version = "3.0.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"
|
||||||
@@ -66,7 +66,9 @@ manager = "fastapi_toolsets.cli.app:cli"
|
|||||||
dev = [
|
dev = [
|
||||||
{include-group = "tests"},
|
{include-group = "tests"},
|
||||||
{include-group = "docs"},
|
{include-group = "docs"},
|
||||||
|
{include-group = "docs-src"},
|
||||||
"fastapi-toolsets[all]",
|
"fastapi-toolsets[all]",
|
||||||
|
"prek>=0.3.8",
|
||||||
"ruff>=0.1.0",
|
"ruff>=0.1.0",
|
||||||
"ty>=0.0.1a0",
|
"ty>=0.0.1a0",
|
||||||
]
|
]
|
||||||
@@ -79,12 +81,16 @@ tests = [
|
|||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
|
"mike",
|
||||||
"mkdocstrings-python>=2.0.2",
|
"mkdocstrings-python>=2.0.2",
|
||||||
"zensical>=0.0.23",
|
"zensical>=0.0.30",
|
||||||
|
]
|
||||||
|
docs-src = [
|
||||||
|
"bcrypt>=4.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["uv_build>=0.10,<0.11.0"]
|
requires = ["uv_build>=0.10,<0.12.0"]
|
||||||
build-backend = "uv_build"
|
build-backend = "uv_build"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
@@ -103,3 +109,6 @@ exclude_lines = [
|
|||||||
"if TYPE_CHECKING:",
|
"if TYPE_CHECKING:",
|
||||||
"raise NotImplementedError",
|
"raise NotImplementedError",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
mike = { git = "https://github.com/squidfunk/mike.git", tag = "2.2.0+zensical-0.1.0" }
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ Example usage:
|
|||||||
return Response(data={"user": user.username}, message="Success")
|
return Response(data={"user": user.username}, message="Success")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "1.3.0"
|
__version__ = "3.0.1"
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ async def load(
|
|||||||
registry = get_fixtures_registry()
|
registry = get_fixtures_registry()
|
||||||
db_context = get_db_context()
|
db_context = get_db_context()
|
||||||
|
|
||||||
context_list = [c.value for c in contexts] if contexts else [Context.BASE]
|
context_list = list(contexts) if contexts else [Context.BASE]
|
||||||
|
|
||||||
ordered = registry.resolve_context_dependencies(*context_list)
|
ordered = registry.resolve_context_dependencies(*context_list)
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
"""Generic async CRUD operations for SQLAlchemy models."""
|
"""Generic async CRUD operations for SQLAlchemy models."""
|
||||||
|
|
||||||
from ..exceptions import InvalidFacetFilterError, NoSearchableFieldsError
|
from ..exceptions import (
|
||||||
from .factory import CrudFactory, JoinType, M2MFieldType, OrderByClause
|
InvalidFacetFilterError,
|
||||||
from .search import (
|
InvalidSearchColumnError,
|
||||||
FacetFieldType,
|
NoSearchableFieldsError,
|
||||||
SearchConfig,
|
UnsupportedFacetTypeError,
|
||||||
get_searchable_fields,
|
|
||||||
)
|
)
|
||||||
|
from ..schemas import PaginationType
|
||||||
|
from ..types import (
|
||||||
|
FacetFieldType,
|
||||||
|
JoinType,
|
||||||
|
M2MFieldType,
|
||||||
|
OrderByClause,
|
||||||
|
SearchFieldType,
|
||||||
|
)
|
||||||
|
from .factory import AsyncCrud, CrudFactory
|
||||||
|
from .search import SearchConfig, get_searchable_fields
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"AsyncCrud",
|
||||||
"CrudFactory",
|
"CrudFactory",
|
||||||
"FacetFieldType",
|
"FacetFieldType",
|
||||||
"get_searchable_fields",
|
"get_searchable_fields",
|
||||||
"InvalidFacetFilterError",
|
"InvalidFacetFilterError",
|
||||||
|
"InvalidSearchColumnError",
|
||||||
"JoinType",
|
"JoinType",
|
||||||
"M2MFieldType",
|
"M2MFieldType",
|
||||||
"NoSearchableFieldsError",
|
"NoSearchableFieldsError",
|
||||||
"OrderByClause",
|
"OrderByClause",
|
||||||
|
"PaginationType",
|
||||||
"SearchConfig",
|
"SearchConfig",
|
||||||
|
"SearchFieldType",
|
||||||
|
"UnsupportedFacetTypeError",
|
||||||
]
|
]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,38 @@
|
|||||||
"""Search utilities for AsyncCrud."""
|
"""Search utilities for AsyncCrud."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections import Counter
|
import functools
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
from typing import TYPE_CHECKING, Any, Literal
|
from typing import TYPE_CHECKING, Any, Literal
|
||||||
|
|
||||||
from sqlalchemy import String, or_, select
|
from sqlalchemy import String, and_, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||||
|
from sqlalchemy.types import (
|
||||||
|
ARRAY,
|
||||||
|
Boolean,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
Enum,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
Time,
|
||||||
|
Uuid,
|
||||||
|
)
|
||||||
|
|
||||||
from ..exceptions import InvalidFacetFilterError, NoSearchableFieldsError
|
from ..exceptions import (
|
||||||
|
InvalidFacetFilterError,
|
||||||
|
InvalidSearchColumnError,
|
||||||
|
NoSearchableFieldsError,
|
||||||
|
UnsupportedFacetTypeError,
|
||||||
|
)
|
||||||
|
from ..types import FacetFieldType, SearchFieldType
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from sqlalchemy.sql.elements import ColumnElement
|
from sqlalchemy.sql.elements import ColumnElement
|
||||||
|
|
||||||
SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any], ...]
|
|
||||||
FacetFieldType = SearchFieldType
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SearchConfig:
|
class SearchConfig:
|
||||||
@@ -37,6 +51,7 @@ class SearchConfig:
|
|||||||
match_mode: Literal["any", "all"] = "any"
|
match_mode: Literal["any", "all"] = "any"
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=128)
|
||||||
def get_searchable_fields(
|
def get_searchable_fields(
|
||||||
model: type[DeclarativeBase],
|
model: type[DeclarativeBase],
|
||||||
*,
|
*,
|
||||||
@@ -82,6 +97,7 @@ def build_search_filters(
|
|||||||
search: str | SearchConfig,
|
search: str | SearchConfig,
|
||||||
search_fields: Sequence[SearchFieldType] | None = None,
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
default_fields: Sequence[SearchFieldType] | None = None,
|
default_fields: Sequence[SearchFieldType] | None = None,
|
||||||
|
search_column: str | None = None,
|
||||||
) -> tuple[list["ColumnElement[bool]"], list[InstrumentedAttribute[Any]]]:
|
) -> tuple[list["ColumnElement[bool]"], list[InstrumentedAttribute[Any]]]:
|
||||||
"""Build SQLAlchemy filter conditions for search.
|
"""Build SQLAlchemy filter conditions for search.
|
||||||
|
|
||||||
@@ -90,6 +106,8 @@ def build_search_filters(
|
|||||||
search: Search string or SearchConfig
|
search: Search string or SearchConfig
|
||||||
search_fields: Fields specified per-call (takes priority)
|
search_fields: Fields specified per-call (takes priority)
|
||||||
default_fields: Default fields (from ClassVar)
|
default_fields: Default fields (from ClassVar)
|
||||||
|
search_column: Optional key to narrow search to a single field.
|
||||||
|
Must match one of the resolved search field keys.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (filter_conditions, joins_needed)
|
Tuple of (filter_conditions, joins_needed)
|
||||||
@@ -101,14 +119,11 @@ def build_search_filters(
|
|||||||
if isinstance(search, str):
|
if isinstance(search, str):
|
||||||
config = SearchConfig(query=search, fields=search_fields)
|
config = SearchConfig(query=search, fields=search_fields)
|
||||||
else:
|
else:
|
||||||
config = search
|
config = (
|
||||||
if search_fields is not None:
|
replace(search, fields=search_fields)
|
||||||
config = SearchConfig(
|
if search_fields is not None
|
||||||
query=config.query,
|
else search
|
||||||
fields=search_fields,
|
)
|
||||||
case_sensitive=config.case_sensitive,
|
|
||||||
match_mode=config.match_mode,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not config.query or not config.query.strip():
|
if not config.query or not config.query.strip():
|
||||||
return [], []
|
return [], []
|
||||||
@@ -119,6 +134,14 @@ def build_search_filters(
|
|||||||
if not fields:
|
if not fields:
|
||||||
raise NoSearchableFieldsError(model)
|
raise NoSearchableFieldsError(model)
|
||||||
|
|
||||||
|
# Narrow to a single column when search_column is specified
|
||||||
|
if search_column is not None:
|
||||||
|
keys = search_field_keys(fields)
|
||||||
|
index = {k: f for k, f in zip(keys, fields)}
|
||||||
|
if search_column not in index:
|
||||||
|
raise InvalidSearchColumnError(search_column, sorted(index))
|
||||||
|
fields = [index[search_column]]
|
||||||
|
|
||||||
query = config.query.strip()
|
query = config.query.strip()
|
||||||
filters: list[ColumnElement[bool]] = []
|
filters: list[ColumnElement[bool]] = []
|
||||||
joins: list[InstrumentedAttribute[Any]] = []
|
joins: list[InstrumentedAttribute[Any]] = []
|
||||||
@@ -153,8 +176,13 @@ def build_search_filters(
|
|||||||
return filters, joins
|
return filters, joins
|
||||||
|
|
||||||
|
|
||||||
|
def search_field_keys(fields: Sequence[SearchFieldType]) -> list[str]:
|
||||||
|
"""Return a human-readable key for each search field."""
|
||||||
|
return facet_keys(fields)
|
||||||
|
|
||||||
|
|
||||||
def facet_keys(facet_fields: Sequence[FacetFieldType]) -> list[str]:
|
def facet_keys(facet_fields: Sequence[FacetFieldType]) -> list[str]:
|
||||||
"""Return a key for each facet field, disambiguating duplicate column keys.
|
"""Return a key for each facet field.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
facet_fields: Sequence of facet fields — either direct columns or
|
facet_fields: Sequence of facet fields — either direct columns or
|
||||||
@@ -163,22 +191,12 @@ def facet_keys(facet_fields: Sequence[FacetFieldType]) -> list[str]:
|
|||||||
Returns:
|
Returns:
|
||||||
A list of string keys, one per facet field, in the same order.
|
A list of string keys, one per facet field, in the same order.
|
||||||
"""
|
"""
|
||||||
raw: list[tuple[str, str | None]] = []
|
keys: list[str] = []
|
||||||
for field in facet_fields:
|
for field in facet_fields:
|
||||||
if isinstance(field, tuple):
|
if isinstance(field, tuple):
|
||||||
rel = field[-2]
|
keys.append("__".join(el.key for el in field))
|
||||||
column = field[-1]
|
|
||||||
raw.append((column.key, rel.key))
|
|
||||||
else:
|
else:
|
||||||
raw.append((field.key, None))
|
keys.append(field.key)
|
||||||
|
|
||||||
counts = Counter(col_key for col_key, _ in raw)
|
|
||||||
keys: list[str] = []
|
|
||||||
for col_key, rel_key in raw:
|
|
||||||
if counts[col_key] > 1 and rel_key is not None:
|
|
||||||
keys.append(f"{rel_key}__{col_key}")
|
|
||||||
else:
|
|
||||||
keys.append(col_key)
|
|
||||||
return keys
|
return keys
|
||||||
|
|
||||||
|
|
||||||
@@ -215,23 +233,37 @@ async def build_facets(
|
|||||||
rels = ()
|
rels = ()
|
||||||
column = field
|
column = field
|
||||||
|
|
||||||
q = select(column).select_from(model).distinct()
|
col_type = column.property.columns[0].type
|
||||||
|
is_array = isinstance(col_type, ARRAY)
|
||||||
|
|
||||||
# Apply base joins (already done on main query, but needed here independently)
|
if is_array:
|
||||||
|
unnested = func.unnest(column).label(column.key)
|
||||||
|
q = select(unnested).select_from(model).distinct()
|
||||||
|
else:
|
||||||
|
q = select(column).select_from(model).distinct()
|
||||||
|
|
||||||
|
# Apply base joins (deduplicated) — needed here independently
|
||||||
|
seen_joins: set[str] = set()
|
||||||
for rel in base_joins or []:
|
for rel in base_joins or []:
|
||||||
q = q.outerjoin(rel)
|
rel_key = str(rel)
|
||||||
|
if rel_key not in seen_joins:
|
||||||
|
seen_joins.add(rel_key)
|
||||||
|
q = q.outerjoin(rel)
|
||||||
|
|
||||||
# Add any extra joins required by this facet field that aren't already in base_joins
|
# Add any extra joins required by this facet field that aren't already applied
|
||||||
for rel in rels:
|
for rel in rels:
|
||||||
if str(rel) not in existing_join_keys:
|
rel_key = str(rel)
|
||||||
|
if rel_key not in existing_join_keys and rel_key not in seen_joins:
|
||||||
|
seen_joins.add(rel_key)
|
||||||
q = q.outerjoin(rel)
|
q = q.outerjoin(rel)
|
||||||
|
|
||||||
if base_filters:
|
if base_filters:
|
||||||
from sqlalchemy import and_
|
|
||||||
|
|
||||||
q = q.where(and_(*base_filters))
|
q = q.where(and_(*base_filters))
|
||||||
|
|
||||||
q = q.order_by(column)
|
if is_array:
|
||||||
|
q = q.order_by(unnested)
|
||||||
|
else:
|
||||||
|
q = q.order_by(column)
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
values = [row[0] for row in result.all() if row[0] is not None]
|
values = [row[0] for row in result.all() if row[0] is not None]
|
||||||
return key, values
|
return key, values
|
||||||
@@ -242,6 +274,22 @@ async def build_facets(
|
|||||||
return dict(pairs)
|
return dict(pairs)
|
||||||
|
|
||||||
|
|
||||||
|
_EQUALITY_TYPES = (String, Integer, Numeric, Date, DateTime, Time, Enum, Uuid)
|
||||||
|
"""Column types that support equality / IN filtering in build_filter_by."""
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_bool(value: Any) -> bool:
|
||||||
|
"""Coerce a string value to a Python bool for Boolean column filtering."""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
if value.lower() == "true":
|
||||||
|
return True
|
||||||
|
if value.lower() == "false":
|
||||||
|
return False
|
||||||
|
raise ValueError(f"Cannot coerce {value!r} to bool")
|
||||||
|
|
||||||
|
|
||||||
def build_filter_by(
|
def build_filter_by(
|
||||||
filter_by: dict[str, Any],
|
filter_by: dict[str, Any],
|
||||||
facet_fields: Sequence[FacetFieldType],
|
facet_fields: Sequence[FacetFieldType],
|
||||||
@@ -287,9 +335,24 @@ def build_filter_by(
|
|||||||
joins.append(rel)
|
joins.append(rel)
|
||||||
added_join_keys.add(rel_key)
|
added_join_keys.add(rel_key)
|
||||||
|
|
||||||
if isinstance(value, list):
|
col_type = column.property.columns[0].type
|
||||||
filters.append(column.in_(value))
|
if isinstance(col_type, Boolean):
|
||||||
|
coerce = _coerce_bool
|
||||||
|
if isinstance(value, list):
|
||||||
|
filters.append(column.in_([coerce(v) for v in value]))
|
||||||
|
else:
|
||||||
|
filters.append(column == coerce(value))
|
||||||
|
elif isinstance(col_type, ARRAY):
|
||||||
|
if isinstance(value, list):
|
||||||
|
filters.append(column.overlap(value))
|
||||||
|
else:
|
||||||
|
filters.append(column.any(value))
|
||||||
|
elif isinstance(col_type, _EQUALITY_TYPES):
|
||||||
|
if isinstance(value, list):
|
||||||
|
filters.append(column.in_(value))
|
||||||
|
else:
|
||||||
|
filters.append(column == value)
|
||||||
else:
|
else:
|
||||||
filters.append(column == value)
|
raise UnsupportedFacetTypeError(key, type(col_type).__name__)
|
||||||
|
|
||||||
return filters, joins
|
return filters, joins
|
||||||
|
|||||||
@@ -7,22 +7,29 @@ from enum import Enum
|
|||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from .exceptions import NotFoundError
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"LockMode",
|
"LockMode",
|
||||||
|
"cleanup_tables",
|
||||||
|
"create_database",
|
||||||
"create_db_context",
|
"create_db_context",
|
||||||
"create_db_dependency",
|
"create_db_dependency",
|
||||||
"lock_tables",
|
|
||||||
"get_transaction",
|
"get_transaction",
|
||||||
|
"lock_tables",
|
||||||
"wait_for_row_change",
|
"wait_for_row_change",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
_SessionT = TypeVar("_SessionT", bound=AsyncSession)
|
||||||
|
|
||||||
|
|
||||||
def create_db_dependency(
|
def create_db_dependency(
|
||||||
session_maker: async_sessionmaker[AsyncSession],
|
session_maker: async_sessionmaker[_SessionT],
|
||||||
) -> Callable[[], AsyncGenerator[AsyncSession, None]]:
|
) -> Callable[[], AsyncGenerator[_SessionT, None]]:
|
||||||
"""Create a FastAPI dependency for database sessions.
|
"""Create a FastAPI dependency for database sessions.
|
||||||
|
|
||||||
Creates a dependency function that yields a session and auto-commits
|
Creates a dependency function that yields a session and auto-commits
|
||||||
@@ -50,8 +57,9 @@ def create_db_dependency(
|
|||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
async def get_db() -> AsyncGenerator[_SessionT, None]:
|
||||||
async with session_maker() as session:
|
async with session_maker() as session:
|
||||||
|
await session.connection()
|
||||||
yield session
|
yield session
|
||||||
if session.in_transaction():
|
if session.in_transaction():
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -60,8 +68,8 @@ def create_db_dependency(
|
|||||||
|
|
||||||
|
|
||||||
def create_db_context(
|
def create_db_context(
|
||||||
session_maker: async_sessionmaker[AsyncSession],
|
session_maker: async_sessionmaker[_SessionT],
|
||||||
) -> Callable[[], AbstractAsyncContextManager[AsyncSession]]:
|
) -> Callable[[], AbstractAsyncContextManager[_SessionT]]:
|
||||||
"""Create a context manager for database sessions.
|
"""Create a context manager for database sessions.
|
||||||
|
|
||||||
Creates a context manager for use outside of FastAPI request handlers,
|
Creates a context manager for use outside of FastAPI request handlers,
|
||||||
@@ -186,6 +194,71 @@ async def lock_tables(
|
|||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
async def create_database(
|
||||||
|
db_name: str,
|
||||||
|
*,
|
||||||
|
server_url: str,
|
||||||
|
) -> None:
|
||||||
|
"""Create a database.
|
||||||
|
|
||||||
|
Connects to *server_url* using ``AUTOCOMMIT`` isolation and issues a
|
||||||
|
``CREATE DATABASE`` statement for *db_name*.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_name: Name of the database to create.
|
||||||
|
server_url: URL used for server-level DDL (must point to an existing
|
||||||
|
database on the same server).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import create_database
|
||||||
|
|
||||||
|
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
|
||||||
|
await create_database("myapp_test", server_url=SERVER_URL)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
engine = create_async_engine(server_url, isolation_level="AUTOCOMMIT")
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text(f"CREATE DATABASE {db_name}"))
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_tables(
|
||||||
|
session: AsyncSession,
|
||||||
|
base: type[DeclarativeBase],
|
||||||
|
) -> None:
|
||||||
|
"""Truncate all tables for fast between-test cleanup.
|
||||||
|
|
||||||
|
Executes a single ``TRUNCATE … RESTART IDENTITY CASCADE`` statement
|
||||||
|
across every table in *base*'s metadata, which is significantly faster
|
||||||
|
than dropping and re-creating tables between tests.
|
||||||
|
|
||||||
|
This is a no-op when the metadata contains no tables.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: An active async database session.
|
||||||
|
base: SQLAlchemy DeclarativeBase class containing model metadata.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_session(worker_db_url):
|
||||||
|
async with create_db_session(worker_db_url, Base) as session:
|
||||||
|
yield session
|
||||||
|
await cleanup_tables(session, Base)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
tables = base.metadata.sorted_tables
|
||||||
|
if not tables:
|
||||||
|
return
|
||||||
|
|
||||||
|
table_names = ", ".join(f'"{t.name}"' for t in tables)
|
||||||
|
await session.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE"))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
_M = TypeVar("_M", bound=DeclarativeBase)
|
_M = TypeVar("_M", bound=DeclarativeBase)
|
||||||
|
|
||||||
|
|
||||||
@@ -216,7 +289,7 @@ async def wait_for_row_change(
|
|||||||
The refreshed model instance with updated values
|
The refreshed model instance with updated values
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
LookupError: If the row does not exist or is deleted during polling
|
NotFoundError: If the row does not exist or is deleted during polling
|
||||||
TimeoutError: If timeout expires before a change is detected
|
TimeoutError: If timeout expires before a change is detected
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
@@ -237,7 +310,7 @@ async def wait_for_row_change(
|
|||||||
"""
|
"""
|
||||||
instance = await session.get(model, pk_value)
|
instance = await session.get(model, pk_value)
|
||||||
if instance is None:
|
if instance is None:
|
||||||
raise LookupError(f"{model.__name__} with pk={pk_value!r} not found")
|
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} not found")
|
||||||
|
|
||||||
if columns is not None:
|
if columns is not None:
|
||||||
watch_cols = columns
|
watch_cols = columns
|
||||||
@@ -261,7 +334,7 @@ async def wait_for_row_change(
|
|||||||
instance = await session.get(model, pk_value)
|
instance = await session.get(model, pk_value)
|
||||||
|
|
||||||
if instance is None:
|
if instance is None:
|
||||||
raise LookupError(f"{model.__name__} with pk={pk_value!r} was deleted")
|
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} was deleted")
|
||||||
|
|
||||||
current = {col: getattr(instance, col) for col in watch_cols}
|
current = {col: getattr(instance, col) for col in watch_cols}
|
||||||
if current != initial:
|
if current != initial:
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
"""Dependency factories for FastAPI routes."""
|
"""Dependency factories for FastAPI routes."""
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
from collections.abc import AsyncGenerator, Callable
|
import typing
|
||||||
from typing import Any, TypeVar, cast
|
from collections.abc import Callable
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
|
from fastapi.params import Depends as DependsClass
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
|
||||||
|
|
||||||
from .crud import CrudFactory
|
from .crud import CrudFactory
|
||||||
|
from .types import ModelType, SessionDependency
|
||||||
|
|
||||||
__all__ = ["BodyDependency", "PathDependency"]
|
__all__ = ["BodyDependency", "PathDependency"]
|
||||||
|
|
||||||
ModelType = TypeVar("ModelType", bound=DeclarativeBase)
|
|
||||||
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]]
|
def _unwrap_session_dep(session_dep: SessionDependency) -> Callable[..., Any]:
|
||||||
|
"""Extract the plain callable from ``Annotated[AsyncSession, Depends(fn)]`` if needed."""
|
||||||
|
if typing.get_origin(session_dep) is typing.Annotated:
|
||||||
|
for arg in typing.get_args(session_dep)[1:]:
|
||||||
|
if isinstance(arg, DependsClass):
|
||||||
|
return arg.dependency
|
||||||
|
return session_dep
|
||||||
|
|
||||||
|
|
||||||
def PathDependency(
|
def PathDependency(
|
||||||
@@ -47,6 +55,7 @@ def PathDependency(
|
|||||||
): ...
|
): ...
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
session_callable = _unwrap_session_dep(session_dep)
|
||||||
crud = CrudFactory(model)
|
crud = CrudFactory(model)
|
||||||
name = (
|
name = (
|
||||||
param_name
|
param_name
|
||||||
@@ -56,7 +65,7 @@ def PathDependency(
|
|||||||
python_type = field.type.python_type
|
python_type = field.type.python_type
|
||||||
|
|
||||||
async def dependency(
|
async def dependency(
|
||||||
session: AsyncSession = Depends(session_dep), **kwargs: Any
|
session: AsyncSession = Depends(session_callable), **kwargs: Any
|
||||||
) -> ModelType:
|
) -> ModelType:
|
||||||
value = kwargs[name]
|
value = kwargs[name]
|
||||||
return await crud.get(session, filters=[field == value])
|
return await crud.get(session, filters=[field == value])
|
||||||
@@ -73,7 +82,7 @@ def PathDependency(
|
|||||||
"session",
|
"session",
|
||||||
inspect.Parameter.KEYWORD_ONLY,
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
annotation=AsyncSession,
|
annotation=AsyncSession,
|
||||||
default=Depends(session_dep),
|
default=Depends(session_callable),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
@@ -115,11 +124,12 @@ def BodyDependency(
|
|||||||
): ...
|
): ...
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
session_callable = _unwrap_session_dep(session_dep)
|
||||||
crud = CrudFactory(model)
|
crud = CrudFactory(model)
|
||||||
python_type = field.type.python_type
|
python_type = field.type.python_type
|
||||||
|
|
||||||
async def dependency(
|
async def dependency(
|
||||||
session: AsyncSession = Depends(session_dep), **kwargs: Any
|
session: AsyncSession = Depends(session_callable), **kwargs: Any
|
||||||
) -> ModelType:
|
) -> ModelType:
|
||||||
value = kwargs[body_field]
|
value = kwargs[body_field]
|
||||||
return await crud.get(session, filters=[field == value])
|
return await crud.get(session, filters=[field == value])
|
||||||
@@ -136,7 +146,7 @@ def BodyDependency(
|
|||||||
"session",
|
"session",
|
||||||
inspect.Parameter.KEYWORD_ONLY,
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
annotation=AsyncSession,
|
annotation=AsyncSession,
|
||||||
default=Depends(session_dep),
|
default=Depends(session_callable),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ from .exceptions import (
|
|||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
InvalidFacetFilterError,
|
InvalidFacetFilterError,
|
||||||
InvalidOrderFieldError,
|
InvalidOrderFieldError,
|
||||||
|
InvalidSearchColumnError,
|
||||||
NoSearchableFieldsError,
|
NoSearchableFieldsError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
UnauthorizedError,
|
UnauthorizedError,
|
||||||
|
UnsupportedFacetTypeError,
|
||||||
generate_error_responses,
|
generate_error_responses,
|
||||||
)
|
)
|
||||||
from .handler import init_exceptions_handlers
|
from .handler import init_exceptions_handlers
|
||||||
@@ -23,7 +25,9 @@ __all__ = [
|
|||||||
"init_exceptions_handlers",
|
"init_exceptions_handlers",
|
||||||
"InvalidFacetFilterError",
|
"InvalidFacetFilterError",
|
||||||
"InvalidOrderFieldError",
|
"InvalidOrderFieldError",
|
||||||
|
"InvalidSearchColumnError",
|
||||||
"NoSearchableFieldsError",
|
"NoSearchableFieldsError",
|
||||||
"NotFoundError",
|
"NotFoundError",
|
||||||
"UnauthorizedError",
|
"UnauthorizedError",
|
||||||
|
"UnsupportedFacetTypeError",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,32 +6,46 @@ from ..schemas import ApiError, ErrorResponse, ResponseStatus
|
|||||||
|
|
||||||
|
|
||||||
class ApiException(Exception):
|
class ApiException(Exception):
|
||||||
"""Base exception for API errors with structured response.
|
"""Base exception for API errors with structured response."""
|
||||||
|
|
||||||
Subclass this to create custom API exceptions with consistent error format.
|
|
||||||
The exception handler will use api_error to generate the response.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
class CustomError(ApiException):
|
|
||||||
api_error = ApiError(
|
|
||||||
code=400,
|
|
||||||
msg="Bad Request",
|
|
||||||
desc="The request was invalid.",
|
|
||||||
err_code="CUSTOM-400",
|
|
||||||
)
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
|
|
||||||
api_error: ClassVar[ApiError]
|
api_error: ClassVar[ApiError]
|
||||||
|
|
||||||
def __init__(self, detail: str | None = None):
|
def __init_subclass__(cls, abstract: bool = False, **kwargs: Any) -> None:
|
||||||
|
super().__init_subclass__(**kwargs)
|
||||||
|
if not abstract and not hasattr(cls, "api_error"):
|
||||||
|
raise TypeError(
|
||||||
|
f"{cls.__name__} must define an 'api_error' class attribute. "
|
||||||
|
"Pass abstract=True when creating intermediate base classes."
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
detail: str | None = None,
|
||||||
|
*,
|
||||||
|
desc: str | None = None,
|
||||||
|
data: Any = None,
|
||||||
|
) -> None:
|
||||||
"""Initialize the exception.
|
"""Initialize the exception.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
detail: Optional override for the error message
|
detail: Optional human-readable message
|
||||||
|
desc: Optional per-instance override for the ``description`` field
|
||||||
|
in the HTTP response body.
|
||||||
|
data: Optional per-instance override for the ``data`` field in the
|
||||||
|
HTTP response body.
|
||||||
"""
|
"""
|
||||||
super().__init__(detail or self.api_error.msg)
|
updates: dict[str, Any] = {}
|
||||||
|
if detail is not None:
|
||||||
|
updates["msg"] = detail
|
||||||
|
if desc is not None:
|
||||||
|
updates["desc"] = desc
|
||||||
|
if data is not None:
|
||||||
|
updates["data"] = data
|
||||||
|
if updates:
|
||||||
|
object.__setattr__(
|
||||||
|
self, "api_error", self.__class__.api_error.model_copy(update=updates)
|
||||||
|
)
|
||||||
|
super().__init__(self.api_error.msg)
|
||||||
|
|
||||||
|
|
||||||
class UnauthorizedError(ApiException):
|
class UnauthorizedError(ApiException):
|
||||||
@@ -92,14 +106,15 @@ class NoSearchableFieldsError(ApiException):
|
|||||||
"""Initialize the exception.
|
"""Initialize the exception.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model: The SQLAlchemy model class that has no searchable fields
|
model: The model class that has no searchable fields configured.
|
||||||
"""
|
"""
|
||||||
self.model = model
|
self.model = model
|
||||||
detail = (
|
super().__init__(
|
||||||
f"No searchable fields found for model '{model.__name__}'. "
|
desc=(
|
||||||
"Provide 'search_fields' parameter or set 'searchable_fields' on the CRUD class."
|
f"No searchable fields found for model '{model.__name__}'. "
|
||||||
|
"Provide 'search_fields' parameter or set 'searchable_fields' on the CRUD class."
|
||||||
|
)
|
||||||
)
|
)
|
||||||
super().__init__(detail)
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidFacetFilterError(ApiException):
|
class InvalidFacetFilterError(ApiException):
|
||||||
@@ -116,16 +131,72 @@ class InvalidFacetFilterError(ApiException):
|
|||||||
"""Initialize the exception.
|
"""Initialize the exception.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
key: The unknown filter key provided by the caller
|
key: The unknown filter key provided by the caller.
|
||||||
valid_keys: Set of valid keys derived from the declared facet_fields
|
valid_keys: Set of valid keys derived from the declared facet_fields.
|
||||||
"""
|
"""
|
||||||
self.key = key
|
self.key = key
|
||||||
self.valid_keys = valid_keys
|
self.valid_keys = valid_keys
|
||||||
detail = (
|
super().__init__(
|
||||||
f"'{key}' is not a declared facet field. "
|
desc=(
|
||||||
f"Valid keys: {sorted(valid_keys) or 'none — set facet_fields on the CRUD class'}."
|
f"'{key}' is not a declared facet field. "
|
||||||
|
f"Valid keys: {sorted(valid_keys) or 'none — set facet_fields on the CRUD class'}."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedFacetTypeError(ApiException):
|
||||||
|
"""Raised when a facet field has a column type not supported by filter_by."""
|
||||||
|
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400,
|
||||||
|
msg="Unsupported Facet Type",
|
||||||
|
desc="The column type is not supported for facet filtering.",
|
||||||
|
err_code="FACET-TYPE-400",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, key: str, col_type: str) -> None:
|
||||||
|
"""Initialize the exception.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The facet field key.
|
||||||
|
col_type: The unsupported column type name.
|
||||||
|
"""
|
||||||
|
self.key = key
|
||||||
|
self.col_type = col_type
|
||||||
|
super().__init__(
|
||||||
|
desc=(
|
||||||
|
f"Facet field '{key}' has unsupported column type '{col_type}'. "
|
||||||
|
f"Supported types: String, Integer, Numeric, Boolean, "
|
||||||
|
f"Date, DateTime, Time, Enum, Uuid, ARRAY."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidSearchColumnError(ApiException):
|
||||||
|
"""Raised when search_column is not one of the configured searchable fields."""
|
||||||
|
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400,
|
||||||
|
msg="Invalid Search Column",
|
||||||
|
desc="The requested search column is not a configured searchable field.",
|
||||||
|
err_code="SEARCH-COL-400",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, column: str, valid_columns: list[str]) -> None:
|
||||||
|
"""Initialize the exception.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
column: The unknown search column provided by the caller.
|
||||||
|
valid_columns: List of valid search column keys.
|
||||||
|
"""
|
||||||
|
self.column = column
|
||||||
|
self.valid_columns = valid_columns
|
||||||
|
super().__init__(
|
||||||
|
desc=(
|
||||||
|
f"'{column}' is not a searchable column. "
|
||||||
|
f"Valid columns: {valid_columns}."
|
||||||
|
)
|
||||||
)
|
)
|
||||||
super().__init__(detail)
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidOrderFieldError(ApiException):
|
class InvalidOrderFieldError(ApiException):
|
||||||
@@ -142,15 +213,14 @@ class InvalidOrderFieldError(ApiException):
|
|||||||
"""Initialize the exception.
|
"""Initialize the exception.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
field: The unknown order field provided by the caller
|
field: The unknown order field provided by the caller.
|
||||||
valid_fields: List of valid field names
|
valid_fields: List of valid field names.
|
||||||
"""
|
"""
|
||||||
self.field = field
|
self.field = field
|
||||||
self.valid_fields = valid_fields
|
self.valid_fields = valid_fields
|
||||||
detail = (
|
super().__init__(
|
||||||
f"'{field}' is not an allowed order field. Valid fields: {valid_fields}."
|
desc=f"'{field}' is not an allowed order field. Valid fields: {valid_fields}."
|
||||||
)
|
)
|
||||||
super().__init__(detail)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_error_responses(
|
def generate_error_responses(
|
||||||
@@ -158,44 +228,39 @@ def generate_error_responses(
|
|||||||
) -> dict[int | str, dict[str, Any]]:
|
) -> dict[int | str, dict[str, Any]]:
|
||||||
"""Generate OpenAPI response documentation for exceptions.
|
"""Generate OpenAPI response documentation for exceptions.
|
||||||
|
|
||||||
Use this to document possible error responses for an endpoint.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
*errors: Exception classes that inherit from ApiException
|
*errors: Exception classes that inherit from ApiException.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict suitable for FastAPI's responses parameter
|
Dict suitable for FastAPI's ``responses`` parameter.
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.exceptions import generate_error_responses, UnauthorizedError, ForbiddenError
|
|
||||||
|
|
||||||
@app.get(
|
|
||||||
"/admin",
|
|
||||||
responses=generate_error_responses(UnauthorizedError, ForbiddenError)
|
|
||||||
)
|
|
||||||
async def admin_endpoint():
|
|
||||||
...
|
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
responses: dict[int | str, dict[str, Any]] = {}
|
responses: dict[int | str, dict[str, Any]] = {}
|
||||||
|
|
||||||
for error in errors:
|
for error in errors:
|
||||||
api_error = error.api_error
|
api_error = error.api_error
|
||||||
|
code = api_error.code
|
||||||
|
|
||||||
responses[api_error.code] = {
|
if code not in responses:
|
||||||
"model": ErrorResponse,
|
responses[code] = {
|
||||||
"description": api_error.msg,
|
"model": ErrorResponse,
|
||||||
"content": {
|
"description": api_error.msg,
|
||||||
"application/json": {
|
"content": {
|
||||||
"example": {
|
"application/json": {
|
||||||
"data": api_error.data,
|
"examples": {},
|
||||||
"status": ResponseStatus.FAIL.value,
|
|
||||||
"message": api_error.msg,
|
|
||||||
"description": api_error.desc,
|
|
||||||
"error_code": api_error.err_code,
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
responses[code]["content"]["application/json"]["examples"][
|
||||||
|
api_error.err_code
|
||||||
|
] = {
|
||||||
|
"summary": api_error.msg,
|
||||||
|
"value": {
|
||||||
|
"data": api_error.data,
|
||||||
|
"status": ResponseStatus.FAIL.value,
|
||||||
|
"message": api_error.msg,
|
||||||
|
"description": api_error.desc,
|
||||||
|
"error_code": api_error.err_code,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +1,41 @@
|
|||||||
"""Exception handlers for FastAPI applications."""
|
"""Exception handlers for FastAPI applications."""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Response, status
|
from fastapi import FastAPI, Request, Response, status
|
||||||
from fastapi.exceptions import RequestValidationError, ResponseValidationError
|
from fastapi.exceptions import (
|
||||||
from fastapi.openapi.utils import get_openapi
|
HTTPException,
|
||||||
|
RequestValidationError,
|
||||||
|
ResponseValidationError,
|
||||||
|
)
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from ..schemas import ErrorResponse, ResponseStatus
|
from ..schemas import ErrorResponse, ResponseStatus
|
||||||
from .exceptions import ApiException
|
from .exceptions import ApiException
|
||||||
|
|
||||||
|
_VALIDATION_LOCATION_PARAMS: frozenset[str] = frozenset(
|
||||||
|
{"body", "query", "path", "header", "cookie"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def init_exceptions_handlers(app: FastAPI) -> FastAPI:
|
def init_exceptions_handlers(app: FastAPI) -> FastAPI:
|
||||||
"""Register exception handlers and custom OpenAPI schema on a FastAPI app.
|
"""Register exception handlers and custom OpenAPI schema on a FastAPI app.
|
||||||
|
|
||||||
Installs handlers for :class:`ApiException`, validation errors, and
|
|
||||||
unhandled exceptions, and replaces the default 422 schema with a
|
|
||||||
consistent error format.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
app: FastAPI application instance
|
app: FastAPI application instance.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The same FastAPI instance (for chaining)
|
The same FastAPI instance (for chaining).
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
init_exceptions_handlers(app)
|
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
_register_exception_handlers(app)
|
_register_exception_handlers(app)
|
||||||
app.openapi = lambda: _custom_openapi(app) # type: ignore[method-assign]
|
_original_openapi = app.openapi
|
||||||
|
app.openapi = lambda: _patched_openapi(app, _original_openapi) # type: ignore[method-assign] # ty:ignore[invalid-assignment]
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def _register_exception_handlers(app: FastAPI) -> None:
|
def _register_exception_handlers(app: FastAPI) -> None:
|
||||||
"""Register all exception handlers on a FastAPI application.
|
"""Register all exception handlers on a FastAPI application."""
|
||||||
|
|
||||||
Args:
|
|
||||||
app: FastAPI application instance
|
|
||||||
|
|
||||||
Example:
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
init_exceptions_handlers(app)
|
|
||||||
"""
|
|
||||||
|
|
||||||
@app.exception_handler(ApiException)
|
@app.exception_handler(ApiException)
|
||||||
async def api_exception_handler(request: Request, exc: ApiException) -> Response:
|
async def api_exception_handler(request: Request, exc: ApiException) -> Response:
|
||||||
@@ -62,12 +47,25 @@ def _register_exception_handlers(app: FastAPI) -> None:
|
|||||||
description=api_error.desc,
|
description=api_error.desc,
|
||||||
error_code=api_error.err_code,
|
error_code=api_error.err_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=api_error.code,
|
status_code=api_error.code,
|
||||||
content=error_response.model_dump(),
|
content=error_response.model_dump(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
|
||||||
|
"""Handle Starlette/FastAPI HTTPException with a consistent error format."""
|
||||||
|
detail = exc.detail if isinstance(exc.detail, str) else "HTTP Error"
|
||||||
|
error_response = ErrorResponse(
|
||||||
|
message=detail,
|
||||||
|
error_code=f"HTTP-{exc.status_code}",
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content=error_response.model_dump(),
|
||||||
|
headers=getattr(exc, "headers", None),
|
||||||
|
)
|
||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
@app.exception_handler(RequestValidationError)
|
||||||
async def request_validation_handler(
|
async def request_validation_handler(
|
||||||
request: Request, exc: RequestValidationError
|
request: Request, exc: RequestValidationError
|
||||||
@@ -90,7 +88,6 @@ def _register_exception_handlers(app: FastAPI) -> None:
|
|||||||
description="An unexpected error occurred. Please try again later.",
|
description="An unexpected error occurred. Please try again later.",
|
||||||
error_code="SERVER-500",
|
error_code="SERVER-500",
|
||||||
)
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
content=error_response.model_dump(),
|
content=error_response.model_dump(),
|
||||||
@@ -105,11 +102,10 @@ def _format_validation_error(
|
|||||||
formatted_errors = []
|
formatted_errors = []
|
||||||
|
|
||||||
for error in errors:
|
for error in errors:
|
||||||
field_path = ".".join(
|
locs = error["loc"]
|
||||||
str(loc)
|
if locs and locs[0] in _VALIDATION_LOCATION_PARAMS:
|
||||||
for loc in error["loc"]
|
locs = locs[1:]
|
||||||
if loc not in ("body", "query", "path", "header", "cookie")
|
field_path = ".".join(str(loc) for loc in locs)
|
||||||
)
|
|
||||||
formatted_errors.append(
|
formatted_errors.append(
|
||||||
{
|
{
|
||||||
"field": field_path or "root",
|
"field": field_path or "root",
|
||||||
@@ -126,39 +122,27 @@ def _format_validation_error(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
content=error_response.model_dump(),
|
content=error_response.model_dump(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _custom_openapi(app: FastAPI) -> dict[str, Any]:
|
def _patched_openapi(
|
||||||
"""Generate custom OpenAPI schema with standardized error format.
|
app: FastAPI, original_openapi: Callable[[], dict[str, Any]]
|
||||||
|
) -> dict[str, Any]:
|
||||||
Replaces default 422 validation error responses with the custom format.
|
"""Generate the OpenAPI schema and replace default 422 responses.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
app: FastAPI application instance
|
app: FastAPI application instance.
|
||||||
|
original_openapi: The previous ``app.openapi`` callable to delegate to.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
OpenAPI schema dict
|
Patched OpenAPI schema dict.
|
||||||
|
|
||||||
Example:
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
init_exceptions_handlers(app) # Automatically sets custom OpenAPI
|
|
||||||
"""
|
"""
|
||||||
if app.openapi_schema:
|
if app.openapi_schema:
|
||||||
return app.openapi_schema
|
return app.openapi_schema
|
||||||
|
|
||||||
openapi_schema = get_openapi(
|
openapi_schema = original_openapi()
|
||||||
title=app.title,
|
|
||||||
version=app.version,
|
|
||||||
openapi_version=app.openapi_version,
|
|
||||||
description=app.description,
|
|
||||||
routes=app.routes,
|
|
||||||
)
|
|
||||||
|
|
||||||
for path_data in openapi_schema.get("paths", {}).values():
|
for path_data in openapi_schema.get("paths", {}).values():
|
||||||
for operation in path_data.values():
|
for operation in path_data.values():
|
||||||
@@ -168,20 +152,25 @@ def _custom_openapi(app: FastAPI) -> dict[str, Any]:
|
|||||||
"description": "Validation Error",
|
"description": "Validation Error",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"example": {
|
"examples": {
|
||||||
"data": {
|
"VAL-422": {
|
||||||
"errors": [
|
"summary": "Validation Error",
|
||||||
{
|
"value": {
|
||||||
"field": "field_name",
|
"data": {
|
||||||
"message": "value is not valid",
|
"errors": [
|
||||||
"type": "value_error",
|
{
|
||||||
}
|
"field": "field_name",
|
||||||
]
|
"message": "value is not valid",
|
||||||
},
|
"type": "value_error",
|
||||||
"status": ResponseStatus.FAIL.value,
|
}
|
||||||
"message": "Validation Error",
|
]
|
||||||
"description": "1 validation error(s) detected",
|
},
|
||||||
"error_code": "VAL-422",
|
"status": ResponseStatus.FAIL.value,
|
||||||
|
"message": "Validation Error",
|
||||||
|
"description": "1 validation error(s) detected",
|
||||||
|
"error_code": "VAL-422",
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from collections.abc import Callable, Sequence
|
from collections.abc import Callable, Sequence
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
@@ -12,6 +13,13 @@ from .enum import Context
|
|||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_contexts(
|
||||||
|
contexts: list[str | Enum] | tuple[str | Enum, ...],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Convert a sequence of any Enum subclass and/or plain strings to a list of strings."""
|
||||||
|
return [c.value if isinstance(c, Enum) else c for c in contexts]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Fixture:
|
class Fixture:
|
||||||
"""A fixture definition with metadata."""
|
"""A fixture definition with metadata."""
|
||||||
@@ -50,26 +58,51 @@ class FixtureRegistry:
|
|||||||
Post(id=1, title="Test", user_id=1),
|
Post(id=1, title="Test", user_id=1),
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Fixtures with the same name may be registered for **different** contexts.
|
||||||
|
When multiple contexts are loaded together, their instances are merged:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@fixtures.register(contexts=[Context.BASE])
|
||||||
|
def users():
|
||||||
|
return [User(id=1, username="admin")]
|
||||||
|
|
||||||
|
@fixtures.register(contexts=[Context.TESTING])
|
||||||
|
def users():
|
||||||
|
return [User(id=2, username="tester")]
|
||||||
|
# load_fixtures_by_context(..., Context.BASE, Context.TESTING)
|
||||||
|
# → loads both User(admin) and User(tester) under the "users" name
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
contexts: list[str | Context] | None = None,
|
contexts: list[str | Enum] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._fixtures: dict[str, Fixture] = {}
|
self._fixtures: dict[str, list[Fixture]] = {}
|
||||||
self._default_contexts: list[str] | None = (
|
self._default_contexts: list[str] | None = (
|
||||||
[c.value if isinstance(c, Context) else c for c in contexts]
|
_normalize_contexts(contexts) if contexts else None
|
||||||
if contexts
|
|
||||||
else None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _validate_no_context_overlap(self, name: str, new_contexts: list[str]) -> None:
|
||||||
|
"""Raise ``ValueError`` if any existing variant for *name* overlaps."""
|
||||||
|
existing_variants = self._fixtures.get(name, [])
|
||||||
|
new_set = set(new_contexts)
|
||||||
|
for variant in existing_variants:
|
||||||
|
if set(variant.contexts) & new_set:
|
||||||
|
raise ValueError(
|
||||||
|
f"Fixture '{name}' already exists in the current registry "
|
||||||
|
f"with overlapping contexts. Use distinct context sets for "
|
||||||
|
f"each variant of the same fixture name."
|
||||||
|
)
|
||||||
|
|
||||||
def register(
|
def register(
|
||||||
self,
|
self,
|
||||||
func: Callable[[], Sequence[DeclarativeBase]] | None = None,
|
func: Callable[[], Sequence[DeclarativeBase]] | None = None,
|
||||||
*,
|
*,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
depends_on: list[str] | None = None,
|
depends_on: list[str] | None = None,
|
||||||
contexts: list[str | Context] | None = None,
|
contexts: list[str | Enum] | None = None,
|
||||||
) -> Callable[..., Any]:
|
) -> Callable[..., Any]:
|
||||||
"""Register a fixture function.
|
"""Register a fixture function.
|
||||||
|
|
||||||
@@ -79,7 +112,8 @@ class FixtureRegistry:
|
|||||||
func: Fixture function returning list of model instances
|
func: Fixture function returning list of model instances
|
||||||
name: Fixture name (defaults to function name)
|
name: Fixture name (defaults to function name)
|
||||||
depends_on: List of fixture names this depends on
|
depends_on: List of fixture names this depends on
|
||||||
contexts: List of contexts this fixture belongs to
|
contexts: List of contexts this fixture belongs to. Both
|
||||||
|
:class:`Context` enum values and plain strings are accepted.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
@@ -90,7 +124,6 @@ class FixtureRegistry:
|
|||||||
@fixtures.register(depends_on=["roles"], contexts=[Context.TESTING])
|
@fixtures.register(depends_on=["roles"], contexts=[Context.TESTING])
|
||||||
def test_users():
|
def test_users():
|
||||||
return [User(id=1, username="test", role_id=1)]
|
return [User(id=1, username="test", role_id=1)]
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(
|
def decorator(
|
||||||
@@ -98,19 +131,20 @@ class FixtureRegistry:
|
|||||||
) -> Callable[[], Sequence[DeclarativeBase]]:
|
) -> Callable[[], Sequence[DeclarativeBase]]:
|
||||||
fixture_name = name or cast(Any, fn).__name__
|
fixture_name = name or cast(Any, fn).__name__
|
||||||
if contexts is not None:
|
if contexts is not None:
|
||||||
fixture_contexts = [
|
fixture_contexts = _normalize_contexts(contexts)
|
||||||
c.value if isinstance(c, Context) else c for c in contexts
|
|
||||||
]
|
|
||||||
elif self._default_contexts is not None:
|
elif self._default_contexts is not None:
|
||||||
fixture_contexts = self._default_contexts
|
fixture_contexts = self._default_contexts
|
||||||
else:
|
else:
|
||||||
fixture_contexts = [Context.BASE.value]
|
fixture_contexts = [Context.BASE.value]
|
||||||
|
|
||||||
self._fixtures[fixture_name] = Fixture(
|
self._validate_no_context_overlap(fixture_name, fixture_contexts)
|
||||||
name=fixture_name,
|
self._fixtures.setdefault(fixture_name, []).append(
|
||||||
func=fn,
|
Fixture(
|
||||||
depends_on=depends_on or [],
|
name=fixture_name,
|
||||||
contexts=fixture_contexts,
|
func=fn,
|
||||||
|
depends_on=depends_on or [],
|
||||||
|
contexts=fixture_contexts,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return fn
|
return fn
|
||||||
|
|
||||||
@@ -121,11 +155,14 @@ class FixtureRegistry:
|
|||||||
def include_registry(self, registry: "FixtureRegistry") -> None:
|
def include_registry(self, registry: "FixtureRegistry") -> None:
|
||||||
"""Include another `FixtureRegistry` in the same current `FixtureRegistry`.
|
"""Include another `FixtureRegistry` in the same current `FixtureRegistry`.
|
||||||
|
|
||||||
|
Fixtures with the same name are allowed as long as their context sets
|
||||||
|
do not overlap. Conflicting contexts raise :class:`ValueError`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
registry: The `FixtureRegistry` to include
|
registry: The `FixtureRegistry` to include
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If a fixture name already exists in the current registry
|
ValueError: If a fixture name already exists with overlapping contexts
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
@@ -139,31 +176,73 @@ class FixtureRegistry:
|
|||||||
registry.include_registry(registry=dev_registry)
|
registry.include_registry(registry=dev_registry)
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
for name, fixture in registry._fixtures.items():
|
for name, variants in registry._fixtures.items():
|
||||||
if name in self._fixtures:
|
for fixture in variants:
|
||||||
raise ValueError(
|
self._validate_no_context_overlap(name, fixture.contexts)
|
||||||
f"Fixture '{name}' already exists in the current registry"
|
self._fixtures.setdefault(name, []).append(fixture)
|
||||||
)
|
|
||||||
self._fixtures[name] = fixture
|
|
||||||
|
|
||||||
def get(self, name: str) -> Fixture:
|
def get(self, name: str) -> Fixture:
|
||||||
"""Get a fixture by name."""
|
"""Get a fixture by name.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If no fixture with *name* is registered.
|
||||||
|
ValueError: If the fixture has multiple context variants — use
|
||||||
|
:meth:`get_variants` in that case.
|
||||||
|
"""
|
||||||
if name not in self._fixtures:
|
if name not in self._fixtures:
|
||||||
raise KeyError(f"Fixture '{name}' not found")
|
raise KeyError(f"Fixture '{name}' not found")
|
||||||
return self._fixtures[name]
|
variants = self._fixtures[name]
|
||||||
|
if len(variants) > 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"Fixture '{name}' has {len(variants)} context variants. "
|
||||||
|
f"Use get_variants('{name}') to retrieve them."
|
||||||
|
)
|
||||||
|
return variants[0]
|
||||||
|
|
||||||
|
def get_variants(self, name: str, *contexts: str | Enum) -> list[Fixture]:
|
||||||
|
"""Return all registered variants for *name*, optionally filtered by context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Fixture name.
|
||||||
|
*contexts: If given, only return variants whose context set
|
||||||
|
intersects with these values. Both :class:`Context` enum
|
||||||
|
values and plain strings are accepted.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching :class:`Fixture` objects (may be empty when a
|
||||||
|
context filter is applied and nothing matches).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If no fixture with *name* is registered.
|
||||||
|
"""
|
||||||
|
if name not in self._fixtures:
|
||||||
|
raise KeyError(f"Fixture '{name}' not found")
|
||||||
|
variants = self._fixtures[name]
|
||||||
|
if not contexts:
|
||||||
|
return list(variants)
|
||||||
|
context_values = set(_normalize_contexts(contexts))
|
||||||
|
return [v for v in variants if set(v.contexts) & context_values]
|
||||||
|
|
||||||
def get_all(self) -> list[Fixture]:
|
def get_all(self) -> list[Fixture]:
|
||||||
"""Get all registered fixtures."""
|
"""Get all registered fixtures (all variants of all names)."""
|
||||||
return list(self._fixtures.values())
|
return [f for variants in self._fixtures.values() for f in variants]
|
||||||
|
|
||||||
def get_by_context(self, *contexts: str | Context) -> list[Fixture]:
|
def get_by_context(self, *contexts: str | Enum) -> list[Fixture]:
|
||||||
"""Get fixtures for specific contexts."""
|
"""Get fixtures for specific contexts."""
|
||||||
context_values = {c.value if isinstance(c, Context) else c for c in contexts}
|
context_values = set(_normalize_contexts(contexts))
|
||||||
return [f for f in self._fixtures.values() if set(f.contexts) & context_values]
|
return [
|
||||||
|
f
|
||||||
|
for variants in self._fixtures.values()
|
||||||
|
for f in variants
|
||||||
|
if set(f.contexts) & context_values
|
||||||
|
]
|
||||||
|
|
||||||
def resolve_dependencies(self, *names: str) -> list[str]:
|
def resolve_dependencies(self, *names: str) -> list[str]:
|
||||||
"""Resolve fixture dependencies in topological order.
|
"""Resolve fixture dependencies in topological order.
|
||||||
|
|
||||||
|
When a fixture name has multiple context variants, the union of all
|
||||||
|
variants' ``depends_on`` lists is used.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
*names: Fixture names to resolve
|
*names: Fixture names to resolve
|
||||||
|
|
||||||
@@ -185,9 +264,20 @@ class FixtureRegistry:
|
|||||||
raise ValueError(f"Circular dependency detected: {name}")
|
raise ValueError(f"Circular dependency detected: {name}")
|
||||||
|
|
||||||
visiting.add(name)
|
visiting.add(name)
|
||||||
fixture = self.get(name)
|
variants = self._fixtures.get(name)
|
||||||
|
if variants is None:
|
||||||
|
raise KeyError(f"Fixture '{name}' not found")
|
||||||
|
|
||||||
for dep in fixture.depends_on:
|
# Union of depends_on across all variants, preserving first-seen order.
|
||||||
|
seen_deps: set[str] = set()
|
||||||
|
all_deps: list[str] = []
|
||||||
|
for variant in variants:
|
||||||
|
for dep in variant.depends_on:
|
||||||
|
if dep not in seen_deps:
|
||||||
|
all_deps.append(dep)
|
||||||
|
seen_deps.add(dep)
|
||||||
|
|
||||||
|
for dep in all_deps:
|
||||||
visit(dep)
|
visit(dep)
|
||||||
|
|
||||||
visiting.remove(name)
|
visiting.remove(name)
|
||||||
@@ -199,7 +289,7 @@ class FixtureRegistry:
|
|||||||
|
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
def resolve_context_dependencies(self, *contexts: str | Context) -> list[str]:
|
def resolve_context_dependencies(self, *contexts: str | Enum) -> list[str]:
|
||||||
"""Resolve all fixtures for contexts with dependencies.
|
"""Resolve all fixtures for contexts with dependencies.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -209,7 +299,9 @@ class FixtureRegistry:
|
|||||||
List of fixture names in load order
|
List of fixture names in load order
|
||||||
"""
|
"""
|
||||||
context_fixtures = self.get_by_context(*contexts)
|
context_fixtures = self.get_by_context(*contexts)
|
||||||
names = [f.name for f in context_fixtures]
|
# Deduplicate names while preserving first-seen order (a name can
|
||||||
|
# appear multiple times if it has variants in different contexts).
|
||||||
|
names = list(dict.fromkeys(f.name for f in context_fixtures))
|
||||||
|
|
||||||
all_deps: set[str] = set()
|
all_deps: set[str] = set()
|
||||||
for name in names:
|
for name in names:
|
||||||
|
|||||||
@@ -1,24 +1,233 @@
|
|||||||
"""Fixture loading utilities for database seeding."""
|
"""Fixture loading utilities for database seeding."""
|
||||||
|
|
||||||
from collections.abc import Callable, Sequence
|
from collections.abc import Callable, Sequence
|
||||||
from typing import Any, TypeVar
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import inspect as sa_inspect
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from ..db import get_transaction
|
from ..db import get_transaction
|
||||||
from ..logger import get_logger
|
from ..logger import get_logger
|
||||||
|
from ..types import ModelType
|
||||||
from .enum import LoadStrategy
|
from .enum import LoadStrategy
|
||||||
from .registry import Context, FixtureRegistry
|
from .registry import FixtureRegistry, _normalize_contexts
|
||||||
|
|
||||||
logger = get_logger()
|
logger = get_logger()
|
||||||
|
|
||||||
T = TypeVar("T", bound=DeclarativeBase)
|
|
||||||
|
def _instance_to_dict(instance: DeclarativeBase) -> dict[str, Any]:
|
||||||
|
"""Extract column values from a model instance, skipping unset server-default columns."""
|
||||||
|
state = sa_inspect(instance)
|
||||||
|
state_dict = state.dict
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for prop in state.mapper.column_attrs:
|
||||||
|
if prop.key not in state_dict:
|
||||||
|
continue
|
||||||
|
val = state_dict[prop.key]
|
||||||
|
if val is None:
|
||||||
|
col = prop.columns[0]
|
||||||
|
|
||||||
|
if (
|
||||||
|
col.server_default is not None
|
||||||
|
or (col.default is not None and col.default.is_callable)
|
||||||
|
or col.autoincrement is True
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
result[prop.key] = val
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _group_by_type(
|
||||||
|
instances: list[DeclarativeBase],
|
||||||
|
) -> list[tuple[type[DeclarativeBase], list[DeclarativeBase]]]:
|
||||||
|
"""Group instances by their concrete model class, preserving insertion order."""
|
||||||
|
groups: dict[type[DeclarativeBase], list[DeclarativeBase]] = {}
|
||||||
|
for instance in instances:
|
||||||
|
groups.setdefault(type(instance), []).append(instance)
|
||||||
|
return list(groups.items())
|
||||||
|
|
||||||
|
|
||||||
|
def _group_by_column_set(
|
||||||
|
dicts: list[dict[str, Any]],
|
||||||
|
instances: list[DeclarativeBase],
|
||||||
|
) -> list[tuple[list[dict[str, Any]], list[DeclarativeBase]]]:
|
||||||
|
"""Group (dict, instance) pairs by their dict key sets."""
|
||||||
|
groups: dict[
|
||||||
|
frozenset[str], tuple[list[dict[str, Any]], list[DeclarativeBase]]
|
||||||
|
] = {}
|
||||||
|
for d, inst in zip(dicts, instances):
|
||||||
|
key = frozenset(d)
|
||||||
|
if key not in groups:
|
||||||
|
groups[key] = ([], [])
|
||||||
|
groups[key][0].append(d)
|
||||||
|
groups[key][1].append(inst)
|
||||||
|
return list(groups.values())
|
||||||
|
|
||||||
|
|
||||||
|
async def _batch_insert(
|
||||||
|
session: AsyncSession,
|
||||||
|
model_cls: type[DeclarativeBase],
|
||||||
|
instances: list[DeclarativeBase],
|
||||||
|
) -> None:
|
||||||
|
"""INSERT all instances — raises on conflict (no duplicate handling)."""
|
||||||
|
dicts = [_instance_to_dict(i) for i in instances]
|
||||||
|
for group_dicts, _ in _group_by_column_set(dicts, instances):
|
||||||
|
await session.execute(pg_insert(model_cls).values(group_dicts))
|
||||||
|
|
||||||
|
|
||||||
|
async def _batch_merge(
|
||||||
|
session: AsyncSession,
|
||||||
|
model_cls: type[DeclarativeBase],
|
||||||
|
instances: list[DeclarativeBase],
|
||||||
|
) -> None:
|
||||||
|
"""UPSERT: insert new rows, update existing ones with the provided values."""
|
||||||
|
mapper = model_cls.__mapper__
|
||||||
|
pk_names = [col.name for col in mapper.primary_key]
|
||||||
|
pk_names_set = set(pk_names)
|
||||||
|
non_pk_cols = [
|
||||||
|
prop.key
|
||||||
|
for prop in mapper.column_attrs
|
||||||
|
if not any(col.name in pk_names_set for col in prop.columns)
|
||||||
|
]
|
||||||
|
|
||||||
|
dicts = [_instance_to_dict(i) for i in instances]
|
||||||
|
for group_dicts, _ in _group_by_column_set(dicts, instances):
|
||||||
|
stmt = pg_insert(model_cls).values(group_dicts)
|
||||||
|
|
||||||
|
inserted_keys = set(group_dicts[0])
|
||||||
|
update_cols = [col for col in non_pk_cols if col in inserted_keys]
|
||||||
|
|
||||||
|
if update_cols:
|
||||||
|
stmt = stmt.on_conflict_do_update(
|
||||||
|
index_elements=pk_names,
|
||||||
|
set_={col: stmt.excluded[col] for col in update_cols},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stmt = stmt.on_conflict_do_nothing(index_elements=pk_names)
|
||||||
|
|
||||||
|
await session.execute(stmt)
|
||||||
|
|
||||||
|
|
||||||
|
async def _batch_skip_existing(
|
||||||
|
session: AsyncSession,
|
||||||
|
model_cls: type[DeclarativeBase],
|
||||||
|
instances: list[DeclarativeBase],
|
||||||
|
) -> list[DeclarativeBase]:
|
||||||
|
"""INSERT only rows that do not already exist; return the inserted ones."""
|
||||||
|
mapper = model_cls.__mapper__
|
||||||
|
pk_names = [col.name for col in mapper.primary_key]
|
||||||
|
|
||||||
|
no_pk: list[DeclarativeBase] = []
|
||||||
|
with_pk_pairs: list[tuple[DeclarativeBase, Any]] = []
|
||||||
|
for inst in instances:
|
||||||
|
pk = _get_primary_key(inst)
|
||||||
|
if pk is None:
|
||||||
|
no_pk.append(inst)
|
||||||
|
else:
|
||||||
|
with_pk_pairs.append((inst, pk))
|
||||||
|
|
||||||
|
loaded: list[DeclarativeBase] = list(no_pk)
|
||||||
|
if no_pk:
|
||||||
|
no_pk_dicts = [_instance_to_dict(i) for i in no_pk]
|
||||||
|
for group_dicts, _ in _group_by_column_set(no_pk_dicts, no_pk):
|
||||||
|
await session.execute(pg_insert(model_cls).values(group_dicts))
|
||||||
|
|
||||||
|
if with_pk_pairs:
|
||||||
|
with_pk = [i for i, _ in with_pk_pairs]
|
||||||
|
with_pk_dicts = [_instance_to_dict(i) for i in with_pk]
|
||||||
|
for group_dicts, group_insts in _group_by_column_set(with_pk_dicts, with_pk):
|
||||||
|
stmt = (
|
||||||
|
pg_insert(model_cls)
|
||||||
|
.values(group_dicts)
|
||||||
|
.on_conflict_do_nothing(index_elements=pk_names)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt.returning(*mapper.primary_key))
|
||||||
|
inserted_pks = {
|
||||||
|
row[0] if len(pk_names) == 1 else tuple(row) for row in result
|
||||||
|
}
|
||||||
|
loaded.extend(
|
||||||
|
inst
|
||||||
|
for inst, pk in zip(
|
||||||
|
group_insts, [_get_primary_key(i) for i in group_insts]
|
||||||
|
)
|
||||||
|
if pk in inserted_pks
|
||||||
|
)
|
||||||
|
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_ordered(
|
||||||
|
session: AsyncSession,
|
||||||
|
registry: FixtureRegistry,
|
||||||
|
ordered_names: list[str],
|
||||||
|
strategy: LoadStrategy,
|
||||||
|
contexts: tuple[str, ...] | None = None,
|
||||||
|
) -> dict[str, list[DeclarativeBase]]:
|
||||||
|
"""Load fixtures in order using batch Core INSERT statements."""
|
||||||
|
results: dict[str, list[DeclarativeBase]] = {}
|
||||||
|
|
||||||
|
for name in ordered_names:
|
||||||
|
variants = (
|
||||||
|
registry.get_variants(name, *contexts)
|
||||||
|
if contexts is not None
|
||||||
|
else registry.get_variants(name)
|
||||||
|
)
|
||||||
|
|
||||||
|
if contexts is not None and not variants:
|
||||||
|
variants = registry.get_variants(name)
|
||||||
|
|
||||||
|
if not variants:
|
||||||
|
results[name] = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
instances = [inst for v in variants for inst in v.func()]
|
||||||
|
|
||||||
|
if not instances:
|
||||||
|
results[name] = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
model_name = type(instances[0]).__name__
|
||||||
|
loaded: list[DeclarativeBase] = []
|
||||||
|
|
||||||
|
async with get_transaction(session):
|
||||||
|
for model_cls, group in _group_by_type(instances):
|
||||||
|
match strategy:
|
||||||
|
case LoadStrategy.INSERT:
|
||||||
|
await _batch_insert(session, model_cls, group)
|
||||||
|
loaded.extend(group)
|
||||||
|
case LoadStrategy.MERGE:
|
||||||
|
await _batch_merge(session, model_cls, group)
|
||||||
|
loaded.extend(group)
|
||||||
|
case LoadStrategy.SKIP_EXISTING:
|
||||||
|
inserted = await _batch_skip_existing(session, model_cls, group)
|
||||||
|
loaded.extend(inserted)
|
||||||
|
|
||||||
|
results[name] = loaded
|
||||||
|
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
||||||
|
"""Get the primary key value of a model instance."""
|
||||||
|
mapper = instance.__class__.__mapper__
|
||||||
|
pk_cols = mapper.primary_key
|
||||||
|
|
||||||
|
if len(pk_cols) == 1:
|
||||||
|
return getattr(instance, pk_cols[0].name, None)
|
||||||
|
|
||||||
|
pk_values = tuple(getattr(instance, col.name, None) for col in pk_cols)
|
||||||
|
if all(v is not None for v in pk_values):
|
||||||
|
return pk_values
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_obj_by_attr(
|
def get_obj_by_attr(
|
||||||
fixtures: Callable[[], Sequence[T]], attr_name: str, value: Any
|
fixtures: Callable[[], Sequence[ModelType]], attr_name: str, value: Any
|
||||||
) -> T:
|
) -> ModelType:
|
||||||
"""Get a SQLAlchemy model instance by matching an attribute value.
|
"""Get a SQLAlchemy model instance by matching an attribute value.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -49,6 +258,8 @@ async def load_fixtures(
|
|||||||
) -> dict[str, list[DeclarativeBase]]:
|
) -> dict[str, list[DeclarativeBase]]:
|
||||||
"""Load specific fixtures by name with dependencies.
|
"""Load specific fixtures by name with dependencies.
|
||||||
|
|
||||||
|
All context variants of each requested fixture are loaded and merged.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: Database session
|
session: Database session
|
||||||
registry: Fixture registry
|
registry: Fixture registry
|
||||||
@@ -57,13 +268,6 @@ async def load_fixtures(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict mapping fixture names to loaded instances
|
Dict mapping fixture names to loaded instances
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
# Loads 'roles' first (dependency), then 'users'
|
|
||||||
result = await load_fixtures(session, fixtures, "users")
|
|
||||||
print(result["users"]) # [User(...), ...]
|
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
ordered = registry.resolve_dependencies(*names)
|
ordered = registry.resolve_dependencies(*names)
|
||||||
return await _load_ordered(session, registry, ordered, strategy)
|
return await _load_ordered(session, registry, ordered, strategy)
|
||||||
@@ -72,7 +276,7 @@ async def load_fixtures(
|
|||||||
async def load_fixtures_by_context(
|
async def load_fixtures_by_context(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
registry: FixtureRegistry,
|
registry: FixtureRegistry,
|
||||||
*contexts: str | Context,
|
*contexts: str | Enum,
|
||||||
strategy: LoadStrategy = LoadStrategy.MERGE,
|
strategy: LoadStrategy = LoadStrategy.MERGE,
|
||||||
) -> dict[str, list[DeclarativeBase]]:
|
) -> dict[str, list[DeclarativeBase]]:
|
||||||
"""Load all fixtures for specific contexts.
|
"""Load all fixtures for specific contexts.
|
||||||
@@ -80,81 +284,15 @@ async def load_fixtures_by_context(
|
|||||||
Args:
|
Args:
|
||||||
session: Database session
|
session: Database session
|
||||||
registry: Fixture registry
|
registry: Fixture registry
|
||||||
*contexts: Contexts to load (e.g., Context.BASE, Context.TESTING)
|
*contexts: Contexts to load (e.g., ``Context.BASE``, ``Context.TESTING``,
|
||||||
|
or plain strings for custom contexts)
|
||||||
strategy: How to handle existing records
|
strategy: How to handle existing records
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict mapping fixture names to loaded instances
|
Dict mapping fixture names to loaded instances
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
# Load base + testing fixtures
|
|
||||||
await load_fixtures_by_context(
|
|
||||||
session, fixtures,
|
|
||||||
Context.BASE, Context.TESTING
|
|
||||||
)
|
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
|
context_strings = tuple(_normalize_contexts(contexts))
|
||||||
ordered = registry.resolve_context_dependencies(*contexts)
|
ordered = registry.resolve_context_dependencies(*contexts)
|
||||||
return await _load_ordered(session, registry, ordered, strategy)
|
return await _load_ordered(
|
||||||
|
session, registry, ordered, strategy, contexts=context_strings
|
||||||
|
)
|
||||||
async def _load_ordered(
|
|
||||||
session: AsyncSession,
|
|
||||||
registry: FixtureRegistry,
|
|
||||||
ordered_names: list[str],
|
|
||||||
strategy: LoadStrategy,
|
|
||||||
) -> dict[str, list[DeclarativeBase]]:
|
|
||||||
"""Load fixtures in order."""
|
|
||||||
results: dict[str, list[DeclarativeBase]] = {}
|
|
||||||
|
|
||||||
for name in ordered_names:
|
|
||||||
fixture = registry.get(name)
|
|
||||||
instances = list(fixture.func())
|
|
||||||
|
|
||||||
if not instances:
|
|
||||||
results[name] = []
|
|
||||||
continue
|
|
||||||
|
|
||||||
model_name = type(instances[0]).__name__
|
|
||||||
loaded: list[DeclarativeBase] = []
|
|
||||||
|
|
||||||
async with get_transaction(session):
|
|
||||||
for instance in instances:
|
|
||||||
if strategy == LoadStrategy.INSERT:
|
|
||||||
session.add(instance)
|
|
||||||
loaded.append(instance)
|
|
||||||
|
|
||||||
elif strategy == LoadStrategy.MERGE:
|
|
||||||
merged = await session.merge(instance)
|
|
||||||
loaded.append(merged)
|
|
||||||
|
|
||||||
elif strategy == LoadStrategy.SKIP_EXISTING:
|
|
||||||
pk = _get_primary_key(instance)
|
|
||||||
if pk is not None:
|
|
||||||
existing = await session.get(type(instance), pk)
|
|
||||||
if existing is None:
|
|
||||||
session.add(instance)
|
|
||||||
loaded.append(instance)
|
|
||||||
else:
|
|
||||||
session.add(instance)
|
|
||||||
loaded.append(instance)
|
|
||||||
|
|
||||||
results[name] = loaded
|
|
||||||
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
|
||||||
"""Get the primary key value of a model instance."""
|
|
||||||
mapper = instance.__class__.__mapper__
|
|
||||||
pk_cols = mapper.primary_key
|
|
||||||
|
|
||||||
if len(pk_cols) == 1:
|
|
||||||
return getattr(instance, pk_cols[0].name, None)
|
|
||||||
|
|
||||||
pk_values = tuple(getattr(instance, col.name, None) for col in pk_cols)
|
|
||||||
if all(v is not None for v in pk_values):
|
|
||||||
return pk_values
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ def configure_logging(
|
|||||||
_SENTINEL = object()
|
_SENTINEL = object()
|
||||||
|
|
||||||
|
|
||||||
def get_logger(name: str | None = _SENTINEL) -> logging.Logger: # type: ignore[assignment]
|
def get_logger(name: str | None = _SENTINEL) -> logging.Logger: # type: ignore[assignment] # ty:ignore[invalid-parameter-default]
|
||||||
"""Return a logger with the given *name*.
|
"""Return a logger with the given *name*.
|
||||||
|
|
||||||
A thin convenience wrapper around :func:`logging.getLogger` that keeps
|
A thin convenience wrapper around :func:`logging.getLogger` that keeps
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Prometheus metrics endpoint for FastAPI applications."""
|
"""Prometheus metrics endpoint for FastAPI applications."""
|
||||||
|
|
||||||
import asyncio
|
import inspect
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@@ -51,19 +51,25 @@ def init_metrics(
|
|||||||
"""
|
"""
|
||||||
for provider in registry.get_providers():
|
for provider in registry.get_providers():
|
||||||
logger.debug("Initialising metric provider '%s'", provider.name)
|
logger.debug("Initialising metric provider '%s'", provider.name)
|
||||||
provider.func()
|
registry._instances[provider.name] = provider.func()
|
||||||
|
|
||||||
collectors = registry.get_collectors()
|
# Partition collectors and cache env check at startup — both are stable for the app lifetime.
|
||||||
|
async_collectors = [
|
||||||
|
c for c in registry.get_collectors() if inspect.iscoroutinefunction(c.func)
|
||||||
|
]
|
||||||
|
sync_collectors = [
|
||||||
|
c for c in registry.get_collectors() if not inspect.iscoroutinefunction(c.func)
|
||||||
|
]
|
||||||
|
multiprocess_mode = _is_multiprocess()
|
||||||
|
|
||||||
@app.get(path, include_in_schema=False)
|
@app.get(path, include_in_schema=False)
|
||||||
async def metrics_endpoint() -> Response:
|
async def metrics_endpoint() -> Response:
|
||||||
for collector in collectors:
|
for collector in sync_collectors:
|
||||||
if asyncio.iscoroutinefunction(collector.func):
|
collector.func()
|
||||||
await collector.func()
|
for collector in async_collectors:
|
||||||
else:
|
await collector.func()
|
||||||
collector.func()
|
|
||||||
|
|
||||||
if _is_multiprocess():
|
if multiprocess_mode:
|
||||||
prom_registry = CollectorRegistry()
|
prom_registry = CollectorRegistry()
|
||||||
multiprocess.MultiProcessCollector(prom_registry)
|
multiprocess.MultiProcessCollector(prom_registry)
|
||||||
output = generate_latest(prom_registry)
|
output = generate_latest(prom_registry)
|
||||||
|
|||||||
@@ -19,31 +19,11 @@ class Metric:
|
|||||||
|
|
||||||
|
|
||||||
class MetricsRegistry:
|
class MetricsRegistry:
|
||||||
"""Registry for managing Prometheus metric providers and collectors.
|
"""Registry for managing Prometheus metric providers and collectors."""
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from prometheus_client import Counter, Gauge
|
|
||||||
from fastapi_toolsets.metrics import MetricsRegistry
|
|
||||||
|
|
||||||
metrics = MetricsRegistry()
|
|
||||||
|
|
||||||
@metrics.register
|
|
||||||
def http_requests():
|
|
||||||
return Counter("http_requests_total", "Total HTTP requests", ["method", "status"])
|
|
||||||
|
|
||||||
@metrics.register(name="db_pool")
|
|
||||||
def database_pool_size():
|
|
||||||
return Gauge("db_pool_size", "Database connection pool size")
|
|
||||||
|
|
||||||
@metrics.register(collect=True)
|
|
||||||
def collect_queue_depth(gauge=Gauge("queue_depth", "Current queue depth")):
|
|
||||||
gauge.set(get_current_queue_depth())
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._metrics: dict[str, Metric] = {}
|
self._metrics: dict[str, Metric] = {}
|
||||||
|
self._instances: dict[str, Any] = {}
|
||||||
|
|
||||||
def register(
|
def register(
|
||||||
self,
|
self,
|
||||||
@@ -61,17 +41,6 @@ class MetricsRegistry:
|
|||||||
name: Metric name (defaults to function name).
|
name: Metric name (defaults to function name).
|
||||||
collect: If ``True``, the function is called on every scrape.
|
collect: If ``True``, the function is called on every scrape.
|
||||||
If ``False`` (default), called once at init time.
|
If ``False`` (default), called once at init time.
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
@metrics.register
|
|
||||||
def my_counter():
|
|
||||||
return Counter("my_counter", "A counter")
|
|
||||||
|
|
||||||
@metrics.register(collect=True, name="queue")
|
|
||||||
def collect_queue_depth():
|
|
||||||
gauge.set(compute_depth())
|
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
@@ -87,6 +56,25 @@ class MetricsRegistry:
|
|||||||
return decorator(func)
|
return decorator(func)
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
def get(self, name: str) -> Any:
|
||||||
|
"""Return the metric instance created by a provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: The metric name (defaults to the provider function name).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If the metric name is unknown or ``init_metrics`` has not
|
||||||
|
been called yet.
|
||||||
|
"""
|
||||||
|
if name not in self._instances:
|
||||||
|
if name in self._metrics:
|
||||||
|
raise KeyError(
|
||||||
|
f"Metric '{name}' exists but has not been initialized yet. "
|
||||||
|
"Ensure init_metrics() has been called before accessing metric instances."
|
||||||
|
)
|
||||||
|
raise KeyError(f"Unknown metric '{name}'.")
|
||||||
|
return self._instances[name]
|
||||||
|
|
||||||
def include_registry(self, registry: "MetricsRegistry") -> None:
|
def include_registry(self, registry: "MetricsRegistry") -> None:
|
||||||
"""Include another :class:`MetricsRegistry` into this one.
|
"""Include another :class:`MetricsRegistry` into this one.
|
||||||
|
|
||||||
@@ -95,18 +83,6 @@ class MetricsRegistry:
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If a metric name already exists in the current registry.
|
ValueError: If a metric name already exists in the current registry.
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
main = MetricsRegistry()
|
|
||||||
sub = MetricsRegistry()
|
|
||||||
|
|
||||||
@sub.register
|
|
||||||
def sub_metric():
|
|
||||||
return Counter("sub_total", "Sub counter")
|
|
||||||
|
|
||||||
main.include_registry(sub)
|
|
||||||
```
|
|
||||||
"""
|
"""
|
||||||
for metric_name, definition in registry._metrics.items():
|
for metric_name, definition in registry._metrics.items():
|
||||||
if metric_name in self._metrics:
|
if metric_name in self._metrics:
|
||||||
|
|||||||
21
src/fastapi_toolsets/models/__init__.py
Normal file
21
src/fastapi_toolsets/models/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""SQLAlchemy model mixins for common column patterns."""
|
||||||
|
|
||||||
|
from .columns import (
|
||||||
|
CreatedAtMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
UUIDMixin,
|
||||||
|
UUIDv7Mixin,
|
||||||
|
UpdatedAtMixin,
|
||||||
|
)
|
||||||
|
from .watched import EventSession, ModelEvent, listens_for
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EventSession",
|
||||||
|
"ModelEvent",
|
||||||
|
"UUIDMixin",
|
||||||
|
"UUIDv7Mixin",
|
||||||
|
"CreatedAtMixin",
|
||||||
|
"UpdatedAtMixin",
|
||||||
|
"TimestampMixin",
|
||||||
|
"listens_for",
|
||||||
|
]
|
||||||
50
src/fastapi_toolsets/models/columns.py
Normal file
50
src/fastapi_toolsets/models/columns.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""SQLAlchemy column mixins for common column patterns."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Uuid, text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
class UUIDMixin:
|
||||||
|
"""Mixin that adds a UUID primary key auto-generated by the database."""
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid,
|
||||||
|
primary_key=True,
|
||||||
|
server_default=text("gen_random_uuid()"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UUIDv7Mixin:
|
||||||
|
"""Mixin that adds a UUIDv7 primary key auto-generated by the database."""
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid,
|
||||||
|
primary_key=True,
|
||||||
|
server_default=text("uuidv7()"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CreatedAtMixin:
|
||||||
|
"""Mixin that adds a ``created_at`` timestamp column."""
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=text("clock_timestamp()"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatedAtMixin:
|
||||||
|
"""Mixin that adds an ``updated_at`` timestamp column."""
|
||||||
|
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=text("clock_timestamp()"),
|
||||||
|
onupdate=text("clock_timestamp()"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin(CreatedAtMixin, UpdatedAtMixin):
|
||||||
|
"""Mixin that combines ``created_at`` and ``updated_at`` timestamp columns."""
|
||||||
290
src/fastapi_toolsets/models/watched.py
Normal file
290
src/fastapi_toolsets/models/watched.py
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
"""Field-change monitoring via SQLAlchemy session events."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from collections.abc import Callable
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import event
|
||||||
|
from sqlalchemy import inspect as sa_inspect
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm.attributes import set_committed_value as _sa_set_committed_value
|
||||||
|
|
||||||
|
from ..logger import get_logger
|
||||||
|
|
||||||
|
_logger = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class ModelEvent(str, Enum):
|
||||||
|
"""Event types dispatched by :class:`EventSession`."""
|
||||||
|
|
||||||
|
CREATE = "create"
|
||||||
|
DELETE = "delete"
|
||||||
|
UPDATE = "update"
|
||||||
|
|
||||||
|
|
||||||
|
_CALLBACK_ERROR_MSG = "Event callback raised an unhandled exception"
|
||||||
|
_SESSION_CREATES = "_ft_creates"
|
||||||
|
_SESSION_DELETES = "_ft_deletes"
|
||||||
|
_SESSION_UPDATES = "_ft_updates"
|
||||||
|
_DEFERRED_STRATEGY_KEY = (("deferred", True), ("instrument", True))
|
||||||
|
_EVENT_HANDLERS: dict[tuple[type, ModelEvent], list[Callable[..., Any]]] = {}
|
||||||
|
_WATCHED_MODELS: set[type] = set()
|
||||||
|
_WATCHED_CACHE: dict[type, bool] = {}
|
||||||
|
_HANDLER_CACHE: dict[tuple[type, ModelEvent], list[Callable[..., Any]]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _invalidate_caches() -> None:
|
||||||
|
"""Clear lookup caches after handler registration."""
|
||||||
|
_WATCHED_CACHE.clear()
|
||||||
|
_HANDLER_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def listens_for(
|
||||||
|
model_class: type,
|
||||||
|
event_types: list[ModelEvent] | None = None,
|
||||||
|
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||||
|
"""Register a callback for one or more model lifecycle events.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_class: The SQLAlchemy model class to listen on.
|
||||||
|
event_types: List of :class:`ModelEvent` values to listen for.
|
||||||
|
Defaults to all event types.
|
||||||
|
"""
|
||||||
|
evs = event_types if event_types is not None else list(ModelEvent)
|
||||||
|
|
||||||
|
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
|
for ev in evs:
|
||||||
|
_EVENT_HANDLERS.setdefault((model_class, ev), []).append(fn)
|
||||||
|
_WATCHED_MODELS.add(model_class)
|
||||||
|
_invalidate_caches()
|
||||||
|
return fn
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def _is_watched(obj: Any) -> bool:
|
||||||
|
"""Return True if *obj*'s type (or any ancestor) has registered handlers."""
|
||||||
|
cls = type(obj)
|
||||||
|
try:
|
||||||
|
return _WATCHED_CACHE[cls]
|
||||||
|
except KeyError:
|
||||||
|
result = any(klass in _WATCHED_MODELS for klass in cls.__mro__)
|
||||||
|
_WATCHED_CACHE[cls] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _get_handlers(cls: type, ev: ModelEvent) -> list[Callable[..., Any]]:
|
||||||
|
"""Return registered handlers for *cls* and *ev*, walking the MRO."""
|
||||||
|
key = (cls, ev)
|
||||||
|
try:
|
||||||
|
return _HANDLER_CACHE[key]
|
||||||
|
except KeyError:
|
||||||
|
handlers: list[Callable[..., Any]] = []
|
||||||
|
for klass in cls.__mro__:
|
||||||
|
handlers.extend(_EVENT_HANDLERS.get((klass, ev), []))
|
||||||
|
_HANDLER_CACHE[key] = handlers
|
||||||
|
return handlers
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_column_attrs(obj: Any) -> dict[str, Any]:
|
||||||
|
"""Read currently-loaded column values into a plain dict."""
|
||||||
|
state = sa_inspect(obj) # InstanceState
|
||||||
|
state_dict = state.dict
|
||||||
|
snapshot: dict[str, Any] = {}
|
||||||
|
for prop in state.mapper.column_attrs:
|
||||||
|
if prop.key in state_dict:
|
||||||
|
snapshot[prop.key] = state_dict[prop.key]
|
||||||
|
elif ( # pragma: no cover
|
||||||
|
not state.expired
|
||||||
|
and prop.strategy_key != _DEFERRED_STRATEGY_KEY
|
||||||
|
and all(
|
||||||
|
col.nullable
|
||||||
|
and col.server_default is None
|
||||||
|
and col.server_onupdate is None
|
||||||
|
for col in prop.columns
|
||||||
|
)
|
||||||
|
):
|
||||||
|
snapshot[prop.key] = None
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def _get_watched_fields(cls: type) -> tuple[str, ...] | None:
|
||||||
|
"""Return the watched fields for *cls*."""
|
||||||
|
fields = getattr(cls, "__watched_fields__", None)
|
||||||
|
if fields is not None and (
|
||||||
|
not isinstance(fields, tuple) or not all(isinstance(f, str) for f in fields)
|
||||||
|
):
|
||||||
|
raise TypeError(
|
||||||
|
f"{cls.__name__}.__watched_fields__ must be a tuple[str, ...], "
|
||||||
|
f"got {type(fields).__name__}"
|
||||||
|
)
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_changes(
|
||||||
|
pending: dict[int, tuple[Any, dict[str, dict[str, Any]]]],
|
||||||
|
obj: Any,
|
||||||
|
changes: dict[str, dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
"""Insert or merge *changes* into *pending* for *obj*."""
|
||||||
|
key = id(obj)
|
||||||
|
if key in pending:
|
||||||
|
existing = pending[key][1]
|
||||||
|
for field, change in changes.items():
|
||||||
|
if field in existing:
|
||||||
|
existing[field]["new"] = change["new"]
|
||||||
|
else:
|
||||||
|
existing[field] = change
|
||||||
|
else:
|
||||||
|
pending[key] = (obj, changes)
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(AsyncSession.sync_session_class, "after_flush")
|
||||||
|
def _after_flush(session: Any, flush_context: Any) -> None:
|
||||||
|
# New objects: capture reference. Attributes will be refreshed after commit.
|
||||||
|
for obj in session.new:
|
||||||
|
if _is_watched(obj):
|
||||||
|
session.info.setdefault(_SESSION_CREATES, []).append(obj)
|
||||||
|
|
||||||
|
# Deleted objects: snapshot now while attributes are still loaded.
|
||||||
|
for obj in session.deleted:
|
||||||
|
if _is_watched(obj):
|
||||||
|
snapshot = _snapshot_column_attrs(obj)
|
||||||
|
session.info.setdefault(_SESSION_DELETES, []).append((obj, snapshot))
|
||||||
|
|
||||||
|
# Dirty objects: read old/new from SQLAlchemy attribute history.
|
||||||
|
for obj in session.dirty:
|
||||||
|
if not _is_watched(obj):
|
||||||
|
continue
|
||||||
|
|
||||||
|
watched = _get_watched_fields(type(obj))
|
||||||
|
changes: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
inst_attrs = sa_inspect(obj).attrs
|
||||||
|
attrs = (
|
||||||
|
((field, inst_attrs[field]) for field in watched)
|
||||||
|
if watched is not None
|
||||||
|
else ((s.key, s) for s in inst_attrs)
|
||||||
|
)
|
||||||
|
for field, attr_state in attrs:
|
||||||
|
history = attr_state.history
|
||||||
|
if history.has_changes() and history.deleted:
|
||||||
|
changes[field] = {
|
||||||
|
"old": history.deleted[0],
|
||||||
|
"new": history.added[0] if history.added else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if changes:
|
||||||
|
_upsert_changes(
|
||||||
|
session.info.setdefault(_SESSION_UPDATES, {}),
|
||||||
|
obj,
|
||||||
|
changes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(AsyncSession.sync_session_class, "after_rollback")
|
||||||
|
def _after_rollback(session: Any) -> None:
|
||||||
|
if session.in_transaction():
|
||||||
|
return
|
||||||
|
session.info.pop(_SESSION_CREATES, None)
|
||||||
|
session.info.pop(_SESSION_DELETES, None)
|
||||||
|
session.info.pop(_SESSION_UPDATES, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _invoke_callback(
|
||||||
|
fn: Callable[..., Any],
|
||||||
|
obj: Any,
|
||||||
|
event_type: ModelEvent,
|
||||||
|
changes: dict[str, dict[str, Any]] | None,
|
||||||
|
) -> None:
|
||||||
|
"""Call *fn* and await the result if it is awaitable."""
|
||||||
|
result = fn(obj, event_type, changes)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
await result
|
||||||
|
|
||||||
|
|
||||||
|
class EventSession(AsyncSession):
|
||||||
|
"""AsyncSession subclass that dispatches lifecycle callbacks after commit."""
|
||||||
|
|
||||||
|
async def commit(self) -> None: # noqa: C901
|
||||||
|
await super().commit()
|
||||||
|
|
||||||
|
creates: list[Any] = self.info.pop(_SESSION_CREATES, [])
|
||||||
|
deletes: list[tuple[Any, dict[str, Any]]] = self.info.pop(_SESSION_DELETES, [])
|
||||||
|
field_changes: dict[int, tuple[Any, dict[str, dict[str, Any]]]] = self.info.pop(
|
||||||
|
_SESSION_UPDATES, {}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not creates and not deletes and not field_changes:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Suppress transient objects (created + deleted in same transaction).
|
||||||
|
if creates and deletes:
|
||||||
|
created_ids = {id(o) for o in creates}
|
||||||
|
deleted_ids = {id(o) for o, _ in deletes}
|
||||||
|
transient_ids = created_ids & deleted_ids
|
||||||
|
if transient_ids:
|
||||||
|
creates = [o for o in creates if id(o) not in transient_ids]
|
||||||
|
deletes = [(o, s) for o, s in deletes if id(o) not in transient_ids]
|
||||||
|
field_changes = {
|
||||||
|
k: v for k, v in field_changes.items() if k not in transient_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
# Suppress updates for deleted objects (row is gone, refresh would fail).
|
||||||
|
if deletes and field_changes:
|
||||||
|
deleted_ids = {id(o) for o, _ in deletes}
|
||||||
|
field_changes = {
|
||||||
|
k: v for k, v in field_changes.items() if k not in deleted_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
# Suppress updates for newly created objects (CREATE-only semantics).
|
||||||
|
if creates and field_changes:
|
||||||
|
create_ids = {id(o) for o in creates}
|
||||||
|
field_changes = {
|
||||||
|
k: v for k, v in field_changes.items() if k not in create_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dispatch CREATE callbacks.
|
||||||
|
for obj in creates:
|
||||||
|
try:
|
||||||
|
state = sa_inspect(obj, raiseerr=False)
|
||||||
|
if (
|
||||||
|
state is None or state.detached or state.transient
|
||||||
|
): # pragma: no cover
|
||||||
|
continue
|
||||||
|
await self.refresh(obj)
|
||||||
|
for handler in _get_handlers(type(obj), ModelEvent.CREATE):
|
||||||
|
await _invoke_callback(handler, obj, ModelEvent.CREATE, None)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
||||||
|
|
||||||
|
# Dispatch DELETE callbacks (restore snapshot; row is gone).
|
||||||
|
for obj, snapshot in deletes:
|
||||||
|
try:
|
||||||
|
for key, value in snapshot.items():
|
||||||
|
_sa_set_committed_value(obj, key, value)
|
||||||
|
for handler in _get_handlers(type(obj), ModelEvent.DELETE):
|
||||||
|
await _invoke_callback(handler, obj, ModelEvent.DELETE, None)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
||||||
|
|
||||||
|
# Dispatch UPDATE callbacks.
|
||||||
|
for obj, changes in field_changes.values():
|
||||||
|
try:
|
||||||
|
state = sa_inspect(obj, raiseerr=False)
|
||||||
|
if (
|
||||||
|
state is None or state.detached or state.transient
|
||||||
|
): # pragma: no cover
|
||||||
|
continue
|
||||||
|
await self.refresh(obj)
|
||||||
|
for handler in _get_handlers(type(obj), ModelEvent.UPDATE):
|
||||||
|
await _invoke_callback(handler, obj, ModelEvent.UPDATE, changes)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
||||||
|
|
||||||
|
async def rollback(self) -> None:
|
||||||
|
await super().rollback()
|
||||||
|
self.info.pop(_SESSION_CREATES, None)
|
||||||
|
self.info.pop(_SESSION_DELETES, None)
|
||||||
|
self.info.pop(_SESSION_UPDATES, None)
|
||||||
@@ -15,7 +15,110 @@ from sqlalchemy.ext.asyncio import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from ..db import create_db_context
|
from ..db import cleanup_tables, create_database
|
||||||
|
from ..models.watched import EventSession
|
||||||
|
|
||||||
|
|
||||||
|
def _get_xdist_worker(default_test_db: str) -> str:
|
||||||
|
"""Return the pytest-xdist worker name, or *default_test_db* when not running under xdist.
|
||||||
|
|
||||||
|
Reads the ``PYTEST_XDIST_WORKER`` environment variable that xdist sets
|
||||||
|
automatically in each worker process (e.g. ``"gw0"``, ``"gw1"``).
|
||||||
|
When xdist is not installed or not active, the variable is absent and
|
||||||
|
*default_test_db* is returned instead.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
default_test_db: Fallback value returned when ``PYTEST_XDIST_WORKER``
|
||||||
|
is not set.
|
||||||
|
"""
|
||||||
|
return os.environ.get("PYTEST_XDIST_WORKER", default_test_db)
|
||||||
|
|
||||||
|
|
||||||
|
def worker_database_url(database_url: str, default_test_db: str) -> str:
|
||||||
|
"""Derive a per-worker database URL for pytest-xdist parallel runs.
|
||||||
|
|
||||||
|
Appends ``_{worker_name}`` to the database name so each xdist worker
|
||||||
|
operates on its own database. When not running under xdist,
|
||||||
|
``_{default_test_db}`` is appended instead.
|
||||||
|
|
||||||
|
The worker name is read from the ``PYTEST_XDIST_WORKER`` environment
|
||||||
|
variable (set automatically by xdist in each worker process).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database_url: Original database connection URL.
|
||||||
|
default_test_db: Suffix appended to the database name when
|
||||||
|
``PYTEST_XDIST_WORKER`` is not set.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A database URL with a worker- or default-specific database name.
|
||||||
|
"""
|
||||||
|
worker = _get_xdist_worker(default_test_db=default_test_db)
|
||||||
|
|
||||||
|
url = make_url(database_url)
|
||||||
|
url = url.set(database=f"{url.database}_{worker}")
|
||||||
|
return url.render_as_string(hide_password=False)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def create_worker_database(
|
||||||
|
database_url: str,
|
||||||
|
default_test_db: str = "test_db",
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""Create and drop a per-worker database for pytest-xdist isolation.
|
||||||
|
|
||||||
|
Derives a worker-specific database URL using :func:`worker_database_url`,
|
||||||
|
then delegates to :func:`~fastapi_toolsets.db.create_database` to create
|
||||||
|
and drop it. Intended for use as a **session-scoped** fixture.
|
||||||
|
|
||||||
|
When running under xdist the database name is suffixed with the worker
|
||||||
|
name (e.g. ``_gw0``). Otherwise it is suffixed with *default_test_db*.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database_url: Original database connection URL (used as the server
|
||||||
|
connection and as the base for the worker database name).
|
||||||
|
default_test_db: Suffix appended to the database name when
|
||||||
|
``PYTEST_XDIST_WORKER`` is not set. Defaults to ``"test_db"``.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
The worker-specific database URL.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.pytest import create_worker_database, create_db_session
|
||||||
|
|
||||||
|
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost/test_db"
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def worker_db_url():
|
||||||
|
async with create_worker_database(DATABASE_URL) as url:
|
||||||
|
yield url
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_session(worker_db_url):
|
||||||
|
async with create_db_session(
|
||||||
|
worker_db_url, Base, cleanup=True
|
||||||
|
) as session:
|
||||||
|
yield session
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
worker_url = worker_database_url(
|
||||||
|
database_url=database_url, default_test_db=default_test_db
|
||||||
|
)
|
||||||
|
worker_db_name = make_url(worker_url).database
|
||||||
|
assert worker_db_name is not None
|
||||||
|
|
||||||
|
engine = create_async_engine(database_url, isolation_level="AUTOCOMMIT")
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text(f"DROP DATABASE IF EXISTS {worker_db_name}"))
|
||||||
|
await create_database(db_name=worker_db_name, server_url=database_url)
|
||||||
|
|
||||||
|
yield worker_url
|
||||||
|
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text(f"DROP DATABASE IF EXISTS {worker_db_name}"))
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -141,175 +244,17 @@ async def create_db_session(
|
|||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(base.metadata.create_all)
|
await conn.run_sync(base.metadata.create_all)
|
||||||
|
|
||||||
# Create session using existing db context utility
|
session_maker = async_sessionmaker(
|
||||||
session_maker = async_sessionmaker(engine, expire_on_commit=expire_on_commit)
|
engine, expire_on_commit=expire_on_commit, class_=EventSession
|
||||||
get_session = create_db_context(session_maker)
|
)
|
||||||
|
async with session_maker() as session:
|
||||||
async with get_session() as session:
|
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
if cleanup:
|
if cleanup:
|
||||||
await cleanup_tables(session, base)
|
await cleanup_tables(session=session, base=base)
|
||||||
|
|
||||||
if drop_tables:
|
if drop_tables:
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(base.metadata.drop_all)
|
await conn.run_sync(base.metadata.drop_all)
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
def _get_xdist_worker(default_test_db: str) -> str:
|
|
||||||
"""Return the pytest-xdist worker name, or *default_test_db* when not running under xdist.
|
|
||||||
|
|
||||||
Reads the ``PYTEST_XDIST_WORKER`` environment variable that xdist sets
|
|
||||||
automatically in each worker process (e.g. ``"gw0"``, ``"gw1"``).
|
|
||||||
When xdist is not installed or not active, the variable is absent and
|
|
||||||
*default_test_db* is returned instead.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
default_test_db: Fallback value returned when ``PYTEST_XDIST_WORKER``
|
|
||||||
is not set.
|
|
||||||
"""
|
|
||||||
return os.environ.get("PYTEST_XDIST_WORKER", default_test_db)
|
|
||||||
|
|
||||||
|
|
||||||
def worker_database_url(database_url: str, default_test_db: str) -> str:
|
|
||||||
"""Derive a per-worker database URL for pytest-xdist parallel runs.
|
|
||||||
|
|
||||||
Appends ``_{worker_name}`` to the database name so each xdist worker
|
|
||||||
operates on its own database. When not running under xdist,
|
|
||||||
``_{default_test_db}`` is appended instead.
|
|
||||||
|
|
||||||
The worker name is read from the ``PYTEST_XDIST_WORKER`` environment
|
|
||||||
variable (set automatically by xdist in each worker process).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
database_url: Original database connection URL.
|
|
||||||
default_test_db: Suffix appended to the database name when
|
|
||||||
``PYTEST_XDIST_WORKER`` is not set.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A database URL with a worker- or default-specific database name.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
# With PYTEST_XDIST_WORKER="gw0":
|
|
||||||
url = worker_database_url(
|
|
||||||
"postgresql+asyncpg://user:pass@localhost/test_db",
|
|
||||||
default_test_db="test",
|
|
||||||
)
|
|
||||||
# "postgresql+asyncpg://user:pass@localhost/test_db_gw0"
|
|
||||||
|
|
||||||
# Without PYTEST_XDIST_WORKER:
|
|
||||||
url = worker_database_url(
|
|
||||||
"postgresql+asyncpg://user:pass@localhost/test_db",
|
|
||||||
default_test_db="test",
|
|
||||||
)
|
|
||||||
# "postgresql+asyncpg://user:pass@localhost/test_db_test"
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
worker = _get_xdist_worker(default_test_db=default_test_db)
|
|
||||||
|
|
||||||
url = make_url(database_url)
|
|
||||||
url = url.set(database=f"{url.database}_{worker}")
|
|
||||||
return url.render_as_string(hide_password=False)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def create_worker_database(
|
|
||||||
database_url: str,
|
|
||||||
default_test_db: str = "test_db",
|
|
||||||
) -> AsyncGenerator[str, None]:
|
|
||||||
"""Create and drop a per-worker database for pytest-xdist isolation.
|
|
||||||
|
|
||||||
Intended for use as a **session-scoped** fixture. Connects to the server
|
|
||||||
using the original *database_url* (with ``AUTOCOMMIT`` isolation for DDL),
|
|
||||||
creates a dedicated database for the worker, and yields the worker-specific
|
|
||||||
URL. On cleanup the worker database is dropped.
|
|
||||||
|
|
||||||
When running under xdist the database name is suffixed with the worker
|
|
||||||
name (e.g. ``_gw0``). Otherwise it is suffixed with *default_test_db*.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
database_url: Original database connection URL.
|
|
||||||
default_test_db: Suffix appended to the database name when
|
|
||||||
``PYTEST_XDIST_WORKER`` is not set. Defaults to ``"test_db"``.
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
The worker-specific database URL.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.pytest import (
|
|
||||||
create_worker_database, create_db_session,
|
|
||||||
)
|
|
||||||
|
|
||||||
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost/test_db"
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
async def worker_db_url():
|
|
||||||
async with create_worker_database(DATABASE_URL) as url:
|
|
||||||
yield url
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def db_session(worker_db_url):
|
|
||||||
async with create_db_session(
|
|
||||||
worker_db_url, Base, cleanup=True
|
|
||||||
) as session:
|
|
||||||
yield session
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
worker_url = worker_database_url(
|
|
||||||
database_url=database_url, default_test_db=default_test_db
|
|
||||||
)
|
|
||||||
worker_db_name = make_url(worker_url).database
|
|
||||||
|
|
||||||
engine = create_async_engine(
|
|
||||||
database_url,
|
|
||||||
isolation_level="AUTOCOMMIT",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
await conn.execute(text(f"DROP DATABASE IF EXISTS {worker_db_name}"))
|
|
||||||
await conn.execute(text(f"CREATE DATABASE {worker_db_name}"))
|
|
||||||
|
|
||||||
yield worker_url
|
|
||||||
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
await conn.execute(text(f"DROP DATABASE IF EXISTS {worker_db_name}"))
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_tables(
|
|
||||||
session: AsyncSession,
|
|
||||||
base: type[DeclarativeBase],
|
|
||||||
) -> None:
|
|
||||||
"""Truncate all tables for fast between-test cleanup.
|
|
||||||
|
|
||||||
Executes a single ``TRUNCATE … RESTART IDENTITY CASCADE`` statement
|
|
||||||
across every table in *base*'s metadata, which is significantly faster
|
|
||||||
than dropping and re-creating tables between tests.
|
|
||||||
|
|
||||||
This is a no-op when the metadata contains no tables.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: An active async database session.
|
|
||||||
base: SQLAlchemy DeclarativeBase class containing model metadata.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
@pytest.fixture
|
|
||||||
async def db_session(worker_db_url):
|
|
||||||
async with create_db_session(worker_db_url, Base) as session:
|
|
||||||
yield session
|
|
||||||
await cleanup_tables(session, Base)
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
tables = base.metadata.sorted_tables
|
|
||||||
if not tables:
|
|
||||||
return
|
|
||||||
|
|
||||||
table_names = ", ".join(f'"{t.name}"' for t in tables)
|
|
||||||
await session.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE"))
|
|
||||||
await session.commit()
|
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
"""Base Pydantic schemas for API responses."""
|
"""Base Pydantic schemas for API responses."""
|
||||||
|
|
||||||
|
import math
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, ClassVar, Generic, TypeVar
|
from typing import Annotated, Any, ClassVar, Generic, Literal, TypeVar, Union
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
||||||
|
|
||||||
|
from .types import DataT
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ApiError",
|
"ApiError",
|
||||||
"CursorPagination",
|
"CursorPagination",
|
||||||
|
"CursorPaginatedResponse",
|
||||||
"ErrorResponse",
|
"ErrorResponse",
|
||||||
"OffsetPagination",
|
"OffsetPagination",
|
||||||
"Pagination",
|
"OffsetPaginatedResponse",
|
||||||
"PaginatedResponse",
|
"PaginatedResponse",
|
||||||
|
"PaginationType",
|
||||||
"PydanticBase",
|
"PydanticBase",
|
||||||
"Response",
|
"Response",
|
||||||
"ResponseStatus",
|
"ResponseStatus",
|
||||||
]
|
]
|
||||||
|
|
||||||
DataT = TypeVar("DataT")
|
|
||||||
|
|
||||||
|
|
||||||
class PydanticBase(BaseModel):
|
class PydanticBase(BaseModel):
|
||||||
"""Base class for all Pydantic models with common configuration."""
|
"""Base class for all Pydantic models with common configuration."""
|
||||||
@@ -96,20 +99,28 @@ class OffsetPagination(PydanticBase):
|
|||||||
"""Pagination metadata for offset-based list responses.
|
"""Pagination metadata for offset-based list responses.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
total_count: Total number of items across all pages
|
total_count: Total number of items across all pages.
|
||||||
|
``None`` when ``include_total=False``.
|
||||||
items_per_page: Number of items per page
|
items_per_page: Number of items per page
|
||||||
page: Current page number (1-indexed)
|
page: Current page number (1-indexed)
|
||||||
has_more: Whether there are more pages
|
has_more: Whether there are more pages
|
||||||
|
pages: Total number of pages
|
||||||
"""
|
"""
|
||||||
|
|
||||||
total_count: int
|
total_count: int | None
|
||||||
items_per_page: int
|
items_per_page: int
|
||||||
page: int
|
page: int
|
||||||
has_more: bool
|
has_more: bool
|
||||||
|
|
||||||
|
@computed_field
|
||||||
# Backward-compatible - will be removed in v2.0
|
@property
|
||||||
Pagination = OffsetPagination
|
def pages(self) -> int | None:
|
||||||
|
"""Total number of pages, or ``None`` when ``total_count`` is unknown."""
|
||||||
|
if self.total_count is None:
|
||||||
|
return None
|
||||||
|
if self.items_per_page == 0:
|
||||||
|
return 0
|
||||||
|
return math.ceil(self.total_count / self.items_per_page)
|
||||||
|
|
||||||
|
|
||||||
class CursorPagination(PydanticBase):
|
class CursorPagination(PydanticBase):
|
||||||
@@ -128,9 +139,67 @@ class CursorPagination(PydanticBase):
|
|||||||
has_more: bool
|
has_more: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationType(str, Enum):
|
||||||
|
"""Pagination strategy selector for :meth:`.AsyncCrud.paginate`."""
|
||||||
|
|
||||||
|
OFFSET = "offset"
|
||||||
|
CURSOR = "cursor"
|
||||||
|
|
||||||
|
|
||||||
class PaginatedResponse(BaseResponse, Generic[DataT]):
|
class PaginatedResponse(BaseResponse, Generic[DataT]):
|
||||||
"""Paginated API response for list endpoints."""
|
"""Paginated API response for list endpoints.
|
||||||
|
|
||||||
|
Base class and return type for endpoints that support both pagination
|
||||||
|
strategies. Use :class:`OffsetPaginatedResponse` or
|
||||||
|
:class:`CursorPaginatedResponse` when the strategy is fixed.
|
||||||
|
|
||||||
|
When used as ``PaginatedResponse[T]`` in a return annotation, subscripting
|
||||||
|
returns ``Annotated[Union[CursorPaginatedResponse[T], OffsetPaginatedResponse[T]], Field(discriminator="pagination_type")]``
|
||||||
|
so FastAPI emits a proper ``oneOf`` + discriminator in the OpenAPI schema.
|
||||||
|
"""
|
||||||
|
|
||||||
data: list[DataT]
|
data: list[DataT]
|
||||||
pagination: OffsetPagination | CursorPagination
|
pagination: OffsetPagination | CursorPagination
|
||||||
|
pagination_type: PaginationType | None = None
|
||||||
filter_attributes: dict[str, list[Any]] | None = None
|
filter_attributes: dict[str, list[Any]] | None = None
|
||||||
|
search_columns: list[str] | None = None
|
||||||
|
|
||||||
|
_discriminated_union_cache: ClassVar[dict[Any, Any]] = {}
|
||||||
|
|
||||||
|
def __class_getitem__( # ty:ignore[invalid-method-override]
|
||||||
|
cls, item: type[Any] | tuple[type[Any], ...]
|
||||||
|
) -> type[Any]:
|
||||||
|
if cls is PaginatedResponse and not isinstance(item, TypeVar):
|
||||||
|
cached = cls._discriminated_union_cache.get(item)
|
||||||
|
if cached is None:
|
||||||
|
cached = Annotated[
|
||||||
|
Union[CursorPaginatedResponse[item], OffsetPaginatedResponse[item]], # ty:ignore[invalid-type-form]
|
||||||
|
Field(discriminator="pagination_type"),
|
||||||
|
]
|
||||||
|
cls._discriminated_union_cache[item] = cached
|
||||||
|
return cached # ty:ignore[invalid-return-type]
|
||||||
|
return super().__class_getitem__(item)
|
||||||
|
|
||||||
|
|
||||||
|
class OffsetPaginatedResponse(PaginatedResponse[DataT]):
|
||||||
|
"""Paginated response with typed offset-based pagination metadata.
|
||||||
|
|
||||||
|
The ``pagination_type`` field is always ``"offset"`` and acts as a
|
||||||
|
discriminator, allowing frontend clients to narrow the union type returned
|
||||||
|
by a unified ``paginate()`` endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pagination: OffsetPagination
|
||||||
|
pagination_type: Literal[PaginationType.OFFSET] = PaginationType.OFFSET
|
||||||
|
|
||||||
|
|
||||||
|
class CursorPaginatedResponse(PaginatedResponse[DataT]):
|
||||||
|
"""Paginated response with typed cursor-based pagination metadata.
|
||||||
|
|
||||||
|
The ``pagination_type`` field is always ``"cursor"`` and acts as a
|
||||||
|
discriminator, allowing frontend clients to narrow the union type returned
|
||||||
|
by a unified ``paginate()`` endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pagination: CursorPagination
|
||||||
|
pagination_type: Literal[PaginationType.CURSOR] = PaginationType.CURSOR
|
||||||
|
|||||||
24
src/fastapi_toolsets/security/__init__.py
Normal file
24
src/fastapi_toolsets/security/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""Authentication helpers for FastAPI using Security()."""
|
||||||
|
|
||||||
|
from .abc import AuthSource
|
||||||
|
from .oauth import (
|
||||||
|
oauth_build_authorization_redirect,
|
||||||
|
oauth_decode_state,
|
||||||
|
oauth_encode_state,
|
||||||
|
oauth_fetch_userinfo,
|
||||||
|
oauth_resolve_provider_urls,
|
||||||
|
)
|
||||||
|
from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"APIKeyHeaderAuth",
|
||||||
|
"AuthSource",
|
||||||
|
"BearerTokenAuth",
|
||||||
|
"CookieAuth",
|
||||||
|
"MultiAuth",
|
||||||
|
"oauth_build_authorization_redirect",
|
||||||
|
"oauth_decode_state",
|
||||||
|
"oauth_encode_state",
|
||||||
|
"oauth_fetch_userinfo",
|
||||||
|
"oauth_resolve_provider_urls",
|
||||||
|
]
|
||||||
53
src/fastapi_toolsets/security/abc.py
Normal file
53
src/fastapi_toolsets/security/abc.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Abstract base class for authentication sources."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.security import SecurityScopes
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
|
"""Wrap *fn* so it can always be awaited, caching the coroutine check at init time."""
|
||||||
|
if inspect.iscoroutinefunction(fn):
|
||||||
|
return fn
|
||||||
|
|
||||||
|
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSource(ABC):
|
||||||
|
"""Abstract base class for authentication sources."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Set up the default FastAPI dependency signature."""
|
||||||
|
source = self
|
||||||
|
|
||||||
|
async def _call(
|
||||||
|
request: Request,
|
||||||
|
security_scopes: SecurityScopes, # noqa: ARG001
|
||||||
|
) -> Any:
|
||||||
|
credential = await source.extract(request)
|
||||||
|
if credential is None:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return await source.authenticate(credential)
|
||||||
|
|
||||||
|
self._call_fn: Callable[..., Any] = _call
|
||||||
|
self.__signature__ = inspect.signature(_call)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def extract(self, request: Request) -> str | None:
|
||||||
|
"""Extract the raw credential from the request without validating."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def authenticate(self, credential: str) -> Any:
|
||||||
|
"""Validate a credential and return the authenticated identity."""
|
||||||
|
|
||||||
|
async def __call__(self, **kwargs: Any) -> Any:
|
||||||
|
"""FastAPI dependency dispatch."""
|
||||||
|
return await self._call_fn(**kwargs)
|
||||||
140
src/fastapi_toolsets/security/oauth.py
Normal file
140
src/fastapi_toolsets/security/oauth.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""OAuth 2.0 / OIDC helper utilities."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
_discovery_cache: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def oauth_resolve_provider_urls(
|
||||||
|
discovery_url: str,
|
||||||
|
) -> tuple[str, str, str | None]:
|
||||||
|
"""Fetch the OIDC discovery document and return endpoint URLs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
discovery_url: URL of the provider's ``/.well-known/openid-configuration``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ``(authorization_url, token_url, userinfo_url)`` tuple.
|
||||||
|
*userinfo_url* is ``None`` when the provider does not advertise one.
|
||||||
|
"""
|
||||||
|
if discovery_url not in _discovery_cache:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(discovery_url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
_discovery_cache[discovery_url] = resp.json()
|
||||||
|
cfg = _discovery_cache[discovery_url]
|
||||||
|
return (
|
||||||
|
cfg["authorization_endpoint"],
|
||||||
|
cfg["token_endpoint"],
|
||||||
|
cfg.get("userinfo_endpoint"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def oauth_fetch_userinfo(
|
||||||
|
*,
|
||||||
|
token_url: str,
|
||||||
|
userinfo_url: str,
|
||||||
|
code: str,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
redirect_uri: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""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:
|
||||||
|
token_url: Provider's token endpoint.
|
||||||
|
userinfo_url: Provider's userinfo endpoint.
|
||||||
|
code: Authorization code received from the provider's callback.
|
||||||
|
client_id: OAuth application client ID.
|
||||||
|
client_secret: OAuth application client secret.
|
||||||
|
redirect_uri: Redirect URI that was used in the authorization request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The JSON payload returned by the userinfo endpoint as a plain ``dict``.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
token_resp = await client.post(
|
||||||
|
token_url,
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
token_resp.raise_for_status()
|
||||||
|
access_token = token_resp.json()["access_token"]
|
||||||
|
|
||||||
|
userinfo_resp = await client.get(
|
||||||
|
userinfo_url,
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
userinfo_resp.raise_for_status()
|
||||||
|
return userinfo_resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def oauth_build_authorization_redirect(
|
||||||
|
authorization_url: str,
|
||||||
|
*,
|
||||||
|
client_id: str,
|
||||||
|
scopes: str,
|
||||||
|
redirect_uri: str,
|
||||||
|
destination: str,
|
||||||
|
) -> RedirectResponse:
|
||||||
|
"""Return an OAuth 2.0 authorization ``RedirectResponse``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
authorization_url: Provider's authorization endpoint.
|
||||||
|
client_id: OAuth application client ID.
|
||||||
|
scopes: Space-separated list of requested scopes.
|
||||||
|
redirect_uri: URI the provider should redirect back to after authorization.
|
||||||
|
destination: URL the user should be sent to after the full OAuth flow
|
||||||
|
completes (encoded as ``state``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A :class:`~fastapi.responses.RedirectResponse` to the provider's
|
||||||
|
authorization page.
|
||||||
|
"""
|
||||||
|
params = urlencode(
|
||||||
|
{
|
||||||
|
"client_id": client_id,
|
||||||
|
"response_type": "code",
|
||||||
|
"scope": scopes,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"state": oauth_encode_state(destination),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return RedirectResponse(f"{authorization_url}?{params}")
|
||||||
|
|
||||||
|
|
||||||
|
def oauth_encode_state(url: str) -> str:
|
||||||
|
"""Base64url-encode a URL to embed as an OAuth ``state`` parameter."""
|
||||||
|
return base64.urlsafe_b64encode(url.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def oauth_decode_state(state: str | None, *, fallback: str) -> str:
|
||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
if not state or state == "null":
|
||||||
|
return fallback
|
||||||
|
try:
|
||||||
|
padded = state + "=" * (4 - len(state) % 4)
|
||||||
|
return base64.urlsafe_b64decode(padded).decode()
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
8
src/fastapi_toolsets/security/sources/__init__.py
Normal file
8
src/fastapi_toolsets/security/sources/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""Built-in authentication source implementations."""
|
||||||
|
|
||||||
|
from .header import APIKeyHeaderAuth
|
||||||
|
from .bearer import BearerTokenAuth
|
||||||
|
from .cookie import CookieAuth
|
||||||
|
from .multi import MultiAuth
|
||||||
|
|
||||||
|
__all__ = ["APIKeyHeaderAuth", "BearerTokenAuth", "CookieAuth", "MultiAuth"]
|
||||||
120
src/fastapi_toolsets/security/sources/bearer.py
Normal file
120
src/fastapi_toolsets/security/sources/bearer.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""Bearer token authentication source."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import secrets
|
||||||
|
from typing import Annotated, Any, Callable
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
from ..abc import AuthSource, _ensure_async
|
||||||
|
|
||||||
|
|
||||||
|
class BearerTokenAuth(AuthSource):
|
||||||
|
"""Bearer token authentication source.
|
||||||
|
|
||||||
|
Wraps :class:`fastapi.security.HTTPBearer` for OpenAPI documentation.
|
||||||
|
The validator is called as ``await validator(credential, **kwargs)``
|
||||||
|
where ``kwargs`` are the extra keyword arguments provided at instantiation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
validator: Sync or async callable that receives the credential and any
|
||||||
|
extra keyword arguments, and returns the authenticated identity
|
||||||
|
(e.g. a ``User`` model). Should raise
|
||||||
|
:class:`~fastapi_toolsets.exceptions.UnauthorizedError` on failure.
|
||||||
|
prefix: Optional token prefix (e.g. ``"user_"``). If set, only tokens
|
||||||
|
whose value starts with this prefix are matched. The prefix is
|
||||||
|
**kept** in the value passed to the validator — store and compare
|
||||||
|
tokens with their prefix included. Use :meth:`generate_token` to
|
||||||
|
create correctly-prefixed tokens. This enables multiple
|
||||||
|
``BearerTokenAuth`` instances in the same app (e.g. ``"user_"``
|
||||||
|
for user tokens, ``"org_"`` for org tokens).
|
||||||
|
**kwargs: Extra keyword arguments forwarded to the validator on every
|
||||||
|
call (e.g. ``role=Role.ADMIN``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
validator: Callable[..., Any],
|
||||||
|
*,
|
||||||
|
prefix: str | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
self._validator = _ensure_async(validator)
|
||||||
|
self._prefix = prefix
|
||||||
|
self._kwargs = kwargs
|
||||||
|
self._scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
async def _call(
|
||||||
|
security_scopes: SecurityScopes, # noqa: ARG001
|
||||||
|
credentials: Annotated[
|
||||||
|
HTTPAuthorizationCredentials | None, Depends(self._scheme)
|
||||||
|
] = None,
|
||||||
|
) -> Any:
|
||||||
|
if credentials is None:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return await self._validate(credentials.credentials)
|
||||||
|
|
||||||
|
self._call_fn = _call
|
||||||
|
self.__signature__ = inspect.signature(_call)
|
||||||
|
|
||||||
|
async def _validate(self, token: str) -> Any:
|
||||||
|
"""Check prefix and call the validator."""
|
||||||
|
if self._prefix is not None and not token.startswith(self._prefix):
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return await self._validator(token, **self._kwargs)
|
||||||
|
|
||||||
|
async def extract(self, request: Any) -> str | None:
|
||||||
|
"""Extract the raw credential from the request without validating.
|
||||||
|
|
||||||
|
Returns ``None`` if no ``Authorization: Bearer`` header is present,
|
||||||
|
the token is empty, or the token does not match the configured prefix.
|
||||||
|
The prefix is included in the returned value.
|
||||||
|
"""
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if not auth.startswith("Bearer "):
|
||||||
|
return None
|
||||||
|
token = auth[7:]
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
if self._prefix is not None and not token.startswith(self._prefix):
|
||||||
|
return None
|
||||||
|
return token
|
||||||
|
|
||||||
|
async def authenticate(self, credential: str) -> Any:
|
||||||
|
"""Validate a credential and return the identity.
|
||||||
|
|
||||||
|
Calls ``await validator(credential, **kwargs)`` where ``kwargs`` are
|
||||||
|
the extra keyword arguments provided at instantiation.
|
||||||
|
"""
|
||||||
|
return await self._validate(credential)
|
||||||
|
|
||||||
|
def require(self, **kwargs: Any) -> "BearerTokenAuth":
|
||||||
|
"""Return a new instance with additional (or overriding) validator kwargs."""
|
||||||
|
return BearerTokenAuth(
|
||||||
|
self._validator,
|
||||||
|
prefix=self._prefix,
|
||||||
|
**{**self._kwargs, **kwargs},
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate_token(self, nbytes: int = 32) -> str:
|
||||||
|
"""Generate a secure random token for this auth source.
|
||||||
|
|
||||||
|
Returns a URL-safe random token. If a prefix is configured it is
|
||||||
|
prepended — the returned value is what you store in your database
|
||||||
|
and return to the client as-is.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nbytes: Number of random bytes before base64 encoding. The
|
||||||
|
resulting string is ``ceil(nbytes * 4 / 3)`` characters
|
||||||
|
(43 chars for the default 32 bytes). Defaults to 32.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ready-to-use token string (e.g. ``"user_Xk3..."``).
|
||||||
|
"""
|
||||||
|
token = secrets.token_urlsafe(nbytes)
|
||||||
|
if self._prefix is not None:
|
||||||
|
return f"{self._prefix}{token}"
|
||||||
|
return token
|
||||||
139
src/fastapi_toolsets/security/sources/cookie.py
Normal file
139
src/fastapi_toolsets/security/sources/cookie.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""Cookie-based authentication source."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Annotated, Any, Callable
|
||||||
|
|
||||||
|
from fastapi import Depends, Request, Response
|
||||||
|
from fastapi.security import APIKeyCookie, SecurityScopes
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
from ..abc import AuthSource, _ensure_async
|
||||||
|
|
||||||
|
|
||||||
|
class CookieAuth(AuthSource):
|
||||||
|
"""Cookie-based authentication source.
|
||||||
|
|
||||||
|
Wraps :class:`fastapi.security.APIKeyCookie` for OpenAPI documentation.
|
||||||
|
Optionally signs the cookie with HMAC-SHA256 to provide stateless, tamper-
|
||||||
|
proof sessions without any database entry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Cookie name.
|
||||||
|
validator: Sync or async callable that receives the cookie value
|
||||||
|
(plain, after signature verification when ``secret_key`` is set)
|
||||||
|
and any extra keyword arguments, and returns the authenticated
|
||||||
|
identity.
|
||||||
|
secret_key: When provided, the cookie is HMAC-SHA256 signed.
|
||||||
|
:meth:`set_cookie` embeds an expiry and signs the payload;
|
||||||
|
:meth:`extract` verifies the signature and expiry before handing
|
||||||
|
the plain value to the validator. When ``None`` (default), the raw
|
||||||
|
cookie value is passed to the validator as-is.
|
||||||
|
ttl: Cookie lifetime in seconds (default 24 h). Only used when
|
||||||
|
``secret_key`` is set.
|
||||||
|
**kwargs: Extra keyword arguments forwarded to the validator on every
|
||||||
|
call (e.g. ``role=Role.ADMIN``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
validator: Callable[..., Any],
|
||||||
|
*,
|
||||||
|
secret_key: str | None = None,
|
||||||
|
ttl: int = 86400,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
self._name = name
|
||||||
|
self._validator = _ensure_async(validator)
|
||||||
|
self._secret_key = secret_key
|
||||||
|
self._ttl = ttl
|
||||||
|
self._kwargs = kwargs
|
||||||
|
self._scheme = APIKeyCookie(name=name, auto_error=False)
|
||||||
|
|
||||||
|
async def _call(
|
||||||
|
security_scopes: SecurityScopes, # noqa: ARG001
|
||||||
|
value: Annotated[str | None, Depends(self._scheme)] = None,
|
||||||
|
) -> Any:
|
||||||
|
if value is None:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
plain = self._verify(value)
|
||||||
|
return await self._validator(plain, **self._kwargs)
|
||||||
|
|
||||||
|
self._call_fn = _call
|
||||||
|
self.__signature__ = inspect.signature(_call)
|
||||||
|
|
||||||
|
def _hmac(self, data: str) -> str:
|
||||||
|
if self._secret_key is None:
|
||||||
|
raise RuntimeError("_hmac called without secret_key configured")
|
||||||
|
return hmac.new(
|
||||||
|
self._secret_key.encode(), data.encode(), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
def _sign(self, value: str) -> str:
|
||||||
|
data = base64.urlsafe_b64encode(
|
||||||
|
json.dumps({"v": value, "exp": int(time.time()) + self._ttl}).encode()
|
||||||
|
).decode()
|
||||||
|
return f"{data}.{self._hmac(data)}"
|
||||||
|
|
||||||
|
def _verify(self, cookie_value: str) -> str:
|
||||||
|
"""Return the plain value, verifying HMAC + expiry when signed."""
|
||||||
|
if not self._secret_key:
|
||||||
|
return cookie_value
|
||||||
|
|
||||||
|
try:
|
||||||
|
data, sig = cookie_value.rsplit(".", 1)
|
||||||
|
except ValueError:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
|
||||||
|
if not hmac.compare_digest(self._hmac(data), sig):
|
||||||
|
raise UnauthorizedError()
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(base64.urlsafe_b64decode(data))
|
||||||
|
value: str = payload["v"]
|
||||||
|
exp: int = payload["exp"]
|
||||||
|
except Exception:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
|
||||||
|
if exp < int(time.time()):
|
||||||
|
raise UnauthorizedError()
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
async def extract(self, request: Request) -> str | None:
|
||||||
|
return request.cookies.get(self._name)
|
||||||
|
|
||||||
|
async def authenticate(self, credential: str) -> Any:
|
||||||
|
plain = self._verify(credential)
|
||||||
|
return await self._validator(plain, **self._kwargs)
|
||||||
|
|
||||||
|
def require(self, **kwargs: Any) -> "CookieAuth":
|
||||||
|
"""Return a new instance with additional (or overriding) validator kwargs."""
|
||||||
|
return CookieAuth(
|
||||||
|
self._name,
|
||||||
|
self._validator,
|
||||||
|
secret_key=self._secret_key,
|
||||||
|
ttl=self._ttl,
|
||||||
|
**{**self._kwargs, **kwargs},
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_cookie(self, response: Response, value: str) -> None:
|
||||||
|
"""Attach the cookie to *response*, signing it when ``secret_key`` is set."""
|
||||||
|
cookie_value = self._sign(value) if self._secret_key else value
|
||||||
|
response.set_cookie(
|
||||||
|
self._name,
|
||||||
|
cookie_value,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=self._ttl,
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_cookie(self, response: Response) -> None:
|
||||||
|
"""Clear the session cookie (logout)."""
|
||||||
|
response.delete_cookie(self._name, httponly=True, samesite="lax")
|
||||||
67
src/fastapi_toolsets/security/sources/header.py
Normal file
67
src/fastapi_toolsets/security/sources/header.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""API key header authentication source."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from typing import Annotated, Any, Callable
|
||||||
|
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
from fastapi.security import APIKeyHeader, SecurityScopes
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
from ..abc import AuthSource, _ensure_async
|
||||||
|
|
||||||
|
|
||||||
|
class APIKeyHeaderAuth(AuthSource):
|
||||||
|
"""API key header authentication source.
|
||||||
|
|
||||||
|
Wraps :class:`fastapi.security.APIKeyHeader` for OpenAPI documentation.
|
||||||
|
The validator is called as ``await validator(api_key, **kwargs)``
|
||||||
|
where ``kwargs`` are the extra keyword arguments provided at instantiation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: HTTP header name that carries the API key (e.g. ``"X-API-Key"``).
|
||||||
|
validator: Sync or async callable that receives the API key and any
|
||||||
|
extra keyword arguments, and returns the authenticated identity.
|
||||||
|
Should raise :class:`~fastapi_toolsets.exceptions.UnauthorizedError`
|
||||||
|
on failure.
|
||||||
|
**kwargs: Extra keyword arguments forwarded to the validator on every
|
||||||
|
call (e.g. ``role=Role.ADMIN``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
validator: Callable[..., Any],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
self._name = name
|
||||||
|
self._validator = _ensure_async(validator)
|
||||||
|
self._kwargs = kwargs
|
||||||
|
self._scheme = APIKeyHeader(name=name, auto_error=False)
|
||||||
|
|
||||||
|
async def _call(
|
||||||
|
security_scopes: SecurityScopes, # noqa: ARG001
|
||||||
|
api_key: Annotated[str | None, Depends(self._scheme)] = None,
|
||||||
|
) -> Any:
|
||||||
|
if api_key is None:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
return await self._validator(api_key, **self._kwargs)
|
||||||
|
|
||||||
|
self._call_fn = _call
|
||||||
|
self.__signature__ = inspect.signature(_call)
|
||||||
|
|
||||||
|
async def extract(self, request: Request) -> str | None:
|
||||||
|
"""Extract the API key from the configured header."""
|
||||||
|
return request.headers.get(self._name) or None
|
||||||
|
|
||||||
|
async def authenticate(self, credential: str) -> Any:
|
||||||
|
"""Validate a credential and return the identity."""
|
||||||
|
return await self._validator(credential, **self._kwargs)
|
||||||
|
|
||||||
|
def require(self, **kwargs: Any) -> "APIKeyHeaderAuth":
|
||||||
|
"""Return a new instance with additional (or overriding) validator kwargs."""
|
||||||
|
return APIKeyHeaderAuth(
|
||||||
|
self._name,
|
||||||
|
self._validator,
|
||||||
|
**{**self._kwargs, **kwargs},
|
||||||
|
)
|
||||||
119
src/fastapi_toolsets/security/sources/multi.py
Normal file
119
src/fastapi_toolsets/security/sources/multi.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""MultiAuth: combine multiple authentication sources into a single callable."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.security import SecurityScopes
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
|
|
||||||
|
from ..abc import AuthSource
|
||||||
|
|
||||||
|
|
||||||
|
class MultiAuth:
|
||||||
|
"""Combine multiple authentication sources into a single callable.
|
||||||
|
|
||||||
|
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:
|
||||||
|
*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:
|
||||||
|
self._sources = sources
|
||||||
|
|
||||||
|
async def _call(
|
||||||
|
request: Request,
|
||||||
|
security_scopes: SecurityScopes, # noqa: ARG001
|
||||||
|
**kwargs: Any, # noqa: ARG001 — absorbs scheme values injected by FastAPI
|
||||||
|
) -> Any:
|
||||||
|
for source in self._sources:
|
||||||
|
credential = await source.extract(request)
|
||||||
|
if credential is not None:
|
||||||
|
return await source.authenticate(credential)
|
||||||
|
raise UnauthorizedError()
|
||||||
|
|
||||||
|
self._call_fn = _call
|
||||||
|
|
||||||
|
# Build a merged signature that includes the security-scheme Depends()
|
||||||
|
# parameters from every source so FastAPI registers them in OpenAPI docs.
|
||||||
|
seen: set[str] = {"request", "security_scopes"}
|
||||||
|
merged: list[inspect.Parameter] = [
|
||||||
|
inspect.Parameter(
|
||||||
|
"request",
|
||||||
|
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||||
|
annotation=Request,
|
||||||
|
),
|
||||||
|
inspect.Parameter(
|
||||||
|
"security_scopes",
|
||||||
|
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||||
|
annotation=SecurityScopes,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for i, source in enumerate(sources):
|
||||||
|
for name, param in inspect.signature(source).parameters.items():
|
||||||
|
if name in seen:
|
||||||
|
continue
|
||||||
|
merged.append(param.replace(name=f"_s{i}_{name}"))
|
||||||
|
seen.add(name)
|
||||||
|
self.__signature__ = inspect.Signature(merged, return_annotation=Any)
|
||||||
|
|
||||||
|
async def __call__(self, **kwargs: Any) -> Any:
|
||||||
|
return await self._call_fn(**kwargs)
|
||||||
|
|
||||||
|
def require(self, **kwargs: Any) -> "MultiAuth":
|
||||||
|
"""Return a new :class:`MultiAuth` with kwargs forwarded to each source.
|
||||||
|
|
||||||
|
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(
|
||||||
|
cast(Any, source).require(**kwargs)
|
||||||
|
if hasattr(source, "require")
|
||||||
|
else source
|
||||||
|
for source in self._sources
|
||||||
|
)
|
||||||
|
return MultiAuth(*new_sources)
|
||||||
27
src/fastapi_toolsets/types.py
Normal file
27
src/fastapi_toolsets/types.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Shared type aliases for the fastapi-toolsets package."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator, Callable, Mapping
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
|
||||||
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||||
|
from sqlalchemy.sql.elements import ColumnElement
|
||||||
|
|
||||||
|
# Generic TypeVars
|
||||||
|
DataT = TypeVar("DataT")
|
||||||
|
ModelType = TypeVar("ModelType", bound=DeclarativeBase)
|
||||||
|
SchemaType = TypeVar("SchemaType", bound=BaseModel)
|
||||||
|
|
||||||
|
# CRUD type aliases
|
||||||
|
JoinType = list[tuple[type[DeclarativeBase] | Any, Any]]
|
||||||
|
M2MFieldType = Mapping[str, QueryableAttribute[Any]]
|
||||||
|
OrderByClause = ColumnElement[Any] | QueryableAttribute[Any]
|
||||||
|
|
||||||
|
# Search / facet type aliases
|
||||||
|
SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any], ...]
|
||||||
|
FacetFieldType = SearchFieldType
|
||||||
|
|
||||||
|
# Dependency type aliases
|
||||||
|
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]] | Any
|
||||||
@@ -14,11 +14,13 @@ from sqlalchemy import (
|
|||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
|
JSON,
|
||||||
Numeric,
|
Numeric,
|
||||||
String,
|
String,
|
||||||
Table,
|
Table,
|
||||||
Uuid,
|
Uuid,
|
||||||
)
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ class User(Base):
|
|||||||
username: Mapped[str] = mapped_column(String(50), unique=True)
|
username: Mapped[str] = mapped_column(String(50), unique=True)
|
||||||
email: Mapped[str] = mapped_column(String(100), unique=True)
|
email: Mapped[str] = mapped_column(String(100), unique=True)
|
||||||
is_active: Mapped[bool] = mapped_column(default=True)
|
is_active: Mapped[bool] = mapped_column(default=True)
|
||||||
|
notes: Mapped[str | None]
|
||||||
role_id: Mapped[uuid.UUID | None] = mapped_column(
|
role_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
ForeignKey("roles.id"), nullable=True
|
ForeignKey("roles.id"), nullable=True
|
||||||
)
|
)
|
||||||
@@ -92,6 +95,15 @@ class IntRole(Base):
|
|||||||
name: Mapped[str] = mapped_column(String(50), unique=True)
|
name: Mapped[str] = mapped_column(String(50), unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Permission(Base):
|
||||||
|
"""Test model with composite primary key."""
|
||||||
|
|
||||||
|
__tablename__ = "permissions"
|
||||||
|
|
||||||
|
subject: Mapped[str] = mapped_column(String(50), primary_key=True)
|
||||||
|
action: Mapped[str] = mapped_column(String(50), primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
class Event(Base):
|
class Event(Base):
|
||||||
"""Test model with DateTime and Date cursor columns."""
|
"""Test model with DateTime and Date cursor columns."""
|
||||||
|
|
||||||
@@ -127,6 +139,28 @@ class Post(Base):
|
|||||||
tags: Mapped[list[Tag]] = relationship(secondary=post_tags)
|
tags: Mapped[list[Tag]] = relationship(secondary=post_tags)
|
||||||
|
|
||||||
|
|
||||||
|
class Transfer(Base):
|
||||||
|
"""Test model with two FKs to the same table (users)."""
|
||||||
|
|
||||||
|
__tablename__ = "transfers"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
amount: Mapped[str] = mapped_column(String(50))
|
||||||
|
sender_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
|
||||||
|
receiver_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
|
||||||
|
|
||||||
|
|
||||||
|
class Article(Base):
|
||||||
|
"""Test article model with ARRAY and JSON columns."""
|
||||||
|
|
||||||
|
__tablename__ = "articles"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
title: Mapped[str] = mapped_column(String(200))
|
||||||
|
labels: Mapped[list[str]] = mapped_column(ARRAY(String))
|
||||||
|
metadata_: Mapped[dict | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class RoleCreate(BaseModel):
|
class RoleCreate(BaseModel):
|
||||||
"""Schema for creating a role."""
|
"""Schema for creating a role."""
|
||||||
|
|
||||||
@@ -162,6 +196,7 @@ class UserRead(PydanticBase):
|
|||||||
|
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
username: str
|
username: str
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
@@ -218,12 +253,26 @@ class PostM2MUpdate(BaseModel):
|
|||||||
tag_ids: list[uuid.UUID] | None = None
|
tag_ids: list[uuid.UUID] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class IntRoleRead(PydanticBase):
|
||||||
|
"""Schema for reading an IntRole."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
class IntRoleCreate(BaseModel):
|
class IntRoleCreate(BaseModel):
|
||||||
"""Schema for creating an IntRole."""
|
"""Schema for creating an IntRole."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class EventRead(PydanticBase):
|
||||||
|
"""Schema for reading an Event."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
class EventCreate(BaseModel):
|
class EventCreate(BaseModel):
|
||||||
"""Schema for creating an Event."""
|
"""Schema for creating an Event."""
|
||||||
|
|
||||||
@@ -232,6 +281,13 @@ class EventCreate(BaseModel):
|
|||||||
scheduled_date: datetime.date
|
scheduled_date: datetime.date
|
||||||
|
|
||||||
|
|
||||||
|
class ProductRead(PydanticBase):
|
||||||
|
"""Schema for reading a Product."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
class ProductCreate(BaseModel):
|
class ProductCreate(BaseModel):
|
||||||
"""Schema for creating a Product."""
|
"""Schema for creating a Product."""
|
||||||
|
|
||||||
@@ -239,6 +295,40 @@ class ProductCreate(BaseModel):
|
|||||||
price: decimal.Decimal
|
price: decimal.Decimal
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleCreate(BaseModel):
|
||||||
|
"""Schema for creating an article."""
|
||||||
|
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
title: str
|
||||||
|
labels: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleRead(PydanticBase):
|
||||||
|
"""Schema for reading an article."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
title: str
|
||||||
|
labels: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class TransferCreate(BaseModel):
|
||||||
|
"""Schema for creating a transfer."""
|
||||||
|
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
amount: str
|
||||||
|
sender_id: uuid.UUID
|
||||||
|
receiver_id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
|
class TransferRead(PydanticBase):
|
||||||
|
"""Schema for reading a transfer."""
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
amount: str
|
||||||
|
|
||||||
|
|
||||||
|
TransferCrud = CrudFactory(Transfer)
|
||||||
|
ArticleCrud = CrudFactory(Article)
|
||||||
RoleCrud = CrudFactory(Role)
|
RoleCrud = CrudFactory(Role)
|
||||||
RoleCursorCrud = CrudFactory(Role, cursor_column=Role.id)
|
RoleCursorCrud = CrudFactory(Role, cursor_column=Role.id)
|
||||||
IntRoleCursorCrud = CrudFactory(IntRole, cursor_column=IntRole.id)
|
IntRoleCursorCrud = CrudFactory(IntRole, cursor_column=IntRole.id)
|
||||||
@@ -290,30 +380,3 @@ async def db_session(engine):
|
|||||||
# Drop tables after test
|
# Drop tables after test
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.drop_all)
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_role_data() -> RoleCreate:
|
|
||||||
"""Sample role creation data."""
|
|
||||||
return RoleCreate(name="admin")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_user_data() -> UserCreate:
|
|
||||||
"""Sample user creation data."""
|
|
||||||
return UserCreate(
|
|
||||||
username="testuser",
|
|
||||||
email="test@example.com",
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_post_data() -> PostCreate:
|
|
||||||
"""Sample post creation data."""
|
|
||||||
return PostCreate(
|
|
||||||
title="Test Post",
|
|
||||||
content="Test content",
|
|
||||||
is_published=True,
|
|
||||||
author_id=uuid.uuid4(),
|
|
||||||
)
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
147
tests/test_db.py
147
tests/test_db.py
@@ -4,18 +4,25 @@ import asyncio
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.engine import make_url
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from fastapi_toolsets.db import (
|
from fastapi_toolsets.db import (
|
||||||
LockMode,
|
LockMode,
|
||||||
|
cleanup_tables,
|
||||||
|
create_database,
|
||||||
create_db_context,
|
create_db_context,
|
||||||
create_db_dependency,
|
create_db_dependency,
|
||||||
get_transaction,
|
get_transaction,
|
||||||
lock_tables,
|
lock_tables,
|
||||||
wait_for_row_change,
|
wait_for_row_change,
|
||||||
)
|
)
|
||||||
|
from fastapi_toolsets.exceptions import NotFoundError
|
||||||
|
from fastapi_toolsets.pytest import create_db_session
|
||||||
|
|
||||||
from .conftest import DATABASE_URL, Base, Role, RoleCrud, User
|
from .conftest import DATABASE_URL, Base, Role, RoleCrud, User, UserCrud
|
||||||
|
|
||||||
|
|
||||||
class TestCreateDbDependency:
|
class TestCreateDbDependency:
|
||||||
@@ -61,6 +68,55 @@ class TestCreateDbDependency:
|
|||||||
await conn.run_sync(Base.metadata.drop_all)
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_in_transaction_on_yield(self):
|
||||||
|
"""Session is already in a transaction when the endpoint body starts."""
|
||||||
|
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
get_db = create_db_dependency(session_factory)
|
||||||
|
|
||||||
|
async for session in get_db():
|
||||||
|
assert session.in_transaction()
|
||||||
|
break
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_update_after_lock_tables_is_persisted(self):
|
||||||
|
"""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)
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
try:
|
||||||
|
get_db = create_db_dependency(session_factory)
|
||||||
|
|
||||||
|
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:
|
||||||
|
result = await RoleCrud.first(
|
||||||
|
verify, [Role.name == "updated_after_lock"]
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
finally:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
class TestCreateDbContext:
|
class TestCreateDbContext:
|
||||||
"""Tests for create_db_context."""
|
"""Tests for create_db_context."""
|
||||||
@@ -307,9 +363,9 @@ class TestWaitForRowChange:
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_nonexistent_row_raises(self, db_session: AsyncSession):
|
async def test_nonexistent_row_raises(self, db_session: AsyncSession):
|
||||||
"""Raises LookupError when the row does not exist."""
|
"""Raises NotFoundError when the row does not exist."""
|
||||||
fake_id = uuid.uuid4()
|
fake_id = uuid.uuid4()
|
||||||
with pytest.raises(LookupError, match="not found"):
|
with pytest.raises(NotFoundError, match="not found"):
|
||||||
await wait_for_row_change(db_session, Role, fake_id, interval=0.05)
|
await wait_for_row_change(db_session, Role, fake_id, interval=0.05)
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -326,7 +382,7 @@ class TestWaitForRowChange:
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_deleted_row_raises(self, db_session: AsyncSession, engine):
|
async def test_deleted_row_raises(self, db_session: AsyncSession, engine):
|
||||||
"""Raises LookupError when the row is deleted during polling."""
|
"""Raises NotFoundError when the row is deleted during polling."""
|
||||||
role = Role(name="delete_role")
|
role = Role(name="delete_role")
|
||||||
db_session.add(role)
|
db_session.add(role)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
@@ -340,6 +396,87 @@ class TestWaitForRowChange:
|
|||||||
await other.commit()
|
await other.commit()
|
||||||
|
|
||||||
delete_task = asyncio.create_task(delete_later())
|
delete_task = asyncio.create_task(delete_later())
|
||||||
with pytest.raises(LookupError):
|
with pytest.raises(NotFoundError):
|
||||||
await wait_for_row_change(db_session, Role, role.id, interval=0.05)
|
await wait_for_row_change(db_session, Role, role.id, interval=0.05)
|
||||||
await delete_task
|
await delete_task
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateDatabase:
|
||||||
|
"""Tests for create_database."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_creates_database(self):
|
||||||
|
"""Database is created by create_database."""
|
||||||
|
target_url = (
|
||||||
|
make_url(DATABASE_URL)
|
||||||
|
.set(database="test_create_db_general")
|
||||||
|
.render_as_string(hide_password=False)
|
||||||
|
)
|
||||||
|
expected_db = make_url(target_url).database
|
||||||
|
assert expected_db is not None
|
||||||
|
|
||||||
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text(f"DROP DATABASE IF EXISTS {expected_db}"))
|
||||||
|
|
||||||
|
await create_database(db_name=expected_db, server_url=DATABASE_URL)
|
||||||
|
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
result = await conn.execute(
|
||||||
|
text("SELECT 1 FROM pg_database WHERE datname = :name"),
|
||||||
|
{"name": expected_db},
|
||||||
|
)
|
||||||
|
assert result.scalar() == 1
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text(f"DROP DATABASE IF EXISTS {expected_db}"))
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanupTables:
|
||||||
|
"""Tests for cleanup_tables helper."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_truncates_all_tables(self):
|
||||||
|
"""All table rows are removed after cleanup_tables."""
|
||||||
|
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
||||||
|
role = Role(id=uuid.uuid4(), name="cleanup_role")
|
||||||
|
session.add(role)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
username="cleanup_user",
|
||||||
|
email="cleanup@test.com",
|
||||||
|
role_id=role.id,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Verify rows exist
|
||||||
|
roles_count = await RoleCrud.count(session)
|
||||||
|
users_count = await UserCrud.count(session)
|
||||||
|
assert roles_count == 1
|
||||||
|
assert users_count == 1
|
||||||
|
|
||||||
|
await cleanup_tables(session, Base)
|
||||||
|
|
||||||
|
# Verify tables are empty
|
||||||
|
roles_count = await RoleCrud.count(session)
|
||||||
|
users_count = await UserCrud.count(session)
|
||||||
|
assert roles_count == 0
|
||||||
|
assert users_count == 0
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_noop_for_empty_metadata(self):
|
||||||
|
"""cleanup_tables does not raise when metadata has no tables."""
|
||||||
|
|
||||||
|
class EmptyBase(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
||||||
|
# Should not raise
|
||||||
|
await cleanup_tables(session, EmptyBase)
|
||||||
|
|||||||
@@ -3,20 +3,42 @@
|
|||||||
import inspect
|
import inspect
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from typing import Any, cast
|
from typing import Annotated, Any, cast
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.params import Depends
|
from fastapi.params import Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from fastapi_toolsets.dependencies import BodyDependency, PathDependency
|
from fastapi_toolsets.dependencies import (
|
||||||
|
BodyDependency,
|
||||||
|
PathDependency,
|
||||||
|
_unwrap_session_dep,
|
||||||
|
)
|
||||||
|
|
||||||
from .conftest import Role, RoleCreate, RoleCrud, User
|
from .conftest import Role, RoleCreate, RoleCrud, User
|
||||||
|
|
||||||
|
|
||||||
async def mock_get_db() -> AsyncGenerator[AsyncSession, None]:
|
async def mock_get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
"""Mock session dependency for testing."""
|
"""Mock session dependency for testing."""
|
||||||
yield None
|
yield None # type: ignore[misc] # ty:ignore[invalid-yield]
|
||||||
|
|
||||||
|
|
||||||
|
MockSessionDep = Annotated[AsyncSession, Depends(mock_get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnwrapSessionDep:
|
||||||
|
def test_plain_callable_returned_as_is(self):
|
||||||
|
"""Plain callable is returned unchanged."""
|
||||||
|
assert _unwrap_session_dep(mock_get_db) is mock_get_db
|
||||||
|
|
||||||
|
def test_annotated_with_depends_unwrapped(self):
|
||||||
|
"""Annotated form with Depends is unwrapped to the plain callable."""
|
||||||
|
assert _unwrap_session_dep(MockSessionDep) is mock_get_db
|
||||||
|
|
||||||
|
def test_annotated_without_depends_returned_as_is(self):
|
||||||
|
"""Annotated form with no Depends falls back to returning session_dep as-is."""
|
||||||
|
annotated_no_dep = Annotated[AsyncSession, "not_a_depends"]
|
||||||
|
assert _unwrap_session_dep(annotated_no_dep) is annotated_no_dep
|
||||||
|
|
||||||
|
|
||||||
class TestPathDependency:
|
class TestPathDependency:
|
||||||
@@ -95,6 +117,39 @@ class TestPathDependency:
|
|||||||
assert result.id == role.id
|
assert result.id == role.id
|
||||||
assert result.name == "test_role"
|
assert result.name == "test_role"
|
||||||
|
|
||||||
|
def test_annotated_session_dep_returns_depends_instance(self):
|
||||||
|
"""PathDependency accepts Annotated[AsyncSession, Depends(...)] form."""
|
||||||
|
dep = PathDependency(Role, Role.id, session_dep=MockSessionDep)
|
||||||
|
assert isinstance(dep, Depends)
|
||||||
|
|
||||||
|
def test_annotated_session_dep_signature(self):
|
||||||
|
"""PathDependency with Annotated session_dep produces a valid signature."""
|
||||||
|
dep = cast(Any, PathDependency(Role, Role.id, session_dep=MockSessionDep))
|
||||||
|
sig = inspect.signature(dep.dependency)
|
||||||
|
|
||||||
|
assert "role_id" in sig.parameters
|
||||||
|
assert "session" in sig.parameters
|
||||||
|
assert isinstance(sig.parameters["session"].default, Depends)
|
||||||
|
|
||||||
|
def test_annotated_session_dep_unwraps_callable(self):
|
||||||
|
"""PathDependency with Annotated form uses the underlying callable, not the Annotated type."""
|
||||||
|
dep = cast(Any, PathDependency(Role, Role.id, session_dep=MockSessionDep))
|
||||||
|
sig = inspect.signature(dep.dependency)
|
||||||
|
|
||||||
|
inner_dep = sig.parameters["session"].default
|
||||||
|
assert inner_dep.dependency is mock_get_db
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_annotated_session_dep_fetches_object(self, db_session):
|
||||||
|
"""PathDependency with Annotated session_dep correctly fetches object from database."""
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="annotated_role"))
|
||||||
|
|
||||||
|
dep = cast(Any, PathDependency(Role, Role.id, session_dep=MockSessionDep))
|
||||||
|
result = await dep.dependency(session=db_session, role_id=role.id)
|
||||||
|
|
||||||
|
assert result.id == role.id
|
||||||
|
assert result.name == "annotated_role"
|
||||||
|
|
||||||
|
|
||||||
class TestBodyDependency:
|
class TestBodyDependency:
|
||||||
"""Tests for BodyDependency factory."""
|
"""Tests for BodyDependency factory."""
|
||||||
@@ -184,3 +239,39 @@ class TestBodyDependency:
|
|||||||
|
|
||||||
assert result.id == role.id
|
assert result.id == role.id
|
||||||
assert result.name == "body_test_role"
|
assert result.name == "body_test_role"
|
||||||
|
|
||||||
|
def test_annotated_session_dep_returns_depends_instance(self):
|
||||||
|
"""BodyDependency accepts Annotated[AsyncSession, Depends(...)] form."""
|
||||||
|
dep = BodyDependency(
|
||||||
|
Role, Role.id, session_dep=MockSessionDep, body_field="role_id"
|
||||||
|
)
|
||||||
|
assert isinstance(dep, Depends)
|
||||||
|
|
||||||
|
def test_annotated_session_dep_unwraps_callable(self):
|
||||||
|
"""BodyDependency with Annotated form uses the underlying callable, not the Annotated type."""
|
||||||
|
dep = cast(
|
||||||
|
Any,
|
||||||
|
BodyDependency(
|
||||||
|
Role, Role.id, session_dep=MockSessionDep, body_field="role_id"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sig = inspect.signature(dep.dependency)
|
||||||
|
|
||||||
|
inner_dep = sig.parameters["session"].default
|
||||||
|
assert inner_dep.dependency is mock_get_db
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_annotated_session_dep_fetches_object(self, db_session):
|
||||||
|
"""BodyDependency with Annotated session_dep correctly fetches object from database."""
|
||||||
|
role = await RoleCrud.create(db_session, RoleCreate(name="body_annotated_role"))
|
||||||
|
|
||||||
|
dep = cast(
|
||||||
|
Any,
|
||||||
|
BodyDependency(
|
||||||
|
Role, Role.id, session_dep=MockSessionDep, body_field="role_id"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
result = await dep.dependency(session=db_session, role_id=role.id)
|
||||||
|
|
||||||
|
assert result.id == role.id
|
||||||
|
assert result.name == "body_annotated_role"
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import datetime
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from docs_src.examples.pagination_search.db import get_db
|
from docs_src.examples.pagination_search.db import get_db
|
||||||
from docs_src.examples.pagination_search.models import Article, Base, Category
|
from docs_src.examples.pagination_search.models import Article, Base, Category
|
||||||
from docs_src.examples.pagination_search.routes import router
|
from docs_src.examples.pagination_search.routes import router
|
||||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||||
|
from fastapi_toolsets.pytest import create_db_session
|
||||||
|
|
||||||
from .conftest import DATABASE_URL
|
from .conftest import DATABASE_URL
|
||||||
|
|
||||||
@@ -35,20 +36,8 @@ def build_app(session: AsyncSession) -> FastAPI:
|
|||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
async def ex_db_session():
|
async def ex_db_session():
|
||||||
"""Isolated session for the example models (separate tables from conftest)."""
|
"""Isolated session for the example models (separate tables from conftest)."""
|
||||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
async with create_db_session(DATABASE_URL, Base) as session:
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
|
|
||||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
||||||
session = session_factory()
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield session
|
yield session
|
||||||
finally:
|
|
||||||
await session.close()
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.drop_all)
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -108,7 +97,7 @@ class TestAppSessionDep:
|
|||||||
gen = get_db()
|
gen = get_db()
|
||||||
session = await gen.__anext__()
|
session = await gen.__anext__()
|
||||||
assert isinstance(session, AsyncSession)
|
assert isinstance(session, AsyncSession)
|
||||||
await session.close()
|
await gen.aclose()
|
||||||
|
|
||||||
|
|
||||||
class TestOffsetPagination:
|
class TestOffsetPagination:
|
||||||
@@ -193,8 +182,7 @@ class TestOffsetPagination:
|
|||||||
body = resp.json()
|
body = resp.json()
|
||||||
fa = body["filter_attributes"]
|
fa = body["filter_attributes"]
|
||||||
assert set(fa["status"]) == {"draft", "published"}
|
assert set(fa["status"]) == {"draft", "published"}
|
||||||
# "name" is unique across all facet fields — no prefix needed
|
assert set(fa["category__name"]) == {"backend", "python"}
|
||||||
assert set(fa["name"]) == {"backend", "python"}
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_filter_attributes_scoped_to_filter(
|
async def test_filter_attributes_scoped_to_filter(
|
||||||
@@ -393,3 +381,105 @@ class TestCursorSorting:
|
|||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["error_code"] == "SORT-422"
|
assert body["error_code"] == "SORT-422"
|
||||||
assert body["status"] == "FAIL"
|
assert body["status"] == "FAIL"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPaginateUnified:
|
||||||
|
"""Tests for the unified GET /articles/ endpoint using paginate()."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_defaults_to_offset_pagination(
|
||||||
|
self, client: AsyncClient, ex_db_session
|
||||||
|
):
|
||||||
|
"""Without pagination_type, defaults to offset pagination."""
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["pagination_type"] == "offset"
|
||||||
|
assert "total_count" in body["pagination"]
|
||||||
|
assert body["pagination"]["total_count"] == 3
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_explicit_offset_pagination(self, client: AsyncClient, ex_db_session):
|
||||||
|
"""pagination_type=offset returns OffsetPagination metadata."""
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
"/articles/?pagination_type=offset&page=1&items_per_page=2"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["pagination_type"] == "offset"
|
||||||
|
assert body["pagination"]["total_count"] == 3
|
||||||
|
assert body["pagination"]["page"] == 1
|
||||||
|
assert body["pagination"]["has_more"] is True
|
||||||
|
assert len(body["data"]) == 2
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cursor_pagination_type(self, client: AsyncClient, ex_db_session):
|
||||||
|
"""pagination_type=cursor returns CursorPagination metadata."""
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/?pagination_type=cursor&items_per_page=2")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["pagination_type"] == "cursor"
|
||||||
|
assert "next_cursor" in body["pagination"]
|
||||||
|
assert "total_count" not in body["pagination"]
|
||||||
|
assert body["pagination"]["has_more"] is True
|
||||||
|
assert len(body["data"]) == 2
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cursor_pagination_navigate_pages(
|
||||||
|
self, client: AsyncClient, ex_db_session
|
||||||
|
):
|
||||||
|
"""Cursor from first page can be used to fetch the next page."""
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
first = await client.get("/articles/?pagination_type=cursor&items_per_page=2")
|
||||||
|
assert first.status_code == 200
|
||||||
|
first_body = first.json()
|
||||||
|
next_cursor = first_body["pagination"]["next_cursor"]
|
||||||
|
assert next_cursor is not None
|
||||||
|
|
||||||
|
second = await client.get(
|
||||||
|
f"/articles/?pagination_type=cursor&items_per_page=2&cursor={next_cursor}"
|
||||||
|
)
|
||||||
|
assert second.status_code == 200
|
||||||
|
second_body = second.json()
|
||||||
|
assert second_body["pagination_type"] == "cursor"
|
||||||
|
assert second_body["pagination"]["has_more"] is False
|
||||||
|
assert len(second_body["data"]) == 1
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cursor_pagination_with_search(
|
||||||
|
self, client: AsyncClient, ex_db_session
|
||||||
|
):
|
||||||
|
"""paginate() with cursor type respects search parameter."""
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/?pagination_type=cursor&search=fastapi")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["pagination_type"] == "cursor"
|
||||||
|
assert len(body["data"]) == 1
|
||||||
|
assert body["data"][0]["title"] == "FastAPI tips"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_offset_pagination_with_filter(
|
||||||
|
self, client: AsyncClient, ex_db_session
|
||||||
|
):
|
||||||
|
"""paginate() with offset type respects filter_by parameter."""
|
||||||
|
await seed(ex_db_session)
|
||||||
|
|
||||||
|
resp = await client.get("/articles/?pagination_type=offset&status=published")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["pagination_type"] == "offset"
|
||||||
|
assert body["pagination"]["total_count"] == 2
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from fastapi_toolsets.exceptions import (
|
from fastapi_toolsets.exceptions import (
|
||||||
@@ -36,8 +37,8 @@ class TestApiException:
|
|||||||
assert error.api_error.msg == "I'm a teapot"
|
assert error.api_error.msg == "I'm a teapot"
|
||||||
assert str(error) == "I'm a teapot"
|
assert str(error) == "I'm a teapot"
|
||||||
|
|
||||||
def test_custom_detail_message(self):
|
def test_detail_overrides_msg_and_str(self):
|
||||||
"""Custom detail overrides default message."""
|
"""detail sets both str(exc) and api_error.msg; class-level msg is unchanged."""
|
||||||
|
|
||||||
class CustomError(ApiException):
|
class CustomError(ApiException):
|
||||||
api_error = ApiError(
|
api_error = ApiError(
|
||||||
@@ -47,8 +48,172 @@ class TestApiException:
|
|||||||
err_code="BAD-400",
|
err_code="BAD-400",
|
||||||
)
|
)
|
||||||
|
|
||||||
error = CustomError("Custom message")
|
error = CustomError("Widget not found")
|
||||||
assert str(error) == "Custom message"
|
assert str(error) == "Widget not found"
|
||||||
|
assert error.api_error.msg == "Widget not found"
|
||||||
|
assert CustomError.api_error.msg == "Bad Request" # class unchanged
|
||||||
|
|
||||||
|
def test_desc_override(self):
|
||||||
|
"""desc kwarg overrides api_error.desc on the instance only."""
|
||||||
|
|
||||||
|
class MyError(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Error", desc="Default.", err_code="ERR-400"
|
||||||
|
)
|
||||||
|
|
||||||
|
err = MyError(desc="Custom desc.")
|
||||||
|
assert err.api_error.desc == "Custom desc."
|
||||||
|
assert MyError.api_error.desc == "Default." # class unchanged
|
||||||
|
|
||||||
|
def test_data_override(self):
|
||||||
|
"""data kwarg sets api_error.data on the instance only."""
|
||||||
|
|
||||||
|
class MyError(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Error", desc="Default.", err_code="ERR-400"
|
||||||
|
)
|
||||||
|
|
||||||
|
err = MyError(data={"key": "value"})
|
||||||
|
assert err.api_error.data == {"key": "value"}
|
||||||
|
assert MyError.api_error.data is None # class unchanged
|
||||||
|
|
||||||
|
def test_desc_and_data_override(self):
|
||||||
|
"""detail, desc and data can all be overridden together."""
|
||||||
|
|
||||||
|
class MyError(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Error", desc="Default.", err_code="ERR-400"
|
||||||
|
)
|
||||||
|
|
||||||
|
err = MyError("custom msg", desc="New desc.", data={"x": 1})
|
||||||
|
assert str(err) == "custom msg"
|
||||||
|
assert err.api_error.msg == "custom msg" # detail also updates msg
|
||||||
|
assert err.api_error.desc == "New desc."
|
||||||
|
assert err.api_error.data == {"x": 1}
|
||||||
|
assert err.api_error.code == 400 # other fields unchanged
|
||||||
|
|
||||||
|
def test_class_api_error_not_mutated_after_instance_override(self):
|
||||||
|
"""Raising with desc/data does not mutate the class-level api_error."""
|
||||||
|
|
||||||
|
class MyError(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Error", desc="Default.", err_code="ERR-400"
|
||||||
|
)
|
||||||
|
|
||||||
|
MyError(desc="Changed", data={"x": 1})
|
||||||
|
assert MyError.api_error.desc == "Default."
|
||||||
|
assert MyError.api_error.data is None
|
||||||
|
|
||||||
|
def test_subclass_uses_super_with_desc_and_data(self):
|
||||||
|
"""Subclasses can delegate detail/desc/data to super().__init__()."""
|
||||||
|
|
||||||
|
class BuildValidationError(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=422,
|
||||||
|
msg="Build Validation Error",
|
||||||
|
desc="The build configuration is invalid.",
|
||||||
|
err_code="BUILD-422",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *errors: str) -> None:
|
||||||
|
super().__init__(
|
||||||
|
f"{len(errors)} validation error(s)",
|
||||||
|
desc=", ".join(errors),
|
||||||
|
data={"errors": [{"message": e} for e in errors]},
|
||||||
|
)
|
||||||
|
|
||||||
|
err = BuildValidationError("Field A is required", "Field B is invalid")
|
||||||
|
assert str(err) == "2 validation error(s)"
|
||||||
|
assert err.api_error.msg == "2 validation error(s)" # detail set msg
|
||||||
|
assert err.api_error.desc == "Field A is required, Field B is invalid"
|
||||||
|
assert err.api_error.data == {
|
||||||
|
"errors": [
|
||||||
|
{"message": "Field A is required"},
|
||||||
|
{"message": "Field B is invalid"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert err.api_error.code == 422 # other fields unchanged
|
||||||
|
|
||||||
|
def test_detail_desc_data_in_http_response(self):
|
||||||
|
"""detail/desc/data overrides all appear correctly in the FastAPI HTTP response."""
|
||||||
|
|
||||||
|
class DynamicError(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Error", desc="Default.", err_code="ERR-400"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, message: str) -> None:
|
||||||
|
super().__init__(
|
||||||
|
message,
|
||||||
|
desc=f"Detail: {message}",
|
||||||
|
data={"reason": message},
|
||||||
|
)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
@app.get("/error")
|
||||||
|
async def raise_error():
|
||||||
|
raise DynamicError("something went wrong")
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/error")
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
body = response.json()
|
||||||
|
assert body["message"] == "something went wrong"
|
||||||
|
assert body["description"] == "Detail: something went wrong"
|
||||||
|
assert body["data"] == {"reason": "something went wrong"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiExceptionGuard:
|
||||||
|
"""Tests for the __init_subclass__ api_error guard."""
|
||||||
|
|
||||||
|
def test_missing_api_error_raises_type_error(self):
|
||||||
|
"""Defining a subclass without api_error raises TypeError at class creation time."""
|
||||||
|
with pytest.raises(
|
||||||
|
TypeError, match="must define an 'api_error' class attribute"
|
||||||
|
):
|
||||||
|
|
||||||
|
class BrokenError(ApiException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_abstract_subclass_skips_guard(self):
|
||||||
|
"""abstract=True allows intermediate base classes without api_error."""
|
||||||
|
|
||||||
|
class BaseGroupError(ApiException, abstract=True):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Concrete child must still define it
|
||||||
|
class ConcreteError(BaseGroupError):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Error", desc="Desc.", err_code="ERR-400"
|
||||||
|
)
|
||||||
|
|
||||||
|
err = ConcreteError()
|
||||||
|
assert err.api_error.code == 400
|
||||||
|
|
||||||
|
def test_abstract_child_still_requires_api_error_on_concrete(self):
|
||||||
|
"""Concrete subclass of an abstract class must define api_error."""
|
||||||
|
|
||||||
|
class Base(ApiException, abstract=True):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
TypeError, match="must define an 'api_error' class attribute"
|
||||||
|
):
|
||||||
|
|
||||||
|
class Concrete(Base):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_inherited_api_error_satisfies_guard(self):
|
||||||
|
"""Subclass that inherits api_error from a parent does not need its own."""
|
||||||
|
|
||||||
|
class ConcreteError(NotFoundError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
err = ConcreteError()
|
||||||
|
assert err.api_error.code == 404
|
||||||
|
|
||||||
|
|
||||||
class TestBuiltInExceptions:
|
class TestBuiltInExceptions:
|
||||||
@@ -90,7 +255,7 @@ class TestGenerateErrorResponses:
|
|||||||
assert responses[404]["description"] == "Not Found"
|
assert responses[404]["description"] == "Not Found"
|
||||||
|
|
||||||
def test_generates_multiple_responses(self):
|
def test_generates_multiple_responses(self):
|
||||||
"""Generates responses for multiple exceptions."""
|
"""Generates responses for multiple exceptions with distinct status codes."""
|
||||||
responses = generate_error_responses(
|
responses = generate_error_responses(
|
||||||
UnauthorizedError,
|
UnauthorizedError,
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
@@ -101,15 +266,24 @@ class TestGenerateErrorResponses:
|
|||||||
assert 403 in responses
|
assert 403 in responses
|
||||||
assert 404 in responses
|
assert 404 in responses
|
||||||
|
|
||||||
def test_response_has_example(self):
|
def test_response_has_named_example(self):
|
||||||
"""Generated response includes example."""
|
"""Generated response uses named examples keyed by err_code."""
|
||||||
responses = generate_error_responses(NotFoundError)
|
responses = generate_error_responses(NotFoundError)
|
||||||
example = responses[404]["content"]["application/json"]["example"]
|
examples = responses[404]["content"]["application/json"]["examples"]
|
||||||
|
|
||||||
assert example["status"] == "FAIL"
|
assert "RES-404" in examples
|
||||||
assert example["error_code"] == "RES-404"
|
value = examples["RES-404"]["value"]
|
||||||
assert example["message"] == "Not Found"
|
assert value["status"] == "FAIL"
|
||||||
assert example["data"] is None
|
assert value["error_code"] == "RES-404"
|
||||||
|
assert value["message"] == "Not Found"
|
||||||
|
assert value["data"] is None
|
||||||
|
|
||||||
|
def test_response_example_has_summary(self):
|
||||||
|
"""Each named example carries a summary equal to api_error.msg."""
|
||||||
|
responses = generate_error_responses(NotFoundError)
|
||||||
|
example = responses[404]["content"]["application/json"]["examples"]["RES-404"]
|
||||||
|
|
||||||
|
assert example["summary"] == "Not Found"
|
||||||
|
|
||||||
def test_response_example_with_data(self):
|
def test_response_example_with_data(self):
|
||||||
"""Generated response includes data when set on ApiError."""
|
"""Generated response includes data when set on ApiError."""
|
||||||
@@ -124,9 +298,49 @@ class TestGenerateErrorResponses:
|
|||||||
)
|
)
|
||||||
|
|
||||||
responses = generate_error_responses(ErrorWithData)
|
responses = generate_error_responses(ErrorWithData)
|
||||||
example = responses[400]["content"]["application/json"]["example"]
|
value = responses[400]["content"]["application/json"]["examples"]["BAD-400"][
|
||||||
|
"value"
|
||||||
|
]
|
||||||
|
|
||||||
assert example["data"] == {"details": "some context"}
|
assert value["data"] == {"details": "some context"}
|
||||||
|
|
||||||
|
def test_two_errors_same_code_both_present(self):
|
||||||
|
"""Two exceptions with the same HTTP code produce two named examples."""
|
||||||
|
|
||||||
|
class BadRequestA(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Bad A", desc="Reason A.", err_code="ERR-A"
|
||||||
|
)
|
||||||
|
|
||||||
|
class BadRequestB(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Bad B", desc="Reason B.", err_code="ERR-B"
|
||||||
|
)
|
||||||
|
|
||||||
|
responses = generate_error_responses(BadRequestA, BadRequestB)
|
||||||
|
|
||||||
|
assert 400 in responses
|
||||||
|
examples = responses[400]["content"]["application/json"]["examples"]
|
||||||
|
assert "ERR-A" in examples
|
||||||
|
assert "ERR-B" in examples
|
||||||
|
assert examples["ERR-A"]["value"]["message"] == "Bad A"
|
||||||
|
assert examples["ERR-B"]["value"]["message"] == "Bad B"
|
||||||
|
|
||||||
|
def test_two_errors_same_code_single_top_level_entry(self):
|
||||||
|
"""Two exceptions with the same HTTP code produce exactly one top-level entry."""
|
||||||
|
|
||||||
|
class BadRequestA(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Bad A", desc="Reason A.", err_code="ERR-A"
|
||||||
|
)
|
||||||
|
|
||||||
|
class BadRequestB(ApiException):
|
||||||
|
api_error = ApiError(
|
||||||
|
code=400, msg="Bad B", desc="Reason B.", err_code="ERR-B"
|
||||||
|
)
|
||||||
|
|
||||||
|
responses = generate_error_responses(BadRequestA, BadRequestB)
|
||||||
|
assert len([k for k in responses if k == 400]) == 1
|
||||||
|
|
||||||
|
|
||||||
class TestInitExceptionsHandlers:
|
class TestInitExceptionsHandlers:
|
||||||
@@ -250,13 +464,68 @@ class TestInitExceptionsHandlers:
|
|||||||
assert data["status"] == "FAIL"
|
assert data["status"] == "FAIL"
|
||||||
assert data["error_code"] == "SERVER-500"
|
assert data["error_code"] == "SERVER-500"
|
||||||
|
|
||||||
def test_custom_openapi_schema(self):
|
def test_handles_http_exception(self):
|
||||||
"""Customizes OpenAPI schema for 422 responses."""
|
"""Handles starlette HTTPException with consistent ErrorResponse envelope."""
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
init_exceptions_handlers(app)
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
@app.get("/protected")
|
||||||
|
async def protected():
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/protected")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "FAIL"
|
||||||
|
assert data["error_code"] == "HTTP-403"
|
||||||
|
assert data["message"] == "Forbidden"
|
||||||
|
|
||||||
|
def test_handles_http_exception_404_from_route(self):
|
||||||
|
"""HTTPException(404) raised inside a route uses the consistent ErrorResponse envelope."""
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
@app.get("/items/{item_id}")
|
||||||
|
async def get_item(item_id: int):
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/items/99")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "FAIL"
|
||||||
|
assert data["error_code"] == "HTTP-404"
|
||||||
|
assert data["message"] == "Item not found"
|
||||||
|
|
||||||
|
def test_handles_http_exception_forwards_headers(self):
|
||||||
|
"""HTTPException with WWW-Authenticate header forwards it in the response."""
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
@app.get("/secure")
|
||||||
|
async def secure():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Not authenticated",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/secure")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.headers.get("www-authenticate") == "Bearer"
|
||||||
|
|
||||||
|
def test_custom_openapi_schema(self):
|
||||||
|
"""Customises OpenAPI schema for 422 responses using named examples."""
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
class Item(BaseModel):
|
class Item(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
@@ -269,8 +538,128 @@ class TestInitExceptionsHandlers:
|
|||||||
post_op = openapi["paths"]["/items"]["post"]
|
post_op = openapi["paths"]["/items"]["post"]
|
||||||
assert "422" in post_op["responses"]
|
assert "422" in post_op["responses"]
|
||||||
resp_422 = post_op["responses"]["422"]
|
resp_422 = post_op["responses"]["422"]
|
||||||
example = resp_422["content"]["application/json"]["example"]
|
examples = resp_422["content"]["application/json"]["examples"]
|
||||||
assert example["error_code"] == "VAL-422"
|
assert "VAL-422" in examples
|
||||||
|
assert examples["VAL-422"]["value"]["error_code"] == "VAL-422"
|
||||||
|
|
||||||
|
def test_custom_openapi_preserves_app_metadata(self):
|
||||||
|
"""_patched_openapi preserves custom FastAPI app-level metadata."""
|
||||||
|
app = FastAPI(
|
||||||
|
title="My API",
|
||||||
|
version="2.0.0",
|
||||||
|
description="Custom description",
|
||||||
|
)
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
schema = app.openapi()
|
||||||
|
assert schema["info"]["title"] == "My API"
|
||||||
|
assert schema["info"]["version"] == "2.0.0"
|
||||||
|
|
||||||
|
def test_handles_response_validation_error(self):
|
||||||
|
"""Handles ResponseValidationError with a structured 422 response."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class CountResponse(BaseModel):
|
||||||
|
count: int
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
@app.get("/broken", response_model=CountResponse)
|
||||||
|
async def broken():
|
||||||
|
return {"count": "not-a-number"} # triggers ResponseValidationError
|
||||||
|
|
||||||
|
client = TestClient(app, raise_server_exceptions=False)
|
||||||
|
response = client.get("/broken")
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
data = response.json()
|
||||||
|
assert data["status"] == "FAIL"
|
||||||
|
assert data["error_code"] == "VAL-422"
|
||||||
|
assert "errors" in data["data"]
|
||||||
|
|
||||||
|
def test_handles_validation_error_with_non_standard_loc(self):
|
||||||
|
"""Validation error with empty loc tuple maps the field to 'root'."""
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
@app.get("/root-error")
|
||||||
|
async def root_error():
|
||||||
|
raise RequestValidationError(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "custom",
|
||||||
|
"loc": (),
|
||||||
|
"msg": "root level error",
|
||||||
|
"input": None,
|
||||||
|
"url": "",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/root-error")
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
data = response.json()
|
||||||
|
assert data["data"]["errors"][0]["field"] == "root"
|
||||||
|
|
||||||
|
def test_openapi_schema_cached_after_first_call(self):
|
||||||
|
"""app.openapi() returns the cached schema on subsequent calls."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
class Item(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
@app.post("/items")
|
||||||
|
async def create_item(item: Item):
|
||||||
|
return item
|
||||||
|
|
||||||
|
schema_first = app.openapi()
|
||||||
|
schema_second = app.openapi()
|
||||||
|
assert schema_first is schema_second
|
||||||
|
|
||||||
|
def test_openapi_skips_operations_without_422(self):
|
||||||
|
"""_patched_openapi leaves operations that have no 422 response unchanged."""
|
||||||
|
app = FastAPI()
|
||||||
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
|
@app.get("/ping")
|
||||||
|
async def ping():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
schema = app.openapi()
|
||||||
|
get_op = schema["paths"]["/ping"]["get"]
|
||||||
|
assert "422" not in get_op["responses"]
|
||||||
|
assert "200" in get_op["responses"]
|
||||||
|
|
||||||
|
def test_openapi_skips_non_dict_path_item_values(self):
|
||||||
|
"""_patched_openapi ignores non-dict values in path items (e.g. path-level parameters)."""
|
||||||
|
from fastapi_toolsets.exceptions.handler import _patched_openapi
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
def fake_openapi() -> dict:
|
||||||
|
return {
|
||||||
|
"paths": {
|
||||||
|
"/items": {
|
||||||
|
"parameters": [
|
||||||
|
{"name": "q", "in": "query"}
|
||||||
|
], # list, not a dict
|
||||||
|
"get": {"responses": {"200": {"description": "OK"}}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
schema = _patched_openapi(app, fake_openapi)
|
||||||
|
# The list value was skipped without error; the GET operation is intact
|
||||||
|
assert schema["paths"]["/items"]["parameters"] == [{"name": "q", "in": "query"}]
|
||||||
|
assert "422" not in schema["paths"]["/items"]["get"]["responses"]
|
||||||
|
|
||||||
|
|
||||||
class TestExceptionIntegration:
|
class TestExceptionIntegration:
|
||||||
@@ -352,12 +741,12 @@ class TestInvalidOrderFieldError:
|
|||||||
assert error.field == "unknown"
|
assert error.field == "unknown"
|
||||||
assert error.valid_fields == ["name", "created_at"]
|
assert error.valid_fields == ["name", "created_at"]
|
||||||
|
|
||||||
def test_message_contains_field_and_valid_fields(self):
|
def test_description_contains_field_and_valid_fields(self):
|
||||||
"""Exception message mentions the bad field and valid options."""
|
"""api_error.desc mentions the bad field and valid options."""
|
||||||
error = InvalidOrderFieldError("bad_field", ["name", "email"])
|
error = InvalidOrderFieldError("bad_field", ["name", "email"])
|
||||||
assert "bad_field" in str(error)
|
assert "bad_field" in error.api_error.desc
|
||||||
assert "name" in str(error)
|
assert "name" in error.api_error.desc
|
||||||
assert "email" in str(error)
|
assert "email" in error.api_error.desc
|
||||||
|
|
||||||
def test_handled_as_422_by_exception_handler(self):
|
def test_handled_as_422_by_exception_handler(self):
|
||||||
"""init_exceptions_handlers turns InvalidOrderFieldError into a 422 response."""
|
"""init_exceptions_handlers turns InvalidOrderFieldError into a 422 response."""
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for fastapi_toolsets.fixtures module."""
|
"""Tests for fastapi_toolsets.fixtures module."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -13,8 +14,22 @@ from fastapi_toolsets.fixtures import (
|
|||||||
load_fixtures,
|
load_fixtures,
|
||||||
load_fixtures_by_context,
|
load_fixtures_by_context,
|
||||||
)
|
)
|
||||||
|
from fastapi_toolsets.fixtures.utils import _get_primary_key, _instance_to_dict
|
||||||
|
|
||||||
from .conftest import Role, User
|
from .conftest import IntRole, Permission, Role, RoleCreate, RoleCrud, User, UserCrud
|
||||||
|
|
||||||
|
|
||||||
|
class AppContext(str, Enum):
|
||||||
|
"""Example user-defined str+Enum context."""
|
||||||
|
|
||||||
|
STAGING = "staging"
|
||||||
|
DEMO = "demo"
|
||||||
|
|
||||||
|
|
||||||
|
class PlainEnumContext(Enum):
|
||||||
|
"""Example user-defined plain Enum context (no str mixin)."""
|
||||||
|
|
||||||
|
STAGING = "staging"
|
||||||
|
|
||||||
|
|
||||||
class TestContext:
|
class TestContext:
|
||||||
@@ -37,6 +52,86 @@ class TestContext:
|
|||||||
assert Context.TESTING.value == "testing"
|
assert Context.TESTING.value == "testing"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCustomEnumContext:
|
||||||
|
"""Custom Enum types are accepted wherever Context/str are expected."""
|
||||||
|
|
||||||
|
def test_cannot_subclass_context_with_members(self):
|
||||||
|
"""Python prohibits extending an Enum that already has members."""
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
|
||||||
|
class MyContext(Context): # noqa: F841 # ty: ignore[subclass-of-final-class]
|
||||||
|
STAGING = "staging"
|
||||||
|
|
||||||
|
def test_custom_enum_values_interchangeable_with_context(self):
|
||||||
|
"""A custom enum with the same .value as a built-in Context member is
|
||||||
|
treated as the same context — fixtures registered under one are found
|
||||||
|
by the other."""
|
||||||
|
|
||||||
|
class AppContextFull(str, Enum):
|
||||||
|
BASE = "base"
|
||||||
|
STAGING = "staging"
|
||||||
|
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry.register(contexts=[Context.BASE])
|
||||||
|
def roles():
|
||||||
|
return []
|
||||||
|
|
||||||
|
# AppContextFull.BASE has value "base" — same as Context.BASE
|
||||||
|
fixtures = registry.get_by_context(AppContextFull.BASE)
|
||||||
|
assert len(fixtures) == 1
|
||||||
|
|
||||||
|
def test_custom_enum_registry_default_contexts(self):
|
||||||
|
"""FixtureRegistry(contexts=[...]) accepts a custom Enum."""
|
||||||
|
registry = FixtureRegistry(contexts=[AppContext.STAGING])
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def data():
|
||||||
|
return []
|
||||||
|
|
||||||
|
fixture = registry.get("data")
|
||||||
|
assert fixture.contexts == ["staging"]
|
||||||
|
|
||||||
|
def test_custom_enum_resolve_context_dependencies(self):
|
||||||
|
"""resolve_context_dependencies accepts a custom Enum context."""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry.register(contexts=[AppContext.STAGING])
|
||||||
|
def staging_roles():
|
||||||
|
return []
|
||||||
|
|
||||||
|
order = registry.resolve_context_dependencies(AppContext.STAGING)
|
||||||
|
assert "staging_roles" in order
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_custom_enum_e2e(self, db_session: AsyncSession):
|
||||||
|
"""End-to-end: register with custom Enum, load with the same Enum."""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry.register(contexts=[AppContext.STAGING])
|
||||||
|
def staging_roles():
|
||||||
|
return [Role(id=uuid.uuid4(), name="staging-admin")]
|
||||||
|
|
||||||
|
result = await load_fixtures_by_context(
|
||||||
|
db_session, registry, AppContext.STAGING
|
||||||
|
)
|
||||||
|
assert len(result["staging_roles"]) == 1
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_plain_enum_e2e(self, db_session: AsyncSession):
|
||||||
|
"""End-to-end: register with plain Enum, load with the same Enum."""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry.register(contexts=[PlainEnumContext.STAGING])
|
||||||
|
def staging_roles():
|
||||||
|
return [Role(id=uuid.uuid4(), name="plain-staging-admin")]
|
||||||
|
|
||||||
|
result = await load_fixtures_by_context(
|
||||||
|
db_session, registry, PlainEnumContext.STAGING
|
||||||
|
)
|
||||||
|
assert len(result["staging_roles"]) == 1
|
||||||
|
|
||||||
|
|
||||||
class TestLoadStrategy:
|
class TestLoadStrategy:
|
||||||
"""Tests for LoadStrategy enum."""
|
"""Tests for LoadStrategy enum."""
|
||||||
|
|
||||||
@@ -405,6 +500,37 @@ class TestDependencyResolution:
|
|||||||
with pytest.raises(ValueError, match="Circular dependency"):
|
with pytest.raises(ValueError, match="Circular dependency"):
|
||||||
registry.resolve_dependencies("a")
|
registry.resolve_dependencies("a")
|
||||||
|
|
||||||
|
def test_resolve_raises_for_unknown_dependency(self):
|
||||||
|
"""KeyError when depends_on references an unregistered fixture."""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry.register(depends_on=["ghost"])
|
||||||
|
def users():
|
||||||
|
return []
|
||||||
|
|
||||||
|
with pytest.raises(KeyError, match="ghost"):
|
||||||
|
registry.resolve_dependencies("users")
|
||||||
|
|
||||||
|
def test_resolve_deduplicates_shared_depends_on_across_variants(self):
|
||||||
|
"""A dep shared by two same-name variants appears only once in the order."""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry.register(contexts=[Context.BASE])
|
||||||
|
def roles():
|
||||||
|
return []
|
||||||
|
|
||||||
|
@registry.register(depends_on=["roles"], contexts=[Context.BASE])
|
||||||
|
def items():
|
||||||
|
return []
|
||||||
|
|
||||||
|
@registry.register(depends_on=["roles"], contexts=[Context.TESTING])
|
||||||
|
def items(): # noqa: F811
|
||||||
|
return []
|
||||||
|
|
||||||
|
order = registry.resolve_dependencies("items")
|
||||||
|
assert order.count("roles") == 1
|
||||||
|
assert order.index("roles") < order.index("items")
|
||||||
|
|
||||||
def test_resolve_context_dependencies(self):
|
def test_resolve_context_dependencies(self):
|
||||||
"""Resolve all fixtures for a context with dependencies."""
|
"""Resolve all fixtures for a context with dependencies."""
|
||||||
registry = FixtureRegistry()
|
registry = FixtureRegistry()
|
||||||
@@ -445,8 +571,6 @@ class TestLoadFixtures:
|
|||||||
assert "roles" in result
|
assert "roles" in result
|
||||||
assert len(result["roles"]) == 2
|
assert len(result["roles"]) == 2
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 2
|
assert count == 2
|
||||||
|
|
||||||
@@ -477,8 +601,6 @@ class TestLoadFixtures:
|
|||||||
assert "roles" in result
|
assert "roles" in result
|
||||||
assert "users" in result
|
assert "users" in result
|
||||||
|
|
||||||
from .conftest import RoleCrud, UserCrud
|
|
||||||
|
|
||||||
assert await RoleCrud.count(db_session) == 1
|
assert await RoleCrud.count(db_session) == 1
|
||||||
assert await UserCrud.count(db_session) == 1
|
assert await UserCrud.count(db_session) == 1
|
||||||
|
|
||||||
@@ -495,11 +617,55 @@ class TestLoadFixtures:
|
|||||||
await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.MERGE)
|
await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.MERGE)
|
||||||
await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.MERGE)
|
await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.MERGE)
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 1
|
assert count == 1
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_merge_does_not_overwrite_omitted_nullable_columns(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""MERGE must not clear nullable columns that the fixture didn't set.
|
||||||
|
|
||||||
|
When a fixture omits a nullable column (e.g. role_id or notes), a re-merge
|
||||||
|
must leave the existing DB value untouched — not overwrite it with NULL.
|
||||||
|
"""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
admin = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
uid = uuid.uuid4()
|
||||||
|
|
||||||
|
# First load: user has role_id and notes set
|
||||||
|
@registry.register
|
||||||
|
def users():
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid,
|
||||||
|
username="alice",
|
||||||
|
email="a@test.com",
|
||||||
|
role_id=admin.id,
|
||||||
|
notes="original",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(db_session, registry, "users", strategy=LoadStrategy.MERGE)
|
||||||
|
|
||||||
|
# Second load: fixture omits role_id and notes
|
||||||
|
registry2 = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry2.register
|
||||||
|
def users(): # noqa: F811
|
||||||
|
return [User(id=uid, username="alice-updated", email="a@test.com")]
|
||||||
|
|
||||||
|
await load_fixtures(db_session, registry2, "users", strategy=LoadStrategy.MERGE)
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
row = (
|
||||||
|
await db_session.execute(select(User).where(User.id == uid))
|
||||||
|
).scalar_one()
|
||||||
|
assert row.username == "alice-updated" # updated column changed
|
||||||
|
assert row.role_id == admin.id # omitted → preserved
|
||||||
|
assert row.notes == "original" # omitted → preserved
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_load_with_skip_existing_strategy(self, db_session: AsyncSession):
|
async def test_load_with_skip_existing_strategy(self, db_session: AsyncSession):
|
||||||
"""Load fixtures with SKIP_EXISTING strategy."""
|
"""Load fixtures with SKIP_EXISTING strategy."""
|
||||||
@@ -524,8 +690,6 @@ class TestLoadFixtures:
|
|||||||
db_session, registry, "roles", strategy=LoadStrategy.SKIP_EXISTING
|
db_session, registry, "roles", strategy=LoadStrategy.SKIP_EXISTING
|
||||||
)
|
)
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
role = await RoleCrud.first(db_session, [Role.id == role_id])
|
role = await RoleCrud.first(db_session, [Role.id == role_id])
|
||||||
assert role is not None
|
assert role is not None
|
||||||
assert role.name == "original"
|
assert role.name == "original"
|
||||||
@@ -551,8 +715,6 @@ class TestLoadFixtures:
|
|||||||
assert "roles" in result
|
assert "roles" in result
|
||||||
assert len(result["roles"]) == 2
|
assert len(result["roles"]) == 2
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 2
|
assert count == 2
|
||||||
|
|
||||||
@@ -592,11 +754,49 @@ class TestLoadFixtures:
|
|||||||
assert "roles" in result
|
assert "roles" in result
|
||||||
assert "other_roles" in result
|
assert "other_roles" in result
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 2
|
assert count == 2
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_skip_existing_skips_if_record_exists(self, db_session: AsyncSession):
|
||||||
|
"""SKIP_EXISTING returns empty loaded list when the record already exists."""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
role_id = uuid.uuid4()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def roles():
|
||||||
|
return [Role(id=role_id, name="admin")]
|
||||||
|
|
||||||
|
# First load — inserts the record.
|
||||||
|
result1 = await load_fixtures(
|
||||||
|
db_session, registry, "roles", strategy=LoadStrategy.SKIP_EXISTING
|
||||||
|
)
|
||||||
|
assert len(result1["roles"]) == 1
|
||||||
|
|
||||||
|
# Remove from identity map so session.get() queries the DB in the second load.
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
|
# Second load — record exists in DB, nothing should be added.
|
||||||
|
result2 = await load_fixtures(
|
||||||
|
db_session, registry, "roles", strategy=LoadStrategy.SKIP_EXISTING
|
||||||
|
)
|
||||||
|
assert result2["roles"] == []
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_skip_existing_null_pk_inserts(self, db_session: AsyncSession):
|
||||||
|
"""SKIP_EXISTING inserts when the instance has no PK set (auto-increment)."""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def int_roles():
|
||||||
|
# No id provided — PK is None before INSERT (autoincrement).
|
||||||
|
return [IntRole(name="member")]
|
||||||
|
|
||||||
|
result = await load_fixtures(
|
||||||
|
db_session, registry, "int_roles", strategy=LoadStrategy.SKIP_EXISTING
|
||||||
|
)
|
||||||
|
assert len(result["int_roles"]) == 1
|
||||||
|
|
||||||
|
|
||||||
class TestLoadFixturesByContext:
|
class TestLoadFixturesByContext:
|
||||||
"""Tests for load_fixtures_by_context function."""
|
"""Tests for load_fixtures_by_context function."""
|
||||||
@@ -618,8 +818,6 @@ class TestLoadFixturesByContext:
|
|||||||
|
|
||||||
await load_fixtures_by_context(db_session, registry, Context.BASE)
|
await load_fixtures_by_context(db_session, registry, Context.BASE)
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 1
|
assert count == 1
|
||||||
|
|
||||||
@@ -646,8 +844,6 @@ class TestLoadFixturesByContext:
|
|||||||
db_session, registry, Context.BASE, Context.TESTING
|
db_session, registry, Context.BASE, Context.TESTING
|
||||||
)
|
)
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 2
|
assert count == 2
|
||||||
|
|
||||||
@@ -675,8 +871,6 @@ class TestLoadFixturesByContext:
|
|||||||
|
|
||||||
await load_fixtures_by_context(db_session, registry, Context.TESTING)
|
await load_fixtures_by_context(db_session, registry, Context.TESTING)
|
||||||
|
|
||||||
from .conftest import RoleCrud, UserCrud
|
|
||||||
|
|
||||||
assert await RoleCrud.count(db_session) == 1
|
assert await RoleCrud.count(db_session) == 1
|
||||||
assert await UserCrud.count(db_session) == 1
|
assert await UserCrud.count(db_session) == 1
|
||||||
|
|
||||||
@@ -755,3 +949,527 @@ class TestGetObjByAttr:
|
|||||||
"""Raises StopIteration when value type doesn't match."""
|
"""Raises StopIteration when value type doesn't match."""
|
||||||
with pytest.raises(StopIteration):
|
with pytest.raises(StopIteration):
|
||||||
get_obj_by_attr(self.roles, "id", "not-a-uuid")
|
get_obj_by_attr(self.roles, "id", "not-a-uuid")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetPrimaryKey:
|
||||||
|
"""Unit tests for the _get_primary_key helper (composite PK paths)."""
|
||||||
|
|
||||||
|
def test_composite_pk_all_set(self):
|
||||||
|
"""Returns a tuple when all composite PK values are set."""
|
||||||
|
instance = Permission(subject="post", action="read")
|
||||||
|
pk = _get_primary_key(instance)
|
||||||
|
assert pk == ("post", "read")
|
||||||
|
|
||||||
|
def test_composite_pk_partial_none(self):
|
||||||
|
"""Returns None when any composite PK value is None."""
|
||||||
|
instance = Permission(subject="post") # action is None
|
||||||
|
pk = _get_primary_key(instance)
|
||||||
|
assert pk is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegistryGetVariants:
|
||||||
|
"""Tests for FixtureRegistry.get and get_variants edge cases."""
|
||||||
|
|
||||||
|
def test_get_raises_value_error_for_multi_variant(self):
|
||||||
|
"""get() raises ValueError when the fixture has multiple context variants."""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry.register(contexts=[Context.BASE])
|
||||||
|
def items():
|
||||||
|
return []
|
||||||
|
|
||||||
|
@registry.register(contexts=[Context.TESTING])
|
||||||
|
def items(): # noqa: F811
|
||||||
|
return []
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="get_variants"):
|
||||||
|
registry.get("items")
|
||||||
|
|
||||||
|
def test_get_variants_raises_key_error_for_unknown(self):
|
||||||
|
"""get_variants() raises KeyError for an unregistered name."""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
with pytest.raises(KeyError, match="not found"):
|
||||||
|
registry.get_variants("no_such_fixture")
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstanceToDict:
|
||||||
|
"""Unit tests for the _instance_to_dict helper."""
|
||||||
|
|
||||||
|
def test_explicit_values_included(self):
|
||||||
|
"""All explicitly set column values appear in the result."""
|
||||||
|
role_id = uuid.uuid4()
|
||||||
|
instance = Role(id=role_id, name="admin")
|
||||||
|
d = _instance_to_dict(instance)
|
||||||
|
assert d["id"] == role_id
|
||||||
|
assert d["name"] == "admin"
|
||||||
|
|
||||||
|
def test_callable_default_none_excluded(self):
|
||||||
|
"""A column whose value is None but has a callable Python-side default
|
||||||
|
(e.g. ``default=uuid.uuid4``) is excluded so the DB generates it."""
|
||||||
|
instance = Role(id=None, name="admin")
|
||||||
|
d = _instance_to_dict(instance)
|
||||||
|
assert "id" not in d
|
||||||
|
assert d["name"] == "admin"
|
||||||
|
|
||||||
|
def test_autoincrement_none_excluded(self):
|
||||||
|
"""A column whose value is None but has autoincrement=True is excluded
|
||||||
|
so the DB generates the value via its sequence."""
|
||||||
|
instance = IntRole(id=None, name="admin")
|
||||||
|
d = _instance_to_dict(instance)
|
||||||
|
assert "id" not in d
|
||||||
|
assert d["name"] == "admin"
|
||||||
|
|
||||||
|
def test_nullable_none_included(self):
|
||||||
|
"""None on a nullable column with no default is kept (explicit NULL)."""
|
||||||
|
instance = User(id=uuid.uuid4(), username="u", email="e@e.com", role_id=None)
|
||||||
|
d = _instance_to_dict(instance)
|
||||||
|
assert "role_id" in d
|
||||||
|
assert d["role_id"] is None
|
||||||
|
|
||||||
|
def test_nullable_str_no_default_omitted_not_in_dict(self):
|
||||||
|
"""Mapped[str | None] with no default, not provided in constructor, is absent from dict."""
|
||||||
|
instance = User(id=uuid.uuid4(), username="u", email="e@e.com")
|
||||||
|
d = _instance_to_dict(instance)
|
||||||
|
assert "notes" not in d
|
||||||
|
|
||||||
|
def test_nullable_str_no_default_explicit_none_included(self):
|
||||||
|
"""Mapped[str | None] with no default, explicitly set to None, is included as NULL."""
|
||||||
|
instance = User(id=uuid.uuid4(), username="u", email="e@e.com", notes=None)
|
||||||
|
d = _instance_to_dict(instance)
|
||||||
|
assert "notes" in d
|
||||||
|
assert d["notes"] is None
|
||||||
|
|
||||||
|
def test_nullable_str_no_default_with_value_included(self):
|
||||||
|
"""Mapped[str | None] with no default and a value set is included normally."""
|
||||||
|
instance = User(id=uuid.uuid4(), username="u", email="e@e.com", notes="hello")
|
||||||
|
d = _instance_to_dict(instance)
|
||||||
|
assert d["notes"] == "hello"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_nullable_str_no_default_insert_roundtrip(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""Fixture loading works for models with Mapped[str | None] (no default).
|
||||||
|
|
||||||
|
Both the omitted-value (→ NULL) and explicit-None paths must insert without error.
|
||||||
|
"""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
|
||||||
|
uid_a = uuid.uuid4()
|
||||||
|
uid_b = uuid.uuid4()
|
||||||
|
uid_c = uuid.uuid4()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def users():
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid_a, username="no_notes", email="a@test.com"
|
||||||
|
), # notes omitted
|
||||||
|
User(
|
||||||
|
id=uid_b, username="null_notes", email="b@test.com", notes=None
|
||||||
|
), # explicit None
|
||||||
|
User(
|
||||||
|
id=uid_c, username="has_notes", email="c@test.com", notes="hi"
|
||||||
|
), # value set
|
||||||
|
]
|
||||||
|
|
||||||
|
result = await load_fixtures(db_session, registry, "users")
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
(await db_session.execute(select(User).order_by(User.username)))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
by_username = {r.username: r for r in rows}
|
||||||
|
|
||||||
|
assert by_username["no_notes"].notes is None
|
||||||
|
assert by_username["null_notes"].notes is None
|
||||||
|
assert by_username["has_notes"].notes == "hi"
|
||||||
|
assert len(result["users"]) == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchMergeNonPkColumns:
|
||||||
|
"""Batch MERGE on a model with no non-PK columns (PK-only table)."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_merge_pk_only_model(self, db_session: AsyncSession):
|
||||||
|
"""MERGE strategy on a PK-only model uses on_conflict_do_nothing."""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def permissions():
|
||||||
|
return [
|
||||||
|
Permission(subject="post", action="read"),
|
||||||
|
Permission(subject="post", action="write"),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = await load_fixtures(
|
||||||
|
db_session, registry, "permissions", strategy=LoadStrategy.MERGE
|
||||||
|
)
|
||||||
|
assert len(result["permissions"]) == 2
|
||||||
|
|
||||||
|
# Run again — conflicts are silently ignored.
|
||||||
|
result2 = await load_fixtures(
|
||||||
|
db_session, registry, "permissions", strategy=LoadStrategy.MERGE
|
||||||
|
)
|
||||||
|
assert len(result2["permissions"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchNullableColumnEdgeCases:
|
||||||
|
"""Deep tests for nullable column handling during batch import."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_insert_batch_mixed_nullable_fk(self, db_session: AsyncSession):
|
||||||
|
"""INSERT batch where some rows set a nullable FK and others don't.
|
||||||
|
|
||||||
|
After normalization the omitted role_id becomes None. For INSERT this
|
||||||
|
is acceptable — both rows should insert successfully with the correct
|
||||||
|
values (one with FK, one with NULL).
|
||||||
|
"""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
admin = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
uid1 = uuid.uuid4()
|
||||||
|
uid2 = uuid.uuid4()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def users():
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid1, username="with_role", email="a@test.com", role_id=admin.id
|
||||||
|
),
|
||||||
|
User(id=uid2, username="no_role", email="b@test.com"),
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(db_session, registry, "users", strategy=LoadStrategy.INSERT)
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
rows = {
|
||||||
|
r.username: r
|
||||||
|
for r in (await db_session.execute(select(User))).scalars().all()
|
||||||
|
}
|
||||||
|
assert rows["with_role"].role_id == admin.id
|
||||||
|
assert rows["no_role"].role_id is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_insert_batch_mixed_nullable_notes(self, db_session: AsyncSession):
|
||||||
|
"""INSERT batch where some rows have notes and others don't.
|
||||||
|
|
||||||
|
Ensures normalization doesn't break the insert and that each row gets
|
||||||
|
the intended value.
|
||||||
|
"""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
uid1 = uuid.uuid4()
|
||||||
|
uid2 = uuid.uuid4()
|
||||||
|
uid3 = uuid.uuid4()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def users():
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid1,
|
||||||
|
username="has_notes",
|
||||||
|
email="a@test.com",
|
||||||
|
notes="important",
|
||||||
|
),
|
||||||
|
User(id=uid2, username="no_notes", email="b@test.com"),
|
||||||
|
User(id=uid3, username="null_notes", email="c@test.com", notes=None),
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(db_session, registry, "users", strategy=LoadStrategy.INSERT)
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
rows = {
|
||||||
|
r.username: r
|
||||||
|
for r in (await db_session.execute(select(User))).scalars().all()
|
||||||
|
}
|
||||||
|
assert rows["has_notes"].notes == "important"
|
||||||
|
assert rows["no_notes"].notes is None
|
||||||
|
assert rows["null_notes"].notes is None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_merge_batch_mixed_nullable_does_not_overwrite(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""MERGE batch where one row sets a nullable column and another omits it.
|
||||||
|
|
||||||
|
If both rows already exist in DB, the row that omits the column must
|
||||||
|
NOT have its existing value overwritten with NULL.
|
||||||
|
|
||||||
|
This is the core normalization bug: _normalize_rows fills missing keys
|
||||||
|
with None, and then MERGE's SET clause includes that column for ALL rows.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
admin = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
uid1 = uuid.uuid4()
|
||||||
|
uid2 = uuid.uuid4()
|
||||||
|
|
||||||
|
# Pre-populate: both users have role_id and notes
|
||||||
|
registry_initial = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry_initial.register
|
||||||
|
def users():
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid1,
|
||||||
|
username="alice",
|
||||||
|
email="a@test.com",
|
||||||
|
role_id=admin.id,
|
||||||
|
notes="alice notes",
|
||||||
|
),
|
||||||
|
User(
|
||||||
|
id=uid2,
|
||||||
|
username="bob",
|
||||||
|
email="b@test.com",
|
||||||
|
role_id=admin.id,
|
||||||
|
notes="bob notes",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(
|
||||||
|
db_session, registry_initial, "users", strategy=LoadStrategy.INSERT
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-merge: alice updates notes, bob omits notes entirely
|
||||||
|
registry_merge = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry_merge.register
|
||||||
|
def users(): # noqa: F811
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid1,
|
||||||
|
username="alice",
|
||||||
|
email="a@test.com",
|
||||||
|
role_id=admin.id,
|
||||||
|
notes="updated",
|
||||||
|
),
|
||||||
|
User(
|
||||||
|
id=uid2,
|
||||||
|
username="bob",
|
||||||
|
email="b@test.com",
|
||||||
|
role_id=admin.id,
|
||||||
|
), # notes omitted
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(
|
||||||
|
db_session, registry_merge, "users", strategy=LoadStrategy.MERGE
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = {
|
||||||
|
r.username: r
|
||||||
|
for r in (await db_session.execute(select(User))).scalars().all()
|
||||||
|
}
|
||||||
|
assert rows["alice"].notes == "updated"
|
||||||
|
# Bob's notes must be preserved, NOT overwritten with NULL
|
||||||
|
assert rows["bob"].notes == "bob notes"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_merge_batch_mixed_nullable_fk_preserves_existing(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""MERGE batch where one row sets role_id and another omits it.
|
||||||
|
|
||||||
|
The row that omits role_id must keep its existing DB value.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
admin = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
editor = await RoleCrud.create(db_session, RoleCreate(name="editor"))
|
||||||
|
uid1 = uuid.uuid4()
|
||||||
|
uid2 = uuid.uuid4()
|
||||||
|
|
||||||
|
# Pre-populate
|
||||||
|
registry_initial = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry_initial.register
|
||||||
|
def users():
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid1,
|
||||||
|
username="alice",
|
||||||
|
email="a@test.com",
|
||||||
|
role_id=admin.id,
|
||||||
|
),
|
||||||
|
User(
|
||||||
|
id=uid2,
|
||||||
|
username="bob",
|
||||||
|
email="b@test.com",
|
||||||
|
role_id=editor.id,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(
|
||||||
|
db_session, registry_initial, "users", strategy=LoadStrategy.INSERT
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-merge: alice changes role, bob omits role_id
|
||||||
|
registry_merge = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry_merge.register
|
||||||
|
def users(): # noqa: F811
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid1,
|
||||||
|
username="alice",
|
||||||
|
email="a@test.com",
|
||||||
|
role_id=editor.id,
|
||||||
|
),
|
||||||
|
User(id=uid2, username="bob", email="b@test.com"), # role_id omitted
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(
|
||||||
|
db_session, registry_merge, "users", strategy=LoadStrategy.MERGE
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = {
|
||||||
|
r.username: r
|
||||||
|
for r in (await db_session.execute(select(User))).scalars().all()
|
||||||
|
}
|
||||||
|
assert rows["alice"].role_id == editor.id # updated
|
||||||
|
assert rows["bob"].role_id == editor.id # must be preserved, NOT NULL
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_insert_batch_mixed_pk_presence(self, db_session: AsyncSession):
|
||||||
|
"""INSERT batch where some rows have explicit PK and others rely on
|
||||||
|
the callable default (uuid.uuid4).
|
||||||
|
|
||||||
|
Normalization adds the PK key with None to rows that omitted it,
|
||||||
|
which can cause NOT NULL violations on the PK column.
|
||||||
|
"""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
explicit_id = uuid.uuid4()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def roles():
|
||||||
|
return [
|
||||||
|
Role(id=explicit_id, name="admin"),
|
||||||
|
Role(name="user"), # PK omitted, relies on default=uuid.uuid4
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.INSERT)
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
rows = (await db_session.execute(select(Role))).scalars().all()
|
||||||
|
assert len(rows) == 2
|
||||||
|
names = {r.name for r in rows}
|
||||||
|
assert names == {"admin", "user"}
|
||||||
|
# The "admin" row must have the explicit ID
|
||||||
|
admin = next(r for r in rows if r.name == "admin")
|
||||||
|
assert admin.id == explicit_id
|
||||||
|
# The "user" row must have a generated UUID (not None)
|
||||||
|
user = next(r for r in rows if r.name == "user")
|
||||||
|
assert user.id is not None
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_skip_existing_batch_mixed_nullable(self, db_session: AsyncSession):
|
||||||
|
"""SKIP_EXISTING with mixed nullable columns inserts correctly.
|
||||||
|
|
||||||
|
Only new rows are inserted; existing rows are untouched regardless of
|
||||||
|
which columns the fixture provides.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
admin = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
uid1 = uuid.uuid4()
|
||||||
|
uid2 = uuid.uuid4()
|
||||||
|
|
||||||
|
# Pre-populate uid1 with notes
|
||||||
|
registry_initial = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry_initial.register
|
||||||
|
def users():
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid1,
|
||||||
|
username="alice",
|
||||||
|
email="a@test.com",
|
||||||
|
role_id=admin.id,
|
||||||
|
notes="keep me",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(
|
||||||
|
db_session, registry_initial, "users", strategy=LoadStrategy.INSERT
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load again with SKIP_EXISTING: uid1 already exists, uid2 is new
|
||||||
|
registry_skip = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry_skip.register
|
||||||
|
def users(): # noqa: F811
|
||||||
|
return [
|
||||||
|
User(id=uid1, username="alice-updated", email="a@test.com"), # exists
|
||||||
|
User(
|
||||||
|
id=uid2,
|
||||||
|
username="bob",
|
||||||
|
email="b@test.com",
|
||||||
|
notes="new user",
|
||||||
|
), # new
|
||||||
|
]
|
||||||
|
|
||||||
|
result = await load_fixtures(
|
||||||
|
db_session, registry_skip, "users", strategy=LoadStrategy.SKIP_EXISTING
|
||||||
|
)
|
||||||
|
assert len(result["users"]) == 1 # only bob inserted
|
||||||
|
|
||||||
|
rows = {
|
||||||
|
r.username: r
|
||||||
|
for r in (await db_session.execute(select(User))).scalars().all()
|
||||||
|
}
|
||||||
|
# alice untouched
|
||||||
|
assert rows["alice"].role_id == admin.id
|
||||||
|
assert rows["alice"].notes == "keep me"
|
||||||
|
# bob inserted correctly
|
||||||
|
assert rows["bob"].notes == "new user"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_insert_batch_every_row_different_nullable_columns(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""Each row in the batch sets a different combination of nullable columns.
|
||||||
|
|
||||||
|
Tests that normalization produces valid SQL for all rows.
|
||||||
|
"""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
admin = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
||||||
|
uid1 = uuid.uuid4()
|
||||||
|
uid2 = uuid.uuid4()
|
||||||
|
uid3 = uuid.uuid4()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def users():
|
||||||
|
return [
|
||||||
|
User(
|
||||||
|
id=uid1,
|
||||||
|
username="all_set",
|
||||||
|
email="a@test.com",
|
||||||
|
role_id=admin.id,
|
||||||
|
notes="full",
|
||||||
|
),
|
||||||
|
User(
|
||||||
|
id=uid2, username="only_role", email="b@test.com", role_id=admin.id
|
||||||
|
),
|
||||||
|
User(
|
||||||
|
id=uid3, username="only_notes", email="c@test.com", notes="partial"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
await load_fixtures(db_session, registry, "users", strategy=LoadStrategy.INSERT)
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
rows = {
|
||||||
|
r.username: r
|
||||||
|
for r in (await db_session.execute(select(User))).scalars().all()
|
||||||
|
}
|
||||||
|
assert rows["all_set"].role_id == admin.id
|
||||||
|
assert rows["all_set"].notes == "full"
|
||||||
|
assert rows["only_role"].role_id == admin.id
|
||||||
|
assert rows["only_role"].notes is None
|
||||||
|
assert rows["only_notes"].role_id is None
|
||||||
|
assert rows["only_notes"].notes == "partial"
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ class TestMetricsImportGuard:
|
|||||||
with patch("builtins.__import__", side_effect=blocking_import):
|
with patch("builtins.__import__", side_effect=blocking_import):
|
||||||
mod = importlib.import_module("fastapi_toolsets.metrics")
|
mod = importlib.import_module("fastapi_toolsets.metrics")
|
||||||
with pytest.raises(ImportError, match="prometheus_client"):
|
with pytest.raises(ImportError, match="prometheus_client"):
|
||||||
mod.init_metrics(None, None) # type: ignore[arg-type]
|
mod.init_metrics(None, None) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
|
||||||
finally:
|
finally:
|
||||||
for key in list(sys.modules):
|
for key in list(sys.modules):
|
||||||
if key.startswith("fastapi_toolsets.metrics"):
|
if key.startswith("fastapi_toolsets.metrics"):
|
||||||
@@ -171,8 +171,15 @@ class TestPytestImportGuard:
|
|||||||
class TestCliImportGuard:
|
class TestCliImportGuard:
|
||||||
"""Tests for CLI module import guard when typer is missing."""
|
"""Tests for CLI module import guard when typer is missing."""
|
||||||
|
|
||||||
def test_import_raises_without_typer(self):
|
@pytest.mark.parametrize(
|
||||||
"""Importing cli.app raises when typer is missing."""
|
"expected_match",
|
||||||
|
[
|
||||||
|
"typer",
|
||||||
|
r"pip install fastapi-toolsets\[cli\]",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_import_raises_without_typer(self, expected_match):
|
||||||
|
"""Importing cli.app raises when typer is missing, with an informative error message."""
|
||||||
saved, blocking_import = _reload_without_package(
|
saved, blocking_import = _reload_without_package(
|
||||||
"fastapi_toolsets.cli.app", ["typer"]
|
"fastapi_toolsets.cli.app", ["typer"]
|
||||||
)
|
)
|
||||||
@@ -186,33 +193,7 @@ class TestCliImportGuard:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with patch("builtins.__import__", side_effect=blocking_import):
|
with patch("builtins.__import__", side_effect=blocking_import):
|
||||||
with pytest.raises(ImportError, match="typer"):
|
with pytest.raises(ImportError, match=expected_match):
|
||||||
importlib.import_module("fastapi_toolsets.cli.app")
|
|
||||||
finally:
|
|
||||||
for key in list(sys.modules):
|
|
||||||
if key.startswith("fastapi_toolsets.cli.app") or key.startswith(
|
|
||||||
"fastapi_toolsets.cli.config"
|
|
||||||
):
|
|
||||||
sys.modules.pop(key, None)
|
|
||||||
sys.modules.update(saved)
|
|
||||||
|
|
||||||
def test_error_message_suggests_cli_extra(self):
|
|
||||||
"""Error message suggests installing the cli extra."""
|
|
||||||
saved, blocking_import = _reload_without_package(
|
|
||||||
"fastapi_toolsets.cli.app", ["typer"]
|
|
||||||
)
|
|
||||||
config_keys = [
|
|
||||||
k for k in sys.modules if k.startswith("fastapi_toolsets.cli.config")
|
|
||||||
]
|
|
||||||
for key in config_keys:
|
|
||||||
if key not in saved:
|
|
||||||
saved[key] = sys.modules.pop(key)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with patch("builtins.__import__", side_effect=blocking_import):
|
|
||||||
with pytest.raises(
|
|
||||||
ImportError, match=r"pip install fastapi-toolsets\[cli\]"
|
|
||||||
):
|
|
||||||
importlib.import_module("fastapi_toolsets.cli.app")
|
importlib.import_module("fastapi_toolsets.cli.app")
|
||||||
finally:
|
finally:
|
||||||
for key in list(sys.modules):
|
for key in list(sys.modules):
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Tests for fastapi_toolsets.metrics module."""
|
"""Tests for fastapi_toolsets.metrics module."""
|
||||||
|
|
||||||
import os
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
@@ -159,6 +158,42 @@ class TestMetricsRegistry:
|
|||||||
assert registry.get_all()[0].func is second
|
assert registry.get_all()[0].func is second
|
||||||
|
|
||||||
|
|
||||||
|
class TestGet:
|
||||||
|
"""Tests for MetricsRegistry.get method."""
|
||||||
|
|
||||||
|
def test_get_returns_instance_after_init(self):
|
||||||
|
"""get() returns the metric instance stored by init_metrics."""
|
||||||
|
app = FastAPI()
|
||||||
|
registry = MetricsRegistry()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def my_gauge():
|
||||||
|
return Gauge("get_test_gauge", "A test gauge")
|
||||||
|
|
||||||
|
init_metrics(app, registry)
|
||||||
|
|
||||||
|
instance = registry.get("my_gauge")
|
||||||
|
assert isinstance(instance, Gauge)
|
||||||
|
|
||||||
|
def test_get_raises_for_registered_but_not_initialized(self):
|
||||||
|
"""get() raises KeyError with an informative message when init_metrics was not called."""
|
||||||
|
registry = MetricsRegistry()
|
||||||
|
|
||||||
|
@registry.register
|
||||||
|
def my_counter():
|
||||||
|
return Counter("get_uninit_counter", "A counter")
|
||||||
|
|
||||||
|
with pytest.raises(KeyError, match="not been initialized yet"):
|
||||||
|
registry.get("my_counter")
|
||||||
|
|
||||||
|
def test_get_raises_for_unknown_name(self):
|
||||||
|
"""get() raises KeyError when the metric name is not registered at all."""
|
||||||
|
registry = MetricsRegistry()
|
||||||
|
|
||||||
|
with pytest.raises(KeyError, match="Unknown metric"):
|
||||||
|
registry.get("nonexistent")
|
||||||
|
|
||||||
|
|
||||||
class TestIncludeRegistry:
|
class TestIncludeRegistry:
|
||||||
"""Tests for MetricsRegistry.include_registry method."""
|
"""Tests for MetricsRegistry.include_registry method."""
|
||||||
|
|
||||||
@@ -251,6 +286,16 @@ class TestIncludeRegistry:
|
|||||||
class TestInitMetrics:
|
class TestInitMetrics:
|
||||||
"""Tests for init_metrics function."""
|
"""Tests for init_metrics function."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def metrics_client(self):
|
||||||
|
"""Create a FastAPI app with MetricsRegistry and return a TestClient."""
|
||||||
|
app = FastAPI()
|
||||||
|
registry = MetricsRegistry()
|
||||||
|
init_metrics(app, registry)
|
||||||
|
client = TestClient(app)
|
||||||
|
yield client
|
||||||
|
client.close()
|
||||||
|
|
||||||
def test_returns_app(self):
|
def test_returns_app(self):
|
||||||
"""Returns the FastAPI app."""
|
"""Returns the FastAPI app."""
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
@@ -258,26 +303,14 @@ class TestInitMetrics:
|
|||||||
result = init_metrics(app, registry)
|
result = init_metrics(app, registry)
|
||||||
assert result is app
|
assert result is app
|
||||||
|
|
||||||
def test_metrics_endpoint_responds(self):
|
def test_metrics_endpoint_responds(self, metrics_client):
|
||||||
"""The /metrics endpoint returns 200."""
|
"""The /metrics endpoint returns 200."""
|
||||||
app = FastAPI()
|
response = metrics_client.get("/metrics")
|
||||||
registry = MetricsRegistry()
|
|
||||||
init_metrics(app, registry)
|
|
||||||
|
|
||||||
client = TestClient(app)
|
|
||||||
response = client.get("/metrics")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
def test_metrics_endpoint_content_type(self):
|
def test_metrics_endpoint_content_type(self, metrics_client):
|
||||||
"""The /metrics endpoint returns prometheus content type."""
|
"""The /metrics endpoint returns prometheus content type."""
|
||||||
app = FastAPI()
|
response = metrics_client.get("/metrics")
|
||||||
registry = MetricsRegistry()
|
|
||||||
init_metrics(app, registry)
|
|
||||||
|
|
||||||
client = TestClient(app)
|
|
||||||
response = client.get("/metrics")
|
|
||||||
|
|
||||||
assert "text/plain" in response.headers["content-type"]
|
assert "text/plain" in response.headers["content-type"]
|
||||||
|
|
||||||
def test_custom_path(self):
|
def test_custom_path(self):
|
||||||
@@ -409,36 +442,33 @@ class TestInitMetrics:
|
|||||||
class TestMultiProcessMode:
|
class TestMultiProcessMode:
|
||||||
"""Tests for multi-process Prometheus mode."""
|
"""Tests for multi-process Prometheus mode."""
|
||||||
|
|
||||||
def test_multiprocess_with_env_var(self):
|
def test_multiprocess_with_env_var(self, monkeypatch):
|
||||||
"""Multi-process mode works when PROMETHEUS_MULTIPROC_DIR is set."""
|
"""Multi-process mode works when PROMETHEUS_MULTIPROC_DIR is set."""
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
os.environ["PROMETHEUS_MULTIPROC_DIR"] = tmpdir
|
monkeypatch.setenv("PROMETHEUS_MULTIPROC_DIR", tmpdir)
|
||||||
try:
|
# Use a separate registry to avoid conflicts with default
|
||||||
# Use a separate registry to avoid conflicts with default
|
prom_registry = CollectorRegistry()
|
||||||
prom_registry = CollectorRegistry()
|
app = FastAPI()
|
||||||
app = FastAPI()
|
registry = MetricsRegistry()
|
||||||
registry = MetricsRegistry()
|
|
||||||
|
|
||||||
@registry.register
|
@registry.register
|
||||||
def mp_counter():
|
def mp_counter():
|
||||||
return Counter(
|
return Counter(
|
||||||
"mp_test_counter",
|
"mp_test_counter",
|
||||||
"A multiprocess counter",
|
"A multiprocess counter",
|
||||||
registry=prom_registry,
|
registry=prom_registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
init_metrics(app, registry)
|
init_metrics(app, registry)
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
response = client.get("/metrics")
|
response = client.get("/metrics")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
finally:
|
|
||||||
del os.environ["PROMETHEUS_MULTIPROC_DIR"]
|
|
||||||
|
|
||||||
def test_single_process_without_env_var(self):
|
def test_single_process_without_env_var(self, monkeypatch):
|
||||||
"""Single-process mode when PROMETHEUS_MULTIPROC_DIR is not set."""
|
"""Single-process mode when PROMETHEUS_MULTIPROC_DIR is not set."""
|
||||||
os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None)
|
monkeypatch.delenv("PROMETHEUS_MULTIPROC_DIR", raising=False)
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
registry = MetricsRegistry()
|
registry = MetricsRegistry()
|
||||||
|
|||||||
1796
tests/test_models.py
Normal file
1796
tests/test_models.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,12 +7,12 @@ from fastapi import Depends, FastAPI
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.engine import make_url
|
from sqlalchemy.engine import make_url
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from fastapi_toolsets.db import get_transaction
|
||||||
from fastapi_toolsets.fixtures import Context, FixtureRegistry
|
from fastapi_toolsets.fixtures import Context, FixtureRegistry
|
||||||
from fastapi_toolsets.pytest import (
|
from fastapi_toolsets.pytest import (
|
||||||
cleanup_tables,
|
|
||||||
create_async_client,
|
create_async_client,
|
||||||
create_db_session,
|
create_db_session,
|
||||||
create_worker_database,
|
create_worker_database,
|
||||||
@@ -337,6 +337,42 @@ class TestCreateDbSession:
|
|||||||
result = await session.execute(select(Role))
|
result = await session.execute(select(Role))
|
||||||
assert result.all() == []
|
assert result.all() == []
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_transaction_commits_visible_to_separate_session(self):
|
||||||
|
"""Data written via get_transaction() is committed and visible to other sessions."""
|
||||||
|
role_id = uuid.uuid4()
|
||||||
|
|
||||||
|
async with create_db_session(DATABASE_URL, Base, drop_tables=False) as session:
|
||||||
|
# Simulate what _create_fixture_function does: insert via get_transaction
|
||||||
|
# with no explicit commit afterward.
|
||||||
|
async with get_transaction(session):
|
||||||
|
role = Role(id=role_id, name="visible_to_other_session")
|
||||||
|
session.add(role)
|
||||||
|
|
||||||
|
# The data must have been committed (begin/commit, not a savepoint),
|
||||||
|
# so a separate engine/session can read it.
|
||||||
|
other_engine = create_async_engine(DATABASE_URL, echo=False)
|
||||||
|
try:
|
||||||
|
other_session_maker = async_sessionmaker(
|
||||||
|
other_engine, expire_on_commit=False
|
||||||
|
)
|
||||||
|
async with other_session_maker() as other:
|
||||||
|
result = await other.execute(select(Role).where(Role.id == role_id))
|
||||||
|
fetched = result.scalar_one_or_none()
|
||||||
|
assert fetched is not None, (
|
||||||
|
"Fixture data inserted via get_transaction() must be committed "
|
||||||
|
"and visible to a separate session. If create_db_session uses "
|
||||||
|
"create_db_context, auto-begin forces get_transaction() into "
|
||||||
|
"savepoints instead of real commits."
|
||||||
|
)
|
||||||
|
assert fetched.name == "visible_to_other_session"
|
||||||
|
finally:
|
||||||
|
await other_engine.dispose()
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as _:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class TestGetXdistWorker:
|
class TestGetXdistWorker:
|
||||||
"""Tests for _get_xdist_worker helper."""
|
"""Tests for _get_xdist_worker helper."""
|
||||||
@@ -406,7 +442,6 @@ class TestCreateWorkerDatabase:
|
|||||||
) as url:
|
) as url:
|
||||||
assert make_url(url).database == expected_db
|
assert make_url(url).database == expected_db
|
||||||
|
|
||||||
# Verify the database exists while inside the context
|
|
||||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
result = await conn.execute(
|
result = await conn.execute(
|
||||||
@@ -416,7 +451,6 @@ class TestCreateWorkerDatabase:
|
|||||||
assert result.scalar() == 1
|
assert result.scalar() == 1
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
# After context exit the database should be dropped
|
|
||||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
result = await conn.execute(
|
result = await conn.execute(
|
||||||
@@ -439,7 +473,6 @@ class TestCreateWorkerDatabase:
|
|||||||
async with create_worker_database(DATABASE_URL) as url:
|
async with create_worker_database(DATABASE_URL) as url:
|
||||||
assert make_url(url).database == expected_db
|
assert make_url(url).database == expected_db
|
||||||
|
|
||||||
# Verify the database exists while inside the context
|
|
||||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
result = await conn.execute(
|
result = await conn.execute(
|
||||||
@@ -449,7 +482,6 @@ class TestCreateWorkerDatabase:
|
|||||||
assert result.scalar() == 1
|
assert result.scalar() == 1
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
# After context exit the database should be dropped
|
|
||||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
result = await conn.execute(
|
result = await conn.execute(
|
||||||
@@ -467,18 +499,15 @@ class TestCreateWorkerDatabase:
|
|||||||
worker_database_url(DATABASE_URL, default_test_db="unused")
|
worker_database_url(DATABASE_URL, default_test_db="unused")
|
||||||
).database
|
).database
|
||||||
|
|
||||||
# Pre-create the database to simulate a stale leftover
|
|
||||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
await conn.execute(text(f"DROP DATABASE IF EXISTS {expected_db}"))
|
await conn.execute(text(f"DROP DATABASE IF EXISTS {expected_db}"))
|
||||||
await conn.execute(text(f"CREATE DATABASE {expected_db}"))
|
await conn.execute(text(f"CREATE DATABASE {expected_db}"))
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
# Should succeed despite the database already existing
|
|
||||||
async with create_worker_database(DATABASE_URL) as url:
|
async with create_worker_database(DATABASE_URL) as url:
|
||||||
assert make_url(url).database == expected_db
|
assert make_url(url).database == expected_db
|
||||||
|
|
||||||
# Verify cleanup after context exit
|
|
||||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
result = await conn.execute(
|
result = await conn.execute(
|
||||||
@@ -487,49 +516,3 @@ class TestCreateWorkerDatabase:
|
|||||||
)
|
)
|
||||||
assert result.scalar() is None
|
assert result.scalar() is None
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
class TestCleanupTables:
|
|
||||||
"""Tests for cleanup_tables helper."""
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_truncates_all_tables(self):
|
|
||||||
"""All table rows are removed after cleanup_tables."""
|
|
||||||
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
|
||||||
role = Role(id=uuid.uuid4(), name="cleanup_role")
|
|
||||||
session.add(role)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
user = User(
|
|
||||||
id=uuid.uuid4(),
|
|
||||||
username="cleanup_user",
|
|
||||||
email="cleanup@test.com",
|
|
||||||
role_id=role.id,
|
|
||||||
)
|
|
||||||
session.add(user)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
# Verify rows exist
|
|
||||||
roles_count = await RoleCrud.count(session)
|
|
||||||
users_count = await UserCrud.count(session)
|
|
||||||
assert roles_count == 1
|
|
||||||
assert users_count == 1
|
|
||||||
|
|
||||||
await cleanup_tables(session, Base)
|
|
||||||
|
|
||||||
# Verify tables are empty
|
|
||||||
roles_count = await RoleCrud.count(session)
|
|
||||||
users_count = await UserCrud.count(session)
|
|
||||||
assert roles_count == 0
|
|
||||||
assert users_count == 0
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_noop_for_empty_metadata(self):
|
|
||||||
"""cleanup_tables does not raise when metadata has no tables."""
|
|
||||||
|
|
||||||
class EmptyBase(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
|
||||||
# Should not raise
|
|
||||||
await cleanup_tables(session, EmptyBase)
|
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ from pydantic import ValidationError
|
|||||||
from fastapi_toolsets.schemas import (
|
from fastapi_toolsets.schemas import (
|
||||||
ApiError,
|
ApiError,
|
||||||
CursorPagination,
|
CursorPagination,
|
||||||
|
CursorPaginatedResponse,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
OffsetPagination,
|
OffsetPagination,
|
||||||
|
OffsetPaginatedResponse,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
Pagination,
|
PaginationType,
|
||||||
Response,
|
Response,
|
||||||
ResponseStatus,
|
ResponseStatus,
|
||||||
)
|
)
|
||||||
@@ -199,19 +201,87 @@ class TestOffsetPagination:
|
|||||||
assert data["page"] == 2
|
assert data["page"] == 2
|
||||||
assert data["has_more"] is True
|
assert data["has_more"] is True
|
||||||
|
|
||||||
def test_pagination_alias_is_offset_pagination(self):
|
def test_total_count_can_be_none(self):
|
||||||
"""Pagination is a backward-compatible alias for OffsetPagination."""
|
"""total_count accepts None (include_total=False mode)."""
|
||||||
assert Pagination is OffsetPagination
|
pagination = OffsetPagination(
|
||||||
|
total_count=None,
|
||||||
|
items_per_page=20,
|
||||||
|
page=1,
|
||||||
|
has_more=True,
|
||||||
|
)
|
||||||
|
assert pagination.total_count is None
|
||||||
|
|
||||||
def test_pagination_alias_constructs_offset_pagination(self):
|
def test_serialization_with_none_total_count(self):
|
||||||
"""Code using Pagination(...) still works unchanged."""
|
"""OffsetPagination serializes total_count=None correctly."""
|
||||||
pagination = Pagination(
|
pagination = OffsetPagination(
|
||||||
total_count=10,
|
total_count=None,
|
||||||
items_per_page=5,
|
items_per_page=20,
|
||||||
page=2,
|
page=1,
|
||||||
has_more=False,
|
has_more=False,
|
||||||
)
|
)
|
||||||
assert isinstance(pagination, OffsetPagination)
|
data = pagination.model_dump()
|
||||||
|
assert data["total_count"] is None
|
||||||
|
|
||||||
|
def test_pages_computed(self):
|
||||||
|
"""pages is ceil(total_count / items_per_page)."""
|
||||||
|
pagination = OffsetPagination(
|
||||||
|
total_count=42,
|
||||||
|
items_per_page=10,
|
||||||
|
page=1,
|
||||||
|
has_more=True,
|
||||||
|
)
|
||||||
|
assert pagination.pages == 5
|
||||||
|
|
||||||
|
def test_pages_exact_division(self):
|
||||||
|
"""pages is exact when total_count is evenly divisible."""
|
||||||
|
pagination = OffsetPagination(
|
||||||
|
total_count=40,
|
||||||
|
items_per_page=10,
|
||||||
|
page=1,
|
||||||
|
has_more=False,
|
||||||
|
)
|
||||||
|
assert pagination.pages == 4
|
||||||
|
|
||||||
|
def test_pages_zero_total(self):
|
||||||
|
"""pages is 0 when total_count is 0."""
|
||||||
|
pagination = OffsetPagination(
|
||||||
|
total_count=0,
|
||||||
|
items_per_page=10,
|
||||||
|
page=1,
|
||||||
|
has_more=False,
|
||||||
|
)
|
||||||
|
assert pagination.pages == 0
|
||||||
|
|
||||||
|
def test_pages_zero_items_per_page(self):
|
||||||
|
"""pages is 0 when items_per_page is 0."""
|
||||||
|
pagination = OffsetPagination(
|
||||||
|
total_count=100,
|
||||||
|
items_per_page=0,
|
||||||
|
page=1,
|
||||||
|
has_more=False,
|
||||||
|
)
|
||||||
|
assert pagination.pages == 0
|
||||||
|
|
||||||
|
def test_pages_none_when_total_count_none(self):
|
||||||
|
"""pages is None when total_count is None (include_total=False)."""
|
||||||
|
pagination = OffsetPagination(
|
||||||
|
total_count=None,
|
||||||
|
items_per_page=20,
|
||||||
|
page=1,
|
||||||
|
has_more=True,
|
||||||
|
)
|
||||||
|
assert pagination.pages is None
|
||||||
|
|
||||||
|
def test_pages_in_serialization(self):
|
||||||
|
"""pages appears in model_dump output."""
|
||||||
|
pagination = OffsetPagination(
|
||||||
|
total_count=25,
|
||||||
|
items_per_page=10,
|
||||||
|
page=1,
|
||||||
|
has_more=True,
|
||||||
|
)
|
||||||
|
data = pagination.model_dump()
|
||||||
|
assert data["pages"] == 3
|
||||||
|
|
||||||
|
|
||||||
class TestCursorPagination:
|
class TestCursorPagination:
|
||||||
@@ -276,7 +346,7 @@ class TestPaginatedResponse:
|
|||||||
|
|
||||||
def test_create_paginated_response(self):
|
def test_create_paginated_response(self):
|
||||||
"""Create PaginatedResponse with data and pagination."""
|
"""Create PaginatedResponse with data and pagination."""
|
||||||
pagination = Pagination(
|
pagination = OffsetPagination(
|
||||||
total_count=30,
|
total_count=30,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=1,
|
page=1,
|
||||||
@@ -294,7 +364,7 @@ class TestPaginatedResponse:
|
|||||||
|
|
||||||
def test_with_custom_message(self):
|
def test_with_custom_message(self):
|
||||||
"""PaginatedResponse with custom message."""
|
"""PaginatedResponse with custom message."""
|
||||||
pagination = Pagination(
|
pagination = OffsetPagination(
|
||||||
total_count=5,
|
total_count=5,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=1,
|
page=1,
|
||||||
@@ -310,13 +380,13 @@ class TestPaginatedResponse:
|
|||||||
|
|
||||||
def test_empty_data(self):
|
def test_empty_data(self):
|
||||||
"""PaginatedResponse with empty data."""
|
"""PaginatedResponse with empty data."""
|
||||||
pagination = Pagination(
|
pagination = OffsetPagination(
|
||||||
total_count=0,
|
total_count=0,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=1,
|
page=1,
|
||||||
has_more=False,
|
has_more=False,
|
||||||
)
|
)
|
||||||
response = PaginatedResponse[dict](
|
response = PaginatedResponse(
|
||||||
data=[],
|
data=[],
|
||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
)
|
)
|
||||||
@@ -325,14 +395,33 @@ class TestPaginatedResponse:
|
|||||||
assert response.data == []
|
assert response.data == []
|
||||||
assert response.pagination.total_count == 0
|
assert response.pagination.total_count == 0
|
||||||
|
|
||||||
|
def test_class_getitem_with_concrete_type_returns_discriminated_union(self):
|
||||||
|
"""PaginatedResponse[T] with a concrete type returns a discriminated Annotated union."""
|
||||||
|
import typing
|
||||||
|
|
||||||
|
alias = PaginatedResponse[dict]
|
||||||
|
args = typing.get_args(alias)
|
||||||
|
# args[0] is the Union, args[1] is the FieldInfo discriminator
|
||||||
|
union_args = typing.get_args(args[0])
|
||||||
|
assert CursorPaginatedResponse[dict] in union_args
|
||||||
|
assert OffsetPaginatedResponse[dict] in union_args
|
||||||
|
|
||||||
|
def test_class_getitem_is_cached(self):
|
||||||
|
"""Repeated subscripting with the same type returns the identical cached object."""
|
||||||
|
assert PaginatedResponse[dict] is PaginatedResponse[dict]
|
||||||
|
|
||||||
|
def test_class_getitem_with_typevar_returns_generic(self):
|
||||||
|
"""PaginatedResponse[TypeVar] falls through to Pydantic generic parametrisation."""
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
alias = PaginatedResponse[T]
|
||||||
|
# Should be a generic alias, not an Annotated union
|
||||||
|
assert not hasattr(alias, "__metadata__")
|
||||||
|
|
||||||
def test_generic_type_hint(self):
|
def test_generic_type_hint(self):
|
||||||
"""PaginatedResponse supports generic type hints."""
|
"""PaginatedResponse supports generic type hints."""
|
||||||
|
pagination = OffsetPagination(
|
||||||
class UserOut:
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
|
|
||||||
pagination = Pagination(
|
|
||||||
total_count=1,
|
total_count=1,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=1,
|
page=1,
|
||||||
@@ -347,7 +436,7 @@ class TestPaginatedResponse:
|
|||||||
|
|
||||||
def test_serialization(self):
|
def test_serialization(self):
|
||||||
"""PaginatedResponse serializes correctly."""
|
"""PaginatedResponse serializes correctly."""
|
||||||
pagination = Pagination(
|
pagination = OffsetPagination(
|
||||||
total_count=100,
|
total_count=100,
|
||||||
items_per_page=10,
|
items_per_page=10,
|
||||||
page=5,
|
page=5,
|
||||||
@@ -385,15 +474,190 @@ class TestPaginatedResponse:
|
|||||||
)
|
)
|
||||||
assert isinstance(response.pagination, CursorPagination)
|
assert isinstance(response.pagination, CursorPagination)
|
||||||
|
|
||||||
def test_pagination_alias_accepted(self):
|
|
||||||
"""Constructing PaginatedResponse with Pagination (alias) still works."""
|
class TestPaginationType:
|
||||||
response = PaginatedResponse(
|
"""Tests for PaginationType enum."""
|
||||||
|
|
||||||
|
def test_offset_value(self):
|
||||||
|
"""OFFSET has string value 'offset'."""
|
||||||
|
assert PaginationType.OFFSET == "offset"
|
||||||
|
assert PaginationType.OFFSET.value == "offset"
|
||||||
|
|
||||||
|
def test_cursor_value(self):
|
||||||
|
"""CURSOR has string value 'cursor'."""
|
||||||
|
assert PaginationType.CURSOR == "cursor"
|
||||||
|
assert PaginationType.CURSOR.value == "cursor"
|
||||||
|
|
||||||
|
def test_is_string_enum(self):
|
||||||
|
"""PaginationType is a string enum."""
|
||||||
|
assert isinstance(PaginationType.OFFSET, str)
|
||||||
|
assert isinstance(PaginationType.CURSOR, str)
|
||||||
|
|
||||||
|
def test_members(self):
|
||||||
|
"""PaginationType has exactly two members."""
|
||||||
|
assert set(PaginationType) == {PaginationType.OFFSET, PaginationType.CURSOR}
|
||||||
|
|
||||||
|
|
||||||
|
class TestOffsetPaginatedResponse:
|
||||||
|
"""Tests for OffsetPaginatedResponse schema."""
|
||||||
|
|
||||||
|
def test_pagination_type_is_offset(self):
|
||||||
|
"""pagination_type is always PaginationType.OFFSET."""
|
||||||
|
response = OffsetPaginatedResponse(
|
||||||
data=[],
|
data=[],
|
||||||
pagination=Pagination(
|
pagination=OffsetPagination(
|
||||||
total_count=0, items_per_page=10, page=1, has_more=False
|
total_count=0, items_per_page=10, page=1, has_more=False
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
assert response.pagination_type is PaginationType.OFFSET
|
||||||
|
|
||||||
|
def test_pagination_type_serializes_to_string(self):
|
||||||
|
"""pagination_type serializes to 'offset' in JSON mode."""
|
||||||
|
response = OffsetPaginatedResponse(
|
||||||
|
data=[],
|
||||||
|
pagination=OffsetPagination(
|
||||||
|
total_count=0, items_per_page=10, page=1, has_more=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert response.model_dump(mode="json")["pagination_type"] == "offset"
|
||||||
|
|
||||||
|
def test_pagination_field_is_typed(self):
|
||||||
|
"""pagination field is OffsetPagination, not the union."""
|
||||||
|
response = OffsetPaginatedResponse(
|
||||||
|
data=[{"id": 1}],
|
||||||
|
pagination=OffsetPagination(
|
||||||
|
total_count=10, items_per_page=5, page=2, has_more=True
|
||||||
|
),
|
||||||
|
)
|
||||||
assert isinstance(response.pagination, OffsetPagination)
|
assert isinstance(response.pagination, OffsetPagination)
|
||||||
|
assert response.pagination.total_count == 10
|
||||||
|
assert response.pagination.page == 2
|
||||||
|
|
||||||
|
def test_is_subclass_of_paginated_response(self):
|
||||||
|
"""OffsetPaginatedResponse IS a PaginatedResponse."""
|
||||||
|
response = OffsetPaginatedResponse(
|
||||||
|
data=[],
|
||||||
|
pagination=OffsetPagination(
|
||||||
|
total_count=0, items_per_page=10, page=1, has_more=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert isinstance(response, PaginatedResponse)
|
||||||
|
|
||||||
|
def test_pagination_type_default_cannot_be_overridden_to_cursor(self):
|
||||||
|
"""pagination_type rejects values other than OFFSET."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
OffsetPaginatedResponse(
|
||||||
|
data=[],
|
||||||
|
pagination=OffsetPagination(
|
||||||
|
total_count=0, items_per_page=10, page=1, has_more=False
|
||||||
|
),
|
||||||
|
pagination_type=PaginationType.CURSOR, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_filter_attributes_defaults_to_none(self):
|
||||||
|
"""filter_attributes defaults to None."""
|
||||||
|
response = OffsetPaginatedResponse(
|
||||||
|
data=[],
|
||||||
|
pagination=OffsetPagination(
|
||||||
|
total_count=0, items_per_page=10, page=1, has_more=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert response.filter_attributes is None
|
||||||
|
|
||||||
|
def test_full_serialization(self):
|
||||||
|
"""Full JSON serialization includes all expected fields."""
|
||||||
|
response = OffsetPaginatedResponse(
|
||||||
|
data=[{"id": 1}],
|
||||||
|
pagination=OffsetPagination(
|
||||||
|
total_count=1, items_per_page=10, page=1, has_more=False
|
||||||
|
),
|
||||||
|
filter_attributes={"status": ["active"]},
|
||||||
|
)
|
||||||
|
data = response.model_dump(mode="json")
|
||||||
|
|
||||||
|
assert data["pagination_type"] == "offset"
|
||||||
|
assert data["status"] == "SUCCESS"
|
||||||
|
assert data["data"] == [{"id": 1}]
|
||||||
|
assert data["pagination"]["total_count"] == 1
|
||||||
|
assert data["filter_attributes"] == {"status": ["active"]}
|
||||||
|
|
||||||
|
|
||||||
|
class TestCursorPaginatedResponse:
|
||||||
|
"""Tests for CursorPaginatedResponse schema."""
|
||||||
|
|
||||||
|
def test_pagination_type_is_cursor(self):
|
||||||
|
"""pagination_type is always PaginationType.CURSOR."""
|
||||||
|
response = CursorPaginatedResponse(
|
||||||
|
data=[],
|
||||||
|
pagination=CursorPagination(
|
||||||
|
next_cursor=None, items_per_page=10, has_more=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert response.pagination_type is PaginationType.CURSOR
|
||||||
|
|
||||||
|
def test_pagination_type_serializes_to_string(self):
|
||||||
|
"""pagination_type serializes to 'cursor' in JSON mode."""
|
||||||
|
response = CursorPaginatedResponse(
|
||||||
|
data=[],
|
||||||
|
pagination=CursorPagination(
|
||||||
|
next_cursor=None, items_per_page=10, has_more=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert response.model_dump(mode="json")["pagination_type"] == "cursor"
|
||||||
|
|
||||||
|
def test_pagination_field_is_typed(self):
|
||||||
|
"""pagination field is CursorPagination, not the union."""
|
||||||
|
response = CursorPaginatedResponse(
|
||||||
|
data=[{"id": 1}],
|
||||||
|
pagination=CursorPagination(
|
||||||
|
next_cursor="abc123",
|
||||||
|
prev_cursor=None,
|
||||||
|
items_per_page=20,
|
||||||
|
has_more=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert isinstance(response.pagination, CursorPagination)
|
||||||
|
assert response.pagination.next_cursor == "abc123"
|
||||||
|
assert response.pagination.has_more is True
|
||||||
|
|
||||||
|
def test_is_subclass_of_paginated_response(self):
|
||||||
|
"""CursorPaginatedResponse IS a PaginatedResponse."""
|
||||||
|
response = CursorPaginatedResponse(
|
||||||
|
data=[],
|
||||||
|
pagination=CursorPagination(
|
||||||
|
next_cursor=None, items_per_page=10, has_more=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert isinstance(response, PaginatedResponse)
|
||||||
|
|
||||||
|
def test_pagination_type_default_cannot_be_overridden_to_offset(self):
|
||||||
|
"""pagination_type rejects values other than CURSOR."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
CursorPaginatedResponse(
|
||||||
|
data=[],
|
||||||
|
pagination=CursorPagination(
|
||||||
|
next_cursor=None, items_per_page=10, has_more=False
|
||||||
|
),
|
||||||
|
pagination_type=PaginationType.OFFSET, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_full_serialization(self):
|
||||||
|
"""Full JSON serialization includes all expected fields."""
|
||||||
|
response = CursorPaginatedResponse(
|
||||||
|
data=[{"id": 1}],
|
||||||
|
pagination=CursorPagination(
|
||||||
|
next_cursor="tok_next",
|
||||||
|
prev_cursor="tok_prev",
|
||||||
|
items_per_page=10,
|
||||||
|
has_more=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
data = response.model_dump(mode="json")
|
||||||
|
|
||||||
|
assert data["pagination_type"] == "cursor"
|
||||||
|
assert data["status"] == "SUCCESS"
|
||||||
|
assert data["pagination"]["next_cursor"] == "tok_next"
|
||||||
|
assert data["pagination"]["prev_cursor"] == "tok_prev"
|
||||||
|
|
||||||
|
|
||||||
class TestFromAttributes:
|
class TestFromAttributes:
|
||||||
|
|||||||
1180
tests/test_security.py
Normal file
1180
tests/test_security.py
Normal file
File diff suppressed because it is too large
Load Diff
454
uv.lock
generated
454
uv.lock
generated
@@ -81,6 +81,76 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
|
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bcrypt"
|
||||||
|
version = "5.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.1.4"
|
version = "2026.1.4"
|
||||||
@@ -113,101 +183,101 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coverage"
|
name = "coverage"
|
||||||
version = "7.13.4"
|
version = "7.13.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" },
|
{ url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" },
|
{ url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" },
|
{ url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" },
|
{ url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" },
|
{ url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" },
|
{ url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" },
|
{ url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" },
|
{ url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" },
|
{ url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" },
|
{ url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" },
|
{ url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" },
|
{ url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" },
|
{ url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" },
|
{ url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" },
|
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" },
|
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" },
|
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" },
|
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" },
|
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" },
|
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" },
|
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
|
{ url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
|
{ url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
|
{ url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
|
{ url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
|
{ url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
|
{ url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
|
{ url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
|
{ url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
|
{ url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
|
{ url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
|
{ url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
|
{ url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
|
{ url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
|
{ url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
|
{ url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
|
{ url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
|
{ url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
|
{ url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
|
{ url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
|
{ url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
|
{ url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
|
{ url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
|
{ url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
|
{ url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
|
{ url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
|
{ url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
|
{ url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
|
{ url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
|
{ url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
|
{ url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
|
{ url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
|
{ url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
|
{ url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
|
{ url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
|
{ url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
|
{ url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
|
{ url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
|
{ url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
|
{ url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@@ -235,7 +305,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.133.1"
|
version = "0.135.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-doc" },
|
{ name = "annotated-doc" },
|
||||||
@@ -244,14 +314,14 @@ dependencies = [
|
|||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/22/6f/0eafed8349eea1fa462238b54a624c8b408cd1ba2795c8e64aa6c34f8ab7/fastapi-0.133.1.tar.gz", hash = "sha256:ed152a45912f102592976fde6cbce7dae1a8a1053da94202e51dd35d184fadd6", size = 378741, upload-time = "2026-02-25T18:18:17.398Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/c9/a175a7779f3599dfa4adfc97a6ce0e157237b3d7941538604aadaf97bfb6/fastapi-0.133.1-py3-none-any.whl", hash = "sha256:658f34ba334605b1617a65adf2ea6461901bdb9af3a3080d63ff791ecf7dc2e2", size = 109029, upload-time = "2026-02-25T18:18:18.578Z" },
|
{ url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "1.3.0"
|
version = "3.0.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
@@ -282,10 +352,13 @@ pytest = [
|
|||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "bcrypt" },
|
||||||
{ name = "coverage" },
|
{ name = "coverage" },
|
||||||
{ name = "fastapi-toolsets", extra = ["all"] },
|
{ name = "fastapi-toolsets", extra = ["all"] },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
{ name = "mike" },
|
||||||
{ name = "mkdocstrings-python" },
|
{ name = "mkdocstrings-python" },
|
||||||
|
{ name = "prek" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-anyio" },
|
{ name = "pytest-anyio" },
|
||||||
{ name = "pytest-cov" },
|
{ name = "pytest-cov" },
|
||||||
@@ -295,9 +368,13 @@ dev = [
|
|||||||
{ name = "zensical" },
|
{ name = "zensical" },
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
|
{ name = "mike" },
|
||||||
{ name = "mkdocstrings-python" },
|
{ name = "mkdocstrings-python" },
|
||||||
{ name = "zensical" },
|
{ name = "zensical" },
|
||||||
]
|
]
|
||||||
|
docs-src = [
|
||||||
|
{ name = "bcrypt" },
|
||||||
|
]
|
||||||
tests = [
|
tests = [
|
||||||
{ name = "coverage" },
|
{ name = "coverage" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
@@ -324,22 +401,27 @@ provides-extras = ["cli", "metrics", "pytest", "all"]
|
|||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ 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"] },
|
||||||
{ name = "httpx", specifier = ">=0.25.0" },
|
{ name = "httpx", specifier = ">=0.25.0" },
|
||||||
|
{ name = "mike", git = "https://github.com/squidfunk/mike.git?tag=2.2.0%2Bzensical-0.1.0" },
|
||||||
{ name = "mkdocstrings-python", specifier = ">=2.0.2" },
|
{ name = "mkdocstrings-python", specifier = ">=2.0.2" },
|
||||||
|
{ name = "prek", specifier = ">=0.3.8" },
|
||||||
{ name = "pytest", specifier = ">=8.0.0" },
|
{ name = "pytest", specifier = ">=8.0.0" },
|
||||||
{ name = "pytest-anyio", specifier = ">=0.0.0" },
|
{ name = "pytest-anyio", specifier = ">=0.0.0" },
|
||||||
{ name = "pytest-cov", specifier = ">=4.0.0" },
|
{ name = "pytest-cov", specifier = ">=4.0.0" },
|
||||||
{ name = "pytest-xdist", specifier = ">=3.0.0" },
|
{ name = "pytest-xdist", specifier = ">=3.0.0" },
|
||||||
{ name = "ruff", specifier = ">=0.1.0" },
|
{ name = "ruff", specifier = ">=0.1.0" },
|
||||||
{ name = "ty", specifier = ">=0.0.1a0" },
|
{ name = "ty", specifier = ">=0.0.1a0" },
|
||||||
{ name = "zensical", specifier = ">=0.0.23" },
|
{ name = "zensical", specifier = ">=0.0.30" },
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
|
{ name = "mike", git = "https://github.com/squidfunk/mike.git?tag=2.2.0%2Bzensical-0.1.0" },
|
||||||
{ name = "mkdocstrings-python", specifier = ">=2.0.2" },
|
{ name = "mkdocstrings-python", specifier = ">=2.0.2" },
|
||||||
{ name = "zensical", specifier = ">=0.0.23" },
|
{ name = "zensical", specifier = ">=0.0.30" },
|
||||||
]
|
]
|
||||||
|
docs-src = [{ name = "bcrypt", specifier = ">=4.0.0" }]
|
||||||
tests = [
|
tests = [
|
||||||
{ name = "coverage", specifier = ">=7.0.0" },
|
{ name = "coverage", specifier = ">=7.0.0" },
|
||||||
{ name = "httpx", specifier = ">=0.25.0" },
|
{ name = "httpx", specifier = ">=0.25.0" },
|
||||||
@@ -417,6 +499,7 @@ wheels = [
|
|||||||
name = "griffelib"
|
name = "griffelib"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ad/06/eccbd311c9e2b3ca45dbc063b93134c57a1ccc7607c5e545264ad092c4a9/griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934", size = 166312, upload-time = "2026-03-23T21:06:55.954Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" },
|
{ url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" },
|
||||||
]
|
]
|
||||||
@@ -601,6 +684,17 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" },
|
{ url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mike"
|
||||||
|
version = "2.2.0+zensical.0.1.0"
|
||||||
|
source = { git = "https://github.com/squidfunk/mike.git?tag=2.2.0%2Bzensical-0.1.0#0f62791256ebeba60d20d2f1d8fe6ec3b7d1e2b3" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "jinja2" },
|
||||||
|
{ name = "pyparsing" },
|
||||||
|
{ name = "verspec" },
|
||||||
|
{ name = "zensical" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mkdocs"
|
name = "mkdocs"
|
||||||
version = "1.6.1"
|
version = "1.6.1"
|
||||||
@@ -720,6 +814,30 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prek"
|
||||||
|
version = "0.3.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/62/ee/03e8180e3fda9de25b6480bd15cc2bde40d573868d50648b0e527b35562f/prek-0.3.8.tar.gz", hash = "sha256:434a214256516f187a3ab15f869d950243be66b94ad47987ee4281b69643a2d9", size = 400224, upload-time = "2026-03-23T08:23:35.981Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/84/40d2ddf362d12c4cd4a25a8c89a862edf87cdfbf1422aa41aac8e315d409/prek-0.3.8-py3-none-linux_armv6l.whl", hash = "sha256:6fb646ada60658fa6dd7771b2e0fb097f005151be222f869dada3eb26d79ed33", size = 5226646, upload-time = "2026-03-23T08:23:18.306Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/52/7308a033fa43b7e8e188797bd2b3b017c0f0adda70fa7af575b1f43ea888/prek-0.3.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f3d7fdadb15efc19c09953c7a33cf2061a70f367d1e1957358d3ad5cc49d0616", size = 5620104, upload-time = "2026-03-23T08:23:40.053Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/b1/f106ac000a91511a9cd80169868daf2f5b693480ef5232cec5517a38a512/prek-0.3.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:72728c3295e79ca443f8c1ec037d2a5b914ec73a358f69cf1bc1964511876bf8", size = 5199867, upload-time = "2026-03-23T08:23:38.066Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/e9/970713f4b019f69de9844e1bab37b8ddb67558e410916f4eb5869a696165/prek-0.3.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:48efc28f2f53b5b8087efca9daaed91572d62df97d5f24a1c7a087fecb5017de", size = 5441801, upload-time = "2026-03-23T08:23:32.617Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/a4/7ef44032b181753e19452ec3b09abb3a32607cf6b0a0508f0604becaaf2b/prek-0.3.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f6ca9d63bacbc448a5c18e955c78d3ac5176c3a17c3baacdd949b1a623e08a36", size = 5155107, upload-time = "2026-03-23T08:23:31.021Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/77/4d9c8985dbba84149760785dfe07093ea1e29d710257dfb7c89615e2234c/prek-0.3.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1000f7029696b4fe712fb1fefd4c55b9c4de72b65509c8e50296370a06f9dc3f", size = 5566541, upload-time = "2026-03-23T08:23:45.694Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/1a/81e6769ac1f7f8346d09ce2ab0b47cf06466acd9ff72e87e5d1f0d98cd32/prek-0.3.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ff0bed0e2c1286522987d982168a86cbbd0d069d840506a46c9fda983515517", size = 6552991, upload-time = "2026-03-23T08:23:21.958Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/fa/ce2df0dd2dc75a9437a52463239d0782998943d7b04e191fb89b83016c34/prek-0.3.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fb087ac0ffda3ac65bbbae9a38326a7fd27ee007bb4a94323ce1eb539d8bbec", size = 5832972, upload-time = "2026-03-23T08:23:20.258Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/6b/9d4269df9073216d296244595a21c253b6475dfc9076c0bd2906be7a436c/prek-0.3.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2e1e5e206ff7b31bd079cce525daddc96cd6bc544d20dc128921ad92f7a4c85d", size = 5448371, upload-time = "2026-03-23T08:23:41.835Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/1d/1e4d8a78abefa5b9d086e5a9f1638a74b5e540eec8a648d9946707701f29/prek-0.3.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:dcea3fe23832a4481bccb7c45f55650cb233be7c805602e788bb7dba60f2d861", size = 5270546, upload-time = "2026-03-23T08:23:24.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/07/34f36551a6319ae36e272bea63a42f59d41d2d47ab0d5fb00eb7b4e88e87/prek-0.3.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:4d25e647e9682f6818ab5c31e7a4b842993c14782a6ffcd128d22b784e0d677f", size = 5124032, upload-time = "2026-03-23T08:23:26.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/01/6d544009bb655e709993411796af77339f439526db4f3b3509c583ad8eb9/prek-0.3.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:de528b82935e33074815acff3c7c86026754d1212136295bc88fe9c43b4231d5", size = 5432245, upload-time = "2026-03-23T08:23:47.877Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/96/1237ee269e9bfa283ffadbcba1f401f48a47aed2b2563eb1002740d6079d/prek-0.3.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6d660f1c25a126e6d9f682fe61449441226514f412a4469f5d71f8f8cad56db2", size = 5950550, upload-time = "2026-03-23T08:23:43.8Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/6b/a574411459049bc691047c9912f375deda10c44a707b6ce98df2b658f0b3/prek-0.3.8-py3-none-win32.whl", hash = "sha256:b0c291c577615d9f8450421dff0b32bfd77a6b0d223ee4115a1f820cb636fdf1", size = 4949501, upload-time = "2026-03-23T08:23:16.338Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/b4/46b59fe49f635acd9f6530778ce577f9d8b49452835726a5311ffc902c67/prek-0.3.8-py3-none-win_amd64.whl", hash = "sha256:bc147fdbdd4ec33fc7a987b893ecb69b1413ac100d95c9889a70f3fd58c73d06", size = 5346551, upload-time = "2026-03-23T08:23:34.501Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/05/9cca1708bb8c65264124eb4b04251e0f65ce5bfc707080bb6b492d5a0df7/prek-0.3.8-py3-none-win_arm64.whl", hash = "sha256:a2614647aeafa817a5802ccb9561e92eedc20dcf840639a1b00826e2c2442515", size = 5190872, upload-time = "2026-03-23T08:23:29.463Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prometheus-client"
|
name = "prometheus-client"
|
||||||
version = "0.24.1"
|
version = "0.24.1"
|
||||||
@@ -843,24 +961,33 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.19.2"
|
version = "2.20.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pymdown-extensions"
|
name = "pymdown-extensions"
|
||||||
version = "10.21"
|
version = "10.21.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "markdown" },
|
{ name = "markdown" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/63/06673d1eb6d8f83c0ea1f677d770e12565fb516928b4109c9e2055656a9e/pymdown_extensions-10.21.tar.gz", hash = "sha256:39f4a020f40773f6b2ff31d2cd2546c2c04d0a6498c31d9c688d2be07e1767d5", size = 853363, upload-time = "2026-02-15T20:44:06.748Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/2c/5b079febdc65e1c3fb2729bf958d18b45be7113828528e8a0b5850dd819a/pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f", size = 268877, upload-time = "2026-02-15T20:44:05.464Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyparsing"
|
||||||
|
version = "3.3.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -894,16 +1021,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest-cov"
|
name = "pytest-cov"
|
||||||
version = "7.0.0"
|
version = "7.1.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "coverage", extra = ["toml"] },
|
{ name = "coverage", extra = ["toml"] },
|
||||||
{ name = "pluggy" },
|
{ name = "pluggy" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1013,27 +1140,27 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.2"
|
version = "0.15.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" },
|
{ url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" },
|
{ url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" },
|
{ url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" },
|
{ url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" },
|
{ url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" },
|
{ url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" },
|
{ url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" },
|
{ url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" },
|
{ url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" },
|
{ url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1177,26 +1304,26 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ty"
|
name = "ty"
|
||||||
version = "0.0.18"
|
version = "0.0.27"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/74/15/9682700d8d60fdca7afa4febc83a2354b29cdcd56e66e19c92b521db3b39/ty-0.0.18.tar.gz", hash = "sha256:04ab7c3db5dcbcdac6ce62e48940d3a0124f377c05499d3f3e004e264ae94b83", size = 5214774, upload-time = "2026-02-20T21:51:31.173Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f4/de/e5cf1f151cf52fe1189e42d03d90909d7d1354fdc0c1847cbb63a0baa3da/ty-0.0.27.tar.gz", hash = "sha256:d7a8de3421d92420b40c94fe7e7d4816037560621903964dd035cf9bd0204a73", size = 5424130, upload-time = "2026-03-31T19:07:20.806Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/d8/920460d4c22ea68fcdeb0b2fb53ea2aeb9c6d7875bde9278d84f2ac767b6/ty-0.0.18-py3-none-linux_armv6l.whl", hash = "sha256:4e5e91b0a79857316ef893c5068afc4b9872f9d257627d9bc8ac4d2715750d88", size = 10280825, upload-time = "2026-02-20T21:51:25.03Z" },
|
{ url = "https://files.pythonhosted.org/packages/fa/20/2a9ea661758bd67f2bfd54ce9daacb5a26c56c5f8b49fbd9a43b365a8a7d/ty-0.0.27-py3-none-linux_armv6l.whl", hash = "sha256:eb14456b8611c9e8287aa9b633f4d2a0d9f3082a31796969e0b50bdda8930281", size = 10571211, upload-time = "2026-03-31T19:07:23.28Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/56/62587de582d3d20d78fcdddd0594a73822ac5a399a12ef512085eb7a4de6/ty-0.0.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee0e578b3f8416e2d5416da9553b78fd33857868aa1384cb7fefeceee5ff102d", size = 10118324, upload-time = "2026-02-20T21:51:22.27Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/b2/8887a51f705d075ddbe78ae7f0d4755ef48d0a90235f67aee289e9cee950/ty-0.0.27-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:02e662184703db7586118df611cf24a000d35dae38d950053d1dd7b6736fd2c4", size = 10427576, upload-time = "2026-03-31T19:07:15.499Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/2d/dbdace8d432a0755a7417f659bfd5b8a4261938ecbdfd7b42f4c454f5aa9/ty-0.0.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3f7a0487d36b939546a91d141f7fc3dbea32fab4982f618d5b04dc9d5b6da21e", size = 9605861, upload-time = "2026-02-20T21:51:16.066Z" },
|
{ url = "https://files.pythonhosted.org/packages/1d/c3/79d88163f508fb709ce19bc0b0a66c7c64b53d372d4caa56172c3d9b3ae8/ty-0.0.27-py3-none-macosx_11_0_arm64.whl", hash = "sha256:be5fc2899441f7f8f7ef40f9ffd006075a5ff6b06c44e8d2aa30e1b900c12f51", size = 9870359, upload-time = "2026-03-31T19:07:36.852Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/d9/de11c0280f778d5fc571393aada7fe9b8bc1dd6a738f2e2c45702b8b3150/ty-0.0.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5e2fa8d45f57ca487a470e4bf66319c09b561150e98ae2a6b1a97ef04c1a4eb", size = 10092701, upload-time = "2026-02-20T21:51:26.862Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/4d/ed1b0db0e1e46b5ed4976bbfe0d1825faf003b4e3774ef28c785ed73e4bb/ty-0.0.27-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30231e652b14742a76b64755e54bf0cb1cd4c128bcaf625222e0ca92a2094887", size = 10380488, upload-time = "2026-03-31T19:07:31.268Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/94/068d4d591d791041732171e7b63c37a54494b2e7d28e88d2167eaa9ad875/ty-0.0.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d75652e9e937f7044b1aca16091193e7ef11dac1c7ec952b7fb8292b7ba1f5f2", size = 10109203, upload-time = "2026-02-20T21:51:11.59Z" },
|
{ url = "https://files.pythonhosted.org/packages/b1/f2/20372f6d510b01570028433064880adec2f8abe68bf0c4603be61a560bef/ty-0.0.27-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a119b1168f64261b3205a37e40b5b6c4aac8fd58e4587988f4e4b22c3c79847", size = 10390248, upload-time = "2026-03-31T19:07:28.345Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/e4/526a4aa56dc0ca2569aaa16880a1ab105c3b416dd70e87e25a05688999f3/ty-0.0.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:563c868edceb8f6ddd5e91113c17d3676b028f0ed380bdb3829b06d9beb90e58", size = 10614200, upload-time = "2026-02-20T21:51:20.298Z" },
|
{ url = "https://files.pythonhosted.org/packages/45/4b/46b31a7311306be1a560f7f20fdc37b5bf718787f60626cd265d9b637554/ty-0.0.27-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e38f4e187b6975d2cbebf0f1eb1221f8f64f6e509bad14d7bb2a91afc97e4956", size = 10878479, upload-time = "2026-03-31T19:07:39.393Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/3d/b68ab20a34122a395880922587fbfc3adf090d22e0fb546d4d20fe8c2621/ty-0.0.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:502e2a1f948bec563a0454fc25b074bf5cf041744adba8794d024277e151d3b0", size = 11153232, upload-time = "2026-02-20T21:51:14.121Z" },
|
{ url = "https://files.pythonhosted.org/packages/42/ba/5231a2a1fb1cebe053a25de8fded95e1a30a1e77d3628a9e58487297bafc/ty-0.0.27-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a07b1a8fbb23844f6d22091275430d9ac617175f34aa99159b268193de210389", size = 11461232, upload-time = "2026-03-31T19:07:02.518Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/ea/678243c042343fcda7e6af36036c18676c355878dcdcd517639586d2cf9e/ty-0.0.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc881dea97021a3aa29134a476937fd8054775c4177d01b94db27fcfb7aab65b", size = 10832934, upload-time = "2026-02-20T21:51:32.92Z" },
|
{ url = "https://files.pythonhosted.org/packages/c3/37/558abab3e1f6670493524f61280b4dfcc3219555f13889223e733381dfab/ty-0.0.27-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d3ec4033031f240836bb0337274bac5c49dde312c7c6d7575451ed719bf8ffa3", size = 11133002, upload-time = "2026-03-31T19:07:18.371Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d8/bd/7f8d647cef8b7b346c0163230a37e903c7461c7248574840b977045c77df/ty-0.0.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:421fcc3bc64cab56f48edb863c7c1c43649ec4d78ff71a1acb5366ad723b6021", size = 10700888, upload-time = "2026-02-20T21:51:09.673Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/38/188c14a57f52160407ce62c6abb556011718fd0bcbe1dca690529ce84c46/ty-0.0.27-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:924a8849afd500d260bf5b7296165a05b7424fbb6b19113f30f3b999d682873f", size = 10986624, upload-time = "2026-03-31T19:07:13.066Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/06/cb3620dc48c5d335ba7876edfef636b2f4498eff4a262ff90033b9e88408/ty-0.0.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0fe5038a7136a0e638a2fb1ad06e3d3c4045314c6ba165c9c303b9aeb4623d6c", size = 10078965, upload-time = "2026-02-20T21:51:07.678Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/f1/667a71393f47d2cd6ba9ed07541b8df3eb63aab1f2ee658e77d91b8362fa/ty-0.0.27-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d8270026c07e7423a1b3a3fd065b46ed1478748f0662518b523b57744f3fa025", size = 10366721, upload-time = "2026-03-31T19:07:00.131Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/27/c77a5a84533fa3b685d592de7b4b108eb1f38851c40fac4e79cc56ec7350/ty-0.0.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d123600a52372677613a719bbb780adeb9b68f47fb5f25acb09171de390e0035", size = 10134659, upload-time = "2026-02-20T21:51:18.311Z" },
|
{ url = "https://files.pythonhosted.org/packages/8b/aa/8edafe41be898bda774249abc5be6edd733e53fb1777d59ea9331e38537d/ty-0.0.27-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e26e9735d3bdfd95d881111ad1cf570eab8188d8c3be36d6bcaad044d38984d8", size = 10412239, upload-time = "2026-03-31T19:07:05.297Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/6e/60af6b88c73469e628ba5253a296da6984e0aa746206f3034c31f1a04ed1/ty-0.0.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bb4bc11d32a1bf96a829bf6b9696545a30a196ac77bbc07cc8d3dfee35e03723", size = 10297494, upload-time = "2026-02-20T21:51:39.631Z" },
|
{ url = "https://files.pythonhosted.org/packages/53/ff/8bafaed4a18d38264f46bdfc427de7ea2974cf9064e4e0bdb1b6e6c724e3/ty-0.0.27-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7c09cc9a699810609acc0090af8d0db68adaee6e60a7c3e05ab80cc954a83db7", size = 10573507, upload-time = "2026-03-31T19:06:57.064Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/90/612dc0b68224c723faed6adac2bd3f930a750685db76dfe17e6b9e534a83/ty-0.0.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dda2efbf374ba4cd704053d04e32f2f784e85c2ddc2400006b0f96f5f7e4b667", size = 10791944, upload-time = "2026-02-20T21:51:37.13Z" },
|
{ url = "https://files.pythonhosted.org/packages/16/2e/63a8284a2fefd08ab56ecbad0fde7dd4b2d4045a31cf24c1d1fcd9643227/ty-0.0.27-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d3e02853bb037221a456e034b1898aaa573e6374fbb53884e33cb7513ccb85a", size = 11090233, upload-time = "2026-03-31T19:07:34.139Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0d/da/f4ada0fd08a9e4138fe3fd2bcd3797753593f423f19b1634a814b9b2a401/ty-0.0.18-py3-none-win32.whl", hash = "sha256:c5768607c94977dacddc2f459ace6a11a408a0f57888dd59abb62d28d4fee4f7", size = 9677964, upload-time = "2026-02-20T21:51:42.039Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/d3/d6fa1cafdfa2b34dbfa304fc6833af8e1669fc34e24d214fa76d2a2e5a25/ty-0.0.27-py3-none-win32.whl", hash = "sha256:34e7377f2047c14dbbb7bf5322e84114db7a5f2cb470db6bee63f8f3550cfc1e", size = 9984415, upload-time = "2026-03-31T19:07:07.98Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/fa/090ed9746e5c59fc26d8f5f96dc8441825171f1f47752f1778dad690b08b/ty-0.0.18-py3-none-win_amd64.whl", hash = "sha256:b78d0fa1103d36fc2fce92f2092adace52a74654ab7884d54cdaec8eb5016a4d", size = 10636576, upload-time = "2026-02-20T21:51:29.159Z" },
|
{ url = "https://files.pythonhosted.org/packages/85/e6/dd4e27da9632b3472d5711ca49dbd3709dbd3e8c73f3af6db9c254235ca9/ty-0.0.27-py3-none-win_amd64.whl", hash = "sha256:3f7e4145aad8b815ed69b324c93b5b773eb864dda366ca16ab8693ff88ce6f36", size = 10961535, upload-time = "2026-03-31T19:07:10.566Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/4f/5dd60904c8105cda4d0be34d3a446c180933c76b84ae0742e58f02133713/ty-0.0.18-py3-none-win_arm64.whl", hash = "sha256:01770c3c82137c6b216aa3251478f0b197e181054ee92243772de553d3586398", size = 10095449, upload-time = "2026-02-20T21:51:34.914Z" },
|
{ url = "https://files.pythonhosted.org/packages/0e/1a/824b3496d66852ed7d5d68d9787711131552b68dce8835ce9410db32e618/ty-0.0.27-py3-none-win_arm64.whl", hash = "sha256:95bf8d01eb96bb2ba3ffc39faff19da595176448e80871a7b362f4d2de58476c", size = 10376689, upload-time = "2026-03-31T19:07:25.732Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1235,6 +1362,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "verspec"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e7/44/8126f9f0c44319b2efc65feaad589cadef4d77ece200ae3c9133d58464d0/verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e", size = 27123, upload-time = "2020-11-30T02:24:09.646Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31", size = 19640, upload-time = "2020-11-30T02:24:08.387Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "watchdog"
|
name = "watchdog"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -1264,7 +1400,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zensical"
|
name = "zensical"
|
||||||
version = "0.0.23"
|
version = "0.0.31"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
@@ -1274,18 +1410,18 @@ dependencies = [
|
|||||||
{ name = "pymdown-extensions" },
|
{ name = "pymdown-extensions" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/ab/a65452b4e769552fd5a78c4996d6cf322630d896ddfd55c5433d96485e8b/zensical-0.0.23.tar.gz", hash = "sha256:5c4fc3aaf075df99d8cf41b9f2566e4d588180d9a89493014d3607dfe50ac4bc", size = 3822451, upload-time = "2026-02-11T21:24:38.373Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/d5/1a/9b6f5285c5aef648db38f9132f49a7059bd2c9d748f68ef0c52ed8afcff3/zensical-0.0.31.tar.gz", hash = "sha256:9c12f07bde70c4bfdb13d6cae1bedf8d18064d257a6e81128a152502b28a8fc3", size = 3891758, upload-time = "2026-04-01T11:30:21.88Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/86/035aa02bd36d26a03a1885bc22a73d4fe61ba0e21d0033cc42baf13d24f6/zensical-0.0.23-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:35d6d3eb803fe73a67187a1a25443408bd02a8dd50e151f4a4bafd40de3f0928", size = 12242966, upload-time = "2026-02-11T21:24:05.894Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/db/cc4e555d2e816f2d91304ff969d62cc3a401ee477dbb7c720b874bec67d6/zensical-0.0.31-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b489936d670733dd204f16b689a2acc0e45b69e42cc4901f5131ae57658b8fbc", size = 12419980, upload-time = "2026-04-01T11:29:44.01Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/68/335dfbb7efc972964f0610736a0ad243dd8a5dcc2ec76b9ddb84c847a4a4/zensical-0.0.23-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:5973267460a190f348f24d445ff0c01e8ed334fd075947687b305e68257f6b18", size = 12125173, upload-time = "2026-02-11T21:24:08.507Z" },
|
{ url = "https://files.pythonhosted.org/packages/e7/c1/6789f73164c7f5821f5defb8a80b1dba8d5af24bdec7db36876793c5afd9/zensical-0.0.31-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d9f678efc0d9918e45eeb8bc62847b2cce23db7393c8c59c1be6d1c064bbaacd", size = 12292301, upload-time = "2026-04-01T11:29:47.277Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/9c/d567da04fbeb077df5cf06a94f947af829ebef0ff5ca7d0ba4910a6cbdf6/zensical-0.0.23-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:953adf1f0b346a6c65fc6e05e6cc1c38a6440fec29c50c76fb29700cc1927006", size = 12489636, upload-time = "2026-02-11T21:24:10.91Z" },
|
{ url = "https://files.pythonhosted.org/packages/4f/9a/6a83ad209081a953e0285d5056e5452c4fbcabd2f104f3797d53e4bdd96f/zensical-0.0.31-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb2b50ecf674997f818e53f12f2a67875a21b0c79ed74c151dfaef2f1475e5bf", size = 12661472, upload-time = "2026-04-01T11:29:50.706Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/6e/481a3ecf8a7b63a35c67f5be1ea548185d55bb1dacead54f76a9550197b2/zensical-0.0.23-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49c1cbd6131dafa056be828e081759184f9b8dd24b99bf38d1e77c8c31b0c720", size = 12421313, upload-time = "2026-02-11T21:24:13.9Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/4a/a82f5c81893b7a607cf9d439b75c3c3894b4ef4d3e92d5d818b4fa5c6f23/zensical-0.0.31-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6fb5c634fe88254770a2d4db5c05b06f1c3ee5e29d2ae3e7efdae8905e435b1d", size = 12603784, upload-time = "2026-04-01T11:29:53.623Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/aa/a95481547f708432636f5f8155917c90d877c244c62124a084f7448b60b2/zensical-0.0.23-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5b7fe22c5d33b2b91899c5df7631ad4ce9cccfabac2560cc92ba73eafe2d297", size = 12761031, upload-time = "2026-02-11T21:24:17.016Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/1c/79c198628b8e006be32dfb1c5b73561757a349a6cf3069600a67ffa62495/zensical-0.0.31-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94e64630552793274db1ec66c971e49a15ad351536d5d12de67ec6da7358ac50", size = 12959832, upload-time = "2026-04-01T11:29:56.736Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/9f/ce1c5af9afd11fe3521a90441aba48c484f98730c6d833d69ee4387ae2e9/zensical-0.0.23-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a3679d6bf6374f503afb74d9f6061da5de83c25922f618042b63a30b16f0389", size = 12527415, upload-time = "2026-02-11T21:24:19.558Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/9d/45839d9ca0f69622e8a3b944f2d8d7f7d2b7c2da78201079c4feb275feb6/zensical-0.0.31-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:738a2fd5832e3b3c10ff642eebaf89c89ca1d28e4451dad0f36fdac53c415577", size = 12704024, upload-time = "2026-04-01T11:29:59.836Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/b8/13a5d4d99f3b77e7bf4e791ef991a611ca2f108ed7eddf20858544ab0a91/zensical-0.0.23-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54d981e21a19c3dcec6e7fa77c4421db47389dfdff20d29fea70df8e1be4062e", size = 12665352, upload-time = "2026-02-11T21:24:22.703Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/5f/451d7f4d94092bc38bd8d514826fb7b0329c188db506795b1d20bd07d517/zensical-0.0.31-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bd601f6132e285ef6c3e4c3852be2094fc0473295a8080003db76a79760f84fb", size = 12837788, upload-time = "2026-04-01T11:30:03.048Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/84/3d0a187ed941826ca26b19a661c41685d8017b2a019afa0d353eb2ebbdba/zensical-0.0.23-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:afde7865cc3c79c99f6df4a911d638fb2c3b472a1b81367d47163f8e3c36f910", size = 12689042, upload-time = "2026-02-11T21:24:26.118Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/39/390a8fc384fb174ebd4450343a0aa2877b3a31ddcedf5ef0b8d26944e12c/zensical-0.0.31-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:dc3b6a9dfb5903c0aa779ef65cd6185add2b8aa1db237be840874b8c9db761b8", size = 12876822, upload-time = "2026-04-01T11:30:06.418Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/65/12466408f428f2cf7140b32d484753db0891debae3c956f4c076b51eeb17/zensical-0.0.23-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:c484674d7b0a3e6d39db83914db932249bccdef2efaf8a5669671c66c16f584d", size = 12834779, upload-time = "2026-02-11T21:24:28.788Z" },
|
{ url = "https://files.pythonhosted.org/packages/d5/60/640da2f095782cf38974cd851fb7afa62651d09a36543a1d8942b31aabdc/zensical-0.0.31-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:ddd4321b275e82c4897aa45b05038ce204b88fb311ad55f8c2af572173a9b56c", size = 13024036, upload-time = "2026-04-01T11:30:09.501Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/ab/0771ac6ffb30e4f04c20374e3beca9e71c3f81112219cdbd86cdc0e3d337/zensical-0.0.23-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:927d12fe2851f355fb3206809e04641d6651bdd2ff4afe9c205721aa3a32aa82", size = 12797057, upload-time = "2026-02-11T21:24:31.383Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/06/0564377cbfccea3653254adfa851c1b20d1696e4b16770c7b2e1dd1ef1d7/zensical-0.0.31-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:147ab4bc17f3088f703aa6c4b9c416411f4ea8ca64d26f6586beae49d97fd3c7", size = 12975505, upload-time = "2026-04-01T11:30:12.268Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/ce/fbd45c00a1cba15508ea3c29b121b4be010254eb65c1512bf11f4478496c/zensical-0.0.23-cp310-abi3-win32.whl", hash = "sha256:ffb79db4244324e9cc063d16adff25a40b145153e5e76d75e0012ba3c05af25d", size = 11837823, upload-time = "2026-02-11T21:24:33.869Z" },
|
{ url = "https://files.pythonhosted.org/packages/35/4b/b8a0c4e5937cb05882dcce667798403e00897135080a69f92363e5e3ff9f/zensical-0.0.31-cp310-abi3-win32.whl", hash = "sha256:03fa11e629a308507693489541f43e751697784e94365e7435b02104aefd1c2c", size = 12011233, upload-time = "2026-04-01T11:30:15.496Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/82/0aebaa8e7d2e6314a85d9b7ff3f7fc74837a94086b56a9d5d8f2240e9b9c/zensical-0.0.23-cp310-abi3-win_amd64.whl", hash = "sha256:a8cfe240dca75231e8e525985366d010d09ee73aec0937930e88f7230694ce01", size = 12036837, upload-time = "2026-02-11T21:24:36.163Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/99/0eacdb466d344c0c86596932201268517be42f3e0bb6c78b2b0cd84c55f6/zensical-0.0.31-cp310-abi3-win_amd64.whl", hash = "sha256:d6621d4bb46af4143560045d4a18c8c76302db56bf1dbb6e2ce107d7fb643e09", size = 12207545, upload-time = "2026-04-01T11:30:19.054Z" },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,10 +2,15 @@
|
|||||||
site_name = "FastAPI Toolsets"
|
site_name = "FastAPI Toolsets"
|
||||||
site_description = "Production-ready utilities for FastAPI applications."
|
site_description = "Production-ready utilities for FastAPI applications."
|
||||||
site_author = "d3vyce"
|
site_author = "d3vyce"
|
||||||
site_url = "https://fastapi-toolsets.d3vyce.fr"
|
site_url = "https://fastapi-toolsets.d3vyce.fr/"
|
||||||
copyright = "Copyright © 2026 d3vyce"
|
copyright = "Copyright © 2026 d3vyce"
|
||||||
repo_url = "https://github.com/d3vyce/fastapi-toolsets"
|
repo_url = "https://github.com/d3vyce/fastapi-toolsets"
|
||||||
|
|
||||||
|
[project.extra.version]
|
||||||
|
provider = "mike"
|
||||||
|
default = "stable"
|
||||||
|
alias = true
|
||||||
|
|
||||||
[project.theme]
|
[project.theme]
|
||||||
custom_dir = "docs/overrides"
|
custom_dir = "docs/overrides"
|
||||||
language = "en"
|
language = "en"
|
||||||
@@ -77,6 +82,10 @@ md_in_html = {}
|
|||||||
"pymdownx.tasklist" = {custom_checkbox = true}
|
"pymdownx.tasklist" = {custom_checkbox = true}
|
||||||
"pymdownx.tilde" = {}
|
"pymdownx.tilde" = {}
|
||||||
|
|
||||||
|
[project.markdown_extensions.pymdownx.emoji]
|
||||||
|
emoji_index = "zensical.extensions.emoji.twemoji"
|
||||||
|
emoji_generator = "zensical.extensions.emoji.to_svg"
|
||||||
|
|
||||||
[project.markdown_extensions."pymdownx.highlight"]
|
[project.markdown_extensions."pymdownx.highlight"]
|
||||||
anchor_linenums = true
|
anchor_linenums = true
|
||||||
line_spans = "__span"
|
line_spans = "__span"
|
||||||
@@ -95,3 +104,50 @@ permalink = true
|
|||||||
[project.markdown_extensions."pymdownx.snippets"]
|
[project.markdown_extensions."pymdownx.snippets"]
|
||||||
base_path = ["."]
|
base_path = ["."]
|
||||||
check_paths = true
|
check_paths = true
|
||||||
|
|
||||||
|
[[project.nav]]
|
||||||
|
Home = "index.md"
|
||||||
|
|
||||||
|
[[project.nav]]
|
||||||
|
Modules = [
|
||||||
|
{CLI = "module/cli.md"},
|
||||||
|
{CRUD = "module/crud.md"},
|
||||||
|
{Database = "module/db.md"},
|
||||||
|
{Dependencies = "module/dependencies.md"},
|
||||||
|
{Exceptions = "module/exceptions.md"},
|
||||||
|
{Fixtures = "module/fixtures.md"},
|
||||||
|
{Logger = "module/logger.md"},
|
||||||
|
{Metrics = "module/metrics.md"},
|
||||||
|
{Models = "module/models.md"},
|
||||||
|
{Pytest = "module/pytest.md"},
|
||||||
|
{Schemas = "module/schemas.md"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[project.nav]]
|
||||||
|
Reference = [
|
||||||
|
{CLI = "reference/cli.md"},
|
||||||
|
{CRUD = "reference/crud.md"},
|
||||||
|
{Database = "reference/db.md"},
|
||||||
|
{Dependencies = "reference/dependencies.md"},
|
||||||
|
{Exceptions = "reference/exceptions.md"},
|
||||||
|
{Fixtures = "reference/fixtures.md"},
|
||||||
|
{Logger = "reference/logger.md"},
|
||||||
|
{Metrics = "reference/metrics.md"},
|
||||||
|
{Models = "reference/models.md"},
|
||||||
|
{Pytest = "reference/pytest.md"},
|
||||||
|
{Schemas = "reference/schemas.md"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[project.nav]]
|
||||||
|
Examples = [
|
||||||
|
{"Pagination & Search" = "examples/pagination-search.md"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[project.nav]]
|
||||||
|
Migration = [
|
||||||
|
{"v3.0" = "migration/v3.md"},
|
||||||
|
{"v2.0" = "migration/v2.md"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[project.nav]]
|
||||||
|
"Changelog ↗" = "https://github.com/d3vyce/fastapi-toolsets/releases"
|
||||||
|
|||||||
Reference in New Issue
Block a user