mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-08 17:34:08 +00:00
Compare commits
4
Commits
v4.1.0
..
6e999985c0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e999985c0
|
||
|
|
c3d1fe977d
|
||
|
|
92036d6b88
|
||
|
|
ba6c267897
|
@@ -6,9 +6,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ Install only the extras you need:
|
|||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]"
|
uv add "fastapi-toolsets[cli]"
|
||||||
uv add "fastapi-toolsets[metrics]"
|
uv add "fastapi-toolsets[metrics]"
|
||||||
uv add "fastapi-toolsets[security]"
|
|
||||||
uv add "fastapi-toolsets[pytest]"
|
uv add "fastapi-toolsets[pytest]"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,7 +56,6 @@ uv add "fastapi-toolsets[all]"
|
|||||||
|
|
||||||
### Optional
|
### Optional
|
||||||
|
|
||||||
- **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers
|
|
||||||
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
||||||
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
||||||
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# Authentication
|
||||||
@@ -130,7 +130,7 @@ Pass `next_cursor` as the `cursor` query parameter on the next request to advanc
|
|||||||
|
|
||||||
!!! info "Added in `v2.3.0`"
|
!!! info "Added in `v2.3.0`"
|
||||||
|
|
||||||
[`paginate()`](../module/crud.md#unified-endpoint-both-strategies) lets a single endpoint support both strategies via a `pagination_type` query parameter. The `pagination_type` field in the response acts as a discriminator for frontend tooling.
|
[`paginate()`](../module/crud.md#unified-paginate--both-strategies-on-one-endpoint) lets a single endpoint support both strategies via a `pagination_type` query parameter. The `pagination_type` field in the response acts as a discriminator for frontend tooling.
|
||||||
|
|
||||||
```python title="routes.py:61:79"
|
```python title="routes.py:61:79"
|
||||||
--8<-- "docs_src/examples/pagination_search/routes.py:61:79"
|
--8<-- "docs_src/examples/pagination_search/routes.py:61:79"
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ Install only the extras you need:
|
|||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]"
|
uv add "fastapi-toolsets[cli]"
|
||||||
uv add "fastapi-toolsets[metrics]"
|
uv add "fastapi-toolsets[metrics]"
|
||||||
uv add "fastapi-toolsets[security]"
|
|
||||||
uv add "fastapi-toolsets[pytest]"
|
uv add "fastapi-toolsets[pytest]"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,7 +56,6 @@ uv add "fastapi-toolsets[all]"
|
|||||||
|
|
||||||
### Optional
|
### Optional
|
||||||
|
|
||||||
- **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers
|
|
||||||
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
||||||
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
||||||
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
# Migrating to v4.0
|
|
||||||
|
|
||||||
This page covers every breaking change introduced in **v4.0** and the steps required to update your code.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Database
|
|
||||||
|
|
||||||
### `lock_tables` now takes a `session_maker` instead of a `session`
|
|
||||||
|
|
||||||
The first argument of `lock_tables` changed from an `AsyncSession` instance to an `async_sessionmaker`.
|
|
||||||
The function creates and manages its own **dedicated session** internally, yielding it to the caller.
|
|
||||||
|
|
||||||
=== "Before (`v3`)"
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.db import lock_tables, LockMode
|
|
||||||
|
|
||||||
async with lock_tables(session=session, tables=[User, Account]):
|
|
||||||
user = await UserCrud.get(session, [User.id == 1])
|
|
||||||
user.balance += 100
|
|
||||||
|
|
||||||
# With a custom lock mode
|
|
||||||
async with lock_tables(session, [Order], mode=LockMode.EXCLUSIVE):
|
|
||||||
await process_order(session, order_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
=== "Now (`v4`)"
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.db import lock_tables, LockMode
|
|
||||||
|
|
||||||
async with lock_tables(session_maker=session_maker, tables=[User, Account]) as session:
|
|
||||||
user = await UserCrud.get(session, [User.id == 1])
|
|
||||||
user.balance += 100
|
|
||||||
|
|
||||||
# With a custom lock mode
|
|
||||||
async with lock_tables(session_maker, [Order], mode=LockMode.EXCLUSIVE) as session:
|
|
||||||
await process_order(session, order_id)
|
|
||||||
```
|
|
||||||
+54
-152
@@ -141,37 +141,6 @@ Use `first` when you only care about any one match and don't need uniqueness:
|
|||||||
user = await UserCrud.first(session=session, filters=[User.is_active == True])
|
user = await UserCrud.first(session=session, filters=[User.is_active == True])
|
||||||
```
|
```
|
||||||
|
|
||||||
## Row locking
|
|
||||||
|
|
||||||
`get`, `get_or_none`, `first`, `get_multi`, and `update` all accept a `with_for_update` parameter that appends a `FOR UPDATE` clause to the underlying `SELECT`, preventing concurrent transactions from modifying the matched rows until the current transaction commits.
|
|
||||||
|
|
||||||
| Value | SQL clause |
|
|
||||||
|---|---|
|
|
||||||
| `False` (default) | no locking |
|
|
||||||
| `True` | `FOR UPDATE` |
|
|
||||||
| `"nowait"` | `FOR UPDATE NOWAIT` |
|
|
||||||
| `"skip_locked"` | `FOR UPDATE SKIP LOCKED` |
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Lock before reading — typical read-modify-write pattern
|
|
||||||
user = await UserCrud.get(session, [User.id == user_id], with_for_update=True)
|
|
||||||
|
|
||||||
# Raise immediately if another transaction holds the lock
|
|
||||||
user = await UserCrud.get(session, [User.id == user_id], with_for_update="nowait")
|
|
||||||
|
|
||||||
# Skip rows already locked by another transaction (e.g. job queues)
|
|
||||||
rows = await JobCrud.get_multi(session, filters=[Job.status == "pending"], with_for_update="skip_locked")
|
|
||||||
|
|
||||||
# Lock atomically as part of update (prevents race between SELECT and UPDATE)
|
|
||||||
user = await UserCrud.update(session, UserUpdate(credits=10), [User.id == user_id], with_for_update=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
`with_for_update` requires an open transaction. Wrap your call in `async with session.begin()` or use the `get_transaction` helper if you are not already inside one.
|
|
||||||
|
|
||||||
!!! note
|
|
||||||
`NOWAIT` raises `sqlalchemy.exc.OperationalError` immediately if the row is locked rather than waiting.
|
|
||||||
|
|
||||||
## 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`)"
|
||||||
@@ -355,12 +324,6 @@ result = await UserCrud.offset_paginate(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
Or via the dependency to narrow which fields are exposed as query parameters:
|
|
||||||
|
|
||||||
```python
|
|
||||||
params = UserCrud.offset_paginate_params(search_fields=[Post.title])
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
||||||
@@ -381,37 +344,13 @@ async def get_users(
|
|||||||
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
|
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
|
||||||
```
|
```
|
||||||
|
|
||||||
The dependency adds two query parameters to the endpoint:
|
|
||||||
|
|
||||||
| Parameter | Type |
|
|
||||||
| --------------- | ------------- |
|
|
||||||
| `search` | `str \| null` |
|
|
||||||
| `search_column` | `str \| null` |
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /posts?search=hello → search all configured columns
|
|
||||||
GET /posts?search=hello&search_column=title → search only Post.title
|
|
||||||
```
|
|
||||||
|
|
||||||
The available search column keys are returned in the `search_columns` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse). Use them to populate a column picker in the UI, or to validate `search_column` values on the client side:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"data": ["..."],
|
|
||||||
"pagination": { "..." },
|
|
||||||
"search_columns": ["content", "author__username", "title"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
!!! info "Key format uses `__` as a separator for relationship chains."
|
|
||||||
A direct column `Post.title` produces `"title"`. A relationship tuple `(Post.author, User.username)` produces `"author__username"`. An unknown `search_column` value raises [`InvalidSearchColumnError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidSearchColumnError) (HTTP 422).
|
|
||||||
|
|
||||||
### Faceted search
|
### Faceted search
|
||||||
|
|
||||||
!!! info "Added in `v1.2`"
|
!!! info "Added in `v1.2`"
|
||||||
|
|
||||||
Declare `facet_fields` on the CRUD class to return distinct column values alongside paginated results. This is useful for populating filter dropdowns or building faceted search UIs. Relationship traversal is supported via tuples, using the same syntax as `searchable_fields`:
|
Declare `facet_fields` on the CRUD class to return distinct column values alongside paginated results. This is useful for populating filter dropdowns or building faceted search UIs.
|
||||||
|
|
||||||
|
Facet fields use the same syntax as `searchable_fields` — direct columns or relationship tuples:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
UserCrud = CrudFactory(
|
UserCrud = CrudFactory(
|
||||||
@@ -433,47 +372,7 @@ result = await UserCrud.offset_paginate(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
Or via the dependency to narrow which fields are exposed as query parameters:
|
The distinct values are returned in the `filter_attributes` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse):
|
||||||
|
|
||||||
```python
|
|
||||||
params = UserCrud.offset_paginate_params(facet_fields=[User.country])
|
|
||||||
```
|
|
||||||
|
|
||||||
Facet filtering is built into the consolidated params dependencies. When `filter=True` (the default), each facet field is exposed as a query parameter and values are collected into `filter_by` automatically:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import Depends
|
|
||||||
|
|
||||||
@router.get("", response_model_exclude_none=True)
|
|
||||||
async def list_users(
|
|
||||||
session: SessionDep,
|
|
||||||
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
|
|
||||||
) -> OffsetPaginatedResponse[UserRead]:
|
|
||||||
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
@router.get("", response_model_exclude_none=True)
|
|
||||||
async def list_users(
|
|
||||||
session: SessionDep,
|
|
||||||
params: Annotated[dict, Depends(UserCrud.cursor_paginate_params())],
|
|
||||||
) -> CursorPaginatedResponse[UserRead]:
|
|
||||||
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
|
|
||||||
```
|
|
||||||
|
|
||||||
Both single-value and multi-value query parameters work:
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /users?status=active → filter_by={"status": ["active"]}
|
|
||||||
GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]}
|
|
||||||
GET /users?role__name=admin&role__name=editor → filter_by={"role__name": ["admin", "editor"]} (IN clause)
|
|
||||||
```
|
|
||||||
|
|
||||||
`filter_by` and `filters` can be combined — both are applied with AND logic.
|
|
||||||
|
|
||||||
The distinct values for each facet field are returned in the `filter_attributes` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse). Use them to populate filter dropdowns in the UI, or to validate `filter_by` keys on the client side:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -488,14 +387,50 @@ The distinct values for each facet field are returned in the `filter_attributes`
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! info "Key format uses `__` as a separator for relationship chains."
|
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).
|
||||||
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"`. An unknown `filter_by` key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError) (HTTP 422).
|
|
||||||
|
!!! info "The keys in `filter_by` are the same keys the client received in `filter_attributes`."
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
UserCrud = CrudFactory(
|
||||||
|
model=User,
|
||||||
|
facet_fields=[User.status, User.country, (User.role, Role.name)],
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("", response_model_exclude_none=True)
|
||||||
|
async def list_users(
|
||||||
|
session: SessionDep,
|
||||||
|
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
|
||||||
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
|
return await UserCrud.offset_paginate(
|
||||||
|
session=session,
|
||||||
|
**params,
|
||||||
|
schema=UserRead,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Both single-value and multi-value query parameters work:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /users?status=active → filter_by={"status": ["active"]}
|
||||||
|
GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]}
|
||||||
|
GET /users?role__name=admin&role__name=editor → filter_by={"role__name": ["admin", "editor"]} (IN clause)
|
||||||
|
```
|
||||||
|
|
||||||
## Sorting
|
## Sorting
|
||||||
|
|
||||||
!!! info "Added in `v1.3`"
|
!!! info "Added in `v1.3`"
|
||||||
|
|
||||||
Declare `order_fields` on the CRUD class. Relationship traversal is supported via tuples, using the same syntax as `searchable_fields` and `facet_fields`:
|
Declare `order_fields` on the CRUD class to expose client-driven column ordering via `order_by` and `order` query parameters.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
UserCrud = CrudFactory(
|
UserCrud = CrudFactory(
|
||||||
@@ -503,27 +438,11 @@ UserCrud = CrudFactory(
|
|||||||
order_fields=[
|
order_fields=[
|
||||||
User.name,
|
User.name,
|
||||||
User.created_at,
|
User.created_at,
|
||||||
(User.role, Role.name), # sort by a related model column
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
You can override `order_fields` per call:
|
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
|
|
||||||
result = await UserCrud.offset_paginate(
|
|
||||||
session=session,
|
|
||||||
order_fields=[User.name],
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Or via the dependency to narrow which fields are exposed as query parameters:
|
|
||||||
|
|
||||||
```python
|
|
||||||
params = UserCrud.offset_paginate_params(order_fields=[User.name])
|
|
||||||
```
|
|
||||||
|
|
||||||
Sorting 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
|
||||||
@@ -533,50 +452,33 @@ from fastapi import Depends
|
|||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_users(
|
async def list_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
|
params: Annotated[dict, Depends(UserCrud.offset_paginate_params(
|
||||||
|
default_order_field=User.created_at,
|
||||||
|
))],
|
||||||
) -> OffsetPaginatedResponse[UserRead]:
|
) -> OffsetPaginatedResponse[UserRead]:
|
||||||
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||||
```
|
```
|
||||||
|
|
||||||
```python
|
|
||||||
@router.get("")
|
|
||||||
async def list_users(
|
|
||||||
session: SessionDep,
|
|
||||||
params: Annotated[dict, Depends(UserCrud.cursor_paginate_params())],
|
|
||||||
) -> CursorPaginatedResponse[UserRead]:
|
|
||||||
return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead)
|
|
||||||
```
|
|
||||||
|
|
||||||
The dependency adds two query parameters to the endpoint:
|
The dependency adds two query parameters to the endpoint:
|
||||||
|
|
||||||
| Parameter | Type |
|
| Parameter | Type |
|
||||||
| ---------- | --------------- |
|
| ---------- | --------------- |
|
||||||
| `order_by` | `str \| null` |
|
| `order_by` | `str | null` |
|
||||||
| `order` | `asc` or `desc` |
|
| `order` | `asc` or `desc` |
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /users?order_by=name&order=asc → ORDER BY users.name ASC
|
GET /users?order_by=name&order=asc → ORDER BY users.name ASC
|
||||||
GET /users?order_by=role__name&order=desc → LEFT JOIN roles ON ... ORDER BY roles.name DESC
|
GET /users?order_by=name&order=desc → ORDER BY users.name DESC
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! info "Relationship tuples are joined automatically."
|
An unknown `order_by` value raises [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) (HTTP 422).
|
||||||
When a relation field is selected, the related table is LEFT OUTER JOINed automatically. 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 override the class-level defaults:
|
||||||
|
|
||||||
The available sort keys are returned in the `order_columns` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse). Use them to populate a sort picker in the UI, or to validate `order_by` values on the client side:
|
```python
|
||||||
|
params = UserCrud.offset_paginate_params(order_fields=[User.name])
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"data": ["..."],
|
|
||||||
"pagination": { "..." },
|
|
||||||
"order_columns": ["created_at", "name", "role__name"]
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! info "Key format uses `__` as a separator for relationship chains."
|
|
||||||
A direct column `User.name` produces `"name"`. A relationship tuple `(User.role, Role.name)` produces `"role__name"`.
|
|
||||||
|
|
||||||
## Relationship loading
|
## Relationship loading
|
||||||
|
|
||||||
!!! info "Added in `v1.1`"
|
!!! info "Added in `v1.1`"
|
||||||
|
|||||||
+5
-88
@@ -1,13 +1,13 @@
|
|||||||
# DB
|
# DB
|
||||||
|
|
||||||
SQLAlchemy async session management with transactions, table locking, advisory locking, and row-change polling.
|
SQLAlchemy async session management with transactions, table locking, and row-change polling.
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
This module has been coded and tested to be compatible with PostgreSQL only.
|
This module has been coded and tested to be compatible with PostgreSQL only.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The `db` module provides helpers to create FastAPI dependencies and context managers for `AsyncSession`, along with utilities for nested transactions, table locks, advisory locks, and polling for row changes.
|
The `db` module provides helpers to create FastAPI dependencies and context managers for `AsyncSession`, along with utilities for nested transactions, table lock and polling for row changes.
|
||||||
|
|
||||||
## Session dependency
|
## Session dependency
|
||||||
|
|
||||||
@@ -57,50 +57,18 @@ async def create_user_with_role(session=session):
|
|||||||
|
|
||||||
## Table locking
|
## Table locking
|
||||||
|
|
||||||
[`lock_tables`](../reference/db.md#fastapi_toolsets.db.lock_tables) acquires PostgreSQL table-level locks before executing critical sections. It opens a **dedicated session** internally and yields it to the caller, so the lock is guaranteed to be released when the context exits:
|
[`lock_tables`](../reference/db.md#fastapi_toolsets.db.lock_tables) acquires PostgreSQL table-level locks before executing critical sections:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import lock_tables, LockMode
|
from fastapi_toolsets.db import lock_tables
|
||||||
|
|
||||||
async with lock_tables(session_maker=session_maker, tables=[User], mode=LockMode.EXCLUSIVE) as session:
|
async with lock_tables(session=session, tables=[User], mode="EXCLUSIVE"):
|
||||||
# No other transaction can modify User until this block exits
|
# No other transaction can modify User until this block exits
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
Available lock modes are defined in [`LockMode`](../reference/db.md#fastapi_toolsets.db.LockMode): `ACCESS_SHARE`, `ROW_SHARE`, `ROW_EXCLUSIVE`, `SHARE_UPDATE_EXCLUSIVE`, `SHARE`, `SHARE_ROW_EXCLUSIVE`, `EXCLUSIVE`, `ACCESS_EXCLUSIVE`.
|
Available lock modes are defined in [`LockMode`](../reference/db.md#fastapi_toolsets.db.LockMode): `ACCESS_SHARE`, `ROW_SHARE`, `ROW_EXCLUSIVE`, `SHARE_UPDATE_EXCLUSIVE`, `SHARE`, `SHARE_ROW_EXCLUSIVE`, `EXCLUSIVE`, `ACCESS_EXCLUSIVE`.
|
||||||
|
|
||||||
## Advisory locking
|
|
||||||
|
|
||||||
[`advisory_lock`](../reference/db.md#fastapi_toolsets.db.advisory_lock) acquires a PostgreSQL session-level advisory lock. The lock is released explicitly when the context exits, regardless of whether the transaction has committed.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.db import advisory_lock
|
|
||||||
|
|
||||||
# Blocking exclusive lock — waits until the lock is free
|
|
||||||
async with advisory_lock(session=session, key=42):
|
|
||||||
...
|
|
||||||
|
|
||||||
# Non-blocking — yields False immediately if already held
|
|
||||||
async with advisory_lock(session=session, key=42, nowait=True) as acquired:
|
|
||||||
if not acquired:
|
|
||||||
raise HTTPException(409, "Resource is locked")
|
|
||||||
|
|
||||||
# Blocking with a timeout — raises DBAPIError if not acquired in time
|
|
||||||
async with advisory_lock(session=session, key=42, timeout="5s"):
|
|
||||||
...
|
|
||||||
|
|
||||||
# Shared — multiple readers allowed simultaneously, blocks exclusive writers
|
|
||||||
async with advisory_lock(session=session, key=42, shared=True):
|
|
||||||
...
|
|
||||||
|
|
||||||
# Two-integer key for namespacing (e.g. lock_type + resource_id)
|
|
||||||
async with advisory_lock(session=session, key=(1, user_id)):
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
!!! note
|
|
||||||
Advisory locks use PostgreSQL session-level functions (`pg_advisory_lock` / `pg_advisory_unlock`). The lock is tied to the database connection, not the SQLAlchemy transaction — it is released when the context exits, even if the surrounding transaction is still open.
|
|
||||||
|
|
||||||
## Row-change polling
|
## Row-change polling
|
||||||
|
|
||||||
[`wait_for_row_change`](../reference/db.md#fastapi_toolsets.db.wait_for_row_change) polls a row until a specific column changes value, useful for waiting on async side effects:
|
[`wait_for_row_change`](../reference/db.md#fastapi_toolsets.db.wait_for_row_change) polls a row until a specific column changes value, useful for waiting on async side effects:
|
||||||
@@ -150,57 +118,6 @@ async def clean(db_session):
|
|||||||
await cleanup_tables(session=db_session, base=Base)
|
await cleanup_tables(session=db_session, base=Base)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Many-to-Many helpers
|
|
||||||
|
|
||||||
SQLAlchemy's ORM collection API triggers lazy-loads when you append to a relationship inside a savepoint (e.g. inside `lock_tables` or a nested `get_transaction`). The three `m2m_*` helpers bypass the ORM collection entirely and issue direct SQL against the association table.
|
|
||||||
|
|
||||||
### `m2m_add` — insert associations
|
|
||||||
|
|
||||||
[`m2m_add`](../reference/db.md#fastapi_toolsets.db.m2m_add) inserts one or more rows into a secondary table without touching the ORM collection:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.db import lock_tables, m2m_add
|
|
||||||
|
|
||||||
async with lock_tables(session_maker, [Tag]) as session:
|
|
||||||
tag = await TagCrud.create(session, TagCreate(name="python"))
|
|
||||||
await m2m_add(session, post, Post.tags, tag)
|
|
||||||
```
|
|
||||||
|
|
||||||
Pass `ignore_conflicts=True` to silently skip associations that already exist:
|
|
||||||
|
|
||||||
```python
|
|
||||||
await m2m_add(session, post, Post.tags, tag, ignore_conflicts=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `m2m_remove` — delete associations
|
|
||||||
|
|
||||||
[`m2m_remove`](../reference/db.md#fastapi_toolsets.db.m2m_remove) deletes specific association rows. Removing a non-existent association is a no-op:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.db import get_transaction, m2m_remove
|
|
||||||
|
|
||||||
async with get_transaction(session):
|
|
||||||
await m2m_remove(session, post, Post.tags, tag1, tag2)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `m2m_set` — replace the full set
|
|
||||||
|
|
||||||
[`m2m_set`](../reference/db.md#fastapi_toolsets.db.m2m_set) atomically replaces all associations: it deletes every existing row for the owner instance then inserts the new set. Passing no related instances clears the association entirely:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.db import get_transaction, m2m_set
|
|
||||||
|
|
||||||
# Replace all tags
|
|
||||||
async with get_transaction(session):
|
|
||||||
await m2m_set(session, post, Post.tags, tag_a, tag_b)
|
|
||||||
|
|
||||||
# Clear all tags
|
|
||||||
async with get_transaction(session):
|
|
||||||
await m2m_set(session, post, Post.tags)
|
|
||||||
```
|
|
||||||
|
|
||||||
All three helpers raise `TypeError` if the relationship attribute is not a Many-to-Many (i.e. has no secondary table).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[:material-api: API Reference](../reference/db.md)
|
[:material-api: API Reference](../reference/db.md)
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ async def list_events(
|
|||||||
|
|
||||||
#### [`PaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse)
|
#### [`PaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse)
|
||||||
|
|
||||||
Return type for endpoints that support **both** pagination strategies via a `pagination_type` query parameter (using [`paginate()`](crud.md#unified-endpoint-both-strategies)).
|
Return type for endpoints that support **both** pagination strategies via a `pagination_type` query parameter (using [`paginate()`](crud.md#unified-paginate--both-strategies-on-one-endpoint)).
|
||||||
|
|
||||||
When used as a return annotation, `PaginatedResponse[T]` automatically expands to `Annotated[Union[CursorPaginatedResponse[T], OffsetPaginatedResponse[T]], Field(discriminator="pagination_type")]`, so FastAPI emits a proper `oneOf` + discriminator in the OpenAPI schema with no extra boilerplate:
|
When used as a return annotation, `PaginatedResponse[T]` automatically expands to `Annotated[Union[CursorPaginatedResponse[T], OffsetPaginatedResponse[T]], Field(discriminator="pagination_type")]`, so FastAPI emits a proper `oneOf` + discriminator in the OpenAPI schema with no extra boilerplate:
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ async def list_users(
|
|||||||
|
|
||||||
#### Pagination metadata models
|
#### Pagination metadata models
|
||||||
|
|
||||||
The optional `filter_attributes` field is populated when `facet_fields` are configured on the CRUD class (see [Filter attributes](crud.md#faceted-search)). It is `None` by default and can be hidden from API responses with `response_model_exclude_none=True`.
|
The optional `filter_attributes` field is populated when `facet_fields` are configured on the CRUD class (see [Filter attributes](crud.md#filter-attributes-facets)). It is `None` by default and can be hidden from API responses with `response_model_exclude_none=True`.
|
||||||
|
|
||||||
### [`ErrorResponse`](../reference/schemas.md#fastapi_toolsets.schemas.ErrorResponse)
|
### [`ErrorResponse`](../reference/schemas.md#fastapi_toolsets.schemas.ErrorResponse)
|
||||||
|
|
||||||
|
|||||||
+77
-164
@@ -4,7 +4,7 @@ Composable authentication helpers for FastAPI that use `Security()` for OpenAPI
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The `security` module provides four auth source classes, a `MultiAuth` factory, and a set of OAuth 2.0 / OIDC helper utilities. Each auth class wraps a FastAPI security scheme for OpenAPI and accepts a validator function called as:
|
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
|
```python
|
||||||
await validator(credential, **kwargs)
|
await validator(credential, **kwargs)
|
||||||
@@ -47,9 +47,12 @@ async def me(user: User = Security(bearer)):
|
|||||||
|
|
||||||
#### Token prefix
|
#### Token prefix
|
||||||
|
|
||||||
The optional `prefix` parameter restricts a `BearerTokenAuth` instance to tokens that start with a given string. The prefix is **kept** in the value passed to the validator — store and compare tokens with their prefix included.
|
The optional `prefix` parameter restricts a `BearerTokenAuth` instance to tokens
|
||||||
|
that start with a given string. The prefix is **kept** in the value passed to the
|
||||||
|
validator — store and compare tokens with their prefix included.
|
||||||
|
|
||||||
This lets you deploy multiple `BearerTokenAuth` instances in the same application and disambiguate them efficiently in `MultiAuth`:
|
This lets you deploy multiple `BearerTokenAuth` instances in the same application
|
||||||
|
and disambiguate them efficiently in `MultiAuth`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
user_bearer = BearerTokenAuth(verify_user, prefix="user_") # matches "Bearer user_..."
|
user_bearer = BearerTokenAuth(verify_user, prefix="user_") # matches "Bearer user_..."
|
||||||
@@ -60,7 +63,9 @@ Use [`generate_token()`](#token-generation) to create correctly-prefixed tokens.
|
|||||||
|
|
||||||
#### Token generation
|
#### Token generation
|
||||||
|
|
||||||
`BearerTokenAuth.generate_token()` produces a secure random token ready to store in your database and return to the client. If a prefix is configured it is prepended automatically:
|
`BearerTokenAuth.generate_token()` produces a secure random token ready to store
|
||||||
|
in your database and return to the client. If a prefix is configured it is
|
||||||
|
prepended automatically:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
bearer = BearerTokenAuth(verify_token, prefix="user_")
|
bearer = BearerTokenAuth(verify_token, prefix="user_")
|
||||||
@@ -70,77 +75,82 @@ await db.store_token(user_id, token)
|
|||||||
return {"access_token": token, "token_type": "bearer"}
|
return {"access_token": token, "token_type": "bearer"}
|
||||||
```
|
```
|
||||||
|
|
||||||
The client sends `Authorization: Bearer user_Xk3mN...` and the validator receives the full token (prefix included) to compare against the stored value.
|
The client sends `Authorization: Bearer user_Xk3mN...` and the validator receives
|
||||||
|
the full token (prefix included) to compare against the stored value.
|
||||||
|
|
||||||
### [`CookieAuth`](../reference/security.md#fastapi_toolsets.security.CookieAuth)
|
### [`CookieAuth`](../reference/security.md#fastapi_toolsets.security.CookieAuth)
|
||||||
|
|
||||||
Reads a named cookie. Wraps `APIKeyCookie` for OpenAPI.
|
Reads a named cookie. Wraps `APIKeyCookie` for OpenAPI.
|
||||||
|
|
||||||
Cookies are issued with the `Secure` flag set by default, meaning they are only transmitted over HTTPS. Set `secure=False` when running locally over plain HTTP:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.security import CookieAuth
|
from fastapi_toolsets.security import CookieAuth
|
||||||
|
|
||||||
# Production (HTTPS) — default
|
|
||||||
cookie_auth = CookieAuth("session", validator=verify_session)
|
cookie_auth = CookieAuth("session", validator=verify_session)
|
||||||
|
|
||||||
# Local development (HTTP only)
|
|
||||||
cookie_auth = CookieAuth("session", validator=verify_session, secure=False)
|
|
||||||
|
|
||||||
@app.get("/me")
|
@app.get("/me")
|
||||||
async def me(user: User = Security(cookie_auth)):
|
async def me(user: User = Security(cookie_auth)):
|
||||||
return user
|
return user
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Signed cookies
|
### [`OAuth2Auth`](../reference/security.md#fastapi_toolsets.security.OAuth2Auth)
|
||||||
|
|
||||||
Pass `secret_key` to enable HMAC-SHA256 signed, tamper-proof cookies. The cookie payload includes an expiry timestamp (`ttl`, default 24 h). No database entry is required — the signature is self-contained.
|
Reads the `Authorization: Bearer <token>` header and registers the token endpoint
|
||||||
|
in OpenAPI via `OAuth2PasswordBearer`.
|
||||||
Use `set_cookie()` to issue the signed cookie on login and `delete_cookie()` to clear it on logout:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Production
|
from fastapi_toolsets.security import OAuth2Auth
|
||||||
cookie_auth = CookieAuth("session", verify_session, secret_key="your-secret")
|
|
||||||
|
|
||||||
# Local development
|
oauth2_auth = OAuth2Auth(token_url="/token", validator=verify_token)
|
||||||
cookie_auth = CookieAuth("session", verify_session, secret_key="your-secret", secure=False)
|
|
||||||
|
|
||||||
@app.post("/login")
|
|
||||||
async def login(response: Response):
|
|
||||||
cookie_auth.set_cookie(response, user_id)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
@app.post("/logout")
|
|
||||||
async def logout(response: Response):
|
|
||||||
cookie_auth.delete_cookie(response)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
@app.get("/me")
|
@app.get("/me")
|
||||||
async def me(user: User = Security(cookie_auth)):
|
async def me(user: User = Security(oauth2_auth)):
|
||||||
return user
|
return user
|
||||||
```
|
```
|
||||||
|
|
||||||
When `secret_key` is not set, the raw cookie value is passed directly to the validator (stateful session behaviour — you manage the session store).
|
### [`OpenIDAuth`](../reference/security.md#fastapi_toolsets.security.OpenIDAuth)
|
||||||
|
|
||||||
### [`APIKeyHeaderAuth`](../reference/security.md#fastapi_toolsets.security.APIKeyHeaderAuth)
|
Reads the `Authorization: Bearer <token>` header and registers the OpenID Connect
|
||||||
|
discovery URL in OpenAPI via `OpenIdConnect`. Token validation is fully delegated
|
||||||
Reads an API key from a named HTTP header. Wraps `APIKeyHeader` for OpenAPI.
|
to your validator — use any OIDC / JWT library (`authlib`, `python-jose`, `PyJWT`).
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.security import APIKeyHeaderAuth
|
from fastapi_toolsets.security import OpenIDAuth
|
||||||
|
|
||||||
api_key_auth = APIKeyHeaderAuth("X-API-Key", validator=verify_api_key)
|
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")
|
@app.get("/data")
|
||||||
async def data(user: User = Security(api_key_auth)):
|
async def data(user: User = Security(multi)):
|
||||||
return user
|
return user
|
||||||
```
|
```
|
||||||
|
|
||||||
The header name is configurable — use any header your API defines (e.g. `"X-API-Key"`, `"Authorization"`, `"X-Service-Token"`).
|
|
||||||
|
|
||||||
## Typed validator kwargs
|
## Typed validator kwargs
|
||||||
|
|
||||||
All auth classes forward extra instantiation keyword arguments to the validator. Arguments can be any type — enums, strings, integers, etc. The validator returns the authenticated identity, which FastAPI injects directly into the route handler.
|
All auth classes forward extra instantiation keyword arguments to the validator.
|
||||||
|
Arguments can be any type — enums, strings, integers, etc. The validator returns
|
||||||
|
the authenticated identity, which FastAPI injects directly into the route handler.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
async def verify_token(token: str, *, role: Role, permission: str) -> User:
|
async def verify_token(token: str, *, role: Role, permission: str) -> User:
|
||||||
@@ -152,11 +162,14 @@ async def verify_token(token: str, *, role: Role, permission: str) -> User:
|
|||||||
bearer = BearerTokenAuth(verify_token, role=Role.ADMIN, permission="billing:read")
|
bearer = BearerTokenAuth(verify_token, role=Role.ADMIN, permission="billing:read")
|
||||||
```
|
```
|
||||||
|
|
||||||
Each auth instance is self-contained — create a separate instance per distinct requirement instead of passing requirements through `Security(scopes=[...])`.
|
Each auth instance is self-contained — create a separate instance per distinct
|
||||||
|
requirement instead of passing requirements through `Security(scopes=[...])`.
|
||||||
|
|
||||||
### Using `.require()` inline
|
### Using `.require()` inline
|
||||||
|
|
||||||
If declaring a new top-level variable per role feels verbose, use `.require()` to create a configured clone directly in the route decorator. The original instance is not mutated:
|
If declaring a new top-level variable per role feels verbose, use `.require()` to
|
||||||
|
create a configured clone directly in the route decorator. The original instance
|
||||||
|
is not mutated:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
bearer = BearerTokenAuth(verify_token)
|
bearer = BearerTokenAuth(verify_token)
|
||||||
@@ -171,14 +184,23 @@ async def profile(user: User = Security(bearer.require(role=Role.USER))):
|
|||||||
```
|
```
|
||||||
|
|
||||||
`.require()` kwargs are merged over existing ones — new values win on conflict.
|
`.require()` kwargs are merged over existing ones — new values win on conflict.
|
||||||
The `prefix` (for `BearerTokenAuth`), cookie name and `secret_key` (for
|
The `prefix` (for `BearerTokenAuth`) and cookie name (for `CookieAuth`) are
|
||||||
`CookieAuth`), and header name (for `APIKeyHeaderAuth`) are always preserved.
|
always preserved.
|
||||||
|
|
||||||
|
`.require()` instances work transparently inside `MultiAuth`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
multi = MultiAuth(
|
||||||
|
user_bearer.require(role=Role.USER),
|
||||||
|
org_bearer.require(role=Role.ADMIN),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
## MultiAuth
|
## MultiAuth
|
||||||
|
|
||||||
[`MultiAuth`](../reference/security.md#fastapi_toolsets.security.MultiAuth) combines multiple auth sources into a single callable. Sources are tried in order; the first one that finds a credential wins.
|
[`MultiAuth`](../reference/security.md#fastapi_toolsets.security.MultiAuth) combines
|
||||||
|
multiple auth sources into a single callable. Sources are tried in order; the
|
||||||
If a credential is extracted but the validator raises, the exception propagates immediately — the remaining sources are **not** tried. This prevents silent fallthrough on invalid credentials.
|
first one that finds a credential wins.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.security import MultiAuth
|
from fastapi_toolsets.security import MultiAuth
|
||||||
@@ -192,7 +214,9 @@ async def data_route(user = Security(multi)):
|
|||||||
|
|
||||||
### Using `.require()` on MultiAuth
|
### Using `.require()` on MultiAuth
|
||||||
|
|
||||||
`MultiAuth` also supports `.require()`, which propagates the kwargs to every source that implements it. Sources that do not (e.g. custom `AuthSource` subclasses) are passed through unchanged:
|
`MultiAuth` also supports `.require()`, which propagates the kwargs to every
|
||||||
|
source that implements it. Sources that do not (e.g. custom `AuthSource`
|
||||||
|
subclasses) are passed through unchanged:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
multi = MultiAuth(bearer, cookie)
|
multi = MultiAuth(bearer, cookie)
|
||||||
@@ -216,7 +240,9 @@ MultiAuth(
|
|||||||
|
|
||||||
### Prefix-based dispatch
|
### Prefix-based dispatch
|
||||||
|
|
||||||
Because `extract()` is pure string matching (no I/O), prefix-based source selection is essentially free. Only the matching source's validator (which may involve DB or network I/O) is ever called:
|
Because `extract()` is pure string matching (no I/O), prefix-based source
|
||||||
|
selection is essentially free. Only the matching source's validator (which may
|
||||||
|
involve DB or network I/O) is ever called:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
user_bearer = BearerTokenAuth(verify_user, prefix="user_")
|
user_bearer = BearerTokenAuth(verify_user, prefix="user_")
|
||||||
@@ -228,127 +254,14 @@ multi = MultiAuth(user_bearer, org_bearer)
|
|||||||
# "Bearer org_acme" → only verify_org runs, receives "org_acme"
|
# "Bearer org_acme" → only verify_org runs, receives "org_acme"
|
||||||
```
|
```
|
||||||
|
|
||||||
Tokens are stored and compared **with their prefix** — use `generate_token()` on each source to issue correctly-prefixed tokens:
|
Tokens are stored and compared **with their prefix** — use `generate_token()` on
|
||||||
|
each source to issue correctly-prefixed tokens:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
user_token = user_bearer.generate_token() # "user_..."
|
user_token = user_bearer.generate_token() # "user_..."
|
||||||
org_token = org_bearer.generate_token() # "org_..."
|
org_token = org_bearer.generate_token() # "org_..."
|
||||||
```
|
```
|
||||||
|
|
||||||
## Custom auth sources
|
|
||||||
|
|
||||||
Subclass [`AuthSource`](../reference/security.md#fastapi_toolsets.security.AuthSource) to implement any credential extraction strategy. You only need to implement `extract()` and `authenticate()`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.security import AuthSource
|
|
||||||
from fastapi_toolsets.exceptions import UnauthorizedError
|
|
||||||
|
|
||||||
class MTLSAuth(AuthSource):
|
|
||||||
async def extract(self, request) -> str | None:
|
|
||||||
return request.headers.get("X-Client-Cert-DN") or None
|
|
||||||
|
|
||||||
async def authenticate(self, credential: str):
|
|
||||||
dn = parse_dn(credential)
|
|
||||||
if dn.get("O") != "MyOrg":
|
|
||||||
raise UnauthorizedError()
|
|
||||||
return {"dn": credential}
|
|
||||||
```
|
|
||||||
|
|
||||||
Custom sources work transparently inside `MultiAuth`.
|
|
||||||
|
|
||||||
## OAuth 2.0 / OIDC helpers
|
|
||||||
|
|
||||||
The module provides standalone async utilities for building OAuth 2.0 / OIDC login flows. They handle provider discovery, authorization redirects, token exchange, and state encoding — leaving JWT validation and session management to your application.
|
|
||||||
|
|
||||||
### Provider discovery
|
|
||||||
|
|
||||||
[`oauth_resolve_provider_urls()`](../reference/security.md#fastapi_toolsets.security.oauth_resolve_provider_urls) fetches the OIDC discovery document and returns the endpoint URLs. Results are cached in-process to avoid repeated network calls:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.security import oauth_resolve_provider_urls
|
|
||||||
|
|
||||||
auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls(
|
|
||||||
"https://accounts.google.com/.well-known/openid-configuration"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns a `(authorization_url, token_url, userinfo_url)` tuple. `userinfo_url` is `None` when the provider does not advertise one.
|
|
||||||
|
|
||||||
### Authorization redirect
|
|
||||||
|
|
||||||
[`oauth_build_authorization_redirect()`](../reference/security.md#fastapi_toolsets.security.oauth_build_authorization_redirect) constructs the redirect to the provider's authorization page. It requires a `state_token` — a random CSRF token generated by [`oauth_generate_state_token()`](../reference/security.md#fastapi_toolsets.security.oauth_generate_state_token) — that must be stored server-side (e.g. in the session) and verified on the callback to prevent login-CSRF attacks ([RFC 6749 §10.12](https://datatracker.ietf.org/doc/html/rfc6749#section-10.12)):
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi import Request
|
|
||||||
from fastapi_toolsets.security import oauth_build_authorization_redirect, oauth_generate_state_token
|
|
||||||
|
|
||||||
@app.get("/auth/google/login")
|
|
||||||
async def google_login(request: Request):
|
|
||||||
auth_url, _, _ = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL)
|
|
||||||
state_token = oauth_generate_state_token()
|
|
||||||
request.session["oauth_state"] = state_token # requires SessionMiddleware
|
|
||||||
return oauth_build_authorization_redirect(
|
|
||||||
auth_url,
|
|
||||||
client_id=GOOGLE_CLIENT_ID,
|
|
||||||
scopes="openid email profile",
|
|
||||||
redirect_uri="https://myapp.com/auth/google/callback",
|
|
||||||
destination="/dashboard",
|
|
||||||
state_token=state_token,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Token exchange and userinfo
|
|
||||||
|
|
||||||
[`oauth_fetch_userinfo()`](../reference/security.md#fastapi_toolsets.security.oauth_fetch_userinfo) performs the two-step exchange: it POSTs the authorization code to the token endpoint, then GETs the userinfo endpoint with the resulting access token.
|
|
||||||
|
|
||||||
On the callback, retrieve the stored token and pass it to [`oauth_decode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_decode_state) to verify the CSRF token before processing the code:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi import HTTPException, Request
|
|
||||||
from fastapi_toolsets.security import oauth_decode_state, oauth_fetch_userinfo
|
|
||||||
|
|
||||||
@app.get("/auth/google/callback")
|
|
||||||
async def google_callback(request: Request, code: str, state: str):
|
|
||||||
# Pop token first — single-use, regardless of whether verification succeeds
|
|
||||||
state_token = request.session.pop("oauth_state", None)
|
|
||||||
if state_token is None:
|
|
||||||
raise HTTPException(status_code=400, detail="missing OAuth state")
|
|
||||||
destination = oauth_decode_state(state, expected_state_token=state_token, fallback="/")
|
|
||||||
if not destination.startswith("/"): # reject absolute URLs to prevent open-redirect
|
|
||||||
destination = "/"
|
|
||||||
|
|
||||||
_, token_url, userinfo_url = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL)
|
|
||||||
userinfo = await oauth_fetch_userinfo(
|
|
||||||
token_url=token_url,
|
|
||||||
userinfo_url=userinfo_url,
|
|
||||||
code=code,
|
|
||||||
client_id=GOOGLE_CLIENT_ID,
|
|
||||||
client_secret=GOOGLE_CLIENT_SECRET,
|
|
||||||
redirect_uri="https://myapp.com/auth/google/callback",
|
|
||||||
required_scopes="openid email profile",
|
|
||||||
)
|
|
||||||
user = await db.upsert_user(email=userinfo["email"])
|
|
||||||
response = RedirectResponse(destination)
|
|
||||||
session_cookie.set_cookie(response, str(user.id))
|
|
||||||
return response
|
|
||||||
```
|
|
||||||
|
|
||||||
Pass `required_scopes` to guard against providers silently granting fewer scopes than requested — `oauth_fetch_userinfo` raises `ValueError` if any are missing.
|
|
||||||
|
|
||||||
### State encoding
|
|
||||||
|
|
||||||
[`oauth_encode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_encode_state) and [`oauth_decode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_decode_state) encode and decode the destination URL together with the CSRF token embedded in the OAuth `state` parameter. `oauth_decode_state` returns `fallback` if `state` is absent, malformed, or the token does not match:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.security import oauth_encode_state, oauth_decode_state
|
|
||||||
|
|
||||||
state_token = oauth_generate_state_token()
|
|
||||||
encoded = oauth_encode_state("/dashboard", state_token)
|
|
||||||
decoded = oauth_decode_state(encoded, expected_state_token=state_token, fallback="/") # "/dashboard"
|
|
||||||
decoded = oauth_decode_state(encoded, expected_state_token="wrong", fallback="/") # "/"
|
|
||||||
decoded = oauth_decode_state(None, expected_state_token=state_token, fallback="/") # "/"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[:material-api: API Reference](../reference/security.md)
|
[:material-api: API Reference](../reference/security.md)
|
||||||
|
|||||||
@@ -7,16 +7,12 @@ You can import them directly from `fastapi_toolsets.db`:
|
|||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import (
|
from fastapi_toolsets.db import (
|
||||||
LockMode,
|
LockMode,
|
||||||
advisory_lock,
|
|
||||||
cleanup_tables,
|
cleanup_tables,
|
||||||
create_database,
|
create_database,
|
||||||
create_db_dependency,
|
create_db_dependency,
|
||||||
create_db_context,
|
create_db_context,
|
||||||
get_transaction,
|
get_transaction,
|
||||||
lock_tables,
|
lock_tables,
|
||||||
m2m_add,
|
|
||||||
m2m_remove,
|
|
||||||
m2m_set,
|
|
||||||
wait_for_row_change,
|
wait_for_row_change,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
@@ -31,16 +27,8 @@ from fastapi_toolsets.db import (
|
|||||||
|
|
||||||
## ::: fastapi_toolsets.db.lock_tables
|
## ::: fastapi_toolsets.db.lock_tables
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.advisory_lock
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.wait_for_row_change
|
## ::: fastapi_toolsets.db.wait_for_row_change
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.create_database
|
## ::: fastapi_toolsets.db.create_database
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.cleanup_tables
|
## ::: fastapi_toolsets.db.cleanup_tables
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.m2m_add
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.m2m_remove
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.m2m_set
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from fastapi_toolsets.exceptions import (
|
|||||||
NotFoundError,
|
NotFoundError,
|
||||||
ConflictError,
|
ConflictError,
|
||||||
NoSearchableFieldsError,
|
NoSearchableFieldsError,
|
||||||
InvalidSearchColumnError,
|
|
||||||
InvalidFacetFilterError,
|
InvalidFacetFilterError,
|
||||||
InvalidOrderFieldError,
|
InvalidOrderFieldError,
|
||||||
generate_error_responses,
|
generate_error_responses,
|
||||||
@@ -32,8 +31,6 @@ from fastapi_toolsets.exceptions import (
|
|||||||
|
|
||||||
## ::: fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError
|
## ::: fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError
|
||||||
|
|
||||||
## ::: fastapi_toolsets.exceptions.exceptions.InvalidSearchColumnError
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError
|
## ::: fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError
|
||||||
|
|
||||||
## ::: fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError
|
## ::: fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError
|
||||||
|
|||||||
@@ -9,14 +9,9 @@ from fastapi_toolsets.security import (
|
|||||||
AuthSource,
|
AuthSource,
|
||||||
BearerTokenAuth,
|
BearerTokenAuth,
|
||||||
CookieAuth,
|
CookieAuth,
|
||||||
APIKeyHeaderAuth,
|
OAuth2Auth,
|
||||||
|
OpenIDAuth,
|
||||||
MultiAuth,
|
MultiAuth,
|
||||||
oauth_build_authorization_redirect,
|
|
||||||
oauth_decode_state,
|
|
||||||
oauth_encode_state,
|
|
||||||
oauth_fetch_userinfo,
|
|
||||||
oauth_generate_state_token,
|
|
||||||
oauth_resolve_provider_urls,
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -26,18 +21,8 @@ from fastapi_toolsets.security import (
|
|||||||
|
|
||||||
## ::: fastapi_toolsets.security.CookieAuth
|
## ::: fastapi_toolsets.security.CookieAuth
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.APIKeyHeaderAuth
|
## ::: fastapi_toolsets.security.OAuth2Auth
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.security.OpenIDAuth
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.MultiAuth
|
## ::: fastapi_toolsets.security.MultiAuth
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_resolve_provider_urls
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_fetch_userinfo
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_generate_state_token
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_build_authorization_redirect
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_encode_state
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.security.oauth_decode_state
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
from fastapi import APIRouter, Form, HTTPException, Response, Security
|
||||||
|
|
||||||
|
from fastapi_toolsets.dependencies import PathDependency
|
||||||
|
|
||||||
|
from .crud import UserCrud, UserTokenCrud
|
||||||
|
from .db import SessionDep
|
||||||
|
from .models import OAuthProvider, User, UserToken
|
||||||
|
from .schemas import (
|
||||||
|
ApiTokenCreateRequest,
|
||||||
|
ApiTokenResponse,
|
||||||
|
RegisterRequest,
|
||||||
|
UserCreate,
|
||||||
|
UserResponse,
|
||||||
|
)
|
||||||
|
from .security import auth, cookie_auth, create_api_token
|
||||||
|
|
||||||
|
ProviderDep = PathDependency(
|
||||||
|
model=OAuthProvider,
|
||||||
|
field=OAuthProvider.slug,
|
||||||
|
session_dep=SessionDep,
|
||||||
|
param_name="slug",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserResponse, status_code=201)
|
||||||
|
async def register(body: RegisterRequest, session: SessionDep):
|
||||||
|
existing = await UserCrud.first(
|
||||||
|
session=session, filters=[User.username == body.username]
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail="Username already taken")
|
||||||
|
|
||||||
|
user = await UserCrud.create(
|
||||||
|
session=session,
|
||||||
|
obj=UserCreate(
|
||||||
|
username=body.username,
|
||||||
|
email=body.email,
|
||||||
|
hashed_password=hash_password(body.password),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/token", status_code=204)
|
||||||
|
async def login(
|
||||||
|
session: SessionDep,
|
||||||
|
response: Response,
|
||||||
|
username: Annotated[str, Form()],
|
||||||
|
password: Annotated[str, Form()],
|
||||||
|
):
|
||||||
|
user = await UserCrud.first(session=session, filters=[User.username == username])
|
||||||
|
|
||||||
|
if (
|
||||||
|
not user
|
||||||
|
or not user.hashed_password
|
||||||
|
or not verify_password(password, user.hashed_password)
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(status_code=403, detail="Account disabled")
|
||||||
|
|
||||||
|
cookie_auth.set_cookie(response, str(user.id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", status_code=204)
|
||||||
|
async def logout(response: Response):
|
||||||
|
cookie_auth.delete_cookie(response)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
async def me(user: User = Security(auth)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tokens", response_model=ApiTokenResponse, status_code=201)
|
||||||
|
async def create_token(
|
||||||
|
body: ApiTokenCreateRequest,
|
||||||
|
user: User = Security(auth),
|
||||||
|
):
|
||||||
|
raw, token_row = await create_api_token(
|
||||||
|
user.id, name=body.name, expires_at=body.expires_at
|
||||||
|
)
|
||||||
|
return ApiTokenResponse(
|
||||||
|
id=token_row.id,
|
||||||
|
name=token_row.name,
|
||||||
|
expires_at=token_row.expires_at,
|
||||||
|
created_at=token_row.created_at,
|
||||||
|
token=raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tokens/{token_id}", status_code=204)
|
||||||
|
async def revoke_token(
|
||||||
|
session: SessionDep,
|
||||||
|
token_id: UUID,
|
||||||
|
user: User = Security(auth),
|
||||||
|
):
|
||||||
|
if not await UserTokenCrud.first(
|
||||||
|
session=session,
|
||||||
|
filters=[UserToken.id == token_id, UserToken.user_id == user.id],
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=404, detail="Token not found")
|
||||||
|
await UserTokenCrud.delete(
|
||||||
|
session=session,
|
||||||
|
filters=[UserToken.id == token_id, UserToken.user_id == user.id],
|
||||||
|
)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import EmailStr
|
||||||
|
|
||||||
|
from fastapi_toolsets.schemas import PydanticBase
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(PydanticBase):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
email: EmailStr | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(PydanticBase):
|
||||||
|
id: UUID
|
||||||
|
username: str
|
||||||
|
email: str | None
|
||||||
|
role: str
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTokenCreateRequest(PydanticBase):
|
||||||
|
name: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTokenResponse(PydanticBase):
|
||||||
|
id: UUID
|
||||||
|
name: str | None
|
||||||
|
expires_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
# Only populated on creation
|
||||||
|
token: str | None = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthProviderResponse(PydanticBase):
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(PydanticBase):
|
||||||
|
username: str
|
||||||
|
email: str | None = None
|
||||||
|
hashed_password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserTokenCreate(PydanticBase):
|
||||||
|
user_id: UUID
|
||||||
|
token_hash: str
|
||||||
|
name: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthAccountCreate(PydanticBase):
|
||||||
|
user_id: UUID
|
||||||
|
provider_id: UUID
|
||||||
|
subject: str
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
|
from fastapi_toolsets.security import (
|
||||||
|
APIKeyHeaderAuth,
|
||||||
|
BearerTokenAuth,
|
||||||
|
CookieAuth,
|
||||||
|
MultiAuth,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .crud import UserCrud, UserTokenCrud
|
||||||
|
from .db import get_db_context
|
||||||
|
from .models import User, UserRole, UserToken
|
||||||
|
from .schemas import UserTokenCreate
|
||||||
|
|
||||||
|
SESSION_COOKIE = "session"
|
||||||
|
SECRET_KEY = "123456789"
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_token(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def _verify_token(token: str, role: UserRole | None = None) -> User:
|
||||||
|
async with get_db_context() as db:
|
||||||
|
user_token = await UserTokenCrud.first(
|
||||||
|
session=db,
|
||||||
|
filters=[UserToken.token_hash == _hash_token(token)],
|
||||||
|
load_options=[selectinload(UserToken.user)],
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_token is None or not user_token.user.is_active:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
|
||||||
|
if user_token.expires_at and user_token.expires_at < datetime.now(timezone.utc):
|
||||||
|
raise UnauthorizedError()
|
||||||
|
|
||||||
|
user = user_token.user
|
||||||
|
|
||||||
|
if role is not None and user.role != role:
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def _verify_cookie(user_id: str, role: UserRole | None = None) -> User:
|
||||||
|
async with get_db_context() as db:
|
||||||
|
user = await UserCrud.first(
|
||||||
|
session=db,
|
||||||
|
filters=[User.id == UUID(user_id)],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise UnauthorizedError()
|
||||||
|
|
||||||
|
if role is not None and user.role != role:
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
bearer_auth = BearerTokenAuth(
|
||||||
|
validator=_verify_token,
|
||||||
|
prefix="ctf_",
|
||||||
|
)
|
||||||
|
header_auth = APIKeyHeaderAuth(
|
||||||
|
name="X-API-Key",
|
||||||
|
validator=_verify_token,
|
||||||
|
)
|
||||||
|
cookie_auth = CookieAuth(
|
||||||
|
name=SESSION_COOKIE,
|
||||||
|
validator=_verify_cookie,
|
||||||
|
secret_key=SECRET_KEY,
|
||||||
|
)
|
||||||
|
auth = MultiAuth(bearer_auth, header_auth, cookie_auth)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_api_token(
|
||||||
|
user_id: UUID,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
expires_at: datetime | None = None,
|
||||||
|
) -> tuple[str, UserToken]:
|
||||||
|
raw = bearer_auth.generate_token()
|
||||||
|
async with get_db_context() as db:
|
||||||
|
token_row = await UserTokenCrud.create(
|
||||||
|
session=db,
|
||||||
|
obj=UserTokenCreate(
|
||||||
|
user_id=user_id,
|
||||||
|
token_hash=_hash_token(raw),
|
||||||
|
name=name,
|
||||||
|
expires_at=expires_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return raw, token_row
|
||||||
+2
-7
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "4.1.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"
|
||||||
@@ -50,17 +50,13 @@ cli = [
|
|||||||
metrics = [
|
metrics = [
|
||||||
"prometheus_client>=0.20.0",
|
"prometheus_client>=0.20.0",
|
||||||
]
|
]
|
||||||
security = [
|
|
||||||
"async-lru>=1.0",
|
|
||||||
"httpx>=0.25.0",
|
|
||||||
]
|
|
||||||
pytest = [
|
pytest = [
|
||||||
"httpx>=0.25.0",
|
"httpx>=0.25.0",
|
||||||
"pytest-xdist>=3.0.0",
|
"pytest-xdist>=3.0.0",
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
]
|
]
|
||||||
all = [
|
all = [
|
||||||
"fastapi-toolsets[cli,metrics,pytest,security]",
|
"fastapi-toolsets[cli,metrics,pytest]",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -77,7 +73,6 @@ dev = [
|
|||||||
"ty>=0.0.1a0",
|
"ty>=0.0.1a0",
|
||||||
]
|
]
|
||||||
tests = [
|
tests = [
|
||||||
"async-lru>=1.0",
|
|
||||||
"coverage>=7.0.0",
|
"coverage>=7.0.0",
|
||||||
"httpx>=0.25.0",
|
"httpx>=0.25.0",
|
||||||
"pytest-anyio>=0.0.0",
|
"pytest-anyio>=0.0.0",
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ Example usage:
|
|||||||
return Response(data={"user": user.username}, message="Success")
|
return Response(data={"user": user.username}, message="Success")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "4.1.0"
|
__version__ = "3.0.1"
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from ..types import (
|
|||||||
JoinType,
|
JoinType,
|
||||||
M2MFieldType,
|
M2MFieldType,
|
||||||
OrderByClause,
|
OrderByClause,
|
||||||
OrderFieldType,
|
|
||||||
SearchFieldType,
|
SearchFieldType,
|
||||||
)
|
)
|
||||||
from .factory import AsyncCrud, CrudFactory
|
from .factory import AsyncCrud, CrudFactory
|
||||||
@@ -29,7 +28,6 @@ __all__ = [
|
|||||||
"M2MFieldType",
|
"M2MFieldType",
|
||||||
"NoSearchableFieldsError",
|
"NoSearchableFieldsError",
|
||||||
"OrderByClause",
|
"OrderByClause",
|
||||||
"OrderFieldType",
|
|
||||||
"PaginationType",
|
"PaginationType",
|
||||||
"SearchConfig",
|
"SearchConfig",
|
||||||
"SearchFieldType",
|
"SearchFieldType",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from collections.abc import Awaitable, Callable, Sequence
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, ClassVar, Generic, Literal, Self, TypeAlias, cast, overload
|
from typing import Any, ClassVar, Generic, Literal, Self, cast, overload
|
||||||
|
|
||||||
from fastapi import Query
|
from fastapi import Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -38,7 +38,6 @@ from ..types import (
|
|||||||
M2MFieldType,
|
M2MFieldType,
|
||||||
ModelType,
|
ModelType,
|
||||||
OrderByClause,
|
OrderByClause,
|
||||||
OrderFieldType,
|
|
||||||
SchemaType,
|
SchemaType,
|
||||||
SearchFieldType,
|
SearchFieldType,
|
||||||
)
|
)
|
||||||
@@ -52,19 +51,6 @@ from .search import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_ForUpdateMode: TypeAlias = bool | Literal["nowait", "skip_locked"]
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_for_update(q: Any, mode: _ForUpdateMode) -> Any:
|
|
||||||
if not mode:
|
|
||||||
return q
|
|
||||||
if mode == "nowait":
|
|
||||||
return q.with_for_update(nowait=True)
|
|
||||||
if mode == "skip_locked":
|
|
||||||
return q.with_for_update(skip_locked=True)
|
|
||||||
return q.with_for_update()
|
|
||||||
|
|
||||||
|
|
||||||
class _CursorDirection(str, Enum):
|
class _CursorDirection(str, Enum):
|
||||||
NEXT = "next"
|
NEXT = "next"
|
||||||
PREV = "prev"
|
PREV = "prev"
|
||||||
@@ -148,7 +134,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
model: ClassVar[type[DeclarativeBase]]
|
model: ClassVar[type[DeclarativeBase]]
|
||||||
searchable_fields: ClassVar[Sequence[SearchFieldType] | None] = None
|
searchable_fields: ClassVar[Sequence[SearchFieldType] | None] = None
|
||||||
facet_fields: ClassVar[Sequence[FacetFieldType] | None] = None
|
facet_fields: ClassVar[Sequence[FacetFieldType] | None] = None
|
||||||
order_fields: ClassVar[Sequence[OrderFieldType] | None] = None
|
order_fields: ClassVar[Sequence[QueryableAttribute[Any]] | None] = None
|
||||||
m2m_fields: ClassVar[M2MFieldType | None] = None
|
m2m_fields: ClassVar[M2MFieldType | None] = None
|
||||||
default_load_options: ClassVar[Sequence[ExecutableOption] | None] = None
|
default_load_options: ClassVar[Sequence[ExecutableOption] | None] = None
|
||||||
cursor_column: ClassVar[Any | None] = None
|
cursor_column: ClassVar[Any | None] = None
|
||||||
@@ -183,18 +169,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
return load_options
|
return load_options
|
||||||
return cls.default_load_options
|
return cls.default_load_options
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _reload_with_options(
|
|
||||||
cls: type[Self], session: AsyncSession, instance: ModelType
|
|
||||||
) -> ModelType:
|
|
||||||
"""Re-query instance by PK with default_load_options applied."""
|
|
||||||
mapper = cls.model.__mapper__
|
|
||||||
pk_filters = [
|
|
||||||
getattr(cls.model, col.key) == getattr(instance, col.key)
|
|
||||||
for col in mapper.primary_key
|
|
||||||
]
|
|
||||||
return await cls.get(session, filters=pk_filters)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _resolve_m2m(
|
async def _resolve_m2m(
|
||||||
cls: type[Self],
|
cls: type[Self],
|
||||||
@@ -305,15 +279,15 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
return search_field_keys(fields)
|
return search_field_keys(fields)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _resolve_order_columns(
|
def _resolve_sort_columns(
|
||||||
cls: type[Self],
|
cls: type[Self],
|
||||||
order_fields: Sequence[OrderFieldType] | None,
|
order_fields: Sequence[QueryableAttribute[Any]] | None,
|
||||||
) -> list[str] | None:
|
) -> list[str] | None:
|
||||||
"""Return sort column keys, or None if no order fields configured."""
|
"""Return sort column keys, or None if no order fields configured."""
|
||||||
fields = order_fields if order_fields is not None else cls.order_fields
|
fields = order_fields if order_fields is not None else cls.order_fields
|
||||||
if not fields:
|
if not fields:
|
||||||
return None
|
return None
|
||||||
return sorted(facet_keys(fields))
|
return sorted(f.key for f in fields)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _build_paginate_params(
|
def _build_paginate_params(
|
||||||
@@ -327,7 +301,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
order: bool,
|
order: bool,
|
||||||
search_fields: Sequence[SearchFieldType] | None,
|
search_fields: Sequence[SearchFieldType] | None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None,
|
facet_fields: Sequence[FacetFieldType] | None,
|
||||||
order_fields: Sequence[OrderFieldType] | None,
|
order_fields: Sequence[QueryableAttribute[Any]] | None,
|
||||||
default_order_field: QueryableAttribute[Any] | None,
|
default_order_field: QueryableAttribute[Any] | None,
|
||||||
default_order: Literal["asc", "desc"],
|
default_order: Literal["asc", "desc"],
|
||||||
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
||||||
@@ -386,15 +360,14 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
)
|
)
|
||||||
reserved_names.update(filter_keys)
|
reserved_names.update(filter_keys)
|
||||||
|
|
||||||
order_field_map: dict[str, OrderFieldType] | None = None
|
order_field_map: dict[str, QueryableAttribute[Any]] | None = None
|
||||||
order_valid_keys: list[str] | None = None
|
order_valid_keys: list[str] | None = None
|
||||||
if order:
|
if order:
|
||||||
resolved_order = (
|
resolved_order = (
|
||||||
order_fields if order_fields is not None else cls.order_fields
|
order_fields if order_fields is not None else cls.order_fields
|
||||||
)
|
)
|
||||||
if resolved_order:
|
if resolved_order:
|
||||||
keys = facet_keys(resolved_order)
|
order_field_map = {f.key: f for f in resolved_order}
|
||||||
order_field_map = dict(zip(keys, resolved_order))
|
|
||||||
order_valid_keys = sorted(order_field_map.keys())
|
order_valid_keys = sorted(order_field_map.keys())
|
||||||
all_params.extend(
|
all_params.extend(
|
||||||
[
|
[
|
||||||
@@ -446,16 +419,9 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
else:
|
else:
|
||||||
field = order_field_map[order_by_val]
|
field = order_field_map[order_by_val]
|
||||||
if field is not None:
|
if field is not None:
|
||||||
if isinstance(field, tuple):
|
result["order_by"] = (
|
||||||
col = field[-1]
|
field.asc() if order_dir == "asc" else field.desc()
|
||||||
result["order_by"] = (
|
)
|
||||||
col.asc() if order_dir == "asc" else col.desc()
|
|
||||||
)
|
|
||||||
result["order_joins"] = list(field[:-1])
|
|
||||||
else:
|
|
||||||
result["order_by"] = (
|
|
||||||
field.asc() if order_dir == "asc" else field.desc()
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
result["order_by"] = None
|
result["order_by"] = None
|
||||||
|
|
||||||
@@ -479,7 +445,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
order: bool = True,
|
order: bool = True,
|
||||||
search_fields: Sequence[SearchFieldType] | None = None,
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
order_fields: Sequence[OrderFieldType] | None = None,
|
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||||
default_order_field: QueryableAttribute[Any] | None = None,
|
default_order_field: QueryableAttribute[Any] | None = None,
|
||||||
default_order: Literal["asc", "desc"] = "asc",
|
default_order: Literal["asc", "desc"] = "asc",
|
||||||
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
||||||
@@ -541,7 +507,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
order: bool = True,
|
order: bool = True,
|
||||||
search_fields: Sequence[SearchFieldType] | None = None,
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
order_fields: Sequence[OrderFieldType] | None = None,
|
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||||
default_order_field: QueryableAttribute[Any] | None = None,
|
default_order_field: QueryableAttribute[Any] | None = None,
|
||||||
default_order: Literal["asc", "desc"] = "asc",
|
default_order: Literal["asc", "desc"] = "asc",
|
||||||
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
||||||
@@ -606,7 +572,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
order: bool = True,
|
order: bool = True,
|
||||||
search_fields: Sequence[SearchFieldType] | None = None,
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
order_fields: Sequence[OrderFieldType] | None = None,
|
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||||
default_order_field: QueryableAttribute[Any] | None = None,
|
default_order_field: QueryableAttribute[Any] | None = None,
|
||||||
default_order: Literal["asc", "desc"] = "asc",
|
default_order: Literal["asc", "desc"] = "asc",
|
||||||
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
||||||
@@ -730,8 +696,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
|
|
||||||
session.add(db_model)
|
session.add(db_model)
|
||||||
await session.refresh(db_model)
|
await session.refresh(db_model)
|
||||||
if cls.default_load_options:
|
|
||||||
db_model = await cls._reload_with_options(session, db_model)
|
|
||||||
result = cast(ModelType, db_model)
|
result = cast(ModelType, db_model)
|
||||||
if schema:
|
if schema:
|
||||||
return Response(data=schema.model_validate(result))
|
return Response(data=schema.model_validate(result))
|
||||||
@@ -746,7 +710,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
with_for_update: _ForUpdateMode = False,
|
with_for_update: bool = False,
|
||||||
load_options: Sequence[ExecutableOption] | None = None,
|
load_options: Sequence[ExecutableOption] | None = None,
|
||||||
schema: type[SchemaType],
|
schema: type[SchemaType],
|
||||||
) -> Response[SchemaType]: ...
|
) -> Response[SchemaType]: ...
|
||||||
@@ -760,7 +724,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
with_for_update: _ForUpdateMode = False,
|
with_for_update: bool = False,
|
||||||
load_options: Sequence[ExecutableOption] | None = None,
|
load_options: Sequence[ExecutableOption] | None = None,
|
||||||
schema: None = ...,
|
schema: None = ...,
|
||||||
) -> ModelType: ...
|
) -> ModelType: ...
|
||||||
@@ -773,7 +737,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
with_for_update: _ForUpdateMode = False,
|
with_for_update: bool = False,
|
||||||
load_options: Sequence[ExecutableOption] | None = None,
|
load_options: Sequence[ExecutableOption] | None = None,
|
||||||
schema: type[BaseModel] | None = None,
|
schema: type[BaseModel] | None = None,
|
||||||
) -> ModelType | Response[Any]:
|
) -> ModelType | Response[Any]:
|
||||||
@@ -818,7 +782,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
with_for_update: _ForUpdateMode = False,
|
with_for_update: bool = False,
|
||||||
load_options: Sequence[ExecutableOption] | None = None,
|
load_options: Sequence[ExecutableOption] | None = None,
|
||||||
schema: type[SchemaType],
|
schema: type[SchemaType],
|
||||||
) -> Response[SchemaType] | None: ...
|
) -> Response[SchemaType] | None: ...
|
||||||
@@ -832,7 +796,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
with_for_update: _ForUpdateMode = False,
|
with_for_update: bool = False,
|
||||||
load_options: Sequence[ExecutableOption] | None = None,
|
load_options: Sequence[ExecutableOption] | None = None,
|
||||||
schema: None = ...,
|
schema: None = ...,
|
||||||
) -> ModelType | None: ...
|
) -> ModelType | None: ...
|
||||||
@@ -845,7 +809,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
with_for_update: _ForUpdateMode = False,
|
with_for_update: bool = False,
|
||||||
load_options: Sequence[ExecutableOption] | None = None,
|
load_options: Sequence[ExecutableOption] | None = None,
|
||||||
schema: type[BaseModel] | None = None,
|
schema: type[BaseModel] | None = None,
|
||||||
) -> ModelType | Response[Any] | None:
|
) -> ModelType | Response[Any] | None:
|
||||||
@@ -877,7 +841,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
if resolved := cls._resolve_load_options(load_options):
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
q = q.options(*resolved)
|
q = q.options(*resolved)
|
||||||
q = _apply_for_update(q, with_for_update)
|
if with_for_update:
|
||||||
|
q = q.with_for_update()
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
item = result.unique().scalar_one_or_none()
|
item = result.unique().scalar_one_or_none()
|
||||||
if item is None:
|
if item is None:
|
||||||
@@ -896,7 +861,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
with_for_update: _ForUpdateMode = False,
|
with_for_update: bool = False,
|
||||||
load_options: Sequence[ExecutableOption] | None = None,
|
load_options: Sequence[ExecutableOption] | None = None,
|
||||||
schema: type[SchemaType],
|
schema: type[SchemaType],
|
||||||
) -> Response[SchemaType] | None: ...
|
) -> Response[SchemaType] | None: ...
|
||||||
@@ -910,7 +875,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
with_for_update: _ForUpdateMode = False,
|
with_for_update: bool = False,
|
||||||
load_options: Sequence[ExecutableOption] | None = None,
|
load_options: Sequence[ExecutableOption] | None = None,
|
||||||
schema: None = ...,
|
schema: None = ...,
|
||||||
) -> ModelType | None: ...
|
) -> ModelType | None: ...
|
||||||
@@ -923,7 +888,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
with_for_update: _ForUpdateMode = False,
|
with_for_update: bool = False,
|
||||||
load_options: Sequence[ExecutableOption] | None = None,
|
load_options: Sequence[ExecutableOption] | None = None,
|
||||||
schema: type[BaseModel] | None = None,
|
schema: type[BaseModel] | None = None,
|
||||||
) -> ModelType | Response[Any] | None:
|
) -> ModelType | Response[Any] | None:
|
||||||
@@ -949,7 +914,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
if resolved := cls._resolve_load_options(load_options):
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
q = q.options(*resolved)
|
q = q.options(*resolved)
|
||||||
q = _apply_for_update(q, with_for_update)
|
if with_for_update:
|
||||||
|
q = q.with_for_update()
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
item = result.unique().scalars().first()
|
item = result.unique().scalars().first()
|
||||||
if item is None:
|
if item is None:
|
||||||
@@ -967,7 +933,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
filters: list[Any] | None = None,
|
filters: list[Any] | None = None,
|
||||||
joins: JoinType | None = None,
|
joins: JoinType | None = None,
|
||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
with_for_update: _ForUpdateMode = False,
|
|
||||||
load_options: Sequence[ExecutableOption] | None = None,
|
load_options: Sequence[ExecutableOption] | None = None,
|
||||||
order_by: OrderByClause | None = None,
|
order_by: OrderByClause | None = None,
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
@@ -980,9 +945,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
filters: List of SQLAlchemy filter conditions
|
filters: List of SQLAlchemy filter conditions
|
||||||
joins: List of (model, condition) tuples for joining related tables
|
joins: List of (model, condition) tuples for joining related tables
|
||||||
outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
|
outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
|
||||||
with_for_update: Lock rows for update. ``True`` for plain ``FOR UPDATE``,
|
|
||||||
``"nowait"`` for ``FOR UPDATE NOWAIT``, ``"skip_locked"`` for
|
|
||||||
``FOR UPDATE SKIP LOCKED``.
|
|
||||||
load_options: SQLAlchemy loader options
|
load_options: SQLAlchemy loader options
|
||||||
order_by: Column or list of columns to order by
|
order_by: Column or list of columns to order by
|
||||||
limit: Max number of rows to return
|
limit: Max number of rows to return
|
||||||
@@ -997,7 +959,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
if resolved := cls._resolve_load_options(load_options):
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
q = q.options(*resolved)
|
q = q.options(*resolved)
|
||||||
q = _apply_for_update(q, with_for_update)
|
|
||||||
if order_by is not None:
|
if order_by is not None:
|
||||||
q = q.order_by(order_by)
|
q = q.order_by(order_by)
|
||||||
if offset is not None:
|
if offset is not None:
|
||||||
@@ -1017,7 +978,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
exclude_unset: bool = True,
|
exclude_unset: bool = True,
|
||||||
exclude_none: bool = False,
|
exclude_none: bool = False,
|
||||||
with_for_update: _ForUpdateMode = False,
|
|
||||||
schema: type[SchemaType],
|
schema: type[SchemaType],
|
||||||
) -> Response[SchemaType]: ...
|
) -> Response[SchemaType]: ...
|
||||||
|
|
||||||
@@ -1031,7 +991,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
exclude_unset: bool = True,
|
exclude_unset: bool = True,
|
||||||
exclude_none: bool = False,
|
exclude_none: bool = False,
|
||||||
with_for_update: _ForUpdateMode = False,
|
|
||||||
schema: None = ...,
|
schema: None = ...,
|
||||||
) -> ModelType: ...
|
) -> ModelType: ...
|
||||||
|
|
||||||
@@ -1044,7 +1003,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
exclude_unset: bool = True,
|
exclude_unset: bool = True,
|
||||||
exclude_none: bool = False,
|
exclude_none: bool = False,
|
||||||
with_for_update: _ForUpdateMode = False,
|
|
||||||
schema: type[BaseModel] | None = None,
|
schema: type[BaseModel] | None = None,
|
||||||
) -> ModelType | Response[Any]:
|
) -> ModelType | Response[Any]:
|
||||||
"""Update a record in the database.
|
"""Update a record in the database.
|
||||||
@@ -1055,9 +1013,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
filters: List of SQLAlchemy filter conditions
|
filters: List of SQLAlchemy filter conditions
|
||||||
exclude_unset: Exclude fields not explicitly set in the schema
|
exclude_unset: Exclude fields not explicitly set in the schema
|
||||||
exclude_none: Exclude fields with None value
|
exclude_none: Exclude fields with None value
|
||||||
with_for_update: Lock the row before updating. ``True`` for plain
|
|
||||||
``FOR UPDATE``, ``"nowait"`` for ``FOR UPDATE NOWAIT``,
|
|
||||||
``"skip_locked"`` for ``FOR UPDATE SKIP LOCKED``.
|
|
||||||
schema: Pydantic schema to serialize the result into. When provided,
|
schema: Pydantic schema to serialize the result into. When provided,
|
||||||
the result is automatically wrapped in a ``Response[schema]``.
|
the result is automatically wrapped in a ``Response[schema]``.
|
||||||
|
|
||||||
@@ -1081,7 +1036,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
db_model = await cls.get(
|
db_model = await cls.get(
|
||||||
session=session,
|
session=session,
|
||||||
filters=filters,
|
filters=filters,
|
||||||
with_for_update=with_for_update,
|
|
||||||
load_options=m2m_load_options or None,
|
load_options=m2m_load_options or None,
|
||||||
)
|
)
|
||||||
values = obj.model_dump(
|
values = obj.model_dump(
|
||||||
@@ -1097,8 +1051,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
for rel_attr, related_instances in m2m_resolved.items():
|
for rel_attr, related_instances in m2m_resolved.items():
|
||||||
setattr(db_model, rel_attr, related_instances)
|
setattr(db_model, rel_attr, related_instances)
|
||||||
await session.refresh(db_model)
|
await session.refresh(db_model)
|
||||||
if cls.default_load_options:
|
|
||||||
db_model = await cls._reload_with_options(session, db_model)
|
|
||||||
if schema:
|
if schema:
|
||||||
return Response(data=schema.model_validate(db_model))
|
return Response(data=schema.model_validate(db_model))
|
||||||
return db_model
|
return db_model
|
||||||
@@ -1261,14 +1213,13 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
load_options: Sequence[ExecutableOption] | None = None,
|
load_options: Sequence[ExecutableOption] | None = None,
|
||||||
order_by: OrderByClause | None = None,
|
order_by: OrderByClause | None = None,
|
||||||
order_joins: list[Any] | None = None,
|
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
items_per_page: int = 20,
|
items_per_page: int = 20,
|
||||||
include_total: bool = True,
|
include_total: bool = True,
|
||||||
search: str | SearchConfig | None = None,
|
search: str | SearchConfig | None = None,
|
||||||
search_fields: Sequence[SearchFieldType] | None = None,
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
search_column: str | None = None,
|
search_column: str | None = None,
|
||||||
order_fields: Sequence[OrderFieldType] | None = None,
|
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
@@ -1326,10 +1277,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
# Apply search joins (always outer joins for search)
|
# Apply search joins (always outer joins for search)
|
||||||
q = _apply_search_joins(q, search_joins)
|
q = _apply_search_joins(q, search_joins)
|
||||||
|
|
||||||
# Apply order joins (relation joins required for order_by field)
|
|
||||||
if order_joins:
|
|
||||||
q = _apply_search_joins(q, order_joins)
|
|
||||||
|
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
if resolved := cls._resolve_load_options(load_options):
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
@@ -1357,7 +1304,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
count_q = count_q.where(and_(*filters))
|
count_q = count_q.where(and_(*filters))
|
||||||
|
|
||||||
count_result = await session.execute(count_q)
|
count_result = await session.execute(count_q)
|
||||||
total_count: int = count_result.scalar_one()
|
total_count: int | None = count_result.scalar_one()
|
||||||
has_more = page * items_per_page < total_count
|
has_more = page * items_per_page < total_count
|
||||||
else:
|
else:
|
||||||
# Fetch one extra row to detect if a next page exists without COUNT
|
# Fetch one extra row to detect if a next page exists without COUNT
|
||||||
@@ -1374,7 +1321,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
session, facet_fields, filters, search_joins
|
session, facet_fields, filters, search_joins
|
||||||
)
|
)
|
||||||
search_columns = cls._resolve_search_columns(search_fields)
|
search_columns = cls._resolve_search_columns(search_fields)
|
||||||
order_columns = cls._resolve_order_columns(order_fields)
|
sort_columns = cls._resolve_sort_columns(order_fields)
|
||||||
|
|
||||||
return OffsetPaginatedResponse(
|
return OffsetPaginatedResponse(
|
||||||
data=items,
|
data=items,
|
||||||
@@ -1386,7 +1333,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
),
|
),
|
||||||
filter_attributes=filter_attributes,
|
filter_attributes=filter_attributes,
|
||||||
search_columns=search_columns,
|
search_columns=search_columns,
|
||||||
order_columns=order_columns,
|
sort_columns=sort_columns,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1400,12 +1347,11 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
load_options: Sequence[ExecutableOption] | None = None,
|
load_options: Sequence[ExecutableOption] | None = None,
|
||||||
order_by: OrderByClause | None = None,
|
order_by: OrderByClause | None = None,
|
||||||
order_joins: list[Any] | None = None,
|
|
||||||
items_per_page: int = 20,
|
items_per_page: int = 20,
|
||||||
search: str | SearchConfig | None = None,
|
search: str | SearchConfig | None = None,
|
||||||
search_fields: Sequence[SearchFieldType] | None = None,
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
search_column: str | None = None,
|
search_column: str | None = None,
|
||||||
order_fields: Sequence[OrderFieldType] | None = None,
|
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
@@ -1481,10 +1427,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
# Apply search joins (always outer joins)
|
# Apply search joins (always outer joins)
|
||||||
q = _apply_search_joins(q, search_joins)
|
q = _apply_search_joins(q, search_joins)
|
||||||
|
|
||||||
# Apply order joins (relation joins required for order_by field)
|
|
||||||
if order_joins:
|
|
||||||
q = _apply_search_joins(q, order_joins)
|
|
||||||
|
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
if resolved := cls._resolve_load_options(load_options):
|
if resolved := cls._resolve_load_options(load_options):
|
||||||
@@ -1543,7 +1485,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
session, facet_fields, filters, search_joins
|
session, facet_fields, filters, search_joins
|
||||||
)
|
)
|
||||||
search_columns = cls._resolve_search_columns(search_fields)
|
search_columns = cls._resolve_search_columns(search_fields)
|
||||||
order_columns = cls._resolve_order_columns(order_fields)
|
sort_columns = cls._resolve_sort_columns(order_fields)
|
||||||
|
|
||||||
return CursorPaginatedResponse(
|
return CursorPaginatedResponse(
|
||||||
data=items,
|
data=items,
|
||||||
@@ -1555,7 +1497,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
),
|
),
|
||||||
filter_attributes=filter_attributes,
|
filter_attributes=filter_attributes,
|
||||||
search_columns=search_columns,
|
search_columns=search_columns,
|
||||||
order_columns=order_columns,
|
sort_columns=sort_columns,
|
||||||
)
|
)
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@@ -1570,7 +1512,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
outer_join: bool = ...,
|
outer_join: bool = ...,
|
||||||
load_options: Sequence[ExecutableOption] | None = ...,
|
load_options: Sequence[ExecutableOption] | None = ...,
|
||||||
order_by: OrderByClause | None = ...,
|
order_by: OrderByClause | None = ...,
|
||||||
order_joins: list[Any] | None = ...,
|
|
||||||
page: int = ...,
|
page: int = ...,
|
||||||
cursor: str | None = ...,
|
cursor: str | None = ...,
|
||||||
items_per_page: int = ...,
|
items_per_page: int = ...,
|
||||||
@@ -1578,7 +1519,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search: str | SearchConfig | None = ...,
|
search: str | SearchConfig | None = ...,
|
||||||
search_fields: Sequence[SearchFieldType] | None = ...,
|
search_fields: Sequence[SearchFieldType] | None = ...,
|
||||||
search_column: str | None = ...,
|
search_column: str | None = ...,
|
||||||
order_fields: Sequence[OrderFieldType] | None = ...,
|
order_fields: Sequence[QueryableAttribute[Any]] | None = ...,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = ...,
|
facet_fields: Sequence[FacetFieldType] | None = ...,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = ...,
|
filter_by: dict[str, Any] | BaseModel | None = ...,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
@@ -1596,7 +1537,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
outer_join: bool = ...,
|
outer_join: bool = ...,
|
||||||
load_options: Sequence[ExecutableOption] | None = ...,
|
load_options: Sequence[ExecutableOption] | None = ...,
|
||||||
order_by: OrderByClause | None = ...,
|
order_by: OrderByClause | None = ...,
|
||||||
order_joins: list[Any] | None = ...,
|
|
||||||
page: int = ...,
|
page: int = ...,
|
||||||
cursor: str | None = ...,
|
cursor: str | None = ...,
|
||||||
items_per_page: int = ...,
|
items_per_page: int = ...,
|
||||||
@@ -1604,7 +1544,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search: str | SearchConfig | None = ...,
|
search: str | SearchConfig | None = ...,
|
||||||
search_fields: Sequence[SearchFieldType] | None = ...,
|
search_fields: Sequence[SearchFieldType] | None = ...,
|
||||||
search_column: str | None = ...,
|
search_column: str | None = ...,
|
||||||
order_fields: Sequence[OrderFieldType] | None = ...,
|
order_fields: Sequence[QueryableAttribute[Any]] | None = ...,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = ...,
|
facet_fields: Sequence[FacetFieldType] | None = ...,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = ...,
|
filter_by: dict[str, Any] | BaseModel | None = ...,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
@@ -1621,7 +1561,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
outer_join: bool = False,
|
outer_join: bool = False,
|
||||||
load_options: Sequence[ExecutableOption] | None = None,
|
load_options: Sequence[ExecutableOption] | None = None,
|
||||||
order_by: OrderByClause | None = None,
|
order_by: OrderByClause | None = None,
|
||||||
order_joins: list[Any] | None = None,
|
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
items_per_page: int = 20,
|
items_per_page: int = 20,
|
||||||
@@ -1629,7 +1568,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search: str | SearchConfig | None = None,
|
search: str | SearchConfig | None = None,
|
||||||
search_fields: Sequence[SearchFieldType] | None = None,
|
search_fields: Sequence[SearchFieldType] | None = None,
|
||||||
search_column: str | None = None,
|
search_column: str | None = None,
|
||||||
order_fields: Sequence[OrderFieldType] | None = None,
|
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
@@ -1684,7 +1623,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
outer_join=outer_join,
|
outer_join=outer_join,
|
||||||
load_options=load_options,
|
load_options=load_options,
|
||||||
order_by=order_by,
|
order_by=order_by,
|
||||||
order_joins=order_joins,
|
|
||||||
items_per_page=items_per_page,
|
items_per_page=items_per_page,
|
||||||
search=search,
|
search=search,
|
||||||
search_fields=search_fields,
|
search_fields=search_fields,
|
||||||
@@ -1704,7 +1642,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
outer_join=outer_join,
|
outer_join=outer_join,
|
||||||
load_options=load_options,
|
load_options=load_options,
|
||||||
order_by=order_by,
|
order_by=order_by,
|
||||||
order_joins=order_joins,
|
|
||||||
page=page,
|
page=page,
|
||||||
items_per_page=items_per_page,
|
items_per_page=items_per_page,
|
||||||
include_total=include_total,
|
include_total=include_total,
|
||||||
@@ -1726,7 +1663,7 @@ def CrudFactory(
|
|||||||
base_class: type[AsyncCrud[Any]] = AsyncCrud,
|
base_class: type[AsyncCrud[Any]] = AsyncCrud,
|
||||||
searchable_fields: Sequence[SearchFieldType] | None = None,
|
searchable_fields: Sequence[SearchFieldType] | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
order_fields: Sequence[OrderFieldType] | None = None,
|
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||||
m2m_fields: M2MFieldType | None = None,
|
m2m_fields: M2MFieldType | None = None,
|
||||||
default_load_options: Sequence[ExecutableOption] | None = None,
|
default_load_options: Sequence[ExecutableOption] | None = None,
|
||||||
cursor_column: Any | None = None,
|
cursor_column: Any | None = None,
|
||||||
|
|||||||
@@ -265,15 +265,7 @@ async def build_facets(
|
|||||||
else:
|
else:
|
||||||
q = q.order_by(column)
|
q = q.order_by(column)
|
||||||
result = await session.execute(q)
|
result = await session.execute(q)
|
||||||
col_type = column.property.columns[0].type
|
values = [row[0] for row in result.all() if row[0] is not None]
|
||||||
enum_class = getattr(col_type, "enum_class", None)
|
|
||||||
values = [
|
|
||||||
row[0].name
|
|
||||||
if (enum_class is not None and isinstance(row[0], enum_class))
|
|
||||||
else row[0]
|
|
||||||
for row in result.all()
|
|
||||||
if row[0] is not None
|
|
||||||
]
|
|
||||||
return key, values
|
return key, values
|
||||||
|
|
||||||
pairs = await asyncio.gather(
|
pairs = await asyncio.gather(
|
||||||
@@ -355,24 +347,6 @@ def build_filter_by(
|
|||||||
filters.append(column.overlap(value))
|
filters.append(column.overlap(value))
|
||||||
else:
|
else:
|
||||||
filters.append(column.any(value))
|
filters.append(column.any(value))
|
||||||
elif isinstance(col_type, Enum):
|
|
||||||
enum_class = col_type.enum_class
|
|
||||||
if enum_class is not None:
|
|
||||||
|
|
||||||
def _coerce_enum(v: Any) -> Any:
|
|
||||||
if isinstance(v, enum_class):
|
|
||||||
return v
|
|
||||||
return enum_class[v] # lookup by name: "PENDING", "RED"
|
|
||||||
|
|
||||||
if isinstance(value, list):
|
|
||||||
filters.append(column.in_([_coerce_enum(v) for v in value]))
|
|
||||||
else:
|
|
||||||
filters.append(column == _coerce_enum(value))
|
|
||||||
else: # pragma: no cover
|
|
||||||
if isinstance(value, list):
|
|
||||||
filters.append(column.in_(value))
|
|
||||||
else:
|
|
||||||
filters.append(column == value)
|
|
||||||
elif isinstance(col_type, _EQUALITY_TYPES):
|
elif isinstance(col_type, _EQUALITY_TYPES):
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
filters.append(column.in_(value))
|
filters.append(column.in_(value))
|
||||||
|
|||||||
+23
-243
@@ -4,28 +4,22 @@ import asyncio
|
|||||||
from collections.abc import AsyncGenerator, Callable
|
from collections.abc import AsyncGenerator, Callable
|
||||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, TypeVar, cast
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
from sqlalchemy import Table, delete, text, tuple_
|
from sqlalchemy import text
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
||||||
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, QueryableAttribute
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from sqlalchemy.orm.relationships import RelationshipProperty
|
|
||||||
|
|
||||||
from .exceptions import NotFoundError
|
from .exceptions import NotFoundError
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"LockMode",
|
"LockMode",
|
||||||
"advisory_lock",
|
|
||||||
"cleanup_tables",
|
"cleanup_tables",
|
||||||
"create_database",
|
"create_database",
|
||||||
"create_db_context",
|
"create_db_context",
|
||||||
"create_db_dependency",
|
"create_db_dependency",
|
||||||
"get_transaction",
|
"get_transaction",
|
||||||
"lock_tables",
|
"lock_tables",
|
||||||
"m2m_add",
|
|
||||||
"m2m_remove",
|
|
||||||
"m2m_set",
|
|
||||||
"wait_for_row_change",
|
"wait_for_row_change",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -152,129 +146,52 @@ class LockMode(str, Enum):
|
|||||||
ACCESS_EXCLUSIVE = "ACCESS EXCLUSIVE"
|
ACCESS_EXCLUSIVE = "ACCESS EXCLUSIVE"
|
||||||
|
|
||||||
|
|
||||||
def lock_tables(
|
@asynccontextmanager
|
||||||
session_maker: async_sessionmaker[_SessionT],
|
async def lock_tables(
|
||||||
|
session: AsyncSession,
|
||||||
tables: list[type[DeclarativeBase]],
|
tables: list[type[DeclarativeBase]],
|
||||||
*,
|
*,
|
||||||
mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE,
|
mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE,
|
||||||
timeout: str = "5s",
|
timeout: str = "5s",
|
||||||
) -> AbstractAsyncContextManager[_SessionT]:
|
) -> AsyncGenerator[AsyncSession, None]:
|
||||||
"""Lock PostgreSQL tables for the duration of a transaction.
|
"""Lock PostgreSQL tables for the duration of a transaction.
|
||||||
|
|
||||||
|
Acquires table-level locks that are held until the transaction ends.
|
||||||
|
Useful for preventing concurrent modifications during critical operations.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session_maker: Async session factory used to create the dedicated
|
session: AsyncSession instance
|
||||||
session.
|
tables: List of SQLAlchemy model classes to lock
|
||||||
tables: List of SQLAlchemy model classes to lock.
|
mode: Lock mode (default: SHARE UPDATE EXCLUSIVE)
|
||||||
mode: Lock mode (default: SHARE UPDATE EXCLUSIVE).
|
timeout: Lock timeout (default: "5s")
|
||||||
timeout: Lock timeout (default: "5s").
|
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
The dedicated session, open within the locked transaction.
|
The session with locked tables
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
SQLAlchemyError: If the lock cannot be acquired within *timeout*.
|
SQLAlchemyError: If lock cannot be acquired within timeout
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import lock_tables, LockMode
|
from fastapi_toolsets.db import lock_tables, LockMode
|
||||||
|
|
||||||
async with lock_tables(session_maker, [User, Account]) as session:
|
async with lock_tables(session, [User, Account]):
|
||||||
# Tables are locked; changes are committed when the context exits.
|
# Tables are locked with SHARE UPDATE EXCLUSIVE mode
|
||||||
user = await UserCrud.get(session, [User.id == 1])
|
user = await UserCrud.get(session, [User.id == 1])
|
||||||
user.balance += 100
|
user.balance += 100
|
||||||
|
|
||||||
# With custom lock mode
|
# With custom lock mode
|
||||||
async with lock_tables(session_maker, [Order], mode=LockMode.EXCLUSIVE) as session:
|
async with lock_tables(session, [Order], mode=LockMode.EXCLUSIVE):
|
||||||
|
# Exclusive lock - no other transactions can access
|
||||||
await process_order(session, order_id)
|
await process_order(session, order_id)
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
table_names = ",".join(table.__tablename__ for table in tables)
|
table_names = ",".join(table.__tablename__ for table in tables)
|
||||||
|
|
||||||
@asynccontextmanager
|
async with get_transaction(session):
|
||||||
async def _lock() -> AsyncGenerator[_SessionT, None]:
|
|
||||||
async with session_maker() as session:
|
|
||||||
try:
|
|
||||||
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
|
|
||||||
await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
|
|
||||||
yield session
|
|
||||||
await session.commit()
|
|
||||||
except BaseException:
|
|
||||||
await session.rollback()
|
|
||||||
raise
|
|
||||||
|
|
||||||
return _lock()
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def advisory_lock(
|
|
||||||
session: AsyncSession,
|
|
||||||
key: int | tuple[int, int],
|
|
||||||
*,
|
|
||||||
shared: bool = False,
|
|
||||||
nowait: bool = False,
|
|
||||||
timeout: str | None = None,
|
|
||||||
) -> AsyncGenerator[bool, None]:
|
|
||||||
"""Acquire a PostgreSQL session-level advisory lock.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: AsyncSession instance.
|
|
||||||
key: Lock key — a single ``int`` (bigint) or a ``(int, int)`` pair for namespacing.
|
|
||||||
shared: Acquire a shared lock (multiple holders allowed). Default is exclusive.
|
|
||||||
nowait: Return ``False`` immediately if the lock is unavailable instead of waiting.
|
|
||||||
timeout: Maximum wait time (e.g. ``"5s"``, ``"500ms"``). Raises ``DBAPIError``
|
|
||||||
if exceeded. Ignored when *nowait* is ``True``.
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
``True`` if the lock was acquired, ``False`` if *nowait* is ``True`` and the lock
|
|
||||||
is already held.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
sqlalchemy.exc.DBAPIError: If *timeout* is set and the lock cannot be acquired
|
|
||||||
in time.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.db import advisory_lock
|
|
||||||
|
|
||||||
async with advisory_lock(session, 42):
|
|
||||||
...
|
|
||||||
|
|
||||||
async with advisory_lock(session, 42, nowait=True) as acquired:
|
|
||||||
if not acquired:
|
|
||||||
raise HTTPException(409, "Resource is locked")
|
|
||||||
|
|
||||||
async with advisory_lock(session, 42, timeout="5s"):
|
|
||||||
...
|
|
||||||
|
|
||||||
async with advisory_lock(session, (1, user_id), shared=True):
|
|
||||||
...
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
suffix = "_shared" if shared else ""
|
|
||||||
acquire_fn = f"{'pg_try_advisory_lock' if nowait else 'pg_advisory_lock'}{suffix}"
|
|
||||||
release_fn = f"pg_advisory_unlock{suffix}"
|
|
||||||
|
|
||||||
if isinstance(key, tuple):
|
|
||||||
k1, k2 = key
|
|
||||||
args = "CAST(:k1 AS integer), CAST(:k2 AS integer)"
|
|
||||||
params: dict[str, int] = {"k1": k1, "k2": k2}
|
|
||||||
else:
|
|
||||||
args = ":k"
|
|
||||||
params = {"k": key}
|
|
||||||
|
|
||||||
acquire_sql = text(f"SELECT {acquire_fn}({args})")
|
|
||||||
release_sql = text(f"SELECT {release_fn}({args})")
|
|
||||||
|
|
||||||
if timeout is not None and not nowait:
|
|
||||||
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
|
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
|
||||||
|
await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
|
||||||
result = await session.execute(acquire_sql, params)
|
yield session
|
||||||
acquired = result.scalar() if nowait else True
|
|
||||||
try:
|
|
||||||
yield acquired
|
|
||||||
finally:
|
|
||||||
if acquired:
|
|
||||||
await session.execute(release_sql, params)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_database(
|
async def create_database(
|
||||||
@@ -422,140 +339,3 @@ async def wait_for_row_change(
|
|||||||
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:
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|
||||||
def _m2m_prop(rel_attr: QueryableAttribute) -> RelationshipProperty: # type: ignore[type-arg]
|
|
||||||
"""Return the validated M2M RelationshipProperty for *rel_attr*.
|
|
||||||
|
|
||||||
Raises TypeError if *rel_attr* is not a Many-to-Many relationship.
|
|
||||||
"""
|
|
||||||
prop = rel_attr.property
|
|
||||||
if not isinstance(prop, RelationshipProperty) or prop.secondary is None:
|
|
||||||
raise TypeError(
|
|
||||||
f"m2m helpers require a Many-to-Many relationship attribute, "
|
|
||||||
f"got {rel_attr!r}. Use a relationship with a secondary table."
|
|
||||||
)
|
|
||||||
return prop
|
|
||||||
|
|
||||||
|
|
||||||
async def m2m_add(
|
|
||||||
session: AsyncSession,
|
|
||||||
instance: DeclarativeBase,
|
|
||||||
rel_attr: QueryableAttribute,
|
|
||||||
*related: DeclarativeBase,
|
|
||||||
ignore_conflicts: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""Insert rows into a Many-to-Many association table without loading the ORM collection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: DB async session.
|
|
||||||
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
|
||||||
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
|
||||||
*related: One or more related instances to associate with ``instance``.
|
|
||||||
ignore_conflicts: When ``True``, silently skip rows that already exist
|
|
||||||
in the association table (``ON CONFLICT DO NOTHING``).
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
|
||||||
"""
|
|
||||||
prop = _m2m_prop(rel_attr)
|
|
||||||
if not related:
|
|
||||||
return
|
|
||||||
|
|
||||||
secondary = cast(Table, prop.secondary)
|
|
||||||
assert secondary is not None # guaranteed by _m2m_prop
|
|
||||||
sync_pairs = prop.secondary_synchronize_pairs
|
|
||||||
assert sync_pairs is not None # set whenever secondary is set
|
|
||||||
|
|
||||||
# synchronize_pairs: [(parent_col, assoc_col), ...]
|
|
||||||
# secondary_synchronize_pairs: [(related_col, assoc_col), ...]
|
|
||||||
rows: list[dict[str, Any]] = []
|
|
||||||
for rel_instance in related:
|
|
||||||
row: dict[str, Any] = {}
|
|
||||||
for parent_col, assoc_col in prop.synchronize_pairs:
|
|
||||||
row[assoc_col.name] = getattr(instance, cast(str, parent_col.key))
|
|
||||||
for related_col, assoc_col in sync_pairs:
|
|
||||||
row[assoc_col.name] = getattr(rel_instance, cast(str, related_col.key))
|
|
||||||
rows.append(row)
|
|
||||||
|
|
||||||
stmt = pg_insert(secondary).values(rows)
|
|
||||||
if ignore_conflicts:
|
|
||||||
stmt = stmt.on_conflict_do_nothing()
|
|
||||||
await session.execute(stmt)
|
|
||||||
|
|
||||||
|
|
||||||
async def m2m_remove(
|
|
||||||
session: AsyncSession,
|
|
||||||
instance: DeclarativeBase,
|
|
||||||
rel_attr: QueryableAttribute,
|
|
||||||
*related: DeclarativeBase,
|
|
||||||
) -> None:
|
|
||||||
"""Remove rows from a Many-to-Many association table without loading the ORM collection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: DB async session.
|
|
||||||
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
|
||||||
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
|
||||||
*related: One or more related instances to disassociate from ``instance``.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
|
||||||
"""
|
|
||||||
prop = _m2m_prop(rel_attr)
|
|
||||||
if not related:
|
|
||||||
return
|
|
||||||
|
|
||||||
secondary = cast(Table, prop.secondary)
|
|
||||||
assert secondary is not None # guaranteed by _m2m_prop
|
|
||||||
related_pairs = prop.secondary_synchronize_pairs
|
|
||||||
assert related_pairs is not None # set whenever secondary is set
|
|
||||||
|
|
||||||
parent_where = [
|
|
||||||
assoc_col == getattr(instance, cast(str, parent_col.key))
|
|
||||||
for parent_col, assoc_col in prop.synchronize_pairs
|
|
||||||
]
|
|
||||||
|
|
||||||
if len(related_pairs) == 1:
|
|
||||||
related_col, assoc_col = related_pairs[0]
|
|
||||||
related_values = [getattr(r, cast(str, related_col.key)) for r in related]
|
|
||||||
related_where = assoc_col.in_(related_values)
|
|
||||||
else:
|
|
||||||
assoc_cols = [ac for _, ac in related_pairs]
|
|
||||||
rel_cols = [rc for rc, _ in related_pairs]
|
|
||||||
related_values_t = [
|
|
||||||
tuple(getattr(r, cast(str, rc.key)) for rc in rel_cols) for r in related
|
|
||||||
]
|
|
||||||
related_where = tuple_(*assoc_cols).in_(related_values_t)
|
|
||||||
|
|
||||||
await session.execute(delete(secondary).where(*parent_where, related_where))
|
|
||||||
|
|
||||||
|
|
||||||
async def m2m_set(
|
|
||||||
session: AsyncSession,
|
|
||||||
instance: DeclarativeBase,
|
|
||||||
rel_attr: QueryableAttribute,
|
|
||||||
*related: DeclarativeBase,
|
|
||||||
) -> None:
|
|
||||||
"""Replace the entire Many-to-Many association set atomically.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: DB async session.
|
|
||||||
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
|
||||||
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
|
||||||
*related: The new complete set of related instances.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
|
||||||
"""
|
|
||||||
prop = _m2m_prop(rel_attr)
|
|
||||||
secondary = cast(Table, prop.secondary)
|
|
||||||
assert secondary is not None # guaranteed by _m2m_prop
|
|
||||||
|
|
||||||
parent_where = [
|
|
||||||
assoc_col == getattr(instance, cast(str, parent_col.key))
|
|
||||||
for parent_col, assoc_col in prop.synchronize_pairs
|
|
||||||
]
|
|
||||||
await session.execute(delete(secondary).where(*parent_where))
|
|
||||||
|
|
||||||
if related:
|
|
||||||
await m2m_add(session, instance, rel_attr, *related)
|
|
||||||
|
|||||||
@@ -2,18 +2,12 @@
|
|||||||
|
|
||||||
from .enum import LoadStrategy
|
from .enum import LoadStrategy
|
||||||
from .registry import Context, FixtureRegistry
|
from .registry import Context, FixtureRegistry
|
||||||
from .utils import (
|
from .utils import get_obj_by_attr, load_fixtures, load_fixtures_by_context
|
||||||
get_field_by_attr,
|
|
||||||
get_obj_by_attr,
|
|
||||||
load_fixtures,
|
|
||||||
load_fixtures_by_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Context",
|
"Context",
|
||||||
"FixtureRegistry",
|
"FixtureRegistry",
|
||||||
"LoadStrategy",
|
"LoadStrategy",
|
||||||
"get_field_by_attr",
|
|
||||||
"get_obj_by_attr",
|
"get_obj_by_attr",
|
||||||
"load_fixtures",
|
"load_fixtures",
|
||||||
"load_fixtures_by_context",
|
"load_fixtures_by_context",
|
||||||
|
|||||||
@@ -40,32 +40,6 @@ def _instance_to_dict(instance: DeclarativeBase) -> dict[str, Any]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _get_table_chain(model_cls: type[DeclarativeBase]) -> list[type[DeclarativeBase]]:
|
|
||||||
"""Return [root, ..., model_cls] for joined-table inheritance, or [model_cls]."""
|
|
||||||
chain: list[type[DeclarativeBase]] = []
|
|
||||||
current = sa_inspect(model_cls)
|
|
||||||
while current is not None:
|
|
||||||
chain.append(current.class_)
|
|
||||||
current = current.inherits
|
|
||||||
chain.reverse()
|
|
||||||
seen: set[int] = set()
|
|
||||||
result: list[type[DeclarativeBase]] = []
|
|
||||||
for cls in chain:
|
|
||||||
tid = id(cls.__table__)
|
|
||||||
if tid not in seen: # pragma: no branch
|
|
||||||
seen.add(tid)
|
|
||||||
result.append(cls)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _instance_to_dict_for_cls(
|
|
||||||
instance: DeclarativeBase, cls: type[DeclarativeBase]
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Like _instance_to_dict but limited to columns belonging to cls's own table."""
|
|
||||||
own_cols = {col.key for col in cls.__table__.columns}
|
|
||||||
return {k: v for k, v in _instance_to_dict(instance).items() if k in own_cols}
|
|
||||||
|
|
||||||
|
|
||||||
def _group_by_type(
|
def _group_by_type(
|
||||||
instances: list[DeclarativeBase],
|
instances: list[DeclarativeBase],
|
||||||
) -> list[tuple[type[DeclarativeBase], list[DeclarativeBase]]]:
|
) -> list[tuple[type[DeclarativeBase], list[DeclarativeBase]]]:
|
||||||
@@ -99,11 +73,9 @@ async def _batch_insert(
|
|||||||
instances: list[DeclarativeBase],
|
instances: list[DeclarativeBase],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""INSERT all instances — raises on conflict (no duplicate handling)."""
|
"""INSERT all instances — raises on conflict (no duplicate handling)."""
|
||||||
for cls in _get_table_chain(model_cls):
|
dicts = [_instance_to_dict(i) for i in instances]
|
||||||
dicts = [_instance_to_dict_for_cls(i, cls) for i in instances]
|
for group_dicts, _ in _group_by_column_set(dicts, instances):
|
||||||
for group_dicts, _ in _group_by_column_set(dicts, instances):
|
await session.execute(pg_insert(model_cls).values(group_dicts))
|
||||||
if group_dicts and group_dicts[0]: # pragma: no branch
|
|
||||||
await session.execute(pg_insert(cls).values(group_dicts))
|
|
||||||
|
|
||||||
|
|
||||||
async def _batch_merge(
|
async def _batch_merge(
|
||||||
@@ -112,30 +84,31 @@ async def _batch_merge(
|
|||||||
instances: list[DeclarativeBase],
|
instances: list[DeclarativeBase],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""UPSERT: insert new rows, update existing ones with the provided values."""
|
"""UPSERT: insert new rows, update existing ones with the provided values."""
|
||||||
for cls in _get_table_chain(model_cls):
|
mapper = model_cls.__mapper__
|
||||||
pk_names = [col.name for col in cls.__table__.primary_key]
|
pk_names = [col.name for col in mapper.primary_key]
|
||||||
pk_names_set = set(pk_names)
|
pk_names_set = set(pk_names)
|
||||||
own_col_keys = {col.key for col in cls.__table__.columns}
|
non_pk_cols = [
|
||||||
non_pk_cols = [k for k in own_col_keys if k not in pk_names_set]
|
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_for_cls(i, cls) for i in instances]
|
dicts = [_instance_to_dict(i) for i in instances]
|
||||||
for group_dicts, _ in _group_by_column_set(dicts, instances):
|
for group_dicts, _ in _group_by_column_set(dicts, instances):
|
||||||
if not group_dicts or not group_dicts[0]: # pragma: no cover
|
stmt = pg_insert(model_cls).values(group_dicts)
|
||||||
continue
|
|
||||||
stmt = pg_insert(cls).values(group_dicts)
|
|
||||||
|
|
||||||
inserted_keys = set(group_dicts[0])
|
inserted_keys = set(group_dicts[0])
|
||||||
update_cols = [col for col in non_pk_cols if col in inserted_keys]
|
update_cols = [col for col in non_pk_cols if col in inserted_keys]
|
||||||
|
|
||||||
if update_cols:
|
if update_cols:
|
||||||
stmt = stmt.on_conflict_do_update(
|
stmt = stmt.on_conflict_do_update(
|
||||||
index_elements=pk_names,
|
index_elements=pk_names,
|
||||||
set_={col: stmt.excluded[col] for col in update_cols},
|
set_={col: stmt.excluded[col] for col in update_cols},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
stmt = stmt.on_conflict_do_nothing(index_elements=pk_names)
|
stmt = stmt.on_conflict_do_nothing(index_elements=pk_names)
|
||||||
|
|
||||||
await session.execute(stmt)
|
await session.execute(stmt)
|
||||||
|
|
||||||
|
|
||||||
async def _batch_skip_existing(
|
async def _batch_skip_existing(
|
||||||
@@ -144,16 +117,6 @@ async def _batch_skip_existing(
|
|||||||
instances: list[DeclarativeBase],
|
instances: list[DeclarativeBase],
|
||||||
) -> list[DeclarativeBase]:
|
) -> list[DeclarativeBase]:
|
||||||
"""INSERT only rows that do not already exist; return the inserted ones."""
|
"""INSERT only rows that do not already exist; return the inserted ones."""
|
||||||
if len(_get_table_chain(model_cls)) > 1:
|
|
||||||
loaded: list[DeclarativeBase] = []
|
|
||||||
for inst in instances:
|
|
||||||
pk = _get_primary_key(inst)
|
|
||||||
if pk is None or not await session.get(model_cls, pk):
|
|
||||||
session.add(inst)
|
|
||||||
loaded.append(inst)
|
|
||||||
await session.flush()
|
|
||||||
return loaded
|
|
||||||
|
|
||||||
mapper = model_cls.__mapper__
|
mapper = model_cls.__mapper__
|
||||||
pk_names = [col.name for col in mapper.primary_key]
|
pk_names = [col.name for col in mapper.primary_key]
|
||||||
|
|
||||||
@@ -166,7 +129,7 @@ async def _batch_skip_existing(
|
|||||||
else:
|
else:
|
||||||
with_pk_pairs.append((inst, pk))
|
with_pk_pairs.append((inst, pk))
|
||||||
|
|
||||||
loaded = list(no_pk)
|
loaded: list[DeclarativeBase] = list(no_pk)
|
||||||
if no_pk:
|
if no_pk:
|
||||||
no_pk_dicts = [_instance_to_dict(i) for i in no_pk]
|
no_pk_dicts = [_instance_to_dict(i) for i in no_pk]
|
||||||
for group_dicts, _ in _group_by_column_set(no_pk_dicts, no_pk):
|
for group_dicts, _ in _group_by_column_set(no_pk_dicts, no_pk):
|
||||||
@@ -216,7 +179,7 @@ async def _load_ordered(
|
|||||||
if contexts is not None and not variants:
|
if contexts is not None and not variants:
|
||||||
variants = registry.get_variants(name)
|
variants = registry.get_variants(name)
|
||||||
|
|
||||||
if not variants: # pragma: no cover
|
if not variants:
|
||||||
results[name] = []
|
results[name] = []
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -241,8 +204,6 @@ async def _load_ordered(
|
|||||||
case LoadStrategy.SKIP_EXISTING:
|
case LoadStrategy.SKIP_EXISTING:
|
||||||
inserted = await _batch_skip_existing(session, model_cls, group)
|
inserted = await _batch_skip_existing(session, model_cls, group)
|
||||||
loaded.extend(inserted)
|
loaded.extend(inserted)
|
||||||
case _: # pragma: no cover
|
|
||||||
pass
|
|
||||||
|
|
||||||
results[name] = loaded
|
results[name] = loaded
|
||||||
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
|
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
|
||||||
@@ -289,31 +250,6 @@ def get_obj_by_attr(
|
|||||||
) from None
|
) from None
|
||||||
|
|
||||||
|
|
||||||
def get_field_by_attr(
|
|
||||||
fixtures: Callable[[], Sequence[ModelType]],
|
|
||||||
attr_name: str,
|
|
||||||
value: Any,
|
|
||||||
*,
|
|
||||||
field: str = "id",
|
|
||||||
) -> Any:
|
|
||||||
"""Get a single field value from a fixture object matched by an attribute.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
fixtures: A fixture function registered via ``@registry.register``
|
|
||||||
that returns a sequence of SQLAlchemy model instances.
|
|
||||||
attr_name: Name of the attribute to match against.
|
|
||||||
value: Value to match.
|
|
||||||
field: Attribute name to return from the matched object (default: ``"id"``).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The value of ``field`` on the first matching model instance.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
StopIteration: If no matching object is found in the fixture group.
|
|
||||||
"""
|
|
||||||
return getattr(get_obj_by_attr(fixtures, attr_name, value), field)
|
|
||||||
|
|
||||||
|
|
||||||
async def load_fixtures(
|
async def load_fixtures(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
registry: FixtureRegistry,
|
registry: FixtureRegistry,
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
"""Pytest plugin for using FixtureRegistry fixtures in tests."""
|
"""Pytest plugin for using FixtureRegistry fixtures in tests."""
|
||||||
|
|
||||||
from collections.abc import Callable, Sequence
|
from collections.abc import Callable, Sequence
|
||||||
from typing import Any, cast
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase, selectinload
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from sqlalchemy.orm.interfaces import ExecutableOption, ORMOption
|
|
||||||
|
|
||||||
from ..db import get_transaction
|
from ..db import get_transaction
|
||||||
from ..fixtures import FixtureRegistry, LoadStrategy
|
from ..fixtures import FixtureRegistry, LoadStrategy
|
||||||
@@ -114,7 +112,7 @@ def _create_fixture_function(
|
|||||||
elif strategy == LoadStrategy.MERGE:
|
elif strategy == LoadStrategy.MERGE:
|
||||||
merged = await session.merge(instance)
|
merged = await session.merge(instance)
|
||||||
loaded.append(merged)
|
loaded.append(merged)
|
||||||
elif strategy == LoadStrategy.SKIP_EXISTING: # pragma: no branch
|
elif strategy == LoadStrategy.SKIP_EXISTING:
|
||||||
pk = _get_primary_key(instance)
|
pk = _get_primary_key(instance)
|
||||||
if pk is not None:
|
if pk is not None:
|
||||||
existing = await session.get(type(instance), pk)
|
existing = await session.get(type(instance), pk)
|
||||||
@@ -127,11 +125,6 @@ def _create_fixture_function(
|
|||||||
session.add(instance)
|
session.add(instance)
|
||||||
loaded.append(instance)
|
loaded.append(instance)
|
||||||
|
|
||||||
if loaded: # pragma: no branch
|
|
||||||
load_options = _relationship_load_options(type(loaded[0]))
|
|
||||||
if load_options:
|
|
||||||
return await _reload_with_relationships(session, loaded, load_options)
|
|
||||||
|
|
||||||
return loaded
|
return loaded
|
||||||
|
|
||||||
# Update function signature to include dependencies
|
# Update function signature to include dependencies
|
||||||
@@ -148,54 +141,6 @@ def _create_fixture_function(
|
|||||||
return created_func
|
return created_func
|
||||||
|
|
||||||
|
|
||||||
def _relationship_load_options(model: type[DeclarativeBase]) -> list[ExecutableOption]:
|
|
||||||
"""Build selectinload options for all direct relationships on a model."""
|
|
||||||
return [
|
|
||||||
selectinload(getattr(model, rel.key)) for rel in model.__mapper__.relationships
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def _reload_with_relationships(
|
|
||||||
session: AsyncSession,
|
|
||||||
instances: list[DeclarativeBase],
|
|
||||||
load_options: list[ExecutableOption],
|
|
||||||
) -> list[DeclarativeBase]:
|
|
||||||
"""Reload instances in a single bulk query with relationship eager-loading.
|
|
||||||
|
|
||||||
Uses one SELECT … WHERE pk IN (…) so selectinload can batch all relationship
|
|
||||||
queries — 1 + N_relationships round-trips regardless of how many instances
|
|
||||||
there are, instead of one session.get() per instance.
|
|
||||||
|
|
||||||
Preserves the original insertion order.
|
|
||||||
"""
|
|
||||||
model = type(instances[0])
|
|
||||||
mapper = model.__mapper__
|
|
||||||
pk_cols = mapper.primary_key
|
|
||||||
|
|
||||||
if len(pk_cols) == 1:
|
|
||||||
pk_attr = getattr(model, pk_cols[0].key)
|
|
||||||
pks = [getattr(inst, pk_cols[0].key) for inst in instances]
|
|
||||||
result = await session.execute(
|
|
||||||
select(model).where(pk_attr.in_(pks)).options(*load_options)
|
|
||||||
)
|
|
||||||
by_pk = {getattr(row, pk_cols[0].key): row for row in result.unique().scalars()}
|
|
||||||
return [by_pk[pk] for pk in pks]
|
|
||||||
|
|
||||||
# Composite PK: fall back to per-instance reload
|
|
||||||
reloaded: list[DeclarativeBase] = []
|
|
||||||
for instance in instances:
|
|
||||||
pk = _get_primary_key(instance)
|
|
||||||
refreshed = await session.get(
|
|
||||||
model,
|
|
||||||
pk,
|
|
||||||
options=cast(list[ORMOption], load_options),
|
|
||||||
populate_existing=True,
|
|
||||||
)
|
|
||||||
if refreshed is not None: # pragma: no branch
|
|
||||||
reloaded.append(refreshed)
|
|
||||||
return reloaded
|
|
||||||
|
|
||||||
|
|
||||||
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
||||||
"""Get the primary key value of a model instance."""
|
"""Get the primary key value of a model instance."""
|
||||||
mapper = instance.__class__.__mapper__
|
mapper = instance.__class__.__mapper__
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ class PaginatedResponse(BaseResponse, Generic[DataT]):
|
|||||||
pagination_type: PaginationType | None = None
|
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
|
search_columns: list[str] | None = None
|
||||||
order_columns: list[str] | None = None
|
sort_columns: list[str] | None = None
|
||||||
|
|
||||||
_discriminated_union_cache: ClassVar[dict[Any, Any]] = {}
|
_discriminated_union_cache: ClassVar[dict[Any, Any]] = {}
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ class PaginatedResponse(BaseResponse, Generic[DataT]):
|
|||||||
]
|
]
|
||||||
cls._discriminated_union_cache[item] = cached
|
cls._discriminated_union_cache[item] = cached
|
||||||
return cached # ty:ignore[invalid-return-type]
|
return cached # ty:ignore[invalid-return-type]
|
||||||
return super().__class_getitem__(item) # ty:ignore[invalid-return-type]
|
return super().__class_getitem__(item)
|
||||||
|
|
||||||
|
|
||||||
class OffsetPaginatedResponse(PaginatedResponse[DataT]):
|
class OffsetPaginatedResponse(PaginatedResponse[DataT]):
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from .oauth import (
|
|||||||
oauth_decode_state,
|
oauth_decode_state,
|
||||||
oauth_encode_state,
|
oauth_encode_state,
|
||||||
oauth_fetch_userinfo,
|
oauth_fetch_userinfo,
|
||||||
oauth_generate_state_token,
|
|
||||||
oauth_resolve_provider_urls,
|
oauth_resolve_provider_urls,
|
||||||
)
|
)
|
||||||
from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth
|
from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth
|
||||||
@@ -21,6 +20,5 @@ __all__ = [
|
|||||||
"oauth_decode_state",
|
"oauth_decode_state",
|
||||||
"oauth_encode_state",
|
"oauth_encode_state",
|
||||||
"oauth_fetch_userinfo",
|
"oauth_fetch_userinfo",
|
||||||
"oauth_generate_state_token",
|
|
||||||
"oauth_resolve_provider_urls",
|
"oauth_resolve_provider_urls",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Abstract base class for authentication sources."""
|
"""Abstract base class for authentication sources."""
|
||||||
|
|
||||||
import functools
|
|
||||||
import inspect
|
import inspect
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
@@ -16,7 +15,6 @@ def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|||||||
if inspect.iscoroutinefunction(fn):
|
if inspect.iscoroutinefunction(fn):
|
||||||
return fn
|
return fn
|
||||||
|
|
||||||
@functools.wraps(fn)
|
|
||||||
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
return fn(*args, **kwargs)
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
"""OAuth 2.0 / OIDC helper utilities."""
|
"""OAuth 2.0 / OIDC helper utilities."""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import secrets
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from async_lru import alru_cache
|
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
_discovery_cache: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
@alru_cache(maxsize=32)
|
|
||||||
async def oauth_resolve_provider_urls(
|
async def oauth_resolve_provider_urls(
|
||||||
discovery_url: str,
|
discovery_url: str,
|
||||||
) -> tuple[str, str, str | None]:
|
) -> tuple[str, str, str | None]:
|
||||||
@@ -26,10 +22,12 @@ async def oauth_resolve_provider_urls(
|
|||||||
A ``(authorization_url, token_url, userinfo_url)`` tuple.
|
A ``(authorization_url, token_url, userinfo_url)`` tuple.
|
||||||
*userinfo_url* is ``None`` when the provider does not advertise one.
|
*userinfo_url* is ``None`` when the provider does not advertise one.
|
||||||
"""
|
"""
|
||||||
async with httpx.AsyncClient() as client:
|
if discovery_url not in _discovery_cache:
|
||||||
resp = await client.get(discovery_url)
|
async with httpx.AsyncClient() as client:
|
||||||
resp.raise_for_status()
|
resp = await client.get(discovery_url)
|
||||||
cfg = resp.json()
|
resp.raise_for_status()
|
||||||
|
_discovery_cache[discovery_url] = resp.json()
|
||||||
|
cfg = _discovery_cache[discovery_url]
|
||||||
return (
|
return (
|
||||||
cfg["authorization_endpoint"],
|
cfg["authorization_endpoint"],
|
||||||
cfg["token_endpoint"],
|
cfg["token_endpoint"],
|
||||||
@@ -45,10 +43,14 @@ async def oauth_fetch_userinfo(
|
|||||||
client_id: str,
|
client_id: str,
|
||||||
client_secret: str,
|
client_secret: str,
|
||||||
redirect_uri: str,
|
redirect_uri: str,
|
||||||
required_scopes: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Exchange an authorization code for tokens and return the userinfo payload.
|
"""Exchange an authorization code for tokens and return the userinfo payload.
|
||||||
|
|
||||||
|
Performs the two-step OAuth 2.0 / OIDC token exchange:
|
||||||
|
|
||||||
|
1. POSTs the authorization *code* to *token_url* to obtain an access token.
|
||||||
|
2. GETs *userinfo_url* using that access token as a Bearer credential.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
token_url: Provider's token endpoint.
|
token_url: Provider's token endpoint.
|
||||||
userinfo_url: Provider's userinfo endpoint.
|
userinfo_url: Provider's userinfo endpoint.
|
||||||
@@ -56,16 +58,9 @@ async def oauth_fetch_userinfo(
|
|||||||
client_id: OAuth application client ID.
|
client_id: OAuth application client ID.
|
||||||
client_secret: OAuth application client secret.
|
client_secret: OAuth application client secret.
|
||||||
redirect_uri: Redirect URI that was used in the authorization request.
|
redirect_uri: Redirect URI that was used in the authorization request.
|
||||||
required_scopes: Space-separated scopes that must be present in the token
|
|
||||||
response ``scope`` field (RFC 6749 §3.3). Raises ``ValueError`` if
|
|
||||||
the provider granted fewer scopes than requested.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The JSON payload returned by the userinfo endpoint as a plain ``dict``.
|
The JSON payload returned by the userinfo endpoint as a plain ``dict``.
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the provider granted a different token type than ``bearer``
|
|
||||||
or did not grant all ``required_scopes``.
|
|
||||||
"""
|
"""
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
token_resp = await client.post(
|
token_resp = await client.post(
|
||||||
@@ -80,20 +75,7 @@ async def oauth_fetch_userinfo(
|
|||||||
headers={"Accept": "application/json"},
|
headers={"Accept": "application/json"},
|
||||||
)
|
)
|
||||||
token_resp.raise_for_status()
|
token_resp.raise_for_status()
|
||||||
token_data = token_resp.json()
|
access_token = token_resp.json()["access_token"]
|
||||||
|
|
||||||
if token_data.get("token_type", "bearer").lower() != "bearer":
|
|
||||||
raise ValueError(
|
|
||||||
f"unsupported token_type: {token_data.get('token_type')!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if required_scopes is not None:
|
|
||||||
granted = set(token_data.get("scope", "").split())
|
|
||||||
missing = set(required_scopes.split()) - granted
|
|
||||||
if missing:
|
|
||||||
raise ValueError(f"provider did not grant required scopes: {missing}")
|
|
||||||
|
|
||||||
access_token = token_data["access_token"]
|
|
||||||
|
|
||||||
userinfo_resp = await client.get(
|
userinfo_resp = await client.get(
|
||||||
userinfo_url,
|
userinfo_url,
|
||||||
@@ -103,11 +85,6 @@ async def oauth_fetch_userinfo(
|
|||||||
return userinfo_resp.json()
|
return userinfo_resp.json()
|
||||||
|
|
||||||
|
|
||||||
def oauth_generate_state_token() -> str:
|
|
||||||
"""Generate a cryptographically random CSRF token for the OAuth ``state`` parameter."""
|
|
||||||
return secrets.token_urlsafe(32)
|
|
||||||
|
|
||||||
|
|
||||||
def oauth_build_authorization_redirect(
|
def oauth_build_authorization_redirect(
|
||||||
authorization_url: str,
|
authorization_url: str,
|
||||||
*,
|
*,
|
||||||
@@ -115,7 +92,6 @@ def oauth_build_authorization_redirect(
|
|||||||
scopes: str,
|
scopes: str,
|
||||||
redirect_uri: str,
|
redirect_uri: str,
|
||||||
destination: str,
|
destination: str,
|
||||||
state_token: str,
|
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
"""Return an OAuth 2.0 authorization ``RedirectResponse``.
|
"""Return an OAuth 2.0 authorization ``RedirectResponse``.
|
||||||
|
|
||||||
@@ -125,10 +101,7 @@ def oauth_build_authorization_redirect(
|
|||||||
scopes: Space-separated list of requested scopes.
|
scopes: Space-separated list of requested scopes.
|
||||||
redirect_uri: URI the provider should redirect back to after authorization.
|
redirect_uri: URI the provider should redirect back to after authorization.
|
||||||
destination: URL the user should be sent to after the full OAuth flow
|
destination: URL the user should be sent to after the full OAuth flow
|
||||||
completes (embedded in ``state``).
|
completes (encoded as ``state``).
|
||||||
state_token: CSRF token generated by :func:`oauth_generate_state_token`.
|
|
||||||
Must be stored server-side (session or signed cookie) and verified via
|
|
||||||
:func:`oauth_decode_state` on the callback endpoint (RFC 6749 §10.12).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A :class:`~fastapi.responses.RedirectResponse` to the provider's
|
A :class:`~fastapi.responses.RedirectResponse` to the provider's
|
||||||
@@ -140,58 +113,28 @@ def oauth_build_authorization_redirect(
|
|||||||
"response_type": "code",
|
"response_type": "code",
|
||||||
"scope": scopes,
|
"scope": scopes,
|
||||||
"redirect_uri": redirect_uri,
|
"redirect_uri": redirect_uri,
|
||||||
"state": oauth_encode_state(destination, state_token),
|
"state": oauth_encode_state(destination),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return RedirectResponse(f"{authorization_url}?{params}")
|
return RedirectResponse(f"{authorization_url}?{params}")
|
||||||
|
|
||||||
|
|
||||||
def oauth_encode_state(url: str, state_token: str) -> str:
|
def oauth_encode_state(url: str) -> str:
|
||||||
"""Encode a destination URL and CSRF token into an OAuth ``state`` parameter.
|
"""Base64url-encode a URL to embed as an OAuth ``state`` parameter."""
|
||||||
|
return base64.urlsafe_b64encode(url.encode()).decode()
|
||||||
|
|
||||||
Args:
|
|
||||||
url: Post-login destination URL.
|
def oauth_decode_state(state: str | None, *, fallback: str) -> str:
|
||||||
state_token: CSRF token from :func:`oauth_generate_state_token`.
|
"""Decode a base64url OAuth ``state`` parameter.
|
||||||
|
|
||||||
|
Handles missing padding (some providers strip ``=``).
|
||||||
|
Returns *fallback* if *state* is absent, the literal string ``"null"``,
|
||||||
|
or cannot be decoded.
|
||||||
"""
|
"""
|
||||||
payload = json.dumps({"n": state_token, "d": url}, separators=(",", ":"))
|
if not state or state == "null":
|
||||||
return base64.urlsafe_b64encode(payload.encode()).decode()
|
|
||||||
|
|
||||||
|
|
||||||
def oauth_decode_state(
|
|
||||||
state: str | None, *, expected_state_token: str, fallback: str
|
|
||||||
) -> str:
|
|
||||||
"""Decode and CSRF-verify an OAuth ``state`` parameter.
|
|
||||||
|
|
||||||
Uses a constant-time comparison for the CSRF token to prevent timing attacks.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
state: Raw ``state`` query parameter from the provider's callback.
|
|
||||||
expected_state_token: The token stored before the authorization redirect.
|
|
||||||
If it does not match the decoded value, ``fallback`` is returned.
|
|
||||||
fallback: URL to return when ``state`` is absent, malformed, or fails
|
|
||||||
CSRF verification.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The destination URL embedded in ``state``, or ``fallback``.
|
|
||||||
|
|
||||||
Important:
|
|
||||||
**Single-use**: delete the stored token from the session immediately
|
|
||||||
after calling this function — whether it matched or not — so that a
|
|
||||||
captured callback URL cannot be replayed.
|
|
||||||
|
|
||||||
**Open-redirect**: validate the returned URL against a known-good
|
|
||||||
origin or relative-path allowlist before issuing the final redirect.
|
|
||||||
Do not forward arbitrary URLs to ``RedirectResponse``.
|
|
||||||
"""
|
|
||||||
if not state or state == "null": # "null" guards against JS JSON.stringify(null)
|
|
||||||
return fallback
|
return fallback
|
||||||
try:
|
try:
|
||||||
padded = state + "=" * (-len(state) % 4)
|
padded = state + "=" * (4 - len(state) % 4)
|
||||||
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
|
return base64.urlsafe_b64decode(padded).decode()
|
||||||
if not isinstance(payload, dict) or not hmac.compare_digest(
|
except Exception:
|
||||||
payload.get("n", "").encode(), expected_state_token.encode()
|
|
||||||
):
|
|
||||||
return fallback
|
|
||||||
return str(payload["d"])
|
|
||||||
except (UnicodeDecodeError, ValueError, binascii.Error, KeyError):
|
|
||||||
return fallback
|
return fallback
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import inspect
|
|||||||
import secrets
|
import secrets
|
||||||
from typing import Annotated, Any, Callable
|
from typing import Annotated, Any, Callable
|
||||||
|
|
||||||
from fastapi import Depends, Request
|
from fastapi import Depends
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes
|
||||||
|
|
||||||
from fastapi_toolsets.exceptions import UnauthorizedError
|
from fastapi_toolsets.exceptions import UnauthorizedError
|
||||||
@@ -66,7 +66,7 @@ class BearerTokenAuth(AuthSource):
|
|||||||
raise UnauthorizedError()
|
raise UnauthorizedError()
|
||||||
return await self._validator(token, **self._kwargs)
|
return await self._validator(token, **self._kwargs)
|
||||||
|
|
||||||
async def extract(self, request: Request) -> str | None:
|
async def extract(self, request: Any) -> str | None:
|
||||||
"""Extract the raw credential from the request without validating.
|
"""Extract the raw credential from the request without validating.
|
||||||
|
|
||||||
Returns ``None`` if no ``Authorization: Bearer`` header is present,
|
Returns ``None`` if no ``Authorization: Bearer`` header is present,
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ class CookieAuth(AuthSource):
|
|||||||
cookie value is passed to the validator as-is.
|
cookie value is passed to the validator as-is.
|
||||||
ttl: Cookie lifetime in seconds (default 24 h). Only used when
|
ttl: Cookie lifetime in seconds (default 24 h). Only used when
|
||||||
``secret_key`` is set.
|
``secret_key`` is set.
|
||||||
secure: Set the ``Secure`` flag on the cookie so it is only transmitted
|
|
||||||
over HTTPS (default ``True``). Set to ``False`` only in local
|
|
||||||
development environments where HTTPS is unavailable.
|
|
||||||
**kwargs: Extra keyword arguments forwarded to the validator on every
|
**kwargs: Extra keyword arguments forwarded to the validator on every
|
||||||
call (e.g. ``role=Role.ADMIN``).
|
call (e.g. ``role=Role.ADMIN``).
|
||||||
"""
|
"""
|
||||||
@@ -50,14 +47,12 @@ class CookieAuth(AuthSource):
|
|||||||
*,
|
*,
|
||||||
secret_key: str | None = None,
|
secret_key: str | None = None,
|
||||||
ttl: int = 86400,
|
ttl: int = 86400,
|
||||||
secure: bool = True,
|
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._name = name
|
self._name = name
|
||||||
self._validator = _ensure_async(validator)
|
self._validator = _ensure_async(validator)
|
||||||
self._secret_key = secret_key
|
self._secret_key = secret_key
|
||||||
self._ttl = ttl
|
self._ttl = ttl
|
||||||
self._secure = secure
|
|
||||||
self._kwargs = kwargs
|
self._kwargs = kwargs
|
||||||
self._scheme = APIKeyCookie(name=name, auto_error=False)
|
self._scheme = APIKeyCookie(name=name, auto_error=False)
|
||||||
|
|
||||||
@@ -125,7 +120,6 @@ class CookieAuth(AuthSource):
|
|||||||
self._validator,
|
self._validator,
|
||||||
secret_key=self._secret_key,
|
secret_key=self._secret_key,
|
||||||
ttl=self._ttl,
|
ttl=self._ttl,
|
||||||
secure=self._secure,
|
|
||||||
**{**self._kwargs, **kwargs},
|
**{**self._kwargs, **kwargs},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -137,12 +131,9 @@ class CookieAuth(AuthSource):
|
|||||||
cookie_value,
|
cookie_value,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
secure=self._secure,
|
|
||||||
max_age=self._ttl,
|
max_age=self._ttl,
|
||||||
)
|
)
|
||||||
|
|
||||||
def delete_cookie(self, response: Response) -> None:
|
def delete_cookie(self, response: Response) -> None:
|
||||||
"""Clear the session cookie (logout)."""
|
"""Clear the session cookie (logout)."""
|
||||||
response.delete_cookie(
|
response.delete_cookie(self._name, httponly=True, samesite="lax")
|
||||||
self._name, httponly=True, samesite="lax", secure=self._secure
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -14,8 +14,42 @@ from ..abc import AuthSource
|
|||||||
class MultiAuth:
|
class MultiAuth:
|
||||||
"""Combine multiple authentication sources into a single callable.
|
"""Combine multiple authentication sources into a single callable.
|
||||||
|
|
||||||
|
Sources are tried in order; the first one whose
|
||||||
|
:meth:`~AuthSource.extract` returns a non-``None`` credential wins.
|
||||||
|
Its :meth:`~AuthSource.authenticate` is called and the result returned.
|
||||||
|
|
||||||
|
If a credential is found but the validator raises, the exception propagates
|
||||||
|
immediately — the remaining sources are **not** tried. This prevents
|
||||||
|
silent fallthrough on invalid credentials.
|
||||||
|
|
||||||
|
If no source provides a credential,
|
||||||
|
:class:`~fastapi_toolsets.exceptions.UnauthorizedError` is raised.
|
||||||
|
|
||||||
|
The :meth:`~AuthSource.extract` method of each source performs only
|
||||||
|
string matching (no I/O), so prefix-based dispatch is essentially free.
|
||||||
|
|
||||||
|
Any :class:`~AuthSource` subclass — including user-defined ones — can be
|
||||||
|
passed as a source.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
*sources: Auth source instances to try in order.
|
*sources: Auth source instances to try in order.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
user_bearer = BearerTokenAuth(verify_user, prefix="user_")
|
||||||
|
org_bearer = BearerTokenAuth(verify_org, prefix="org_")
|
||||||
|
cookie = CookieAuth("session", verify_session)
|
||||||
|
|
||||||
|
multi = MultiAuth(user_bearer, org_bearer, cookie)
|
||||||
|
|
||||||
|
@app.get("/data")
|
||||||
|
async def data_route(user = Security(multi)):
|
||||||
|
return user
|
||||||
|
|
||||||
|
# Apply a shared requirement to all sources at once
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin_route(user = Security(multi.require(role=Role.ADMIN))):
|
||||||
|
return user
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *sources: AuthSource) -> None:
|
def __init__(self, *sources: AuthSource) -> None:
|
||||||
@@ -61,7 +95,21 @@ class MultiAuth:
|
|||||||
return await self._call_fn(**kwargs)
|
return await self._call_fn(**kwargs)
|
||||||
|
|
||||||
def require(self, **kwargs: Any) -> "MultiAuth":
|
def require(self, **kwargs: Any) -> "MultiAuth":
|
||||||
"""Return a new :class:`MultiAuth` with kwargs forwarded to each source."""
|
"""Return a new :class:`MultiAuth` with kwargs forwarded to each source.
|
||||||
|
|
||||||
|
Calls ``.require(**kwargs)`` on every source that supports it. Sources
|
||||||
|
that do not implement ``.require()`` (e.g. custom :class:`~AuthSource`
|
||||||
|
subclasses) are passed through unchanged.
|
||||||
|
|
||||||
|
New kwargs are merged over each source's existing kwargs — new values
|
||||||
|
win on conflict::
|
||||||
|
|
||||||
|
multi = MultiAuth(bearer, cookie)
|
||||||
|
|
||||||
|
@app.get("/admin")
|
||||||
|
async def admin(user = Security(multi.require(role=Role.ADMIN))):
|
||||||
|
return user
|
||||||
|
"""
|
||||||
new_sources = tuple(
|
new_sources = tuple(
|
||||||
cast(Any, source).require(**kwargs)
|
cast(Any, source).require(**kwargs)
|
||||||
if hasattr(source, "require")
|
if hasattr(source, "require")
|
||||||
|
|||||||
@@ -19,10 +19,9 @@ JoinType = list[tuple[type[DeclarativeBase] | Any, Any]]
|
|||||||
M2MFieldType = Mapping[str, QueryableAttribute[Any]]
|
M2MFieldType = Mapping[str, QueryableAttribute[Any]]
|
||||||
OrderByClause = ColumnElement[Any] | QueryableAttribute[Any]
|
OrderByClause = ColumnElement[Any] | QueryableAttribute[Any]
|
||||||
|
|
||||||
# Search / facet / order type aliases
|
# Search / facet type aliases
|
||||||
SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any], ...]
|
SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any], ...]
|
||||||
FacetFieldType = SearchFieldType
|
FacetFieldType = SearchFieldType
|
||||||
OrderFieldType = SearchFieldType
|
|
||||||
|
|
||||||
# Dependency type aliases
|
# Dependency type aliases
|
||||||
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]] | Any
|
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]] | Any
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -13,7 +12,6 @@ from sqlalchemy import (
|
|||||||
Column,
|
Column,
|
||||||
Date,
|
Date,
|
||||||
DateTime,
|
DateTime,
|
||||||
Enum as SAEnum,
|
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
JSON,
|
JSON,
|
||||||
@@ -141,35 +139,6 @@ class Post(Base):
|
|||||||
tags: Mapped[list[Tag]] = relationship(secondary=post_tags)
|
tags: Mapped[list[Tag]] = relationship(secondary=post_tags)
|
||||||
|
|
||||||
|
|
||||||
class OrderStatus(int, Enum):
|
|
||||||
"""Integer-backed enum for order status."""
|
|
||||||
|
|
||||||
PENDING = 1
|
|
||||||
PROCESSING = 2
|
|
||||||
SHIPPED = 3
|
|
||||||
CANCELLED = 4
|
|
||||||
|
|
||||||
|
|
||||||
class Color(str, Enum):
|
|
||||||
"""String-backed enum for color."""
|
|
||||||
|
|
||||||
RED = "red"
|
|
||||||
GREEN = "green"
|
|
||||||
BLUE = "blue"
|
|
||||||
|
|
||||||
|
|
||||||
class Order(Base):
|
|
||||||
"""Test model with an IntEnum column (Enum(int, Enum)) and a raw Integer column."""
|
|
||||||
|
|
||||||
__tablename__ = "orders"
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
|
||||||
name: Mapped[str] = mapped_column(String(100))
|
|
||||||
status: Mapped[OrderStatus] = mapped_column(SAEnum(OrderStatus))
|
|
||||||
priority: Mapped[int] = mapped_column(Integer)
|
|
||||||
color: Mapped[Color] = mapped_column(SAEnum(Color))
|
|
||||||
|
|
||||||
|
|
||||||
class Transfer(Base):
|
class Transfer(Base):
|
||||||
"""Test model with two FKs to the same table (users)."""
|
"""Test model with two FKs to the same table (users)."""
|
||||||
|
|
||||||
@@ -192,35 +161,6 @@ class Article(Base):
|
|||||||
metadata_: Mapped[dict | None] = mapped_column("metadata", JSON, nullable=True)
|
metadata_: Mapped[dict | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class Challenge(Base):
|
|
||||||
"""Base challenge model (root of joined-table inheritance hierarchy)."""
|
|
||||||
|
|
||||||
__tablename__ = "challenges"
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
|
||||||
title: Mapped[str] = mapped_column(String(200))
|
|
||||||
challenge_type: Mapped[str] = mapped_column(String(50))
|
|
||||||
points: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
|
|
||||||
__mapper_args__ = {
|
|
||||||
"polymorphic_on": "challenge_type",
|
|
||||||
"polymorphic_identity": "challenge",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ChallengeStandard(Challenge):
|
|
||||||
"""Standard challenge — child table in joined-table inheritance."""
|
|
||||||
|
|
||||||
__tablename__ = "challenge_standard"
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(ForeignKey("challenges.id"), primary_key=True)
|
|
||||||
difficulty: Mapped[str] = mapped_column(String(50))
|
|
||||||
|
|
||||||
__mapper_args__ = {
|
|
||||||
"polymorphic_identity": "standard",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RoleCreate(BaseModel):
|
class RoleCreate(BaseModel):
|
||||||
"""Schema for creating a role."""
|
"""Schema for creating a role."""
|
||||||
|
|
||||||
@@ -371,26 +311,6 @@ class ArticleRead(PydanticBase):
|
|||||||
labels: list[str]
|
labels: list[str]
|
||||||
|
|
||||||
|
|
||||||
class OrderCreate(BaseModel):
|
|
||||||
"""Schema for creating an order."""
|
|
||||||
|
|
||||||
id: uuid.UUID | None = None
|
|
||||||
name: str
|
|
||||||
status: OrderStatus
|
|
||||||
priority: int = 0
|
|
||||||
color: Color = Color.RED
|
|
||||||
|
|
||||||
|
|
||||||
class OrderRead(PydanticBase):
|
|
||||||
"""Schema for reading an order."""
|
|
||||||
|
|
||||||
id: uuid.UUID
|
|
||||||
name: str
|
|
||||||
status: OrderStatus
|
|
||||||
priority: int
|
|
||||||
color: Color
|
|
||||||
|
|
||||||
|
|
||||||
class TransferCreate(BaseModel):
|
class TransferCreate(BaseModel):
|
||||||
"""Schema for creating a transfer."""
|
"""Schema for creating a transfer."""
|
||||||
|
|
||||||
@@ -407,7 +327,6 @@ class TransferRead(PydanticBase):
|
|||||||
amount: str
|
amount: str
|
||||||
|
|
||||||
|
|
||||||
OrderCrud = CrudFactory(Order)
|
|
||||||
TransferCrud = CrudFactory(Transfer)
|
TransferCrud = CrudFactory(Transfer)
|
||||||
ArticleCrud = CrudFactory(Article)
|
ArticleCrud = CrudFactory(Article)
|
||||||
RoleCrud = CrudFactory(Role)
|
RoleCrud = CrudFactory(Role)
|
||||||
@@ -439,21 +358,6 @@ async def engine():
|
|||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
|
||||||
async def session_maker(engine):
|
|
||||||
"""Provide a session factory with tables created and dropped around the test."""
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
|
|
||||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield factory
|
|
||||||
finally:
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.drop_all)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
async def db_session(engine):
|
async def db_session(engine):
|
||||||
"""Create a test database session with tables.
|
"""Create a test database session with tables.
|
||||||
|
|||||||
+8
-262
@@ -247,8 +247,8 @@ class TestResolveSearchColumns:
|
|||||||
assert "username" not in result
|
assert "username" not in result
|
||||||
|
|
||||||
|
|
||||||
class TestResolveOrderColumns:
|
class TestResolveSortColumns:
|
||||||
"""Tests for _resolve_order_columns logic."""
|
"""Tests for _resolve_sort_columns logic."""
|
||||||
|
|
||||||
def test_returns_none_when_no_order_fields(self):
|
def test_returns_none_when_no_order_fields(self):
|
||||||
"""Returns None when cls.order_fields is None and no order_fields passed."""
|
"""Returns None when cls.order_fields is None and no order_fields passed."""
|
||||||
@@ -256,24 +256,24 @@ class TestResolveOrderColumns:
|
|||||||
class AbstractCrud(AsyncCrud[User]):
|
class AbstractCrud(AsyncCrud[User]):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
assert AbstractCrud._resolve_order_columns(None) is None
|
assert AbstractCrud._resolve_sort_columns(None) is None
|
||||||
|
|
||||||
def test_returns_none_when_empty_order_fields_passed(self):
|
def test_returns_none_when_empty_order_fields_passed(self):
|
||||||
"""Returns None when an empty list is passed explicitly."""
|
"""Returns None when an empty list is passed explicitly."""
|
||||||
crud = CrudFactory(User)
|
crud = CrudFactory(User)
|
||||||
assert crud._resolve_order_columns([]) is None
|
assert crud._resolve_sort_columns([]) is None
|
||||||
|
|
||||||
def test_returns_keys_from_class_order_fields(self):
|
def test_returns_keys_from_class_order_fields(self):
|
||||||
"""Returns sorted column keys from cls.order_fields when no override passed."""
|
"""Returns sorted column keys from cls.order_fields when no override passed."""
|
||||||
crud = CrudFactory(User, order_fields=[User.username])
|
crud = CrudFactory(User, order_fields=[User.username])
|
||||||
result = crud._resolve_order_columns(None)
|
result = crud._resolve_sort_columns(None)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert "username" in result
|
assert "username" in result
|
||||||
|
|
||||||
def test_order_fields_override_takes_priority(self):
|
def test_order_fields_override_takes_priority(self):
|
||||||
"""Explicit order_fields override cls.order_fields."""
|
"""Explicit order_fields override cls.order_fields."""
|
||||||
crud = CrudFactory(User, order_fields=[User.username])
|
crud = CrudFactory(User, order_fields=[User.username])
|
||||||
result = crud._resolve_order_columns([User.email])
|
result = crud._resolve_sort_columns([User.email])
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert "email" in result
|
assert "email" in result
|
||||||
assert "username" not in result
|
assert "username" not in result
|
||||||
@@ -281,25 +281,10 @@ class TestResolveOrderColumns:
|
|||||||
def test_returns_sorted_keys(self):
|
def test_returns_sorted_keys(self):
|
||||||
"""Keys are returned in sorted order."""
|
"""Keys are returned in sorted order."""
|
||||||
crud = CrudFactory(User, order_fields=[User.email, User.username])
|
crud = CrudFactory(User, order_fields=[User.email, User.username])
|
||||||
result = crud._resolve_order_columns(None)
|
result = crud._resolve_sort_columns(None)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result == sorted(result)
|
assert result == sorted(result)
|
||||||
|
|
||||||
def test_relation_tuple_produces_dunder_key(self):
|
|
||||||
"""A (rel, column) tuple produces a 'rel__column' key."""
|
|
||||||
crud = CrudFactory(User, order_fields=[(User.role, Role.name)])
|
|
||||||
result = crud._resolve_order_columns(None)
|
|
||||||
assert result == ["role__name"]
|
|
||||||
|
|
||||||
def test_mixed_flat_and_relation_fields(self):
|
|
||||||
"""Flat and relation fields can be mixed; keys are sorted."""
|
|
||||||
crud = CrudFactory(User, order_fields=[User.username, (User.role, Role.name)])
|
|
||||||
result = crud._resolve_order_columns(None)
|
|
||||||
assert result is not None
|
|
||||||
assert "username" in result
|
|
||||||
assert "role__name" in result
|
|
||||||
assert result == sorted(result)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDefaultLoadOptionsIntegration:
|
class TestDefaultLoadOptionsIntegration:
|
||||||
"""Integration tests for default_load_options with real DB queries."""
|
"""Integration tests for default_load_options with real DB queries."""
|
||||||
@@ -380,43 +365,6 @@ class TestDefaultLoadOptionsIntegration:
|
|||||||
assert result.data[0].role is not None
|
assert result.data[0].role is not None
|
||||||
assert result.data[0].role.name == "admin"
|
assert result.data[0].role.name == "admin"
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_default_load_options_applied_to_create(
|
|
||||||
self, db_session: AsyncSession
|
|
||||||
):
|
|
||||||
"""default_load_options loads relationships after create()."""
|
|
||||||
UserWithDefaultLoad = CrudFactory(
|
|
||||||
User, default_load_options=[selectinload(User.role)]
|
|
||||||
)
|
|
||||||
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
|
||||||
user = await UserWithDefaultLoad.create(
|
|
||||||
db_session,
|
|
||||||
UserCreate(username="alice", email="alice@test.com", role_id=role.id),
|
|
||||||
)
|
|
||||||
assert user.role is not None
|
|
||||||
assert user.role.name == "admin"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_default_load_options_applied_to_update(
|
|
||||||
self, db_session: AsyncSession
|
|
||||||
):
|
|
||||||
"""default_load_options loads relationships after update()."""
|
|
||||||
UserWithDefaultLoad = CrudFactory(
|
|
||||||
User, default_load_options=[selectinload(User.role)]
|
|
||||||
)
|
|
||||||
role = await RoleCrud.create(db_session, RoleCreate(name="admin"))
|
|
||||||
user = await UserCrud.create(
|
|
||||||
db_session,
|
|
||||||
UserCreate(username="alice", email="alice@test.com"),
|
|
||||||
)
|
|
||||||
updated = await UserWithDefaultLoad.update(
|
|
||||||
db_session,
|
|
||||||
UserUpdate(role_id=role.id),
|
|
||||||
filters=[User.id == user.id],
|
|
||||||
)
|
|
||||||
assert updated.role is not None
|
|
||||||
assert updated.role.name == "admin"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_load_options_overrides_default_load_options(
|
async def test_load_options_overrides_default_load_options(
|
||||||
self, db_session: AsyncSession
|
self, db_session: AsyncSession
|
||||||
@@ -670,28 +618,6 @@ class TestCrudFirst:
|
|||||||
assert role is not None
|
assert role is not None
|
||||||
assert role.name == "admin"
|
assert role.name == "admin"
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_first_with_for_update_nowait(self, db_session: AsyncSession):
|
|
||||||
"""First with with_for_update='nowait' emits FOR UPDATE NOWAIT."""
|
|
||||||
await RoleCrud.create(db_session, RoleCreate(name="nowait_first"))
|
|
||||||
|
|
||||||
role = await RoleCrud.first(
|
|
||||||
db_session, [Role.name == "nowait_first"], with_for_update="nowait"
|
|
||||||
)
|
|
||||||
assert role is not None
|
|
||||||
assert role.name == "nowait_first"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_first_with_for_update_skip_locked(self, db_session: AsyncSession):
|
|
||||||
"""First with with_for_update='skip_locked' emits FOR UPDATE SKIP LOCKED."""
|
|
||||||
await RoleCrud.create(db_session, RoleCreate(name="skip_first"))
|
|
||||||
|
|
||||||
role = await RoleCrud.first(
|
|
||||||
db_session, [Role.name == "skip_first"], with_for_update="skip_locked"
|
|
||||||
)
|
|
||||||
assert role is not None
|
|
||||||
assert role.name == "skip_first"
|
|
||||||
|
|
||||||
|
|
||||||
class TestCrudGetMulti:
|
class TestCrudGetMulti:
|
||||||
"""Tests for CRUD get_multi operations."""
|
"""Tests for CRUD get_multi operations."""
|
||||||
@@ -757,45 +683,6 @@ class TestCrudGetMulti:
|
|||||||
names = [r.name for r in roles]
|
names = [r.name for r in roles]
|
||||||
assert names == ["alpha", "bravo", "charlie"]
|
assert names == ["alpha", "bravo", "charlie"]
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_get_multi_with_for_update(self, db_session: AsyncSession):
|
|
||||||
"""get_multi() with with_for_update=True locks the rows."""
|
|
||||||
await RoleCrud.create(db_session, RoleCreate(name="lock1"))
|
|
||||||
await RoleCrud.create(db_session, RoleCreate(name="lock2"))
|
|
||||||
|
|
||||||
roles = await RoleCrud.get_multi(
|
|
||||||
db_session,
|
|
||||||
filters=[Role.name.in_(["lock1", "lock2"])],
|
|
||||||
with_for_update=True,
|
|
||||||
)
|
|
||||||
assert len(roles) == 2
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_get_multi_with_for_update_nowait(self, db_session: AsyncSession):
|
|
||||||
"""get_multi() with with_for_update='nowait' emits FOR UPDATE NOWAIT."""
|
|
||||||
await RoleCrud.create(db_session, RoleCreate(name="nowait_multi"))
|
|
||||||
|
|
||||||
roles = await RoleCrud.get_multi(
|
|
||||||
db_session,
|
|
||||||
filters=[Role.name == "nowait_multi"],
|
|
||||||
with_for_update="nowait",
|
|
||||||
)
|
|
||||||
assert len(roles) == 1
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_get_multi_with_for_update_skip_locked(
|
|
||||||
self, db_session: AsyncSession
|
|
||||||
):
|
|
||||||
"""get_multi() with with_for_update='skip_locked' emits FOR UPDATE SKIP LOCKED."""
|
|
||||||
await RoleCrud.create(db_session, RoleCreate(name="skip_multi"))
|
|
||||||
|
|
||||||
roles = await RoleCrud.get_multi(
|
|
||||||
db_session,
|
|
||||||
filters=[Role.name == "skip_multi"],
|
|
||||||
with_for_update="skip_locked",
|
|
||||||
)
|
|
||||||
assert len(roles) == 1
|
|
||||||
|
|
||||||
|
|
||||||
class TestCrudUpdate:
|
class TestCrudUpdate:
|
||||||
"""Tests for CRUD update operations."""
|
"""Tests for CRUD update operations."""
|
||||||
@@ -842,48 +729,6 @@ class TestCrudUpdate:
|
|||||||
assert updated.email == "john@test.com"
|
assert updated.email == "john@test.com"
|
||||||
assert updated.is_active is True
|
assert updated.is_active is True
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_update_with_for_update(self, db_session: AsyncSession):
|
|
||||||
"""update() with with_for_update=True locks the row before writing."""
|
|
||||||
role = await RoleCrud.create(db_session, RoleCreate(name="before"))
|
|
||||||
|
|
||||||
updated = await RoleCrud.update(
|
|
||||||
db_session,
|
|
||||||
RoleUpdate(name="after"),
|
|
||||||
[Role.id == role.id],
|
|
||||||
with_for_update=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert updated.name == "after"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_update_with_for_update_nowait(self, db_session: AsyncSession):
|
|
||||||
"""update() with with_for_update='nowait' locks the row with NOWAIT."""
|
|
||||||
role = await RoleCrud.create(db_session, RoleCreate(name="before_nowait"))
|
|
||||||
|
|
||||||
updated = await RoleCrud.update(
|
|
||||||
db_session,
|
|
||||||
RoleUpdate(name="after_nowait"),
|
|
||||||
[Role.id == role.id],
|
|
||||||
with_for_update="nowait",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert updated.name == "after_nowait"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_update_with_for_update_skip_locked(self, db_session: AsyncSession):
|
|
||||||
"""update() with with_for_update='skip_locked' locks the row with SKIP LOCKED."""
|
|
||||||
role = await RoleCrud.create(db_session, RoleCreate(name="before_skip"))
|
|
||||||
|
|
||||||
updated = await RoleCrud.update(
|
|
||||||
db_session,
|
|
||||||
RoleUpdate(name="after_skip"),
|
|
||||||
[Role.id == role.id],
|
|
||||||
with_for_update="skip_locked",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert updated.name == "after_skip"
|
|
||||||
|
|
||||||
|
|
||||||
class TestCrudDelete:
|
class TestCrudDelete:
|
||||||
"""Tests for CRUD delete operations."""
|
"""Tests for CRUD delete operations."""
|
||||||
@@ -2713,7 +2558,7 @@ class TestCursorPaginateSearchJoins:
|
|||||||
|
|
||||||
|
|
||||||
class TestGetWithForUpdate:
|
class TestGetWithForUpdate:
|
||||||
"""Tests for get/get_or_none with_for_update variants."""
|
"""Tests for get() with with_for_update=True."""
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_get_with_for_update(self, db_session: AsyncSession):
|
async def test_get_with_for_update(self, db_session: AsyncSession):
|
||||||
@@ -2729,105 +2574,6 @@ class TestGetWithForUpdate:
|
|||||||
assert result.id == role.id
|
assert result.id == role.id
|
||||||
assert result.name == "locked"
|
assert result.name == "locked"
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_get_with_for_update_nowait(self, db_session: AsyncSession):
|
|
||||||
"""get() with with_for_update='nowait' emits FOR UPDATE NOWAIT."""
|
|
||||||
role = await RoleCrud.create(db_session, RoleCreate(name="nowait"))
|
|
||||||
|
|
||||||
result = await RoleCrud.get(
|
|
||||||
db_session,
|
|
||||||
filters=[Role.id == role.id],
|
|
||||||
with_for_update="nowait",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.id == role.id
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_get_with_for_update_skip_locked(self, db_session: AsyncSession):
|
|
||||||
"""get() with with_for_update='skip_locked' emits FOR UPDATE SKIP LOCKED."""
|
|
||||||
role = await RoleCrud.create(db_session, RoleCreate(name="skip"))
|
|
||||||
|
|
||||||
result = await RoleCrud.get(
|
|
||||||
db_session,
|
|
||||||
filters=[Role.id == role.id],
|
|
||||||
with_for_update="skip_locked",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.id == role.id
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_get_or_none_with_for_update(self, db_session: AsyncSession):
|
|
||||||
"""get_or_none() with with_for_update=True locks the row."""
|
|
||||||
role = await RoleCrud.create(db_session, RoleCreate(name="locked2"))
|
|
||||||
|
|
||||||
result = await RoleCrud.get_or_none(
|
|
||||||
db_session,
|
|
||||||
[Role.id == role.id],
|
|
||||||
with_for_update=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert result.id == role.id
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_get_or_none_with_for_update_nowait(self, db_session: AsyncSession):
|
|
||||||
"""get_or_none() with with_for_update='nowait' emits FOR UPDATE NOWAIT."""
|
|
||||||
role = await RoleCrud.create(db_session, RoleCreate(name="nowait2"))
|
|
||||||
|
|
||||||
result = await RoleCrud.get_or_none(
|
|
||||||
db_session,
|
|
||||||
[Role.id == role.id],
|
|
||||||
with_for_update="nowait",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert result.id == role.id
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_get_or_none_with_for_update_skip_locked(
|
|
||||||
self, db_session: AsyncSession
|
|
||||||
):
|
|
||||||
"""get_or_none() with with_for_update='skip_locked' emits FOR UPDATE SKIP LOCKED."""
|
|
||||||
role = await RoleCrud.create(db_session, RoleCreate(name="skip2"))
|
|
||||||
|
|
||||||
result = await RoleCrud.get_or_none(
|
|
||||||
db_session,
|
|
||||||
[Role.id == role.id],
|
|
||||||
with_for_update="skip_locked",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert result.id == role.id
|
|
||||||
|
|
||||||
def test_for_update_sql_clauses(self):
|
|
||||||
"""Verify _apply_for_update emits the correct SQL FOR UPDATE clauses."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
from fastapi_toolsets.crud.factory import _apply_for_update
|
|
||||||
|
|
||||||
base = select(Role)
|
|
||||||
|
|
||||||
plain = str(_apply_for_update(base, True).compile(dialect=postgresql.dialect()))
|
|
||||||
assert "FOR UPDATE" in plain
|
|
||||||
assert "NOWAIT" not in plain
|
|
||||||
assert "SKIP LOCKED" not in plain
|
|
||||||
|
|
||||||
nowait = str(
|
|
||||||
_apply_for_update(base, "nowait").compile(dialect=postgresql.dialect())
|
|
||||||
)
|
|
||||||
assert "FOR UPDATE NOWAIT" in nowait
|
|
||||||
|
|
||||||
skip = str(
|
|
||||||
_apply_for_update(base, "skip_locked").compile(dialect=postgresql.dialect())
|
|
||||||
)
|
|
||||||
assert "FOR UPDATE SKIP LOCKED" in skip
|
|
||||||
|
|
||||||
no_lock = str(
|
|
||||||
_apply_for_update(base, False).compile(dialect=postgresql.dialect())
|
|
||||||
)
|
|
||||||
assert "FOR UPDATE" not in no_lock
|
|
||||||
|
|
||||||
|
|
||||||
class TestCursorPaginateColumnTypes:
|
class TestCursorPaginateColumnTypes:
|
||||||
"""Tests for cursor_paginate() covering DateTime, Date and Numeric column types."""
|
"""Tests for cursor_paginate() covering DateTime, Date and Numeric column types."""
|
||||||
|
|||||||
+25
-374
@@ -23,12 +23,6 @@ from .conftest import (
|
|||||||
ArticleCreate,
|
ArticleCreate,
|
||||||
ArticleCrud,
|
ArticleCrud,
|
||||||
ArticleRead,
|
ArticleRead,
|
||||||
Color,
|
|
||||||
Order,
|
|
||||||
OrderCreate,
|
|
||||||
OrderCrud,
|
|
||||||
OrderRead,
|
|
||||||
OrderStatus,
|
|
||||||
Role,
|
Role,
|
||||||
RoleCreate,
|
RoleCreate,
|
||||||
RoleCrud,
|
RoleCrud,
|
||||||
@@ -1127,253 +1121,6 @@ class TestFilterBy:
|
|||||||
assert "JSON" in exc_info.value.col_type
|
assert "JSON" in exc_info.value.col_type
|
||||||
|
|
||||||
|
|
||||||
class TestFilterByIntEnum:
|
|
||||||
"""Tests for filter_by on columns typed as (int, Enum) / IntEnum."""
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_intenum_member(self, db_session: AsyncSession):
|
|
||||||
"""filter_by with an IntEnum member value filters correctly."""
|
|
||||||
OrderFacetCrud = CrudFactory(Order, facet_fields=[Order.status])
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-1", status=OrderStatus.PENDING)
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-2", status=OrderStatus.SHIPPED)
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-3", status=OrderStatus.PENDING)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await OrderFacetCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={"status": OrderStatus.PENDING},
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
|
||||||
assert result.pagination.total_count == 2
|
|
||||||
names = {o.name for o in result.data}
|
|
||||||
assert names == {"order-1", "order-3"}
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_plain_int_value_raises(self, db_session: AsyncSession):
|
|
||||||
"""filter_by with a plain int on an IntEnum column raises KeyError — use name or member."""
|
|
||||||
OrderFacetCrud = CrudFactory(Order, facet_fields=[Order.status])
|
|
||||||
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
await OrderFacetCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={"status": 1},
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_intenum_list(self, db_session: AsyncSession):
|
|
||||||
"""filter_by with a list of IntEnum members produces an IN filter."""
|
|
||||||
OrderFacetCrud = CrudFactory(Order, facet_fields=[Order.status])
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-1", status=OrderStatus.PENDING)
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-2", status=OrderStatus.SHIPPED)
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-3", status=OrderStatus.CANCELLED)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await OrderFacetCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={"status": [OrderStatus.PENDING, OrderStatus.SHIPPED]},
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
|
||||||
assert result.pagination.total_count == 2
|
|
||||||
names = {o.name for o in result.data}
|
|
||||||
assert names == {"order-1", "order-2"}
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_plain_int_list_raises(self, db_session: AsyncSession):
|
|
||||||
"""filter_by with a list of plain ints on an IntEnum column raises KeyError — use names or members."""
|
|
||||||
OrderFacetCrud = CrudFactory(Order, facet_fields=[Order.status])
|
|
||||||
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
await OrderFacetCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={"status": [1, 3]},
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_intenum_name_string(self, db_session: AsyncSession):
|
|
||||||
"""filter_by with the enum member name as a string filters correctly."""
|
|
||||||
OrderFacetCrud = CrudFactory(Order, facet_fields=[Order.status])
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-1", status=OrderStatus.PENDING)
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-2", status=OrderStatus.SHIPPED)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await OrderFacetCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={
|
|
||||||
"status": "PENDING"
|
|
||||||
}, # name as string, e.g. from HTTP query param
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
|
||||||
assert result.pagination.total_count == 1
|
|
||||||
assert result.data[0].name == "order-1"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_intenum_name_string_list(self, db_session: AsyncSession):
|
|
||||||
"""filter_by with a list of enum name strings produces an IN filter."""
|
|
||||||
OrderFacetCrud = CrudFactory(Order, facet_fields=[Order.status])
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-1", status=OrderStatus.PENDING)
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-2", status=OrderStatus.SHIPPED)
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session, OrderCreate(name="order-3", status=OrderStatus.CANCELLED)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await OrderFacetCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={"status": ["PENDING", "SHIPPED"]},
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
|
||||||
assert result.pagination.total_count == 2
|
|
||||||
names = {o.name for o in result.data}
|
|
||||||
assert names == {"order-1", "order-2"}
|
|
||||||
|
|
||||||
|
|
||||||
class TestFilterByStrEnum:
|
|
||||||
"""Tests for filter_by on columns typed as (str, Enum) / StrEnum (lines 364-367)."""
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_strenum_member(self, db_session: AsyncSession):
|
|
||||||
"""filter_by with a StrEnum member on a string Enum column filters correctly."""
|
|
||||||
OrderColorCrud = CrudFactory(Order, facet_fields=[Order.color])
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session,
|
|
||||||
OrderCreate(name="red-order", status=OrderStatus.PENDING, color=Color.RED),
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session,
|
|
||||||
OrderCreate(
|
|
||||||
name="blue-order", status=OrderStatus.PENDING, color=Color.BLUE
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await OrderColorCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={"color": Color.RED},
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
|
||||||
assert result.pagination.total_count == 1
|
|
||||||
assert result.data[0].name == "red-order"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_strenum_list(self, db_session: AsyncSession):
|
|
||||||
"""filter_by with a list of StrEnum members produces an IN filter."""
|
|
||||||
OrderColorCrud = CrudFactory(Order, facet_fields=[Order.color])
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session,
|
|
||||||
OrderCreate(name="red-order", status=OrderStatus.PENDING, color=Color.RED),
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session,
|
|
||||||
OrderCreate(
|
|
||||||
name="green-order", status=OrderStatus.PENDING, color=Color.GREEN
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session,
|
|
||||||
OrderCreate(
|
|
||||||
name="blue-order", status=OrderStatus.PENDING, color=Color.BLUE
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await OrderColorCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={"color": [Color.RED, Color.BLUE]},
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
|
||||||
assert result.pagination.total_count == 2
|
|
||||||
names = {o.name for o in result.data}
|
|
||||||
assert names == {"red-order", "blue-order"}
|
|
||||||
|
|
||||||
|
|
||||||
class TestFilterByIntegerColumn:
|
|
||||||
"""Tests for filter_by on plain Integer columns with IntEnum values."""
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_integer_column_with_intenum_member(
|
|
||||||
self, db_session: AsyncSession
|
|
||||||
):
|
|
||||||
"""filter_by with an IntEnum member on an Integer column works correctly."""
|
|
||||||
OrderPriorityCrud = CrudFactory(Order, facet_fields=[Order.priority])
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session,
|
|
||||||
OrderCreate(
|
|
||||||
name="order-1", status=OrderStatus.PENDING, priority=OrderStatus.PENDING
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session,
|
|
||||||
OrderCreate(
|
|
||||||
name="order-2", status=OrderStatus.SHIPPED, priority=OrderStatus.SHIPPED
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await OrderPriorityCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={
|
|
||||||
"priority": OrderStatus.PENDING
|
|
||||||
}, # IntEnum member on Integer col
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
|
||||||
assert result.pagination.total_count == 1
|
|
||||||
assert result.data[0].name == "order-1"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filter_by_integer_column_with_plain_int(
|
|
||||||
self, db_session: AsyncSession
|
|
||||||
):
|
|
||||||
"""filter_by with a plain int on an Integer column works correctly."""
|
|
||||||
OrderPriorityCrud = CrudFactory(Order, facet_fields=[Order.priority])
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session,
|
|
||||||
OrderCreate(name="order-1", status=OrderStatus.PENDING, priority=1),
|
|
||||||
)
|
|
||||||
await OrderCrud.create(
|
|
||||||
db_session,
|
|
||||||
OrderCreate(name="order-2", status=OrderStatus.SHIPPED, priority=3),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await OrderPriorityCrud.offset_paginate(
|
|
||||||
db_session,
|
|
||||||
filter_by={"priority": 1},
|
|
||||||
schema=OrderRead,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(result.pagination, OffsetPagination)
|
|
||||||
assert result.pagination.total_count == 1
|
|
||||||
assert result.data[0].name == "order-1"
|
|
||||||
|
|
||||||
|
|
||||||
class TestFilterParamsViaConsolidated:
|
class TestFilterParamsViaConsolidated:
|
||||||
"""Tests for filter params via consolidated offset_paginate_params()."""
|
"""Tests for filter params via consolidated offset_paginate_params()."""
|
||||||
|
|
||||||
@@ -1769,14 +1516,14 @@ class TestSearchColumns:
|
|||||||
assert result.data[0].username == "bob"
|
assert result.data[0].username == "bob"
|
||||||
|
|
||||||
|
|
||||||
class TestOrderColumns:
|
class TestSortColumns:
|
||||||
"""Tests for order_columns in paginated responses."""
|
"""Tests for sort_columns in paginated responses."""
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_order_columns_returned_in_offset_paginate(
|
async def test_sort_columns_returned_in_offset_paginate(
|
||||||
self, db_session: AsyncSession
|
self, db_session: AsyncSession
|
||||||
):
|
):
|
||||||
"""offset_paginate response includes order_columns."""
|
"""offset_paginate response includes sort_columns."""
|
||||||
UserSortCrud = CrudFactory(User, order_fields=[User.username, User.email])
|
UserSortCrud = CrudFactory(User, order_fields=[User.username, User.email])
|
||||||
await UserCrud.create(
|
await UserCrud.create(
|
||||||
db_session, UserCreate(username="alice", email="a@test.com")
|
db_session, UserCreate(username="alice", email="a@test.com")
|
||||||
@@ -1784,15 +1531,15 @@ class TestOrderColumns:
|
|||||||
|
|
||||||
result = await UserSortCrud.offset_paginate(db_session, schema=UserRead)
|
result = await UserSortCrud.offset_paginate(db_session, schema=UserRead)
|
||||||
|
|
||||||
assert result.order_columns is not None
|
assert result.sort_columns is not None
|
||||||
assert "username" in result.order_columns
|
assert "username" in result.sort_columns
|
||||||
assert "email" in result.order_columns
|
assert "email" in result.sort_columns
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_order_columns_returned_in_cursor_paginate(
|
async def test_sort_columns_returned_in_cursor_paginate(
|
||||||
self, db_session: AsyncSession
|
self, db_session: AsyncSession
|
||||||
):
|
):
|
||||||
"""cursor_paginate response includes order_columns."""
|
"""cursor_paginate response includes sort_columns."""
|
||||||
UserSortCursorCrud = CrudFactory(
|
UserSortCursorCrud = CrudFactory(
|
||||||
User,
|
User,
|
||||||
cursor_column=User.id,
|
cursor_column=User.id,
|
||||||
@@ -1804,24 +1551,24 @@ class TestOrderColumns:
|
|||||||
|
|
||||||
result = await UserSortCursorCrud.cursor_paginate(db_session, schema=UserRead)
|
result = await UserSortCursorCrud.cursor_paginate(db_session, schema=UserRead)
|
||||||
|
|
||||||
assert result.order_columns is not None
|
assert result.sort_columns is not None
|
||||||
assert "username" in result.order_columns
|
assert "username" in result.sort_columns
|
||||||
assert "email" in result.order_columns
|
assert "email" in result.sort_columns
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_order_columns_none_when_no_order_fields(
|
async def test_sort_columns_none_when_no_order_fields(
|
||||||
self, db_session: AsyncSession
|
self, db_session: AsyncSession
|
||||||
):
|
):
|
||||||
"""order_columns is None when no order_fields are configured."""
|
"""sort_columns is None when no order_fields are configured."""
|
||||||
result = await UserCrud.offset_paginate(db_session, schema=UserRead)
|
result = await UserCrud.offset_paginate(db_session, schema=UserRead)
|
||||||
|
|
||||||
assert result.order_columns is None
|
assert result.sort_columns is None
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_order_columns_override_in_offset_paginate(
|
async def test_sort_columns_override_in_offset_paginate(
|
||||||
self, db_session: AsyncSession
|
self, db_session: AsyncSession
|
||||||
):
|
):
|
||||||
"""order_fields override in offset_paginate is reflected in order_columns."""
|
"""order_fields override in offset_paginate is reflected in sort_columns."""
|
||||||
await UserCrud.create(
|
await UserCrud.create(
|
||||||
db_session, UserCreate(username="alice", email="a@test.com")
|
db_session, UserCreate(username="alice", email="a@test.com")
|
||||||
)
|
)
|
||||||
@@ -1830,13 +1577,13 @@ class TestOrderColumns:
|
|||||||
db_session, order_fields=[User.email], schema=UserRead
|
db_session, order_fields=[User.email], schema=UserRead
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.order_columns == ["email"]
|
assert result.sort_columns == ["email"]
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_order_columns_override_in_cursor_paginate(
|
async def test_sort_columns_override_in_cursor_paginate(
|
||||||
self, db_session: AsyncSession
|
self, db_session: AsyncSession
|
||||||
):
|
):
|
||||||
"""order_fields override in cursor_paginate is reflected in order_columns."""
|
"""order_fields override in cursor_paginate is reflected in sort_columns."""
|
||||||
UserCursorCrud = CrudFactory(User, cursor_column=User.id)
|
UserCursorCrud = CrudFactory(User, cursor_column=User.id)
|
||||||
await UserCrud.create(
|
await UserCrud.create(
|
||||||
db_session, UserCreate(username="alice", email="a@test.com")
|
db_session, UserCreate(username="alice", email="a@test.com")
|
||||||
@@ -1846,13 +1593,13 @@ class TestOrderColumns:
|
|||||||
db_session, order_fields=[User.username], schema=UserRead
|
db_session, order_fields=[User.username], schema=UserRead
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.order_columns == ["username"]
|
assert result.sort_columns == ["username"]
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_order_columns_are_sorted_alphabetically(
|
async def test_sort_columns_are_sorted_alphabetically(
|
||||||
self, db_session: AsyncSession
|
self, db_session: AsyncSession
|
||||||
):
|
):
|
||||||
"""order_columns keys are returned in alphabetical order."""
|
"""sort_columns keys are returned in alphabetical order."""
|
||||||
UserSortCrud = CrudFactory(User, order_fields=[User.email, User.username])
|
UserSortCrud = CrudFactory(User, order_fields=[User.email, User.username])
|
||||||
await UserCrud.create(
|
await UserCrud.create(
|
||||||
db_session, UserCreate(username="alice", email="a@test.com")
|
db_session, UserCreate(username="alice", email="a@test.com")
|
||||||
@@ -1860,18 +1607,8 @@ class TestOrderColumns:
|
|||||||
|
|
||||||
result = await UserSortCrud.offset_paginate(db_session, schema=UserRead)
|
result = await UserSortCrud.offset_paginate(db_session, schema=UserRead)
|
||||||
|
|
||||||
assert result.order_columns is not None
|
assert result.sort_columns is not None
|
||||||
assert result.order_columns == sorted(result.order_columns)
|
assert result.sort_columns == sorted(result.sort_columns)
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_relation_order_field_in_order_columns(
|
|
||||||
self, db_session: AsyncSession
|
|
||||||
):
|
|
||||||
"""A relation tuple order field produces 'rel__column' key in order_columns."""
|
|
||||||
UserSortCrud = CrudFactory(User, order_fields=[(User.role, Role.name)])
|
|
||||||
result = await UserSortCrud.offset_paginate(db_session, schema=UserRead)
|
|
||||||
|
|
||||||
assert result.order_columns == ["role__name"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestOrderParamsViaConsolidated:
|
class TestOrderParamsViaConsolidated:
|
||||||
@@ -2028,92 +1765,6 @@ class TestOrderParamsViaConsolidated:
|
|||||||
assert result.data[0].username == "alice"
|
assert result.data[0].username == "alice"
|
||||||
assert result.data[1].username == "charlie"
|
assert result.data[1].username == "charlie"
|
||||||
|
|
||||||
def test_relation_order_field_key_in_enum(self):
|
|
||||||
"""A relation tuple field produces a 'rel__column' key in the order_by enum."""
|
|
||||||
UserOrderCrud = CrudFactory(User, order_fields=[(User.role, Role.name)])
|
|
||||||
dep = UserOrderCrud.offset_paginate_params(search=False, filter=False)
|
|
||||||
|
|
||||||
sig = inspect.signature(dep)
|
|
||||||
description = sig.parameters["order_by"].default.description
|
|
||||||
assert "role__name" in description
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_relation_order_field_produces_order_joins(self):
|
|
||||||
"""Selecting a relation order field emits order_by and order_joins."""
|
|
||||||
UserOrderCrud = CrudFactory(User, order_fields=[(User.role, Role.name)])
|
|
||||||
dep = UserOrderCrud.offset_paginate_params(search=False, filter=False)
|
|
||||||
result = await dep(
|
|
||||||
page=1, items_per_page=20, order_by="role__name", order="asc"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "order_by" in result
|
|
||||||
assert "order_joins" in result
|
|
||||||
assert result["order_joins"] == [User.role]
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_relation_order_integrates_with_offset_paginate(
|
|
||||||
self, db_session: AsyncSession
|
|
||||||
):
|
|
||||||
"""Relation order field joins the related table and sorts correctly."""
|
|
||||||
UserOrderCrud = CrudFactory(User, order_fields=[(User.role, Role.name)])
|
|
||||||
role_b = await RoleCrud.create(db_session, RoleCreate(name="beta"))
|
|
||||||
role_a = await RoleCrud.create(db_session, RoleCreate(name="alpha"))
|
|
||||||
await UserCrud.create(
|
|
||||||
db_session,
|
|
||||||
UserCreate(username="u1", email="u1@test.com", role_id=role_b.id),
|
|
||||||
)
|
|
||||||
await UserCrud.create(
|
|
||||||
db_session,
|
|
||||||
UserCreate(username="u2", email="u2@test.com", role_id=role_a.id),
|
|
||||||
)
|
|
||||||
await UserCrud.create(
|
|
||||||
db_session, UserCreate(username="u3", email="u3@test.com")
|
|
||||||
)
|
|
||||||
|
|
||||||
dep = UserOrderCrud.offset_paginate_params(search=False, filter=False)
|
|
||||||
params = await dep(
|
|
||||||
page=1, items_per_page=20, order_by="role__name", order="asc"
|
|
||||||
)
|
|
||||||
result = await UserOrderCrud.offset_paginate(
|
|
||||||
db_session, **params, schema=UserRead
|
|
||||||
)
|
|
||||||
|
|
||||||
usernames = [u.username for u in result.data]
|
|
||||||
# u2 (alpha) before u1 (beta); u3 (no role, NULL) comes last or first depending on DB
|
|
||||||
assert usernames.index("u2") < usernames.index("u1")
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_relation_order_integrates_with_cursor_paginate(
|
|
||||||
self, db_session: AsyncSession
|
|
||||||
):
|
|
||||||
"""Relation order field works with cursor_paginate (order_joins applied)."""
|
|
||||||
UserOrderCrud = CrudFactory(
|
|
||||||
User,
|
|
||||||
order_fields=[(User.role, Role.name)],
|
|
||||||
cursor_column=User.id,
|
|
||||||
)
|
|
||||||
role_b = await RoleCrud.create(db_session, RoleCreate(name="zeta"))
|
|
||||||
role_a = await RoleCrud.create(db_session, RoleCreate(name="alpha"))
|
|
||||||
await UserCrud.create(
|
|
||||||
db_session,
|
|
||||||
UserCreate(username="cx1", email="cx1@test.com", role_id=role_b.id),
|
|
||||||
)
|
|
||||||
await UserCrud.create(
|
|
||||||
db_session,
|
|
||||||
UserCreate(username="cx2", email="cx2@test.com", role_id=role_a.id),
|
|
||||||
)
|
|
||||||
|
|
||||||
dep = UserOrderCrud.cursor_paginate_params(search=False, filter=False)
|
|
||||||
params = await dep(
|
|
||||||
cursor=None, items_per_page=20, order_by="role__name", order="asc"
|
|
||||||
)
|
|
||||||
result = await UserOrderCrud.cursor_paginate(
|
|
||||||
db_session, **params, schema=UserRead
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.data is not None
|
|
||||||
assert len(result.data) == 2
|
|
||||||
|
|
||||||
|
|
||||||
class TestOffsetPaginateParamsSchema:
|
class TestOffsetPaginateParamsSchema:
|
||||||
"""Tests for AsyncCrud.offset_paginate_params()."""
|
"""Tests for AsyncCrud.offset_paginate_params()."""
|
||||||
|
|||||||
+49
-575
@@ -4,45 +4,25 @@ import asyncio
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import (
|
from sqlalchemy import text
|
||||||
Column,
|
|
||||||
ForeignKey,
|
|
||||||
ForeignKeyConstraint,
|
|
||||||
String,
|
|
||||||
Table,
|
|
||||||
Uuid,
|
|
||||||
select,
|
|
||||||
text,
|
|
||||||
)
|
|
||||||
from sqlalchemy.engine import make_url
|
from sqlalchemy.engine import make_url
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
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 (
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
DeclarativeBase,
|
|
||||||
Mapped,
|
|
||||||
mapped_column,
|
|
||||||
relationship,
|
|
||||||
selectinload,
|
|
||||||
)
|
|
||||||
|
|
||||||
from fastapi_toolsets.db import (
|
from fastapi_toolsets.db import (
|
||||||
LockMode,
|
LockMode,
|
||||||
advisory_lock,
|
|
||||||
cleanup_tables,
|
cleanup_tables,
|
||||||
create_database,
|
create_database,
|
||||||
create_db_context,
|
create_db_context,
|
||||||
create_db_dependency,
|
create_db_dependency,
|
||||||
get_transaction,
|
get_transaction,
|
||||||
lock_tables,
|
lock_tables,
|
||||||
m2m_add,
|
|
||||||
m2m_remove,
|
|
||||||
m2m_set,
|
|
||||||
wait_for_row_change,
|
wait_for_row_change,
|
||||||
)
|
)
|
||||||
from fastapi_toolsets.exceptions import NotFoundError
|
from fastapi_toolsets.exceptions import NotFoundError
|
||||||
from fastapi_toolsets.pytest import create_db_session
|
from fastapi_toolsets.pytest import create_db_session
|
||||||
|
|
||||||
from .conftest import DATABASE_URL, Base, Post, Role, RoleCrud, Tag, User, UserCrud
|
from .conftest import DATABASE_URL, Base, Role, RoleCrud, User, UserCrud
|
||||||
|
|
||||||
|
|
||||||
class TestCreateDbDependency:
|
class TestCreateDbDependency:
|
||||||
@@ -102,23 +82,13 @@ class TestCreateDbDependency:
|
|||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_no_commit_when_not_in_transaction(self):
|
async def test_update_after_lock_tables_is_persisted(self):
|
||||||
"""Dependency skips commit if the session is no longer in a transaction on exit."""
|
"""Changes made after lock_tables exits (before endpoint returns) are committed.
|
||||||
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():
|
Regression: without the auto-begin fix, lock_tables would start and commit a
|
||||||
# Manually commit — session exits the transaction
|
real outer transaction, leaving the session idle. Any modifications after that
|
||||||
await session.commit()
|
point were silently dropped.
|
||||||
assert not session.in_transaction()
|
"""
|
||||||
# The dependency's post-yield path must not call commit again (no error)
|
|
||||||
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_data_inside_lock_is_committed(self):
|
|
||||||
"""Changes made inside lock_tables are committed when the context exits."""
|
|
||||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
@@ -126,12 +96,21 @@ class TestCreateDbDependency:
|
|||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with lock_tables(session_factory, [Role]) as session:
|
get_db = create_db_dependency(session_factory)
|
||||||
role = Role(name="lock_committed")
|
|
||||||
session.add(role)
|
async for session in get_db():
|
||||||
|
async with lock_tables(session, [Role]):
|
||||||
|
role = Role(name="lock_then_update")
|
||||||
|
session.add(role)
|
||||||
|
await session.flush()
|
||||||
|
# lock_tables has exited — outer transaction must still be open
|
||||||
|
assert session.in_transaction()
|
||||||
|
role.name = "updated_after_lock"
|
||||||
|
|
||||||
async with session_factory() as verify:
|
async with session_factory() as verify:
|
||||||
result = await RoleCrud.first(verify, [Role.name == "lock_committed"])
|
result = await RoleCrud.first(
|
||||||
|
verify, [Role.name == "updated_after_lock"]
|
||||||
|
)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
finally:
|
finally:
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
@@ -274,144 +253,54 @@ class TestLockTables:
|
|||||||
"""Tests for lock_tables context manager (PostgreSQL-specific)."""
|
"""Tests for lock_tables context manager (PostgreSQL-specific)."""
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_lock_single_table(self, session_maker):
|
async def test_lock_single_table(self, db_session: AsyncSession):
|
||||||
"""Lock a single table; changes inside are committed on context exit."""
|
"""Lock a single table."""
|
||||||
async with lock_tables(session_maker, [Role]) as session:
|
async with lock_tables(db_session, [Role]):
|
||||||
|
# Inside the lock, we can still perform operations
|
||||||
role = Role(name="locked_role")
|
role = Role(name="locked_role")
|
||||||
session.add(role)
|
db_session.add(role)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
async with session_maker() as verify:
|
# After lock is released, verify the data was committed
|
||||||
result = await RoleCrud.first(verify, [Role.name == "locked_role"])
|
result = await RoleCrud.first(db_session, [Role.name == "locked_role"])
|
||||||
assert result is not None
|
assert result is not None
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_lock_multiple_tables(self, session_maker):
|
async def test_lock_multiple_tables(self, db_session: AsyncSession):
|
||||||
"""Lock multiple tables."""
|
"""Lock multiple tables."""
|
||||||
async with lock_tables(session_maker, [Role, User]) as session:
|
async with lock_tables(db_session, [Role, User]):
|
||||||
role = Role(name="multi_lock_role")
|
role = Role(name="multi_lock_role")
|
||||||
session.add(role)
|
db_session.add(role)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
async with session_maker() as verify:
|
result = await RoleCrud.first(db_session, [Role.name == "multi_lock_role"])
|
||||||
result = await RoleCrud.first(verify, [Role.name == "multi_lock_role"])
|
assert result is not None
|
||||||
assert result is not None
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_lock_with_custom_mode(self, session_maker):
|
async def test_lock_with_custom_mode(self, db_session: AsyncSession):
|
||||||
"""Lock with custom lock mode."""
|
"""Lock with custom lock mode."""
|
||||||
async with lock_tables(
|
async with lock_tables(db_session, [Role], mode=LockMode.EXCLUSIVE):
|
||||||
session_maker, [Role], mode=LockMode.EXCLUSIVE
|
|
||||||
) as session:
|
|
||||||
role = Role(name="exclusive_lock_role")
|
role = Role(name="exclusive_lock_role")
|
||||||
session.add(role)
|
db_session.add(role)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
async with session_maker() as verify:
|
result = await RoleCrud.first(db_session, [Role.name == "exclusive_lock_role"])
|
||||||
result = await RoleCrud.first(verify, [Role.name == "exclusive_lock_role"])
|
assert result is not None
|
||||||
assert result is not None
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_lock_rollback_on_exception(self, session_maker):
|
async def test_lock_rollback_on_exception(self, db_session: AsyncSession):
|
||||||
"""Lock context rolls back on exception."""
|
"""Lock context rolls back on exception."""
|
||||||
try:
|
try:
|
||||||
async with lock_tables(session_maker, [Role]) as session:
|
async with lock_tables(db_session, [Role]):
|
||||||
role = Role(name="lock_rollback_role")
|
role = Role(name="lock_rollback_role")
|
||||||
session.add(role)
|
db_session.add(role)
|
||||||
await session.flush()
|
await db_session.flush()
|
||||||
raise ValueError("Simulated error")
|
raise ValueError("Simulated error")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async with session_maker() as verify:
|
result = await RoleCrud.first(db_session, [Role.name == "lock_rollback_role"])
|
||||||
result = await RoleCrud.first(verify, [Role.name == "lock_rollback_role"])
|
assert result is None
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestAdvisoryLock:
|
|
||||||
"""Tests for advisory_lock context manager (PostgreSQL-specific)."""
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_blocking_exclusive_acquires(self, db_session: AsyncSession):
|
|
||||||
"""Blocking exclusive lock acquires and yields True."""
|
|
||||||
async with advisory_lock(db_session, 1001) as acquired:
|
|
||||||
assert acquired is True
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_nowait_returns_true_when_free(self, db_session: AsyncSession):
|
|
||||||
"""nowait=True yields True when the lock is available."""
|
|
||||||
async with advisory_lock(db_session, 1002, nowait=True) as acquired:
|
|
||||||
assert acquired is True
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_nowait_returns_false_when_contended(self, session_maker):
|
|
||||||
"""nowait=True yields False when another session holds the lock."""
|
|
||||||
async with session_maker() as holder:
|
|
||||||
async with holder.begin():
|
|
||||||
async with advisory_lock(holder, 1003):
|
|
||||||
async with session_maker() as contender:
|
|
||||||
async with contender.begin():
|
|
||||||
async with advisory_lock(
|
|
||||||
contender, 1003, nowait=True
|
|
||||||
) as acquired:
|
|
||||||
assert acquired is False
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_shared_allows_concurrent_readers(self, session_maker):
|
|
||||||
"""Two shared locks on the same key are both acquired."""
|
|
||||||
async with session_maker() as s1, session_maker() as s2:
|
|
||||||
async with s1.begin(), s2.begin():
|
|
||||||
async with advisory_lock(s1, 1004, shared=True) as a1:
|
|
||||||
async with advisory_lock(s2, 1004, shared=True, nowait=True) as a2:
|
|
||||||
assert a1 is True
|
|
||||||
assert a2 is True
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_tuple_key(self, db_session: AsyncSession):
|
|
||||||
"""(int, int) key variant acquires the lock."""
|
|
||||||
async with advisory_lock(db_session, (7, 42)) as acquired:
|
|
||||||
assert acquired is True
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_tuple_key_nowait_contended(self, session_maker):
|
|
||||||
"""Tuple key nowait returns False when contended."""
|
|
||||||
async with session_maker() as holder:
|
|
||||||
async with holder.begin():
|
|
||||||
async with advisory_lock(holder, (7, 99)):
|
|
||||||
async with session_maker() as contender:
|
|
||||||
async with contender.begin():
|
|
||||||
async with advisory_lock(
|
|
||||||
contender, (7, 99), nowait=True
|
|
||||||
) as acquired:
|
|
||||||
assert acquired is False
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_lock_released_at_context_exit(self, session_maker):
|
|
||||||
"""Lock is released when the context exits, even while the transaction is still open."""
|
|
||||||
async with session_maker() as s1:
|
|
||||||
async with s1.begin():
|
|
||||||
async with advisory_lock(s1, 1005):
|
|
||||||
pass # lock released here — transaction still active
|
|
||||||
|
|
||||||
async with session_maker() as s2:
|
|
||||||
async with s2.begin():
|
|
||||||
async with advisory_lock(s2, 1005, nowait=True) as acquired:
|
|
||||||
assert (
|
|
||||||
acquired is True
|
|
||||||
) # s1 still in transaction but lock is free
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_timeout_raises_when_contended(self, session_maker):
|
|
||||||
"""timeout= raises when the lock cannot be acquired within the interval."""
|
|
||||||
from sqlalchemy.exc import DBAPIError
|
|
||||||
|
|
||||||
async with session_maker() as holder:
|
|
||||||
async with holder.begin():
|
|
||||||
async with advisory_lock(holder, 1006):
|
|
||||||
async with session_maker() as contender:
|
|
||||||
async with contender.begin():
|
|
||||||
with pytest.raises(DBAPIError):
|
|
||||||
async with advisory_lock(
|
|
||||||
contender, 1006, timeout="10ms"
|
|
||||||
):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class TestWaitForRowChange:
|
class TestWaitForRowChange:
|
||||||
@@ -591,418 +480,3 @@ class TestCleanupTables:
|
|||||||
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
||||||
# Should not raise
|
# Should not raise
|
||||||
await cleanup_tables(session, EmptyBase)
|
await cleanup_tables(session, EmptyBase)
|
||||||
|
|
||||||
|
|
||||||
class TestM2MAdd:
|
|
||||||
"""Tests for m2m_add helper."""
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_adds_single_related(self, db_session: AsyncSession):
|
|
||||||
"""Associates one related instance via the secondary table."""
|
|
||||||
user = User(username="m2m_author", email="m2m@test.com")
|
|
||||||
db_session.add(user)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
post = Post(title="Post A", author_id=user.id)
|
|
||||||
tag = Tag(name="python")
|
|
||||||
db_session.add_all([post, tag])
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
async with get_transaction(db_session):
|
|
||||||
await m2m_add(db_session, post, Post.tags, tag)
|
|
||||||
|
|
||||||
result = await db_session.execute(
|
|
||||||
select(Post).where(Post.id == post.id).options(selectinload(Post.tags))
|
|
||||||
)
|
|
||||||
loaded = result.scalar_one()
|
|
||||||
assert len(loaded.tags) == 1
|
|
||||||
assert loaded.tags[0].id == tag.id
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_adds_multiple_related(self, db_session: AsyncSession):
|
|
||||||
"""Associates multiple related instances in a single call."""
|
|
||||||
user = User(username="m2m_author2", email="m2m2@test.com")
|
|
||||||
db_session.add(user)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
post = Post(title="Post B", author_id=user.id)
|
|
||||||
tag1 = Tag(name="web")
|
|
||||||
tag2 = Tag(name="api")
|
|
||||||
tag3 = Tag(name="async")
|
|
||||||
db_session.add_all([post, tag1, tag2, tag3])
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
async with get_transaction(db_session):
|
|
||||||
await m2m_add(db_session, post, Post.tags, tag1, tag2, tag3)
|
|
||||||
|
|
||||||
result = await db_session.execute(
|
|
||||||
select(Post).where(Post.id == post.id).options(selectinload(Post.tags))
|
|
||||||
)
|
|
||||||
loaded = result.scalar_one()
|
|
||||||
assert {t.id for t in loaded.tags} == {tag1.id, tag2.id, tag3.id}
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_noop_for_empty_related(self, db_session: AsyncSession):
|
|
||||||
"""Calling with no related instances is a no-op."""
|
|
||||||
user = User(username="m2m_author3", email="m2m3@test.com")
|
|
||||||
db_session.add(user)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
post = Post(title="Post C", author_id=user.id)
|
|
||||||
db_session.add(post)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
async with get_transaction(db_session):
|
|
||||||
await m2m_add(db_session, post, Post.tags) # no related instances
|
|
||||||
|
|
||||||
result = await db_session.execute(
|
|
||||||
select(Post).where(Post.id == post.id).options(selectinload(Post.tags))
|
|
||||||
)
|
|
||||||
loaded = result.scalar_one()
|
|
||||||
assert loaded.tags == []
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_ignore_conflicts_true(self, db_session: AsyncSession):
|
|
||||||
"""Duplicate inserts are silently skipped when ignore_conflicts=True."""
|
|
||||||
user = User(username="m2m_author4", email="m2m4@test.com")
|
|
||||||
db_session.add(user)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
post = Post(title="Post D", author_id=user.id)
|
|
||||||
tag = Tag(name="duplicate_tag")
|
|
||||||
db_session.add_all([post, tag])
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
async with get_transaction(db_session):
|
|
||||||
await m2m_add(db_session, post, Post.tags, tag)
|
|
||||||
|
|
||||||
# Second call with ignore_conflicts=True must not raise
|
|
||||||
async with get_transaction(db_session):
|
|
||||||
await m2m_add(db_session, post, Post.tags, tag, ignore_conflicts=True)
|
|
||||||
|
|
||||||
result = await db_session.execute(
|
|
||||||
select(Post).where(Post.id == post.id).options(selectinload(Post.tags))
|
|
||||||
)
|
|
||||||
loaded = result.scalar_one()
|
|
||||||
assert len(loaded.tags) == 1
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_ignore_conflicts_false_raises(self, db_session: AsyncSession):
|
|
||||||
"""Duplicate inserts raise IntegrityError when ignore_conflicts=False (default)."""
|
|
||||||
user = User(username="m2m_author5", email="m2m5@test.com")
|
|
||||||
db_session.add(user)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
post = Post(title="Post E", author_id=user.id)
|
|
||||||
tag = Tag(name="conflict_tag")
|
|
||||||
db_session.add_all([post, tag])
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
async with get_transaction(db_session):
|
|
||||||
await m2m_add(db_session, post, Post.tags, tag)
|
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
async with get_transaction(db_session):
|
|
||||||
await m2m_add(db_session, post, Post.tags, tag)
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_non_m2m_raises_type_error(self, db_session: AsyncSession):
|
|
||||||
"""Passing a non-M2M relationship attribute raises TypeError."""
|
|
||||||
user = User(username="m2m_author6", email="m2m6@test.com")
|
|
||||||
db_session.add(user)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
role = Role(name="type_err_role")
|
|
||||||
db_session.add(role)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
with pytest.raises(TypeError, match="Many-to-Many"):
|
|
||||||
await m2m_add(db_session, user, User.role, role)
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_works_inside_lock_tables(self, session_maker):
|
|
||||||
"""m2m_add works correctly inside a lock_tables context."""
|
|
||||||
async with lock_tables(session_maker, [Tag]) as session:
|
|
||||||
user = User(username="m2m_lock_author", email="m2m_lock@test.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
tag = Tag(name="locked_tag")
|
|
||||||
session.add(tag)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
post = Post(title="Post Lock", author_id=user.id)
|
|
||||||
session.add(post)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
await m2m_add(session, post, Post.tags, tag)
|
|
||||||
|
|
||||||
async with session_maker() as verify:
|
|
||||||
result = await verify.execute(
|
|
||||||
select(Post).where(Post.id == post.id).options(selectinload(Post.tags))
|
|
||||||
)
|
|
||||||
loaded = result.scalar_one()
|
|
||||||
assert len(loaded.tags) == 1
|
|
||||||
assert loaded.tags[0].name == "locked_tag"
|
|
||||||
|
|
||||||
|
|
||||||
class _LocalBase(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
_comp_assoc = Table(
|
|
||||||
"_comp_assoc",
|
|
||||||
_LocalBase.metadata,
|
|
||||||
Column("owner_id", Uuid, ForeignKey("_comp_owners.id"), primary_key=True),
|
|
||||||
Column("item_group", String(50), primary_key=True),
|
|
||||||
Column("item_code", String(50), primary_key=True),
|
|
||||||
ForeignKeyConstraint(
|
|
||||||
["item_group", "item_code"],
|
|
||||||
["_comp_items.group_id", "_comp_items.item_code"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _CompOwner(_LocalBase):
|
|
||||||
__tablename__ = "_comp_owners"
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
|
||||||
items: Mapped[list["_CompItem"]] = relationship(secondary=_comp_assoc)
|
|
||||||
|
|
||||||
|
|
||||||
class _CompItem(_LocalBase):
|
|
||||||
__tablename__ = "_comp_items"
|
|
||||||
group_id: Mapped[str] = mapped_column(String(50), primary_key=True)
|
|
||||||
item_code: Mapped[str] = mapped_column(String(50), primary_key=True)
|
|
||||||
|
|
||||||
|
|
||||||
class TestM2MRemove:
|
|
||||||
"""Tests for m2m_remove helper."""
|
|
||||||
|
|
||||||
async def _setup(
|
|
||||||
self, session: AsyncSession, username: str, email: str, *tag_names: str
|
|
||||||
):
|
|
||||||
"""Create a user, post, and tags; associate all tags with the post."""
|
|
||||||
user = User(username=username, email=email)
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
post = Post(title=f"Post {username}", author_id=user.id)
|
|
||||||
tags = [Tag(name=n) for n in tag_names]
|
|
||||||
session.add(post)
|
|
||||||
session.add_all(tags)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
async with get_transaction(session):
|
|
||||||
await m2m_add(session, post, Post.tags, *tags)
|
|
||||||
|
|
||||||
return post, tags
|
|
||||||
|
|
||||||
async def _load_tags(self, session: AsyncSession, post: Post) -> list[Tag]:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Post).where(Post.id == post.id).options(selectinload(Post.tags))
|
|
||||||
)
|
|
||||||
return result.scalar_one().tags
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_removes_single(self, db_session: AsyncSession):
|
|
||||||
"""Removes one association, leaving others intact."""
|
|
||||||
post, (tag1, tag2) = await self._setup(
|
|
||||||
db_session, "rm_author1", "rm1@test.com", "tag_rm_a", "tag_rm_b"
|
|
||||||
)
|
|
||||||
|
|
||||||
async with get_transaction(db_session):
|
|
||||||
await m2m_remove(db_session, post, Post.tags, tag1)
|
|
||||||
|
|
||||||
remaining = await self._load_tags(db_session, post)
|
|
||||||
assert len(remaining) == 1
|
|
||||||
assert remaining[0].id == tag2.id
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_removes_multiple(self, db_session: AsyncSession):
|
|
||||||
"""Removes multiple associations in one call."""
|
|
||||||
post, (tag1, tag2, tag3) = await self._setup(
|
|
||||||
db_session, "rm_author2", "rm2@test.com", "tag_rm_c", "tag_rm_d", "tag_rm_e"
|
|
||||||
)
|
|
||||||
|
|
||||||
async with get_transaction(db_session):
|
|
||||||
await m2m_remove(db_session, post, Post.tags, tag1, tag3)
|
|
||||||
|
|
||||||
remaining = await self._load_tags(db_session, post)
|
|
||||||
assert len(remaining) == 1
|
|
||||||
assert remaining[0].id == tag2.id
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_noop_for_empty_related(self, db_session: AsyncSession):
|
|
||||||
"""Calling with no related instances is a no-op."""
|
|
||||||
post, (tag,) = await self._setup(
|
|
||||||
db_session, "rm_author3", "rm3@test.com", "tag_rm_f"
|
|
||||||
)
|
|
||||||
|
|
||||||
async with get_transaction(db_session):
|
|
||||||
await m2m_remove(db_session, post, Post.tags)
|
|
||||||
|
|
||||||
remaining = await self._load_tags(db_session, post)
|
|
||||||
assert len(remaining) == 1
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_idempotent_for_missing_association(self, db_session: AsyncSession):
|
|
||||||
"""Removing a non-existent association does not raise."""
|
|
||||||
post, (tag1,) = await self._setup(
|
|
||||||
db_session, "rm_author4", "rm4@test.com", "tag_rm_g"
|
|
||||||
)
|
|
||||||
tag2 = Tag(name="tag_rm_h")
|
|
||||||
db_session.add(tag2)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
# tag2 was never associated — should not raise
|
|
||||||
async with get_transaction(db_session):
|
|
||||||
await m2m_remove(db_session, post, Post.tags, tag2)
|
|
||||||
|
|
||||||
remaining = await self._load_tags(db_session, post)
|
|
||||||
assert len(remaining) == 1
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_non_m2m_raises_type_error(self, db_session: AsyncSession):
|
|
||||||
"""Passing a non-M2M relationship attribute raises TypeError."""
|
|
||||||
user = User(username="rm_author5", email="rm5@test.com")
|
|
||||||
db_session.add(user)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
role = Role(name="rm_type_err_role")
|
|
||||||
db_session.add(role)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
with pytest.raises(TypeError, match="Many-to-Many"):
|
|
||||||
await m2m_remove(db_session, user, User.role, role)
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_removes_composite_pk_related(self):
|
|
||||||
"""Composite-PK branch: DELETE uses tuple IN when related side has multi-col PK."""
|
|
||||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(_LocalBase.metadata.create_all)
|
|
||||||
|
|
||||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
||||||
try:
|
|
||||||
async with session_factory() as session:
|
|
||||||
owner = _CompOwner()
|
|
||||||
item1 = _CompItem(group_id="g1", item_code="c1")
|
|
||||||
item2 = _CompItem(group_id="g1", item_code="c2")
|
|
||||||
session.add_all([owner, item1, item2])
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
async with get_transaction(session):
|
|
||||||
await m2m_add(session, owner, _CompOwner.items, item1, item2)
|
|
||||||
|
|
||||||
async with get_transaction(session):
|
|
||||||
await m2m_remove(session, owner, _CompOwner.items, item1)
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
async with session_factory() as verify:
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
result = await verify.execute(
|
|
||||||
select(_CompOwner)
|
|
||||||
.where(_CompOwner.id == owner.id)
|
|
||||||
.options(selectinload(_CompOwner.items))
|
|
||||||
)
|
|
||||||
loaded = result.scalar_one()
|
|
||||||
assert len(loaded.items) == 1
|
|
||||||
assert (loaded.items[0].group_id, loaded.items[0].item_code) == (
|
|
||||||
"g1",
|
|
||||||
"c2",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(_LocalBase.metadata.drop_all)
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
class TestM2MSet:
|
|
||||||
"""Tests for m2m_set helper."""
|
|
||||||
|
|
||||||
async def _load_tags(self, session: AsyncSession, post: Post) -> list[Tag]:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Post).where(Post.id == post.id).options(selectinload(Post.tags))
|
|
||||||
)
|
|
||||||
return result.scalar_one().tags
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_replaces_existing_set(self, db_session: AsyncSession):
|
|
||||||
"""Replaces the full association set atomically."""
|
|
||||||
user = User(username="set_author1", email="set1@test.com")
|
|
||||||
db_session.add(user)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
post = Post(title="Post Set A", author_id=user.id)
|
|
||||||
tag1 = Tag(name="tag_set_a")
|
|
||||||
tag2 = Tag(name="tag_set_b")
|
|
||||||
tag3 = Tag(name="tag_set_c")
|
|
||||||
db_session.add_all([post, tag1, tag2, tag3])
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
async with get_transaction(db_session):
|
|
||||||
await m2m_add(db_session, post, Post.tags, tag1, tag2)
|
|
||||||
|
|
||||||
async with get_transaction(db_session):
|
|
||||||
await m2m_set(db_session, post, Post.tags, tag3)
|
|
||||||
|
|
||||||
remaining = await self._load_tags(db_session, post)
|
|
||||||
assert len(remaining) == 1
|
|
||||||
assert remaining[0].id == tag3.id
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_clears_all_when_no_related(self, db_session: AsyncSession):
|
|
||||||
"""Passing no related instances clears all associations."""
|
|
||||||
user = User(username="set_author2", email="set2@test.com")
|
|
||||||
db_session.add(user)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
post = Post(title="Post Set B", author_id=user.id)
|
|
||||||
tag = Tag(name="tag_set_d")
|
|
||||||
db_session.add_all([post, tag])
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
async with get_transaction(db_session):
|
|
||||||
await m2m_add(db_session, post, Post.tags, tag)
|
|
||||||
|
|
||||||
async with get_transaction(db_session):
|
|
||||||
await m2m_set(db_session, post, Post.tags)
|
|
||||||
|
|
||||||
remaining = await self._load_tags(db_session, post)
|
|
||||||
assert remaining == []
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_set_on_empty_then_populate(self, db_session: AsyncSession):
|
|
||||||
"""m2m_set works on a post with no existing associations."""
|
|
||||||
user = User(username="set_author3", email="set3@test.com")
|
|
||||||
db_session.add(user)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
post = Post(title="Post Set C", author_id=user.id)
|
|
||||||
tag1 = Tag(name="tag_set_e")
|
|
||||||
tag2 = Tag(name="tag_set_f")
|
|
||||||
db_session.add_all([post, tag1, tag2])
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
async with get_transaction(db_session):
|
|
||||||
await m2m_set(db_session, post, Post.tags, tag1, tag2)
|
|
||||||
|
|
||||||
remaining = await self._load_tags(db_session, post)
|
|
||||||
assert {t.id for t in remaining} == {tag1.id, tag2.id}
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_non_m2m_raises_type_error(self, db_session: AsyncSession):
|
|
||||||
"""Passing a non-M2M relationship attribute raises TypeError."""
|
|
||||||
user = User(username="set_author4", email="set4@test.com")
|
|
||||||
db_session.add(user)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
role = Role(name="set_type_err_role")
|
|
||||||
db_session.add(role)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
with pytest.raises(TypeError, match="Many-to-Many"):
|
|
||||||
await m2m_set(db_session, user, User.role, role)
|
|
||||||
|
|||||||
+2
-286
@@ -10,29 +10,13 @@ from fastapi_toolsets.fixtures import (
|
|||||||
Context,
|
Context,
|
||||||
FixtureRegistry,
|
FixtureRegistry,
|
||||||
LoadStrategy,
|
LoadStrategy,
|
||||||
get_field_by_attr,
|
|
||||||
get_obj_by_attr,
|
get_obj_by_attr,
|
||||||
load_fixtures,
|
load_fixtures,
|
||||||
load_fixtures_by_context,
|
load_fixtures_by_context,
|
||||||
)
|
)
|
||||||
from fastapi_toolsets.fixtures.utils import (
|
from fastapi_toolsets.fixtures.utils import _get_primary_key, _instance_to_dict
|
||||||
_get_primary_key,
|
|
||||||
_get_table_chain,
|
|
||||||
_instance_to_dict,
|
|
||||||
_instance_to_dict_for_cls,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .conftest import (
|
from .conftest import IntRole, Permission, Role, RoleCreate, RoleCrud, User, UserCrud
|
||||||
Challenge,
|
|
||||||
ChallengeStandard,
|
|
||||||
IntRole,
|
|
||||||
Permission,
|
|
||||||
Role,
|
|
||||||
RoleCreate,
|
|
||||||
RoleCrud,
|
|
||||||
User,
|
|
||||||
UserCrud,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AppContext(str, Enum):
|
class AppContext(str, Enum):
|
||||||
@@ -967,41 +951,6 @@ class TestGetObjByAttr:
|
|||||||
get_obj_by_attr(self.roles, "id", "not-a-uuid")
|
get_obj_by_attr(self.roles, "id", "not-a-uuid")
|
||||||
|
|
||||||
|
|
||||||
class TestGetFieldByAttr:
|
|
||||||
"""Tests for get_field_by_attr helper function."""
|
|
||||||
|
|
||||||
def setup_method(self):
|
|
||||||
self.registry = FixtureRegistry()
|
|
||||||
self.role_id_1 = uuid.uuid4()
|
|
||||||
self.role_id_2 = uuid.uuid4()
|
|
||||||
role_id_1 = self.role_id_1
|
|
||||||
role_id_2 = self.role_id_2
|
|
||||||
|
|
||||||
@self.registry.register
|
|
||||||
def roles() -> list[Role]:
|
|
||||||
return [
|
|
||||||
Role(id=role_id_1, name="admin"),
|
|
||||||
Role(id=role_id_2, name="user"),
|
|
||||||
]
|
|
||||||
|
|
||||||
self.roles = roles
|
|
||||||
|
|
||||||
def test_returns_id_by_default(self):
|
|
||||||
"""Returns the id field when no field is specified."""
|
|
||||||
result = get_field_by_attr(self.roles, "name", "admin")
|
|
||||||
assert result == self.role_id_1
|
|
||||||
|
|
||||||
def test_returns_specified_field(self):
|
|
||||||
"""Returns the requested field instead of id."""
|
|
||||||
result = get_field_by_attr(self.roles, "id", self.role_id_2, field="name")
|
|
||||||
assert result == "user"
|
|
||||||
|
|
||||||
def test_no_match_raises_stop_iteration(self):
|
|
||||||
"""Propagates StopIteration from get_obj_by_attr when no match found."""
|
|
||||||
with pytest.raises(StopIteration, match="No object with name=missing"):
|
|
||||||
get_field_by_attr(self.roles, "name", "missing")
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetPrimaryKey:
|
class TestGetPrimaryKey:
|
||||||
"""Unit tests for the _get_primary_key helper (composite PK paths)."""
|
"""Unit tests for the _get_primary_key helper (composite PK paths)."""
|
||||||
|
|
||||||
@@ -1524,236 +1473,3 @@ class TestBatchNullableColumnEdgeCases:
|
|||||||
assert rows["only_role"].notes is None
|
assert rows["only_role"].notes is None
|
||||||
assert rows["only_notes"].role_id is None
|
assert rows["only_notes"].role_id is None
|
||||||
assert rows["only_notes"].notes == "partial"
|
assert rows["only_notes"].notes == "partial"
|
||||||
|
|
||||||
|
|
||||||
class TestJoinedTableInheritance:
|
|
||||||
"""Tests for fixture batch helpers with SQLAlchemy joined-table inheritance.
|
|
||||||
|
|
||||||
Regression coverage for the KeyError raised when _batch_insert/_batch_merge
|
|
||||||
used model_cls.__mapper__.column_attrs (which includes inherited parent columns)
|
|
||||||
against a pg_insert targeting only the child table.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_get_table_chain_plain_model(self):
|
|
||||||
"""_get_table_chain returns [model_cls] for a non-inherited model."""
|
|
||||||
chain = _get_table_chain(Role)
|
|
||||||
assert chain == [Role]
|
|
||||||
|
|
||||||
def test_get_table_chain_jti_child(self):
|
|
||||||
"""_get_table_chain returns [root, child] for a joined-table child."""
|
|
||||||
chain = _get_table_chain(ChallengeStandard)
|
|
||||||
assert chain == [Challenge, ChallengeStandard]
|
|
||||||
|
|
||||||
def test_instance_to_dict_for_cls_root(self):
|
|
||||||
"""_instance_to_dict_for_cls scopes to root table columns only."""
|
|
||||||
cid = uuid.uuid4()
|
|
||||||
inst = ChallengeStandard(
|
|
||||||
id=cid, title="root-only", challenge_type="standard", difficulty="easy"
|
|
||||||
)
|
|
||||||
d = _instance_to_dict_for_cls(inst, Challenge)
|
|
||||||
assert "id" in d
|
|
||||||
assert "title" in d
|
|
||||||
assert "challenge_type" in d
|
|
||||||
assert "difficulty" not in d # child column excluded
|
|
||||||
|
|
||||||
def test_instance_to_dict_for_cls_child(self):
|
|
||||||
"""_instance_to_dict_for_cls scopes to child table columns only."""
|
|
||||||
cid = uuid.uuid4()
|
|
||||||
inst = ChallengeStandard(
|
|
||||||
id=cid, title="child-only", challenge_type="standard", difficulty="hard"
|
|
||||||
)
|
|
||||||
d = _instance_to_dict_for_cls(inst, ChallengeStandard)
|
|
||||||
assert "id" in d
|
|
||||||
assert "difficulty" in d
|
|
||||||
assert "title" not in d # parent column excluded
|
|
||||||
assert "challenge_type" not in d # parent column excluded
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_insert_strategy_jti(self, db_session: AsyncSession):
|
|
||||||
"""INSERT strategy correctly inserts both root and child table rows."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
registry = FixtureRegistry()
|
|
||||||
cid1 = uuid.uuid4()
|
|
||||||
cid2 = uuid.uuid4()
|
|
||||||
|
|
||||||
@registry.register
|
|
||||||
def challenges():
|
|
||||||
return [
|
|
||||||
ChallengeStandard(
|
|
||||||
id=cid1, title="Alpha", challenge_type="standard", difficulty="easy"
|
|
||||||
),
|
|
||||||
ChallengeStandard(
|
|
||||||
id=cid2, title="Beta", challenge_type="standard", difficulty="hard"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
result = await load_fixtures(
|
|
||||||
db_session, registry, "challenges", strategy=LoadStrategy.INSERT
|
|
||||||
)
|
|
||||||
assert len(result["challenges"]) == 2
|
|
||||||
|
|
||||||
rows = (await db_session.execute(select(ChallengeStandard))).scalars().all()
|
|
||||||
by_title = {r.title: r for r in rows}
|
|
||||||
assert by_title["Alpha"].difficulty == "easy"
|
|
||||||
assert by_title["Beta"].difficulty == "hard"
|
|
||||||
assert by_title["Alpha"].challenge_type == "standard"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_merge_strategy_jti_insert(self, db_session: AsyncSession):
|
|
||||||
"""MERGE strategy inserts new JTI rows correctly."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
registry = FixtureRegistry()
|
|
||||||
cid = uuid.uuid4()
|
|
||||||
|
|
||||||
@registry.register
|
|
||||||
def challenges():
|
|
||||||
return [
|
|
||||||
ChallengeStandard(
|
|
||||||
id=cid,
|
|
||||||
title="Gamma",
|
|
||||||
challenge_type="standard",
|
|
||||||
difficulty="medium",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
result = await load_fixtures(
|
|
||||||
db_session, registry, "challenges", strategy=LoadStrategy.MERGE
|
|
||||||
)
|
|
||||||
assert len(result["challenges"]) == 1
|
|
||||||
|
|
||||||
row = (
|
|
||||||
await db_session.execute(
|
|
||||||
select(ChallengeStandard).where(ChallengeStandard.id == cid)
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
assert row.title == "Gamma"
|
|
||||||
assert row.difficulty == "medium"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_merge_strategy_jti_upsert(self, db_session: AsyncSession):
|
|
||||||
"""MERGE strategy updates existing JTI rows on re-load."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
registry = FixtureRegistry()
|
|
||||||
cid = uuid.uuid4()
|
|
||||||
|
|
||||||
@registry.register
|
|
||||||
def challenges():
|
|
||||||
return [
|
|
||||||
ChallengeStandard(
|
|
||||||
id=cid,
|
|
||||||
title="Original",
|
|
||||||
challenge_type="standard",
|
|
||||||
difficulty="easy",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
await load_fixtures(
|
|
||||||
db_session, registry, "challenges", strategy=LoadStrategy.MERGE
|
|
||||||
)
|
|
||||||
|
|
||||||
registry2 = FixtureRegistry()
|
|
||||||
|
|
||||||
@registry2.register
|
|
||||||
def challenges(): # noqa: F811
|
|
||||||
return [
|
|
||||||
ChallengeStandard(
|
|
||||||
id=cid,
|
|
||||||
title="Updated",
|
|
||||||
challenge_type="standard",
|
|
||||||
difficulty="hard",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
await load_fixtures(
|
|
||||||
db_session, registry2, "challenges", strategy=LoadStrategy.MERGE
|
|
||||||
)
|
|
||||||
|
|
||||||
row = (
|
|
||||||
await db_session.execute(
|
|
||||||
select(ChallengeStandard).where(ChallengeStandard.id == cid)
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
assert row.title == "Updated"
|
|
||||||
assert row.difficulty == "hard"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_skip_existing_strategy_jti_inserts_new(
|
|
||||||
self, db_session: AsyncSession
|
|
||||||
):
|
|
||||||
"""SKIP_EXISTING inserts a new JTI row and returns it."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
registry = FixtureRegistry()
|
|
||||||
cid = uuid.uuid4()
|
|
||||||
|
|
||||||
@registry.register
|
|
||||||
def challenges():
|
|
||||||
return [
|
|
||||||
ChallengeStandard(
|
|
||||||
id=cid, title="New", challenge_type="standard", difficulty="easy"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
result = await load_fixtures(
|
|
||||||
db_session, registry, "challenges", strategy=LoadStrategy.SKIP_EXISTING
|
|
||||||
)
|
|
||||||
assert len(result["challenges"]) == 1
|
|
||||||
|
|
||||||
row = (
|
|
||||||
await db_session.execute(
|
|
||||||
select(ChallengeStandard).where(ChallengeStandard.id == cid)
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
assert row.title == "New"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_skip_existing_strategy_jti_skips_existing(
|
|
||||||
self, db_session: AsyncSession
|
|
||||||
):
|
|
||||||
"""SKIP_EXISTING does not overwrite an existing JTI row."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
registry = FixtureRegistry()
|
|
||||||
cid = uuid.uuid4()
|
|
||||||
|
|
||||||
@registry.register
|
|
||||||
def challenges():
|
|
||||||
return [
|
|
||||||
ChallengeStandard(
|
|
||||||
id=cid, title="First", challenge_type="standard", difficulty="easy"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
await load_fixtures(
|
|
||||||
db_session, registry, "challenges", strategy=LoadStrategy.SKIP_EXISTING
|
|
||||||
)
|
|
||||||
db_session.expunge_all()
|
|
||||||
|
|
||||||
registry2 = FixtureRegistry()
|
|
||||||
|
|
||||||
@registry2.register
|
|
||||||
def challenges(): # noqa: F811
|
|
||||||
return [
|
|
||||||
ChallengeStandard(
|
|
||||||
id=cid,
|
|
||||||
title="Overwrite",
|
|
||||||
challenge_type="standard",
|
|
||||||
difficulty="hard",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
result = await load_fixtures(
|
|
||||||
db_session, registry2, "challenges", strategy=LoadStrategy.SKIP_EXISTING
|
|
||||||
)
|
|
||||||
assert result["challenges"] == []
|
|
||||||
|
|
||||||
row = (
|
|
||||||
await db_session.execute(
|
|
||||||
select(ChallengeStandard).where(ChallengeStandard.id == cid)
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
assert row.title == "First"
|
|
||||||
assert row.difficulty == "easy"
|
|
||||||
|
|||||||
+6
-23
@@ -7,7 +7,6 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import String
|
from sqlalchemy import String
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
import fastapi_toolsets.models.watched as _watched_module
|
import fastapi_toolsets.models.watched as _watched_module
|
||||||
@@ -21,7 +20,6 @@ from fastapi_toolsets.models import (
|
|||||||
listens_for,
|
listens_for,
|
||||||
)
|
)
|
||||||
from fastapi_toolsets.models.watched import (
|
from fastapi_toolsets.models.watched import (
|
||||||
EventSession,
|
|
||||||
_EVENT_HANDLERS,
|
_EVENT_HANDLERS,
|
||||||
_SESSION_CREATES,
|
_SESSION_CREATES,
|
||||||
_SESSION_DELETES,
|
_SESSION_DELETES,
|
||||||
@@ -340,23 +338,6 @@ async def mixin_session_expire():
|
|||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
|
||||||
async def mixin_session_maker():
|
|
||||||
"""Provide an EventSession-backed session factory with MixinBase tables."""
|
|
||||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(MixinBase.metadata.create_all)
|
|
||||||
|
|
||||||
factory = async_sessionmaker(engine, expire_on_commit=False, class_=EventSession)
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield factory
|
|
||||||
finally:
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(MixinBase.metadata.drop_all)
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
class TestUUIDMixin:
|
class TestUUIDMixin:
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_uuid_generated_by_db(self, mixin_session):
|
async def test_uuid_generated_by_db(self, mixin_session):
|
||||||
@@ -1578,13 +1559,15 @@ class TestEventSessionWithGetTransaction:
|
|||||||
assert creates[0]["obj_id"] == survivor.id
|
assert creates[0]["obj_id"] == survivor.id
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_lock_tables_with_events(self, mixin_session_maker):
|
async def test_lock_tables_with_events(self, mixin_session):
|
||||||
"""Events fire correctly when lock_tables commits on context exit."""
|
"""Events fire correctly after lock_tables context."""
|
||||||
from fastapi_toolsets.db import lock_tables
|
from fastapi_toolsets.db import lock_tables
|
||||||
|
|
||||||
async with lock_tables(mixin_session_maker, [WatchedModel]) as session:
|
async with lock_tables(mixin_session, [WatchedModel]):
|
||||||
obj = WatchedModel(status="locked", other="x")
|
obj = WatchedModel(status="locked", other="x")
|
||||||
session.add(obj)
|
mixin_session.add(obj)
|
||||||
|
|
||||||
|
await mixin_session.commit()
|
||||||
|
|
||||||
creates = [e for e in _test_events if e["event"] == "create"]
|
creates = [e for e in _test_events if e["event"] == "create"]
|
||||||
assert len(creates) == 1
|
assert len(creates) == 1
|
||||||
|
|||||||
+12
-219
@@ -1,18 +1,17 @@
|
|||||||
"""Tests for fastapi_toolsets.pytest module."""
|
"""Tests for fastapi_toolsets.pytest module."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import Depends, FastAPI
|
from fastapi import Depends, FastAPI
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy import ForeignKey, String, 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, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from fastapi_toolsets.db import get_transaction
|
from fastapi_toolsets.db import get_transaction
|
||||||
from fastapi_toolsets.fixtures import Context, FixtureRegistry, LoadStrategy
|
from fastapi_toolsets.fixtures import Context, FixtureRegistry
|
||||||
from fastapi_toolsets.pytest import (
|
from fastapi_toolsets.pytest import (
|
||||||
create_async_client,
|
create_async_client,
|
||||||
create_db_session,
|
create_db_session,
|
||||||
@@ -20,23 +19,9 @@ from fastapi_toolsets.pytest import (
|
|||||||
register_fixtures,
|
register_fixtures,
|
||||||
worker_database_url,
|
worker_database_url,
|
||||||
)
|
)
|
||||||
from fastapi_toolsets.pytest.plugin import (
|
|
||||||
_get_primary_key,
|
|
||||||
_relationship_load_options,
|
|
||||||
_reload_with_relationships,
|
|
||||||
)
|
|
||||||
from fastapi_toolsets.pytest.utils import _get_xdist_worker
|
from fastapi_toolsets.pytest.utils import _get_xdist_worker
|
||||||
|
|
||||||
from .conftest import (
|
from .conftest import DATABASE_URL, Base, Role, RoleCrud, User, UserCrud
|
||||||
DATABASE_URL,
|
|
||||||
Base,
|
|
||||||
IntRole,
|
|
||||||
Permission,
|
|
||||||
Role,
|
|
||||||
RoleCrud,
|
|
||||||
User,
|
|
||||||
UserCrud,
|
|
||||||
)
|
|
||||||
|
|
||||||
test_registry = FixtureRegistry()
|
test_registry = FixtureRegistry()
|
||||||
|
|
||||||
@@ -151,8 +136,14 @@ class TestGeneratedFixtures:
|
|||||||
async def test_fixture_relationships_work(
|
async def test_fixture_relationships_work(
|
||||||
self, db_session: AsyncSession, fixture_users: list[User]
|
self, db_session: AsyncSession, fixture_users: list[User]
|
||||||
):
|
):
|
||||||
"""Loaded fixtures have working relationships directly accessible."""
|
"""Loaded fixtures have working relationships."""
|
||||||
user = next(u for u in fixture_users if u.id == USER_ADMIN_ID)
|
# Load user with role relationship
|
||||||
|
user = await UserCrud.get(
|
||||||
|
db_session,
|
||||||
|
[User.id == USER_ADMIN_ID],
|
||||||
|
load_options=[selectinload(User.role)],
|
||||||
|
)
|
||||||
|
|
||||||
assert user.role is not None
|
assert user.role is not None
|
||||||
assert user.role.name == "plugin_admin"
|
assert user.role.name == "plugin_admin"
|
||||||
|
|
||||||
@@ -186,15 +177,6 @@ class TestGeneratedFixtures:
|
|||||||
assert users[0].username == "plugin_admin"
|
assert users[0].username == "plugin_admin"
|
||||||
assert users[1].username == "plugin_user"
|
assert users[1].username == "plugin_user"
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_fixture_auto_loads_relationships(
|
|
||||||
self, db_session: AsyncSession, fixture_users: list[User]
|
|
||||||
):
|
|
||||||
"""Fixtures automatically eager-load all direct relationships."""
|
|
||||||
user = next(u for u in fixture_users if u.username == "plugin_admin")
|
|
||||||
assert user.role is not None
|
|
||||||
assert user.role.name == "plugin_admin"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_multiple_fixtures_in_same_test(
|
async def test_multiple_fixtures_in_same_test(
|
||||||
self,
|
self,
|
||||||
@@ -534,192 +516,3 @@ class TestCreateWorkerDatabase:
|
|||||||
)
|
)
|
||||||
assert result.scalar() is None
|
assert result.scalar() is None
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
class _LocalBase(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class _Group(_LocalBase):
|
|
||||||
__tablename__ = "_test_groups"
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
|
||||||
name: Mapped[str] = mapped_column(String(50))
|
|
||||||
|
|
||||||
|
|
||||||
class _CompositeItem(_LocalBase):
|
|
||||||
"""Model with composite PK and a relationship — exercises the fallback path."""
|
|
||||||
|
|
||||||
__tablename__ = "_test_composite_items"
|
|
||||||
group_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
ForeignKey("_test_groups.id"), primary_key=True
|
|
||||||
)
|
|
||||||
item_code: Mapped[str] = mapped_column(String(50), primary_key=True)
|
|
||||||
group: Mapped["_Group"] = relationship()
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetPrimaryKey:
|
|
||||||
"""Unit tests for _get_primary_key — no DB needed."""
|
|
||||||
|
|
||||||
def test_single_pk_returns_value(self):
|
|
||||||
rid = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
|
||||||
role = Role(id=rid, name="x")
|
|
||||||
assert _get_primary_key(role) == rid
|
|
||||||
|
|
||||||
def test_composite_pk_all_set_returns_tuple(self):
|
|
||||||
perm = Permission(subject="posts", action="read")
|
|
||||||
assert _get_primary_key(perm) == ("posts", "read")
|
|
||||||
|
|
||||||
def test_composite_pk_partial_none_returns_none(self):
|
|
||||||
perm = Permission(subject=None, action="read")
|
|
||||||
assert _get_primary_key(perm) is None
|
|
||||||
|
|
||||||
def test_composite_pk_all_none_returns_none(self):
|
|
||||||
perm = Permission(subject=None, action=None)
|
|
||||||
assert _get_primary_key(perm) is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestRelationshipLoadOptions:
|
|
||||||
"""Unit tests for _relationship_load_options — no DB needed."""
|
|
||||||
|
|
||||||
def test_empty_for_model_with_no_relationships(self):
|
|
||||||
assert _relationship_load_options(IntRole) == []
|
|
||||||
|
|
||||||
def test_returns_options_for_model_with_relationships(self):
|
|
||||||
opts = _relationship_load_options(User)
|
|
||||||
assert len(opts) >= 1
|
|
||||||
|
|
||||||
|
|
||||||
class TestFixtureStrategies:
|
|
||||||
"""Integration tests covering INSERT, SKIP_EXISTING, empty fixture, no-rels model."""
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_empty_fixture_returns_empty_list(self, db_session: AsyncSession):
|
|
||||||
"""Fixture function returning [] produces an empty list."""
|
|
||||||
registry = FixtureRegistry()
|
|
||||||
|
|
||||||
@registry.register()
|
|
||||||
def empty() -> list[Role]:
|
|
||||||
return []
|
|
||||||
|
|
||||||
local_ns: dict = {}
|
|
||||||
register_fixtures(registry, local_ns, session_fixture="db_session")
|
|
||||||
inner = local_ns["fixture_empty"].__wrapped__ # type: ignore[attr-defined]
|
|
||||||
result = await inner(db_session=db_session)
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_insert_strategy_no_relationships(self, db_session: AsyncSession):
|
|
||||||
"""INSERT strategy adds instances; model with no rels skips reload (line 135)."""
|
|
||||||
registry = FixtureRegistry()
|
|
||||||
|
|
||||||
@registry.register()
|
|
||||||
def int_roles() -> list[IntRole]:
|
|
||||||
return [IntRole(name="insert_role")]
|
|
||||||
|
|
||||||
local_ns: dict = {}
|
|
||||||
register_fixtures(
|
|
||||||
registry,
|
|
||||||
local_ns,
|
|
||||||
session_fixture="db_session",
|
|
||||||
strategy=LoadStrategy.INSERT,
|
|
||||||
)
|
|
||||||
inner = local_ns["fixture_int_roles"].__wrapped__ # type: ignore[attr-defined]
|
|
||||||
result = await inner(db_session=db_session)
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0].name == "insert_role"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_skip_existing_inserts_new_record(self, db_session: AsyncSession):
|
|
||||||
"""SKIP_EXISTING inserts when the record does not yet exist."""
|
|
||||||
registry = FixtureRegistry()
|
|
||||||
role_id = uuid.uuid4()
|
|
||||||
|
|
||||||
@registry.register()
|
|
||||||
def new_roles() -> list[Role]:
|
|
||||||
return [Role(id=role_id, name="skip_new")]
|
|
||||||
|
|
||||||
local_ns: dict = {}
|
|
||||||
register_fixtures(
|
|
||||||
registry,
|
|
||||||
local_ns,
|
|
||||||
session_fixture="db_session",
|
|
||||||
strategy=LoadStrategy.SKIP_EXISTING,
|
|
||||||
)
|
|
||||||
inner = local_ns["fixture_new_roles"].__wrapped__ # type: ignore[attr-defined]
|
|
||||||
result = await inner(db_session=db_session)
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0].id == role_id
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_skip_existing_returns_existing_record(
|
|
||||||
self, db_session: AsyncSession
|
|
||||||
):
|
|
||||||
"""SKIP_EXISTING returns the existing DB record when PK already present."""
|
|
||||||
role_id = uuid.uuid4()
|
|
||||||
existing = Role(id=role_id, name="already_there")
|
|
||||||
db_session.add(existing)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
registry = FixtureRegistry()
|
|
||||||
|
|
||||||
@registry.register()
|
|
||||||
def dup_roles() -> list[Role]:
|
|
||||||
return [Role(id=role_id, name="should_not_overwrite")]
|
|
||||||
|
|
||||||
local_ns: dict = {}
|
|
||||||
register_fixtures(
|
|
||||||
registry,
|
|
||||||
local_ns,
|
|
||||||
session_fixture="db_session",
|
|
||||||
strategy=LoadStrategy.SKIP_EXISTING,
|
|
||||||
)
|
|
||||||
inner = local_ns["fixture_dup_roles"].__wrapped__ # type: ignore[attr-defined]
|
|
||||||
result = await inner(db_session=db_session)
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0].name == "already_there"
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_skip_existing_null_pk_inserts(self, db_session: AsyncSession):
|
|
||||||
"""SKIP_EXISTING with null PK (auto-increment) falls through to session.add()."""
|
|
||||||
registry = FixtureRegistry()
|
|
||||||
|
|
||||||
@registry.register()
|
|
||||||
def auto_roles() -> list[IntRole]:
|
|
||||||
return [IntRole(name="auto_int")]
|
|
||||||
|
|
||||||
local_ns: dict = {}
|
|
||||||
register_fixtures(
|
|
||||||
registry,
|
|
||||||
local_ns,
|
|
||||||
session_fixture="db_session",
|
|
||||||
strategy=LoadStrategy.SKIP_EXISTING,
|
|
||||||
)
|
|
||||||
inner = local_ns["fixture_auto_roles"].__wrapped__ # type: ignore[attr-defined]
|
|
||||||
result = await inner(db_session=db_session)
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0].name == "auto_int"
|
|
||||||
|
|
||||||
|
|
||||||
class TestReloadWithRelationshipsCompositePK:
|
|
||||||
"""Integration test for _reload_with_relationships composite-PK fallback."""
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_composite_pk_fallback_loads_relationships(self):
|
|
||||||
"""Models with composite PKs are reloaded per-instance via session.get()."""
|
|
||||||
async with create_db_session(DATABASE_URL, _LocalBase) as session:
|
|
||||||
group = _Group(id=uuid.uuid4(), name="g1")
|
|
||||||
session.add(group)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
item = _CompositeItem(group_id=group.id, item_code="A")
|
|
||||||
session.add(item)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
load_opts = _relationship_load_options(_CompositeItem)
|
|
||||||
assert load_opts # _CompositeItem has 'group' relationship
|
|
||||||
|
|
||||||
reloaded = await _reload_with_relationships(session, [item], load_opts)
|
|
||||||
assert len(reloaded) == 1
|
|
||||||
reloaded_item = cast(_CompositeItem, reloaded[0])
|
|
||||||
assert reloaded_item.group is not None
|
|
||||||
assert reloaded_item.group.name == "g1"
|
|
||||||
|
|||||||
+23
-184
@@ -18,7 +18,6 @@ from fastapi_toolsets.security import (
|
|||||||
oauth_decode_state,
|
oauth_decode_state,
|
||||||
oauth_encode_state,
|
oauth_encode_state,
|
||||||
oauth_fetch_userinfo,
|
oauth_fetch_userinfo,
|
||||||
oauth_generate_state_token,
|
|
||||||
oauth_resolve_provider_urls,
|
oauth_resolve_provider_urls,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -761,10 +760,7 @@ class TestCookieAuthSigned:
|
|||||||
"""set_cookie signs the value; the signed cookie is verified on read."""
|
"""set_cookie signs the value; the signed cookie is verified on read."""
|
||||||
from fastapi import Response
|
from fastapi import Response
|
||||||
|
|
||||||
# secure=False for test client which runs over plain HTTP
|
auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET)
|
||||||
auth = CookieAuth(
|
|
||||||
"session", cookie_validator, secret_key=self.SECRET, secure=False
|
|
||||||
)
|
|
||||||
|
|
||||||
def setup(app: FastAPI):
|
def setup(app: FastAPI):
|
||||||
@app.get("/login")
|
@app.get("/login")
|
||||||
@@ -782,26 +778,6 @@ class TestCookieAuthSigned:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {"session": VALID_COOKIE}
|
assert response.json() == {"session": VALID_COOKIE}
|
||||||
|
|
||||||
def test_set_cookie_has_secure_flag_by_default(self):
|
|
||||||
"""set_cookie includes Secure flag when secure=True (the default)."""
|
|
||||||
from starlette.responses import Response as StarletteResponse
|
|
||||||
|
|
||||||
auth = CookieAuth("session", cookie_validator, secret_key=self.SECRET)
|
|
||||||
response = StarletteResponse()
|
|
||||||
auth.set_cookie(response, "value")
|
|
||||||
assert "secure" in response.headers["set-cookie"].lower()
|
|
||||||
|
|
||||||
def test_set_cookie_no_secure_flag_when_disabled(self):
|
|
||||||
"""set_cookie omits Secure flag when secure=False (local dev)."""
|
|
||||||
from starlette.responses import Response as StarletteResponse
|
|
||||||
|
|
||||||
auth = CookieAuth(
|
|
||||||
"session", cookie_validator, secret_key=self.SECRET, secure=False
|
|
||||||
)
|
|
||||||
response = StarletteResponse()
|
|
||||||
auth.set_cookie(response, "value")
|
|
||||||
assert "secure" not in response.headers["set-cookie"].lower()
|
|
||||||
|
|
||||||
def test_tampered_signature_returns_401(self):
|
def test_tampered_signature_returns_401(self):
|
||||||
"""A cookie whose HMAC signature has been modified is rejected."""
|
"""A cookie whose HMAC signature has been modified is rejected."""
|
||||||
import base64 as _b64
|
import base64 as _b64
|
||||||
@@ -1013,64 +989,28 @@ def _make_async_client_mock(get_return=None, post_return=None):
|
|||||||
|
|
||||||
class TestEncodeDecodeOAuthState:
|
class TestEncodeDecodeOAuthState:
|
||||||
def test_encode_returns_base64url_string(self):
|
def test_encode_returns_base64url_string(self):
|
||||||
result = oauth_encode_state("https://example.com/dashboard", "test-state-token")
|
result = oauth_encode_state("https://example.com/dashboard")
|
||||||
assert isinstance(result, str)
|
assert isinstance(result, str)
|
||||||
assert "+" not in result
|
assert "+" not in result
|
||||||
assert "/" not in result
|
assert "/" not in result
|
||||||
|
|
||||||
def test_round_trip(self):
|
def test_round_trip(self):
|
||||||
url = "https://example.com/after-login?next=/home"
|
url = "https://example.com/after-login?next=/home"
|
||||||
state_token = "test-state-token"
|
assert oauth_decode_state(oauth_encode_state(url), fallback="/") == url
|
||||||
assert (
|
|
||||||
oauth_decode_state(
|
|
||||||
oauth_encode_state(url, state_token),
|
|
||||||
expected_state_token=state_token,
|
|
||||||
fallback="/",
|
|
||||||
)
|
|
||||||
== url
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_decode_none_returns_fallback(self):
|
def test_decode_none_returns_fallback(self):
|
||||||
assert (
|
assert oauth_decode_state(None, fallback="/home") == "/home"
|
||||||
oauth_decode_state(None, expected_state_token="any", fallback="/home")
|
|
||||||
== "/home"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_decode_null_string_returns_fallback(self):
|
def test_decode_null_string_returns_fallback(self):
|
||||||
assert (
|
assert oauth_decode_state("null", fallback="/home") == "/home"
|
||||||
oauth_decode_state("null", expected_state_token="any", fallback="/home")
|
|
||||||
== "/home"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_decode_invalid_base64_returns_fallback(self):
|
def test_decode_invalid_base64_returns_fallback(self):
|
||||||
assert (
|
assert oauth_decode_state("!!!notbase64!!!", fallback="/home") == "/home"
|
||||||
oauth_decode_state(
|
|
||||||
"!!!notbase64!!!", expected_state_token="any", fallback="/home"
|
|
||||||
)
|
|
||||||
== "/home"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_decode_handles_missing_padding(self):
|
def test_decode_handles_missing_padding(self):
|
||||||
url = "https://example.com/x"
|
url = "https://example.com/x"
|
||||||
state_token = "test-state-token"
|
encoded = oauth_encode_state(url).rstrip("=")
|
||||||
encoded = oauth_encode_state(url, state_token).rstrip("=")
|
assert oauth_decode_state(encoded, fallback="/") == url
|
||||||
assert (
|
|
||||||
oauth_decode_state(encoded, expected_state_token=state_token, fallback="/")
|
|
||||||
== url
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_decode_wrong_state_token_returns_fallback(self):
|
|
||||||
url = "https://example.com/dashboard"
|
|
||||||
encoded = oauth_encode_state(url, "correct-token")
|
|
||||||
assert (
|
|
||||||
oauth_decode_state(
|
|
||||||
encoded, expected_state_token="wrong-token", fallback="/"
|
|
||||||
)
|
|
||||||
== "/"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_generate_state_token_is_random(self):
|
|
||||||
assert oauth_generate_state_token() != oauth_generate_state_token()
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildAuthorizationRedirect:
|
class TestBuildAuthorizationRedirect:
|
||||||
@@ -1083,19 +1023,16 @@ class TestBuildAuthorizationRedirect:
|
|||||||
scopes="openid email",
|
scopes="openid email",
|
||||||
redirect_uri="https://app.example.com/callback",
|
redirect_uri="https://app.example.com/callback",
|
||||||
destination="https://app.example.com/dashboard",
|
destination="https://app.example.com/dashboard",
|
||||||
state_token="test-state-token",
|
|
||||||
)
|
)
|
||||||
assert isinstance(response, RedirectResponse)
|
assert isinstance(response, RedirectResponse)
|
||||||
|
|
||||||
def test_redirect_location_contains_all_params(self):
|
def test_redirect_location_contains_all_params(self):
|
||||||
state_token = "test-state-token"
|
|
||||||
response = oauth_build_authorization_redirect(
|
response = oauth_build_authorization_redirect(
|
||||||
"https://auth.example.com/authorize",
|
"https://auth.example.com/authorize",
|
||||||
client_id="my-client",
|
client_id="my-client",
|
||||||
scopes="openid email",
|
scopes="openid email",
|
||||||
redirect_uri="https://app.example.com/callback",
|
redirect_uri="https://app.example.com/callback",
|
||||||
destination="https://app.example.com/dashboard",
|
destination="https://app.example.com/dashboard",
|
||||||
state_token=state_token,
|
|
||||||
)
|
)
|
||||||
location = response.headers["location"]
|
location = response.headers["location"]
|
||||||
parsed = urlparse(location)
|
parsed = urlparse(location)
|
||||||
@@ -1109,9 +1046,7 @@ class TestBuildAuthorizationRedirect:
|
|||||||
assert params["scope"] == ["openid email"]
|
assert params["scope"] == ["openid email"]
|
||||||
assert params["redirect_uri"] == ["https://app.example.com/callback"]
|
assert params["redirect_uri"] == ["https://app.example.com/callback"]
|
||||||
assert (
|
assert (
|
||||||
oauth_decode_state(
|
oauth_decode_state(params["state"][0], fallback="")
|
||||||
params["state"][0], expected_state_token=state_token, fallback=""
|
|
||||||
)
|
|
||||||
== "https://app.example.com/dashboard"
|
== "https://app.example.com/dashboard"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1133,11 +1068,11 @@ class TestResolveProviderUrls:
|
|||||||
mock_resp.json.return_value = self._discovery()
|
mock_resp.json.return_value = self._discovery()
|
||||||
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
||||||
|
|
||||||
oauth_resolve_provider_urls.cache_clear()
|
with patch("fastapi_toolsets.security.oauth._discovery_cache", {}):
|
||||||
with patch("httpx.AsyncClient", return_value=cm):
|
with patch("httpx.AsyncClient", return_value=cm):
|
||||||
auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls(
|
auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls(
|
||||||
"https://auth.example.com/.well-known/openid-configuration"
|
"https://auth.example.com/.well-known/openid-configuration"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert auth_url == "https://auth.example.com/authorize"
|
assert auth_url == "https://auth.example.com/authorize"
|
||||||
assert token_url == "https://auth.example.com/token"
|
assert token_url == "https://auth.example.com/token"
|
||||||
@@ -1150,11 +1085,11 @@ class TestResolveProviderUrls:
|
|||||||
mock_resp.json.return_value = self._discovery(userinfo=False)
|
mock_resp.json.return_value = self._discovery(userinfo=False)
|
||||||
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
||||||
|
|
||||||
oauth_resolve_provider_urls.cache_clear()
|
with patch("fastapi_toolsets.security.oauth._discovery_cache", {}):
|
||||||
with patch("httpx.AsyncClient", return_value=cm):
|
with patch("httpx.AsyncClient", return_value=cm):
|
||||||
_, _, userinfo_url = await oauth_resolve_provider_urls(
|
_, _, userinfo_url = await oauth_resolve_provider_urls(
|
||||||
"https://auth.example.com/.well-known/openid-configuration"
|
"https://auth.example.com/.well-known/openid-configuration"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert userinfo_url is None
|
assert userinfo_url is None
|
||||||
|
|
||||||
@@ -1166,10 +1101,10 @@ class TestResolveProviderUrls:
|
|||||||
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
cm, mock_client = _make_async_client_mock(get_return=mock_resp)
|
||||||
|
|
||||||
url = "https://auth.example.com/.well-known/openid-configuration"
|
url = "https://auth.example.com/.well-known/openid-configuration"
|
||||||
oauth_resolve_provider_urls.cache_clear()
|
with patch("fastapi_toolsets.security.oauth._discovery_cache", {}):
|
||||||
with patch("httpx.AsyncClient", return_value=cm):
|
with patch("httpx.AsyncClient", return_value=cm):
|
||||||
await oauth_resolve_provider_urls(url)
|
await oauth_resolve_provider_urls(url)
|
||||||
await oauth_resolve_provider_urls(url)
|
await oauth_resolve_provider_urls(url)
|
||||||
|
|
||||||
assert mock_client.get.call_count == 1
|
assert mock_client.get.call_count == 1
|
||||||
|
|
||||||
@@ -1243,99 +1178,3 @@ class TestFetchUserinfo:
|
|||||||
"https://auth.example.com/userinfo",
|
"https://auth.example.com/userinfo",
|
||||||
headers={"Authorization": "Bearer tok123"},
|
headers={"Authorization": "Bearer tok123"},
|
||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_raises_on_unsupported_token_type(self):
|
|
||||||
token_resp = MagicMock()
|
|
||||||
token_resp.raise_for_status = MagicMock()
|
|
||||||
token_resp.json.return_value = {"access_token": "tok123", "token_type": "mac"}
|
|
||||||
|
|
||||||
cm, _ = _make_async_client_mock(post_return=token_resp, get_return=MagicMock())
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient", return_value=cm):
|
|
||||||
with pytest.raises(ValueError, match="unsupported token_type"):
|
|
||||||
await oauth_fetch_userinfo(
|
|
||||||
token_url="https://auth.example.com/token",
|
|
||||||
userinfo_url="https://auth.example.com/userinfo",
|
|
||||||
code="authcode123",
|
|
||||||
client_id="client-id",
|
|
||||||
client_secret="client-secret",
|
|
||||||
redirect_uri="https://app.example.com/callback",
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_accepts_bearer_token_type_case_insensitive(self):
|
|
||||||
token_resp = MagicMock()
|
|
||||||
token_resp.raise_for_status = MagicMock()
|
|
||||||
token_resp.json.return_value = {
|
|
||||||
"access_token": "tok123",
|
|
||||||
"token_type": "Bearer",
|
|
||||||
}
|
|
||||||
|
|
||||||
userinfo_resp = MagicMock()
|
|
||||||
userinfo_resp.raise_for_status = MagicMock()
|
|
||||||
userinfo_resp.json.return_value = {"sub": "user-1"}
|
|
||||||
|
|
||||||
cm, _ = _make_async_client_mock(
|
|
||||||
post_return=token_resp, get_return=userinfo_resp
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient", return_value=cm):
|
|
||||||
result = await oauth_fetch_userinfo(
|
|
||||||
token_url="https://auth.example.com/token",
|
|
||||||
userinfo_url="https://auth.example.com/userinfo",
|
|
||||||
code="authcode123",
|
|
||||||
client_id="client-id",
|
|
||||||
client_secret="client-secret",
|
|
||||||
redirect_uri="https://app.example.com/callback",
|
|
||||||
)
|
|
||||||
assert result == {"sub": "user-1"}
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_raises_when_required_scopes_not_granted(self):
|
|
||||||
token_resp = MagicMock()
|
|
||||||
token_resp.raise_for_status = MagicMock()
|
|
||||||
token_resp.json.return_value = {"access_token": "tok123", "scope": "openid"}
|
|
||||||
|
|
||||||
cm, _ = _make_async_client_mock(post_return=token_resp, get_return=MagicMock())
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient", return_value=cm):
|
|
||||||
with pytest.raises(ValueError, match="required scopes"):
|
|
||||||
await oauth_fetch_userinfo(
|
|
||||||
token_url="https://auth.example.com/token",
|
|
||||||
userinfo_url="https://auth.example.com/userinfo",
|
|
||||||
code="authcode123",
|
|
||||||
client_id="client-id",
|
|
||||||
client_secret="client-secret",
|
|
||||||
redirect_uri="https://app.example.com/callback",
|
|
||||||
required_scopes="openid email profile",
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_passes_when_all_required_scopes_granted(self):
|
|
||||||
token_resp = MagicMock()
|
|
||||||
token_resp.raise_for_status = MagicMock()
|
|
||||||
token_resp.json.return_value = {
|
|
||||||
"access_token": "tok123",
|
|
||||||
"scope": "openid email profile",
|
|
||||||
}
|
|
||||||
|
|
||||||
userinfo_resp = MagicMock()
|
|
||||||
userinfo_resp.raise_for_status = MagicMock()
|
|
||||||
userinfo_resp.json.return_value = {"sub": "user-1", "email": "a@b.com"}
|
|
||||||
|
|
||||||
cm, _ = _make_async_client_mock(
|
|
||||||
post_return=token_resp, get_return=userinfo_resp
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient", return_value=cm):
|
|
||||||
result = await oauth_fetch_userinfo(
|
|
||||||
token_url="https://auth.example.com/token",
|
|
||||||
userinfo_url="https://auth.example.com/userinfo",
|
|
||||||
code="authcode123",
|
|
||||||
client_id="client-id",
|
|
||||||
client_secret="client-secret",
|
|
||||||
redirect_uri="https://app.example.com/callback",
|
|
||||||
required_scopes="openid email",
|
|
||||||
)
|
|
||||||
assert result["email"] == "a@b.com"
|
|
||||||
|
|||||||
@@ -33,15 +33,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-lru"
|
|
||||||
version = "2.3.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6", size = 16332, upload-time = "2026-03-19T01:04:32.413Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asyncpg"
|
name = "asyncpg"
|
||||||
version = "0.31.0"
|
version = "0.31.0"
|
||||||
@@ -192,101 +183,101 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coverage"
|
name = "coverage"
|
||||||
version = "7.14.0"
|
version = "7.13.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" }
|
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/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480", size = 219795, upload-time = "2026-05-10T17:59:48.198Z" },
|
{ 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/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4", size = 220299, upload-time = "2026-05-10T17:59:49.683Z" },
|
{ 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/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7", size = 250721, upload-time = "2026-05-10T17:59:51.494Z" },
|
{ 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/fd/35/202235eb5c3c14c212462cd91d61b7386bf8fc44bc7a77f4742d2a69174b/coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed", size = 252633, upload-time = "2026-05-10T17:59:53.244Z" },
|
{ 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/bb/80/5f596e8995785124ee191c42535664c5e62c65995b66f4ca21e28ae04c81/coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980", size = 254743, upload-time = "2026-05-10T17:59:55.021Z" },
|
{ 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/1e/6d/0d178825be2350f0adb27984d0aa7cf84bbdab201f6fb926b535d23a8f5f/coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0", size = 256700, upload-time = "2026-05-10T17:59:56.511Z" },
|
{ 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/19/5b/9e549c2f6e9dfea472adadba06c294e64735dabc2dd19015fac082095013/coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742", size = 250854, upload-time = "2026-05-10T17:59:57.94Z" },
|
{ 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/3d/1c/b94f9f5f36396021ee2f62c5834b12e6a3d31f0bed5d6fc6d1c3caec087c/coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5", size = 252433, upload-time = "2026-05-10T17:59:59.688Z" },
|
{ 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/b5/cb/d192cd8e1345eccabc32016f2d39072ecd10cb4f4b983ed8d0ebdeaf00dc/coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327", size = 250494, upload-time = "2026-05-10T18:00:01.953Z" },
|
{ 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/53/c5/aac9f460a41d835dbddef1d377f105f6ac2311d0f3c1588e9f51046d8813/coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d", size = 254261, upload-time = "2026-05-10T18:00:03.779Z" },
|
{ 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/23/aa/7af7c0081980a9cb3d289c5a435a4b7657dcecbd128e25c580e6a50389b5/coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20", size = 250216, upload-time = "2026-05-10T18:00:05.262Z" },
|
{ 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/35/60/a4257538ce2f6b978aeb51870d6c4208c510928a03db7e0339bb625dccb7/coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c", size = 251125, upload-time = "2026-05-10T18:00:06.858Z" },
|
{ 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/a1/ab/f91af47642ec1aa53490e835a95847168d9c77fc39aa58527604c051e145/coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3", size = 222300, upload-time = "2026-05-10T18:00:08.608Z" },
|
{ 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/f0/f0/a71ddbd874431e7a7cd96071f0c331cfbbad07704833c765d24ffbab8a67/coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1", size = 223241, upload-time = "2026-05-10T18:00:10.746Z" },
|
{ 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/d8/6e/d9d312a5151a96cd110efee32efc3fc97b01ebd86203fe618ccb29cf4c92/coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627", size = 221908, upload-time = "2026-05-10T18:00:12.242Z" },
|
{ 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/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" },
|
{ 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/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" },
|
{ 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/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" },
|
{ 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/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" },
|
{ 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/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" },
|
{ 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/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" },
|
{ 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/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" },
|
{ 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/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" },
|
{ 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/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" },
|
{ 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/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" },
|
{ 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/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" },
|
{ 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/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" },
|
{ 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/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" },
|
{ 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/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" },
|
{ 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/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" },
|
{ 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/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" },
|
{ 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/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" },
|
{ 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/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" },
|
{ 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/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" },
|
{ 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/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" },
|
{ 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/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" },
|
{ 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/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" },
|
{ 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/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" },
|
{ 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/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" },
|
{ 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/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" },
|
{ 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/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" },
|
{ 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/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" },
|
{ 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/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" },
|
{ 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/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" },
|
{ 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/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" },
|
{ 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/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" },
|
{ 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/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" },
|
{ 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/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" },
|
{ 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/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" },
|
{ 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/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" },
|
{ 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/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" },
|
{ 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/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" },
|
{ 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/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" },
|
{ 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/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" },
|
{ 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/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" },
|
{ 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/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" },
|
{ 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/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" },
|
{ 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/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" },
|
{ 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/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" },
|
{ 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/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" },
|
{ 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/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" },
|
{ 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/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" },
|
{ 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/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" },
|
{ 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/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" },
|
{ 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/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" },
|
{ 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/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" },
|
{ 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/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" },
|
{ 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/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" },
|
{ 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/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" },
|
{ 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/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" },
|
{ 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/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" },
|
{ 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/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" },
|
{ 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/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" },
|
{ 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/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" },
|
{ 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/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" },
|
{ 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/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" },
|
{ 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/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" },
|
{ 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/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" },
|
{ 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/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" },
|
{ 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/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" },
|
{ 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/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" },
|
{ 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/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" },
|
{ 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/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" },
|
{ 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/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" },
|
{ 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/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" },
|
{ 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/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" },
|
{ 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/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" },
|
{ 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/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" },
|
{ 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/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" },
|
{ 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/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" },
|
{ 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/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" },
|
{ 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]
|
||||||
@@ -314,7 +305,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.136.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" },
|
||||||
@@ -323,14 +314,14 @@ dependencies = [
|
|||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/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/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" },
|
{ url = "https://files.pythonhosted.org/packages/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 = "4.1.0"
|
version = "3.0.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
@@ -341,7 +332,6 @@ dependencies = [
|
|||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
all = [
|
all = [
|
||||||
{ name = "async-lru" },
|
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "prometheus-client" },
|
{ name = "prometheus-client" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
@@ -359,14 +349,9 @@ pytest = [
|
|||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-xdist" },
|
{ name = "pytest-xdist" },
|
||||||
]
|
]
|
||||||
security = [
|
|
||||||
{ name = "async-lru" },
|
|
||||||
{ name = "httpx" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "async-lru" },
|
|
||||||
{ name = "bcrypt" },
|
{ name = "bcrypt" },
|
||||||
{ name = "coverage" },
|
{ name = "coverage" },
|
||||||
{ name = "fastapi-toolsets", extra = ["all"] },
|
{ name = "fastapi-toolsets", extra = ["all"] },
|
||||||
@@ -391,7 +376,6 @@ docs-src = [
|
|||||||
{ name = "bcrypt" },
|
{ name = "bcrypt" },
|
||||||
]
|
]
|
||||||
tests = [
|
tests = [
|
||||||
{ name = "async-lru" },
|
|
||||||
{ name = "coverage" },
|
{ name = "coverage" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
@@ -402,12 +386,10 @@ tests = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "async-lru", marker = "extra == 'security'", specifier = ">=1.0" },
|
|
||||||
{ name = "asyncpg", specifier = ">=0.29.0" },
|
{ name = "asyncpg", specifier = ">=0.29.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.100.0" },
|
{ name = "fastapi", specifier = ">=0.100.0" },
|
||||||
{ name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest", "security"], marker = "extra == 'all'" },
|
{ name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest"], marker = "extra == 'all'" },
|
||||||
{ name = "httpx", marker = "extra == 'pytest'", specifier = ">=0.25.0" },
|
{ name = "httpx", marker = "extra == 'pytest'", specifier = ">=0.25.0" },
|
||||||
{ name = "httpx", marker = "extra == 'security'", specifier = ">=0.25.0" },
|
|
||||||
{ name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" },
|
{ name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.0" },
|
{ name = "pydantic", specifier = ">=2.0" },
|
||||||
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
|
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
|
||||||
@@ -415,11 +397,10 @@ requires-dist = [
|
|||||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
||||||
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" },
|
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["cli", "metrics", "security", "pytest", "all"]
|
provides-extras = ["cli", "metrics", "pytest", "all"]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "async-lru", specifier = ">=1.0" },
|
|
||||||
{ name = "bcrypt", specifier = ">=4.0.0" },
|
{ name = "bcrypt", specifier = ">=4.0.0" },
|
||||||
{ name = "coverage", specifier = ">=7.0.0" },
|
{ name = "coverage", specifier = ">=7.0.0" },
|
||||||
{ name = "fastapi-toolsets", extras = ["all"] },
|
{ name = "fastapi-toolsets", extras = ["all"] },
|
||||||
@@ -442,7 +423,6 @@ docs = [
|
|||||||
]
|
]
|
||||||
docs-src = [{ name = "bcrypt", specifier = ">=4.0.0" }]
|
docs-src = [{ name = "bcrypt", specifier = ">=4.0.0" }]
|
||||||
tests = [
|
tests = [
|
||||||
{ name = "async-lru", specifier = ">=1.0" },
|
|
||||||
{ name = "coverage", specifier = ">=7.0.0" },
|
{ name = "coverage", specifier = ">=7.0.0" },
|
||||||
{ name = "httpx", specifier = ">=0.25.0" },
|
{ name = "httpx", specifier = ">=0.25.0" },
|
||||||
{ name = "pytest", specifier = ">=8.0.0" },
|
{ name = "pytest", specifier = ">=8.0.0" },
|
||||||
@@ -563,11 +543,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.15"
|
version = "3.11"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -836,40 +816,40 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prek"
|
name = "prek"
|
||||||
version = "0.4.1"
|
version = "0.3.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/01/1d2c238c6f226d75881cd7a5532e980f4d524babc3c034d16ad89e88b6e1/prek-0.4.1.tar.gz", hash = "sha256:622a8812bda87cf4ddcae2dab5ccecc55b88d70c677129dbe25e975d923179f0", size = 452606, upload-time = "2026-05-20T04:27:19.259Z" }
|
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 = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/ca/0274343faf2672d649b1e648053d3cb48fdfef7a390b43713d95880ebb67/prek-0.4.1-py3-none-linux_armv6l.whl", hash = "sha256:10e7e78ffe65dfba7d687a8c71b2f473554d1ba60f43c742105da4c0030feed9", size = 5515584, upload-time = "2026-05-20T04:27:29.386Z" },
|
{ 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/37/4e/6a067f530194a6e4141c36463eece92356dfd7f924ffe0cbf456bdca723b/prek-0.4.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b25807e0aa57d2118747e127b58e7a1bf41d5d7b3323f5f3f1f3cb10031245cc", size = 5878925, upload-time = "2026-05-20T04:27:31.71Z" },
|
{ 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/e3/3d/a334c0f5b88fadca888eadfc1fb3d7f1dc8358b1a534d0987339ecb8eb92/prek-0.4.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:efa95331c4c171a867c0064c19d8a4abc94a1c1c920c8b8092f2d7d87f4b90a8", size = 5440994, upload-time = "2026-05-20T04:27:40.578Z" },
|
{ 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/0c/3b/fa6eb635495c3576e65d7f42a48b9fdf4926dd052010df506ed98e9f9680/prek-0.4.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d1805123ab5d730629de588bf319ea39e7078b589b3288c95740f1b4780a1d4", size = 5692369, upload-time = "2026-05-20T04:27:23.184Z" },
|
{ 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/70/cb/9d9078723b3facb40289444332ca82bf38c0e1db3b5a907af461aba12324/prek-0.4.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:051c442b570b53756225410240577bee1aeace6be52955dfacf45a9783223b36", size = 5430031, upload-time = "2026-05-20T04:27:27.475Z" },
|
{ 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/ff/96/2d8cc6b5425215cd0b610f1dcef3f6f0f23db2a2b85f1a6fca43b7e7fe24/prek-0.4.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76663998827a2cbc94f5e209319809655489b5bd1f8e70568a623372e80253f0", size = 5834244, upload-time = "2026-05-20T04:27:44.229Z" },
|
{ 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/59/e0/cce02f3ade48a6d4bffb25e5f0ac28d10928263b0a4f53ecc72954957f4e/prek-0.4.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ab3460641762edf128b1ec8e833ce7e9ae015d1268a894560cb90d3393a7527", size = 6711903, upload-time = "2026-05-20T04:27:34.128Z" },
|
{ 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/c9/2a/ccd581b6222277a2aa095530844d5bb76db4547042f05a9cb649476bf904/prek-0.4.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e69a9c02ead38706a5d2a4ae209dccba08ccb5d0026e1d08e723c66ab964750", size = 6084138, upload-time = "2026-05-20T04:27:46.549Z" },
|
{ 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/0a/b7/6164a7dc6bb4796cfc19445be798302cc7625b62e2bec89ffb4272d7f983/prek-0.4.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:dc744fedf98df8a00a9e3bcd629b163fee5e9f9e22bce66029d9945241586165", size = 5698950, upload-time = "2026-05-20T04:27:36.165Z" },
|
{ 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/96/40/8151d6445a0f41ad60e979db39d8b0c6b074aad919cf5c73233281f0dff1/prek-0.4.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c0877e82c52359d655fe1072b3a5228639184d1d5f03c6803b6530cd6da1ef20", size = 5538662, upload-time = "2026-05-20T04:27:15.045Z" },
|
{ 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/96/d7/1f9892a45bb2dc8a3b4b89eb08f5de1cf745fcd7df9e535463ba4d41cebe/prek-0.4.1-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:60928d1dad45ff3e491d3083a50643cc213aa2d54f1dbd8d702d7193773c020e", size = 5406581, upload-time = "2026-05-20T04:27:21.101Z" },
|
{ 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/9f/b8/94ddac155b502859e4dc7943db99fa7fffecfa3878a2ef11726a8e72fad0/prek-0.4.1-py3-none-musllinux_1_1_i686.whl", hash = "sha256:17ffa9d8dd40791b9b99cafe558c5cc28e78e5be57607b280b15f0dab90264e9", size = 5688880, upload-time = "2026-05-20T04:27:25.27Z" },
|
{ 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/d0/fd/e93d3853d1bdc06b281fff2aaf4106e19610fe5187c67c9ff13195f2df59/prek-0.4.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:cdf4503a240369f66321213d9c4bc6f925014b64ff7121de9e9920c9b9838ce2", size = 6203536, upload-time = "2026-05-20T04:27:42.366Z" },
|
{ 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/14/c7/760969d6bfc77e3eba04f6c3801c81076e96a908a6c277c142a4b0f31f4e/prek-0.4.1-py3-none-win32.whl", hash = "sha256:7c515492ef3585e6bcd7b83f1bb1cb131038abc88ed2c843de1e4c3ceb865b19", size = 5208995, upload-time = "2026-05-20T04:27:38.331Z" },
|
{ 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/89/12/d43daf290a73dbc3e1a3eabb9077e45df661923949bee045de67cbe82524/prek-0.4.1-py3-none-win_amd64.whl", hash = "sha256:8fa707971465d8ad021c907e43691aad7bb98942943e61e294ece5f95d9fbc78", size = 5591734, upload-time = "2026-05-20T04:27:12.744Z" },
|
{ 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/ab/36/2ab7647fe1e84bba2baae7f04de241197eed62683fb3085e164de266d111/prek-0.4.1-py3-none-win_arm64.whl", hash = "sha256:5b4a348537924b20e208cbd87ef58e96ec37d691c5bec2969209c40de0ecf72e", size = 5423147, upload-time = "2026-05-20T04:27:17.023Z" },
|
{ 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.25.0"
|
version = "0.24.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" },
|
{ url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.13.4"
|
version = "2.12.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-types" },
|
{ name = "annotated-types" },
|
||||||
@@ -877,111 +857,106 @@ dependencies = [
|
|||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic-core"
|
name = "pydantic-core"
|
||||||
version = "2.46.4"
|
version = "2.41.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
|
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
|
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
|
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
|
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
|
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
|
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
|
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
|
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
|
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
|
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
|
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
|
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
|
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
|
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
|
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
|
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
|
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
|
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
|
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
|
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
|
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
|
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
|
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
|
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
|
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
|
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
|
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
|
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
|
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
|
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
|
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
|
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
|
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
|
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
|
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
|
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
|
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
|
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
|
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
|
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
|
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
|
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
|
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
|
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
|
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
|
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
|
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -995,15 +970,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pymdown-extensions"
|
name = "pymdown-extensions"
|
||||||
version = "10.21.3"
|
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/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" }
|
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/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" },
|
{ 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]]
|
[[package]]
|
||||||
@@ -1017,7 +992,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.0.3"
|
version = "9.0.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
@@ -1026,9 +1001,9 @@ dependencies = [
|
|||||||
{ name = "pluggy" },
|
{ name = "pluggy" },
|
||||||
{ name = "pygments" },
|
{ name = "pygments" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1165,27 +1140,27 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.13"
|
version = "0.15.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" }
|
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/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" },
|
{ 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/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" },
|
{ 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/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" },
|
{ 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/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" },
|
{ 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/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" },
|
{ 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/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" },
|
{ 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/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" },
|
{ 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/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" },
|
{ 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/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" },
|
{ 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/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" },
|
{ 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/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" },
|
{ 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/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" },
|
{ 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/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" },
|
{ 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/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" },
|
{ 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/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" },
|
{ 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/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" },
|
{ 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/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" },
|
{ 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]]
|
||||||
@@ -1329,32 +1304,31 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ty"
|
name = "ty"
|
||||||
version = "0.0.38"
|
version = "0.0.27"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/33/3b/45be6b37d5060d6917bf7f1f234c00d360fc5f8b7486f8a96af640e25661/ty-0.0.38.tar.gz", hash = "sha256:fbc8d47f7630457669ab41e333dc093897fdb7ead1ffc94dcf8f30b5d39aa56d", size = 5681218, upload-time = "2026-05-20T00:15:32.781Z" }
|
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/54/43/ea9b4e57d6a266670dbe34858e92f6093ca054ad1b48f1c82580a72340fb/ty-0.0.38-py3-none-linux_armv6l.whl", hash = "sha256:3501dcf44ca03f813f9cb4fabfdf601adc0ac1337c411405b470530679e37a45", size = 11289326, upload-time = "2026-05-20T00:14:52.371Z" },
|
{ 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/5d/ff/24e2f623a1c6b5f5ccf8bf82fccd937033c6a7dba57a4028c7f41270fa4a/ty-0.0.38-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b34b4094b76252c3e8c90762cdd5e8a9f1101534484745ff4b480f71eb38ac2e", size = 11063047, upload-time = "2026-05-20T00:14:42.832Z" },
|
{ 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/e9/41/4f0d910f0acbd20b358eda80a5cd6a8361d27ff5b8e87ab559d3f69f125e/ty-0.0.38-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c518ad33a877677365baab2e21d82cf59ffee789203a15a143f5179ee5a1d3f8", size = 10494436, upload-time = "2026-05-20T00:15:24.425Z" },
|
{ 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/69/d8/da06833422082aa98b169a391f9197e2d73865e96c90b6979ac886b890a2/ty-0.0.38-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9238494722303eccddc6a27eb647948b694eecd6b974910d13b9e6cd46bbeb6a", size = 11000992, upload-time = "2026-05-20T00:14:58.368Z" },
|
{ 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/16/f7/e1172197fb827e6410ca3eb0dc68ef2789f3c70683696f2a0ce5c90764fd/ty-0.0.38-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31d91d7336c5d51bf822ac0df512f300584ca4dcca041fc6a6d7df03a8ddbb31", size = 11058583, upload-time = "2026-05-20T00:15:11.314Z" },
|
{ 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/5b/61/7fbaf0c05981e006a8804287819c574dff90a6bf8e96efad7226be0700aa/ty-0.0.38-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65165879814993450710b9349791e4898c65e36b1e14eec554884c06a2f20ff1", size = 11531036, upload-time = "2026-05-20T00:15:14.62Z" },
|
{ 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/49/e3/47c0c64e401d50f925df3e52479d4e7626754b2a9e38201d142fdacd6252/ty-0.0.38-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d61868b8d1c4033bf8088191de953fed245c2f9e1bb9d2d53e5699170b0924c", size = 12129991, upload-time = "2026-05-20T00:14:39.475Z" },
|
{ 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/90/99/2f452d02901bcd7f1b109cf5b848727ce37f372c3406143aa52d1305d40e/ty-0.0.38-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8f9a9175548c98dbff7707865738c07c2b1f8e07a09b8c68101baebb5dac59a4", size = 11756167, upload-time = "2026-05-20T00:15:27.526Z" },
|
{ 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/dd/0c/c7e14d111c813e1a20b82e944f1c997c4631a2bb710eaa64fb6b26835e13/ty-0.0.38-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:375d3a964c6b4aea2e9237fdb5eb9ed03dc43088986a94209a28a4ea3b62001c", size = 11637099, upload-time = "2026-05-20T00:15:21.261Z" },
|
{ 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/37/de/ab02659dd1ed62898db7db4d37f9937c80854dd45e95093fa0fe10328d82/ty-0.0.38-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:cdfd547782c45267aa0b52abad31bd406bf4768c264532ef9e2360cd3c6ce048", size = 11813583, upload-time = "2026-05-20T00:14:45.875Z" },
|
{ 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/7e/57/bd1b5ebf4e71a4295484afac0202df1740b0807762b86744b1bef4534984/ty-0.0.38-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:858bc675b75626470abe4e6c3b3934b853642b04f2ac4d7139fcefea3b48b213", size = 10975405, upload-time = "2026-05-20T00:15:30.354Z" },
|
{ 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/e7/55/0305c78711bbd23922cf291996a08ef9544f4179da98e9a75c14e608f379/ty-0.0.38-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:54be4f00432870da42cd74fe145a3362fd248e22d032c74bd807cb45bf068f94", size = 11097551, upload-time = "2026-05-20T00:14:55.179Z" },
|
{ 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/7c/4f/7effe7f9a6ac9719eb7234172c01739c5f888bb47f9acc2ea8da1f4afed3/ty-0.0.38-py3-none-musllinux_1_2_i686.whl", hash = "sha256:494af66a76a86dbf16a3003d3b63b03484aa4c7489dfe11f3ee5413b98b22d60", size = 11214391, upload-time = "2026-05-20T00:15:18.094Z" },
|
{ 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/75/cd/d9fdfec3a74a6ad0209fa5e7113ae29d4f457d0651cfbb813b4c6563e0d4/ty-0.0.38-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3d92527c4be78a5ce6d32e8bb0aa2a6988d4076eddf1294e56fdaf06d1a98e7e", size = 11730871, upload-time = "2026-05-20T00:14:49.219Z" },
|
{ 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/0e/4a/beefade12d109b4f7793d61b04b4478b1ad4d1465a719e7ff55b2d42461a/ty-0.0.38-py3-none-win32.whl", hash = "sha256:36fc5dd5dc09207ff3004b1560a79a3fb8d12456daeec914a7b802a918da654c", size = 10548583, upload-time = "2026-05-20T00:15:07.892Z" },
|
{ 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/15/64/941b205e2e46cc2297c245c64aa7691410b7454fa4d07a6cb3cf59487833/ty-0.0.38-py3-none-win_amd64.whl", hash = "sha256:eef0a8956ba14514076b1a963d13eb32986d9ebad7f0527b3cc01cb68bf35147", size = 11650542, upload-time = "2026-05-20T00:15:01.441Z" },
|
{ 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" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/02/c1c4f9ec4b94d95190636fa13f79c32f65165fbe3a0503882d4df164d2ac/ty-0.0.38-py3-none-win_arm64.whl", hash = "sha256:79abfc8658a026c30b1c955613437dab3ef4b12feca56a3e6df50903cc39e07f", size = 11010307, upload-time = "2026-05-20T00:15:04.567Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typer"
|
name = "typer"
|
||||||
version = "0.25.1"
|
version = "0.24.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-doc" },
|
{ name = "annotated-doc" },
|
||||||
@@ -1362,9 +1336,9 @@ dependencies = [
|
|||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "shellingham" },
|
{ name = "shellingham" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" },
|
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1426,30 +1400,28 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zensical"
|
name = "zensical"
|
||||||
version = "0.0.41"
|
version = "0.0.31"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
{ name = "deepmerge" },
|
{ name = "deepmerge" },
|
||||||
{ name = "jinja2" },
|
|
||||||
{ name = "markdown" },
|
{ name = "markdown" },
|
||||||
{ name = "pygments" },
|
{ name = "pygments" },
|
||||||
{ name = "pymdown-extensions" },
|
{ name = "pymdown-extensions" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "tomli" },
|
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/89/d6/b3e931233e53a2377ef5915cc6e786845c3263306874a469af8fb569ef9c/zensical-0.0.41.tar.gz", hash = "sha256:6c3c90301123749dfc26a210d6c080f0691253c7c765ad308a10b4518369a6fe", size = 3927788, upload-time = "2026-05-09T14:35:29.005Z" }
|
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/72/08/ee18207c9b4e3ada74a0f4adf253bea90da39ae43772761cd91072e3a1fc/zensical-0.0.41-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f06a0015dcfdf7aeca73f4998a401db65db0ae2dd72da9629a7be8f9a4d0b7b6", size = 12701539, upload-time = "2026-05-09T14:34:48.6Z" },
|
{ 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/4c/93/d4635fbbce8171cf71dd64285d9f6d5773a2b624b928f1dd8acaf1ee9f9f/zensical-0.0.41-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:4e524ce68c9ff082ffaded9f742407097cf51bab692b7bc18d3c174b966174fe", size = 12560038, upload-time = "2026-05-09T14:34:51.666Z" },
|
{ 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/f2/4a/1730a30377bbb0914ed740e0e289d379b0552673b6cf912aefe7a205440c/zensical-0.0.41-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4afe35331cd2394c408cd362458936479cc0ed4fb272478498e4794aafc7414", size = 12942926, upload-time = "2026-05-09T14:34:54.393Z" },
|
{ 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/32/e3/d9a0416ef4edc043ce9f404a66f1934f102bcb645b103abb26b180ba5680/zensical-0.0.41-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:15a850285050f03aeb3b67ce7d99943093059fe8d32fc7731fa9f27be45c64cc", size = 12912711, upload-time = "2026-05-09T14:34:57.174Z" },
|
{ 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/68/d0/775852783bef835425306a2fcd8236ef14fd19160e1b4261e192bf2d9f54/zensical-0.0.41-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:35052e9dbefabe3a71c4836cfc4afa6c9469e5eeddc2a3ee750803ae3fe777dc", size = 13275869, upload-time = "2026-05-09T14:34:59.93Z" },
|
{ 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/c3/95/554273cc09a270ced0213d3e0aac8b3fc2b472fc2b26771d56fc8fd55047/zensical-0.0.41-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a47f459205fb55f64dcb6c65e9f3c2fa00a2b4306c5ef1b71b9a50c45007071d", size = 12980177, upload-time = "2026-05-09T14:35:02.81Z" },
|
{ 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/ec/b5/d74d5040b3121db5c72b0134f0455641b90b1277fb1330a8e5e0029ca8d3/zensical-0.0.41-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:aa3b3b3a4e6f75f6bb3c1aca1fad7a96cebf54cbd4e31122f6876503b8801666", size = 13119629, upload-time = "2026-05-09T14:35:07.105Z" },
|
{ 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/62/9a/93527acd7750092d7fca2e6c43fe2b8f1e85e1c96a1002baf6a08201c6f7/zensical-0.0.41-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:565133fd48b2ce939698c174c0c1c6470407a8fb6a90a2bb0eeec97cd4344444", size = 13182183, upload-time = "2026-05-09T14:35:10.105Z" },
|
{ 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/b2/7e/d77e4c809bfcbad40db85a6a7beeda2ee5c964232e0186783c3a837a7d0b/zensical-0.0.41-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:cec0a2b05eaaace0c7424bab3f2884da03ade212cac4ba4487c58691ec13ec65", size = 13330444, upload-time = "2026-05-09T14:35:13.245Z" },
|
{ 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/fd/e8/ecbb7e34bff88aa892c676b8b2e2ddf425f94d66cbb84b80016095191b77/zensical-0.0.41-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1736f0cb7686628cc6f53952d208423f20b542f0c16b0c2ddd7e702bf6e41fdd", size = 13263093, upload-time = "2026-05-09T14:35:20.826Z" },
|
{ 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/c1/6f/48b2f81ce708d19bb807d94716f2772ec4b74389b6d29024669fc470df08/zensical-0.0.41-cp310-abi3-win32.whl", hash = "sha256:34a78645c68fba152faacb66516c895283166154f8b15b61440a6c21c84f0974", size = 12253644, upload-time = "2026-05-09T14:35:23.598Z" },
|
{ 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/a0/92/5cf943133f61b996965743deeaff467f278135521f58d83ca68d2601ded3/zensical-0.0.41-cp310-abi3-win_amd64.whl", hash = "sha256:00d80cd573152e0efb655143bbdfe8788eb4b33167a802639fdb1b1800b724ac", size = 12483190, upload-time = "2026-05-09T14:35:26.43Z" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -121,7 +121,6 @@ Modules = [
|
|||||||
{Models = "module/models.md"},
|
{Models = "module/models.md"},
|
||||||
{Pytest = "module/pytest.md"},
|
{Pytest = "module/pytest.md"},
|
||||||
{Schemas = "module/schemas.md"},
|
{Schemas = "module/schemas.md"},
|
||||||
{Security = "module/security.md"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[project.nav]]
|
[[project.nav]]
|
||||||
@@ -137,7 +136,6 @@ Reference = [
|
|||||||
{Models = "reference/models.md"},
|
{Models = "reference/models.md"},
|
||||||
{Pytest = "reference/pytest.md"},
|
{Pytest = "reference/pytest.md"},
|
||||||
{Schemas = "reference/schemas.md"},
|
{Schemas = "reference/schemas.md"},
|
||||||
{Security = "reference/security.md"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[project.nav]]
|
[[project.nav]]
|
||||||
@@ -147,7 +145,6 @@ Examples = [
|
|||||||
|
|
||||||
[[project.nav]]
|
[[project.nav]]
|
||||||
Migration = [
|
Migration = [
|
||||||
{"v4.0" = "migration/v4.md"},
|
|
||||||
{"v3.0" = "migration/v3.md"},
|
{"v3.0" = "migration/v3.md"},
|
||||||
{"v2.0" = "migration/v2.md"},
|
{"v2.0" = "migration/v2.md"},
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user