mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-05 08:04:08 +00:00
Compare commits
111
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59fcbdae50 | ||
|
|
88ecfa8486
|
||
|
|
b4a7335e5e | ||
|
|
97828cc156 | ||
|
|
e7540c6e27 | ||
|
|
7779dcbadd | ||
|
|
ea354813d1 | ||
|
|
fd83a7142d | ||
|
|
a407677be0 | ||
|
|
ea5b5695b5 | ||
|
|
651326d54b | ||
|
|
169bf710f0 | ||
|
|
adc0ff14c1 | ||
|
|
19a823dc8b | ||
|
|
c15aee22b0 | ||
|
|
7da08ffd9a | ||
|
|
51acf61d0f | ||
|
|
436caa3141 | ||
|
|
a0d51e043d | ||
|
|
507f3d99d1 | ||
|
|
a49d256f17 | ||
|
|
03433f53b1 | ||
|
|
3eed5f01d4 | ||
|
|
61dc4cf9b0 | ||
|
|
9eb7d006db | ||
|
|
31f8ad96f7 | ||
|
|
953d58e5cc | ||
|
|
498e75c624 | ||
|
|
541bfeb1a4 | ||
|
|
c16909f585 | ||
|
|
c256e58d64 | ||
|
|
040c023b8a | ||
|
|
06d49d5dc4
|
||
|
|
de06839a16 | ||
|
|
ff367e4281 | ||
|
|
9cb4c1474f | ||
|
|
44ba5bdd4b | ||
|
|
fe2c0f3eff | ||
|
|
70e0b3b9d5 | ||
|
|
1e021005bc | ||
|
|
025f1907fd
|
||
|
|
9698a0743b | ||
|
|
22f307d0fc | ||
|
|
2641881df5
|
||
|
|
49b579bcec | ||
|
|
4bb4287922 | ||
|
|
43445e931e
|
||
|
|
27a36b0c82 | ||
|
|
d81adde685 | ||
|
|
c96779f10c | ||
|
|
880009dd9a | ||
|
|
3e2518b803 | ||
|
|
98328d4e20 | ||
|
|
b0c35bfc8a
|
||
|
|
cd928688af | ||
|
|
3ea8a612e5 | ||
|
|
3c5e7b361f | ||
|
|
f25d3ba536 | ||
|
|
9f3af9d4c4 | ||
|
|
f5f94744be | ||
|
|
f80dae285a
|
||
|
|
7f7734f0f3 | ||
|
|
0356cffeb4 | ||
|
|
b6796180f7 | ||
|
|
72b8236645 | ||
|
|
0cc31848b1 | ||
|
|
75758326bf | ||
|
|
2a427a2946 | ||
|
|
f57c9e40b9 | ||
|
|
7faf252c23 | ||
|
|
d4ce652c1c | ||
|
|
38982d43f8 | ||
|
|
189e9e1a13 | ||
|
|
99eabe3b23 | ||
|
|
bfd0207991 | ||
|
|
b9aa094dda | ||
|
|
6bdc537558 | ||
|
|
2ad20ebe3c | ||
|
|
929e9cc0fd | ||
|
|
1a67da91e6 | ||
|
|
596991f0f8 | ||
|
|
155a208b8e | ||
|
|
713f66f48c | ||
|
|
6b246b93b0 | ||
|
|
0945fadfb8 | ||
|
|
639e466794 | ||
|
|
732f0194eb | ||
|
|
e368318ead | ||
|
|
2115036a8d
|
||
|
|
dcdaab52cf | ||
|
|
298177e821 | ||
|
|
0499299378 | ||
|
|
a4fa53dd6f | ||
|
|
b7f75b2d30 | ||
|
|
860b415665 | ||
|
|
94e7d79d06
|
||
|
|
9268b576b4 | ||
|
|
863e6ce6e9 | ||
|
|
c7397faea4 | ||
|
|
aca3f62a6b | ||
|
|
8030e1988c | ||
|
|
e2c2c1c835 | ||
|
|
025b954d01 | ||
|
|
0ed93d62c8
|
||
|
|
2a49814818 | ||
|
|
f8e090c7c3 | ||
|
|
54decaf3e1 | ||
|
|
6b127d9645
|
||
|
|
8bed96f4bf | ||
|
|
74d15e13bc | ||
|
|
e38d8d2d4f |
@@ -11,7 +11,7 @@ jobs:
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -15,7 +18,7 @@ jobs:
|
||||
name: Lint (Ruff)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
@@ -36,7 +39,7 @@ jobs:
|
||||
name: Type Check (ty)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
@@ -74,7 +77,7 @@ jobs:
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
@@ -93,7 +96,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: matrix.python-version == '3.14'
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
report_type: coverage
|
||||
@@ -102,7 +105,7 @@ jobs:
|
||||
|
||||
- name: Upload test results to Codecov
|
||||
if: matrix.python-version == '3.14'
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
report_type: test_results
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ Pass `next_cursor` as the `cursor` query parameter on the next request to advanc
|
||||
|
||||
!!! info "Added in `v2.3.0`"
|
||||
|
||||
[`paginate()`](../module/crud.md#unified-paginate--both-strategies-on-one-endpoint) lets a single endpoint support both strategies via a `pagination_type` query parameter. The `pagination_type` field in the response acts as a discriminator for frontend tooling.
|
||||
[`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.
|
||||
|
||||
```python title="routes.py:61:79"
|
||||
--8<-- "docs_src/examples/pagination_search/routes.py:61:79"
|
||||
|
||||
+10
-3
@@ -85,13 +85,17 @@ The `paginate` shorthand was an alias for `offset_paginate`. It has been removed
|
||||
=== "Before (`v1`)"
|
||||
|
||||
```python
|
||||
result = await UserCrud.paginate(session=session, page=2, items_per_page=20, schema=UserRead)
|
||||
result = await UserCrud.paginate(
|
||||
session=session, page=2, items_per_page=20, schema=UserRead
|
||||
)
|
||||
```
|
||||
|
||||
=== "Now (`v2`)"
|
||||
|
||||
```python
|
||||
result = await UserCrud.offset_paginate(session=session, page=2, items_per_page=20, schema=UserRead)
|
||||
result = await UserCrud.offset_paginate(
|
||||
session=session, page=2, items_per_page=20, schema=UserRead
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -124,8 +128,11 @@ For shared base classes that are not meant to be raised directly, use `abstract=
|
||||
class BillingError(ApiException, abstract=True):
|
||||
"""Base for all billing-related errors — not raised directly."""
|
||||
|
||||
|
||||
class PaymentRequiredError(BillingError):
|
||||
api_error = ApiError(code=402, msg="Payment Required", desc="...", err_code="BILLING-402")
|
||||
api_error = ApiError(
|
||||
code=402, msg="Payment Required", desc="...", err_code="BILLING-402"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+13
-2
@@ -45,12 +45,16 @@ Each new method accepts `search`, `filter`, and `order` boolean toggles (all `Tr
|
||||
```python
|
||||
from fastapi_toolsets.crud import OrderByClause
|
||||
|
||||
|
||||
@router.get("/offset")
|
||||
async def list_articles_offset(
|
||||
session: SessionDep,
|
||||
params: Annotated[dict, Depends(ArticleCrud.offset_params(default_page_size=20))],
|
||||
filter_by: Annotated[dict, Depends(ArticleCrud.filter_params())],
|
||||
order_by: Annotated[OrderByClause | None, Depends(ArticleCrud.order_params(default_field=Article.created_at))],
|
||||
order_by: Annotated[
|
||||
OrderByClause | None,
|
||||
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
|
||||
],
|
||||
search: str | None = None,
|
||||
) -> OffsetPaginatedResponse[ArticleRead]:
|
||||
return await ArticleCrud.offset_paginate(
|
||||
@@ -79,7 +83,9 @@ Each new method accepts `search`, `filter`, and `order` boolean toggles (all `Tr
|
||||
),
|
||||
],
|
||||
) -> OffsetPaginatedResponse[ArticleRead]:
|
||||
return await ArticleCrud.offset_paginate(session=session, **params, schema=ArticleRead)
|
||||
return await ArticleCrud.offset_paginate(
|
||||
session=session, **params, schema=ArticleRead
|
||||
)
|
||||
```
|
||||
|
||||
The same pattern applies to `cursor_paginate_params()` and `paginate_params()`. To disable a feature, pass the toggle:
|
||||
@@ -109,6 +115,7 @@ Model method callbacks (`on_create`, `on_delete`, `on_update`) and the `@watch`
|
||||
```python
|
||||
from fastapi_toolsets.models import WatchedFieldsMixin, watch
|
||||
|
||||
|
||||
@watch("status")
|
||||
class Order(Base, UUIDMixin, WatchedFieldsMixin):
|
||||
__tablename__ = "orders"
|
||||
@@ -131,21 +138,25 @@ Model method callbacks (`on_create`, `on_delete`, `on_update`) and the `@watch`
|
||||
```python
|
||||
from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for
|
||||
|
||||
|
||||
class Order(Base, UUIDMixin):
|
||||
__tablename__ = "orders"
|
||||
__watched_fields__ = ("status",)
|
||||
|
||||
status: Mapped[str]
|
||||
|
||||
|
||||
@listens_for(Order, [ModelEvent.CREATE])
|
||||
async def on_order_created(order: Order, event_type: ModelEvent, changes: None):
|
||||
await notify_new_order(order.id)
|
||||
|
||||
|
||||
@listens_for(Order, [ModelEvent.UPDATE])
|
||||
async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict):
|
||||
if "status" in changes:
|
||||
await notify_status_change(order.id, changes["status"])
|
||||
|
||||
|
||||
@listens_for(Order, [ModelEvent.DELETE])
|
||||
async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None):
|
||||
await notify_order_cancelled(order.id)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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=session, tables=[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=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE
|
||||
) as session:
|
||||
await process_order(session, order_id)
|
||||
```
|
||||
@@ -0,0 +1,166 @@
|
||||
# Migrating to v5.0
|
||||
|
||||
This page covers every breaking change introduced in **v5.0** and the steps required to update your code.
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
`db.py` is now the `db/` package, built around one object, [`Database`](../reference/db.md#fastapi_toolsets.db.Database), that owns the engine and sessionmaker. The free functions that took a `session_maker` you built and passed around yourself are gone from request-handling code; `Database` builds the sessionmaker for you.
|
||||
|
||||
### `create_db_dependency` / `create_db_context` removed in favor of `Database`
|
||||
|
||||
Build one `Database` with your URL (or an existing `engine=`), then use the instance directly as the FastAPI dependency, and `db.session()` for sessions outside request handlers.
|
||||
|
||||
=== "Before (`v4`)"
|
||||
|
||||
```python
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
from fastapi_toolsets.db import create_db_dependency, create_db_context
|
||||
|
||||
engine = create_async_engine("postgresql+asyncpg://...")
|
||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
get_db = create_db_dependency(session_maker=SessionLocal)
|
||||
get_db_context = create_db_context(session_maker=SessionLocal)
|
||||
|
||||
|
||||
@app.get("/users")
|
||||
async def list_users(session: AsyncSession = Depends(get_db)): ...
|
||||
|
||||
|
||||
async def seed():
|
||||
async with get_db_context() as session:
|
||||
...
|
||||
```
|
||||
|
||||
=== "Now (`v5`)"
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import Database
|
||||
|
||||
db = Database(url="postgresql+asyncpg://...")
|
||||
|
||||
|
||||
@app.get("/users")
|
||||
async def list_users(session: AsyncSession = Depends(db)): ...
|
||||
|
||||
|
||||
async def seed():
|
||||
async with db.session() as session:
|
||||
...
|
||||
```
|
||||
|
||||
Call `db.install(app)` to also commit before the response is sent (instead of in dependency teardown) and to dispose the engine on shutdown. See [the db module docs](../module/db.md#committing-before-the-response).
|
||||
|
||||
### `get_transaction` renamed to `transaction`
|
||||
|
||||
Same behavior (savepoint when already in a transaction, new transaction otherwise), new name, same import path.
|
||||
|
||||
=== "Before (`v4`)"
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import get_transaction
|
||||
|
||||
async with get_transaction(session=session):
|
||||
session.add(model)
|
||||
```
|
||||
|
||||
=== "Now (`v5`)"
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import transaction
|
||||
|
||||
async with transaction(session=session):
|
||||
session.add(model)
|
||||
```
|
||||
|
||||
If you have a `Database` instance, `db.begin()` opens a session already inside a transaction:
|
||||
|
||||
```python
|
||||
async with db.begin() as session:
|
||||
session.add(User(name="ada"))
|
||||
```
|
||||
|
||||
### `lock_tables` is now also a `Database` method
|
||||
|
||||
The free `lock_tables(session_maker, tables, ...)` function still exists for callers who manage their own session factory, but prefer `db.lock_tables(tables, ...)`, which drops the `session_maker` argument:
|
||||
|
||||
=== "Before (`v4`)"
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import lock_tables, LockMode
|
||||
|
||||
async with lock_tables(
|
||||
session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE
|
||||
) as session:
|
||||
await process_order(session, order_id)
|
||||
```
|
||||
|
||||
=== "Now (`v5`)"
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import LockMode
|
||||
|
||||
async with db.lock_tables(tables=[Order], mode=LockMode.EXCLUSIVE) as session:
|
||||
await process_order(session, order_id)
|
||||
```
|
||||
|
||||
### `create_database` and `cleanup_tables` moved to `fastapi_toolsets.db.testing`
|
||||
|
||||
=== "Before (`v4`)"
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import create_database, cleanup_tables
|
||||
```
|
||||
|
||||
=== "Now (`v5`)"
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db.testing import create_database, cleanup_tables
|
||||
```
|
||||
|
||||
## Fixtures
|
||||
|
||||
### `get_obj_by_attr` / `get_field_by_attr` are now `FixtureRegistry` methods
|
||||
|
||||
Both also change their first argument: instead of the fixture *function*, pass the fixture's *registered name* and let the registry look it up.
|
||||
|
||||
=== "Before (`v4`)"
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.fixtures import get_obj_by_attr, get_field_by_attr
|
||||
|
||||
|
||||
@fixtures.register(depends_on=["roles"])
|
||||
def users():
|
||||
admin_role = get_obj_by_attr(fixtures=roles, attr_name="name", value="admin")
|
||||
admin_role_id = get_field_by_attr(fixtures=roles, attr_name="name", value="admin")
|
||||
return [User(id=1, username="alice", role_id=admin_role.id)]
|
||||
```
|
||||
|
||||
=== "Now (`v5`)"
|
||||
|
||||
```python
|
||||
@fixtures.register(depends_on=["roles"])
|
||||
def users():
|
||||
admin_role = fixtures.obj(name="roles", attr_name="name", value="admin")
|
||||
admin_role_id = fixtures.field(name="roles", attr_name="name", value="admin")
|
||||
return [User(id=1, username="alice", role_id=admin_role.id)]
|
||||
```
|
||||
|
||||
### Loading/listing by context now always includes `Context.BASE`
|
||||
|
||||
`FixtureRegistry.get_variants`/`get_by_context`, `load_fixtures_by_context`, and the `fixtures list`/`fixtures load` CLI commands now implicitly union every queried context with `Context.BASE`. In `v4`, requesting a single context (e.g. `"testing"`) returned only fixtures tagged with that context; in `v5` it also returns every `Context.BASE`-tagged fixture.
|
||||
|
||||
If you relied on strict exclusion of base fixtures, move them out of `Context.BASE` into their own context.
|
||||
|
||||
### `load_fixtures` / `load_fixtures_by_context` now return reloaded instances
|
||||
|
||||
Loaded objects are re-queried from the database after insert, with all relationships eager-loaded, instead of returning the exact objects your fixture function constructed. Code that compares returned objects by identity to the fixture function's output, or that expected relationships to remain unloaded, should be updated — and expect one extra query per model type per load.
|
||||
|
||||
## Security
|
||||
|
||||
The security module has been removed and moved to a dedicated python package: [`fastapi-multiauth`](https://github.com/d3vyce/fastapi-multiauth).
|
||||
|
||||
Run `uv add fastapi-multiauth` and replace `from fastapi_toolsets.security import ...` with `from fastapi_multiauth import ...`.
|
||||
+17
-2
@@ -24,7 +24,7 @@ Configure the CLI in your `pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[tool.fastapi-toolsets]
|
||||
cli = "myapp.cli:cli" # Custom Typer app
|
||||
custom_cli = "myapp.cli:cli" # Custom Typer app
|
||||
fixtures = "myapp.fixtures:registry" # FixtureRegistry instance
|
||||
db_context = "myapp.db:db_context" # Async context manager for sessions
|
||||
```
|
||||
@@ -68,6 +68,20 @@ manager fixtures --help
|
||||
╰──────────────────────────────────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
### `fixtures load`
|
||||
|
||||
```bash
|
||||
manager fixtures load [CONTEXTS]... [--strategy merge|insert|skip_existing] [--dry-run]
|
||||
```
|
||||
|
||||
`CONTEXTS` defaults to `Context.BASE` when omitted, and can also be set via the `FIXTURES_CONTEXT` environment variable, handy for CI/deploy scripts that shouldn't need an explicit argument per environment:
|
||||
|
||||
```bash
|
||||
FIXTURES_CONTEXT=testing manager fixtures load
|
||||
```
|
||||
|
||||
An explicit CLI argument always takes precedence over the environment variable.
|
||||
|
||||
## Custom CLI
|
||||
|
||||
You can extend the CLI by providing your own Typer app. The `manager` entry point will merge your app's commands with the built-in ones:
|
||||
@@ -78,6 +92,7 @@ import typer
|
||||
|
||||
cli = typer.Typer()
|
||||
|
||||
|
||||
@cli.command()
|
||||
def hello():
|
||||
print("Hello from my app!")
|
||||
@@ -85,7 +100,7 @@ def hello():
|
||||
|
||||
```toml
|
||||
[tool.fastapi-toolsets]
|
||||
cli = "myapp.cli:cli"
|
||||
custom_cli = "myapp.cli:cli"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+179
-51
@@ -30,6 +30,7 @@ UserCrud = CrudFactory(model=User)
|
||||
from fastapi_toolsets.crud.factory import AsyncCrud
|
||||
from myapp.models import User
|
||||
|
||||
|
||||
class UserCrud(AsyncCrud[User]):
|
||||
model = User
|
||||
searchable_fields = [User.username, User.email]
|
||||
@@ -61,6 +62,7 @@ from fastapi_toolsets.crud.factory import AsyncCrud
|
||||
|
||||
T = TypeVar("T", bound=DeclarativeBase)
|
||||
|
||||
|
||||
class AuditedCrud(AsyncCrud[T], Generic[T]):
|
||||
"""Base CRUD with custom function"""
|
||||
|
||||
@@ -101,7 +103,9 @@ user = await UserCrud.first(session=session, filters=[User.email == email])
|
||||
users = await UserCrud.get_multi(session=session, filters=[User.is_active == True])
|
||||
|
||||
# Update
|
||||
user = await UserCrud.update(session=session, obj=UserUpdateSchema(username="bob"), filters=[User.id == user_id])
|
||||
user = await UserCrud.update(
|
||||
session=session, obj=UserUpdateSchema(username="bob"), filters=[User.id == user_id]
|
||||
)
|
||||
|
||||
# Delete
|
||||
await UserCrud.delete(session=session, filters=[User.id == user_id])
|
||||
@@ -141,6 +145,41 @@ 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])
|
||||
```
|
||||
|
||||
## 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 `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
|
||||
|
||||
!!! info "Added in `v1.1` (only offset_pagination via `paginate` if `<v1.1`)"
|
||||
@@ -162,6 +201,7 @@ Three pagination methods are available. All return a typed response whose `pagi
|
||||
from typing import Annotated
|
||||
from fastapi import Depends
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_users(
|
||||
session: SessionDep,
|
||||
@@ -197,7 +237,9 @@ By default `offset_paginate` runs two queries: one for the page items and one `C
|
||||
@router.get("")
|
||||
async def get_users(
|
||||
session: SessionDep,
|
||||
params: Annotated[dict, Depends(UserCrud.offset_paginate_params(include_total=False))],
|
||||
params: Annotated[
|
||||
dict, Depends(UserCrud.offset_paginate_params(include_total=False))
|
||||
],
|
||||
) -> OffsetPaginatedResponse[UserRead]:
|
||||
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||
```
|
||||
@@ -272,6 +314,7 @@ PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at)
|
||||
```python
|
||||
from fastapi_toolsets.schemas import PaginatedResponse
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
session: SessionDep,
|
||||
@@ -324,6 +367,12 @@ 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):
|
||||
|
||||
```python
|
||||
@@ -344,13 +393,37 @@ async def get_users(
|
||||
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
|
||||
|
||||
!!! 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.
|
||||
|
||||
Facet fields use the same syntax as `searchable_fields` — direct columns or relationship tuples:
|
||||
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`:
|
||||
|
||||
```python
|
||||
UserCrud = CrudFactory(
|
||||
@@ -372,7 +445,48 @@ result = await UserCrud.offset_paginate(
|
||||
)
|
||||
```
|
||||
|
||||
The distinct values are returned in the `filter_attributes` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse):
|
||||
Or via the dependency to narrow which fields are exposed as query parameters:
|
||||
|
||||
```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
|
||||
{
|
||||
@@ -387,50 +501,24 @@ The distinct values are returned in the `filter_attributes` field of [`Paginated
|
||||
}
|
||||
```
|
||||
|
||||
Use `filter_by` to pass the client's chosen filter values directly — no need to build SQLAlchemy conditions by hand. Any unknown key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError).
|
||||
!!! info "Key format uses `__` as a separator for relationship chains."
|
||||
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"`.
|
||||
#### Skipping facet queries
|
||||
|
||||
`filter_by` and `filters` can be combined — both are applied with AND logic.
|
||||
!!! info "Added in `v5.1.0`"
|
||||
|
||||
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:
|
||||
Facet values only change with the filters, not with the page. Pass `include_facets=False` to `offset_paginate_params()` / `cursor_paginate_params()` on pages 2..N to skip the facet queries entirely (`filter_attributes` will be `None`):
|
||||
|
||||
```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)
|
||||
params: Annotated[dict, Depends(UserCrud.offset_paginate_params(include_facets=False))]
|
||||
```
|
||||
|
||||
## Sorting
|
||||
|
||||
!!! info "Added in `v1.3`"
|
||||
|
||||
Declare `order_fields` on the CRUD class to expose client-driven column ordering via `order_by` and `order` query parameters.
|
||||
Declare `order_fields` on the CRUD class. Relationship traversal is supported via tuples, using the same syntax as `searchable_fields` and `facet_fields`:
|
||||
|
||||
```python
|
||||
UserCrud = CrudFactory(
|
||||
@@ -438,47 +526,81 @@ UserCrud = CrudFactory(
|
||||
order_fields=[
|
||||
User.name,
|
||||
User.created_at,
|
||||
(User.role, Role.name), # sort by a related model column
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
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:
|
||||
You can override `order_fields` per call:
|
||||
|
||||
```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
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
session: SessionDep,
|
||||
params: Annotated[dict, Depends(UserCrud.offset_paginate_params(
|
||||
default_order_field=User.created_at,
|
||||
))],
|
||||
params: Annotated[dict, Depends(UserCrud.offset_paginate_params())],
|
||||
) -> OffsetPaginatedResponse[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:
|
||||
|
||||
| Parameter | Type |
|
||||
| ---------- | --------------- |
|
||||
| `order_by` | `str | null` |
|
||||
| `order_by` | `str \| null` |
|
||||
| `order` | `asc` or `desc` |
|
||||
|
||||
```
|
||||
GET /users?order_by=name&order=asc → ORDER BY users.name ASC
|
||||
GET /users?order_by=name&order=desc → ORDER BY users.name DESC
|
||||
GET /users?order_by=role__name&order=desc → LEFT JOIN roles ON ... ORDER BY roles.name DESC
|
||||
```
|
||||
|
||||
An unknown `order_by` value raises [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) (HTTP 422).
|
||||
!!! info "Relationship tuples are joined automatically."
|
||||
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:
|
||||
|
||||
```python
|
||||
params = UserCrud.offset_paginate_params(order_fields=[User.name])
|
||||
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:
|
||||
|
||||
```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
|
||||
|
||||
!!! info "Added in `v1.1`"
|
||||
@@ -524,7 +646,9 @@ PostCrud = CrudFactory(
|
||||
m2m_fields={"tag_ids": Post.tags},
|
||||
)
|
||||
|
||||
post = await PostCrud.create(session=session, obj=PostCreateSchema(title="Hello", tag_ids=[1, 2, 3]))
|
||||
post = await PostCrud.create(
|
||||
session=session, obj=PostCreateSchema(title="Hello", tag_ids=[1, 2, 3])
|
||||
)
|
||||
```
|
||||
|
||||
## Upsert
|
||||
@@ -551,6 +675,7 @@ class UserRead(PydanticBase):
|
||||
id: UUID
|
||||
username: str
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{uuid}",
|
||||
responses=generate_error_responses(NotFoundError),
|
||||
@@ -562,12 +687,15 @@ async def get_user(session: SessionDep, uuid: UUID) -> Response[UserRead]:
|
||||
schema=UserRead,
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
session: SessionDep,
|
||||
params: Annotated[dict, Depends(crud.UserCrud.offset_paginate_params())],
|
||||
) -> OffsetPaginatedResponse[UserRead]:
|
||||
return await crud.UserCrud.offset_paginate(session=session, **params, schema=UserRead)
|
||||
return await crud.UserCrud.offset_paginate(
|
||||
session=session, **params, schema=UserRead
|
||||
)
|
||||
```
|
||||
|
||||
The schema must have `from_attributes=True` (or inherit from [`PydanticBase`](../reference/schemas.md#fastapi_toolsets.schemas.PydanticBase)) so it can be built from SQLAlchemy model instances.
|
||||
|
||||
+190
-38
@@ -1,77 +1,181 @@
|
||||
# DB
|
||||
|
||||
SQLAlchemy async session management with transactions, table locking, and row-change polling.
|
||||
SQLAlchemy async session management with transactions, table locking, advisory locking, and row-change polling.
|
||||
|
||||
!!! info
|
||||
This module has been coded and tested to be compatible with PostgreSQL only.
|
||||
|
||||
## Overview
|
||||
|
||||
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.
|
||||
The `db` module is built around one object, [`Database`](../reference/db.md#fastapi_toolsets.db.Database), which owns the engine and sessionmaker and exposes the FastAPI dependency, a commit-before-response middleware, session/transaction context managers, and table locking. Free helpers cover savepoint-aware transactions, advisory locks, many-to-many association tables, and row-change polling.
|
||||
|
||||
## Session dependency
|
||||
## Setup
|
||||
|
||||
Use [`create_db_dependency`](../reference/db.md#fastapi_toolsets.db.create_db_dependency) to create a FastAPI dependency that yields a session and auto-commits on success:
|
||||
Create one `Database` for your app. Provide a **URL** (the facade builds and disposes the engine) or pass an existing **`engine=`** you own (e.g. for Alembic or `event.listen`). The session factory is built internally with `expire_on_commit=False`.
|
||||
|
||||
```python
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
from fastapi_toolsets.db import create_db_dependency
|
||||
from fastapi import Depends, FastAPI
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
engine = create_async_engine(url="postgresql+asyncpg://...", future=True)
|
||||
session_maker = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||
from fastapi_toolsets.db import Database
|
||||
|
||||
get_db = create_db_dependency(session_maker=session_maker)
|
||||
db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(session: AsyncSession = Depends(get_db)):
|
||||
...
|
||||
app = FastAPI()
|
||||
db.install(app) # commit middleware + engine disposal on shutdown
|
||||
|
||||
|
||||
@app.get("/users")
|
||||
async def list_users(session: AsyncSession = Depends(db)): ...
|
||||
```
|
||||
|
||||
The `Database` instance **is** the dependency: use it directly as `Depends(db)`. The whole request runs as a single transaction (CRUD writes use savepoints under it).
|
||||
|
||||
The **URL** may be a plain string or a Pydantic [`PostgresDsn`](https://docs.pydantic.dev/latest/api/networks/#pydantic.networks.PostgresDsn). In URL mode you can tune the engine: pass `connect_args` for DBAPI-level options and any other keyword for `create_async_engine` (e.g. `pool_size`, `echo`, `pool_pre_ping`):
|
||||
|
||||
```python
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import PostgresDsn
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
database_url: PostgresDsn
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
db = Database(
|
||||
settings.database_url,
|
||||
pool_size=20,
|
||||
pool_pre_ping=True,
|
||||
connect_args={"server_settings": {"application_name": "myapp"}},
|
||||
)
|
||||
```
|
||||
|
||||
## Committing before the response
|
||||
|
||||
[`db.install(app)`](../reference/db.md#fastapi_toolsets.db.Database) adds a middleware that commits the request's session when the response starts, after the endpoint returns and before the body is sent. With the middleware installed, the dependency does not commit again.
|
||||
|
||||
The request is committed as a single transaction:
|
||||
|
||||
- **Read-after-write**: a follow-up request sees the write.
|
||||
- **Atomicity**: multi-write endpoints roll back as a unit on failure.
|
||||
- **Errors roll back**: on a raised exception the session rolls back and nothing is committed.
|
||||
|
||||
Without `install`, the session commits in the dependency teardown, which runs after the response has been sent.
|
||||
|
||||
!!! warning "Streaming / SSE endpoints"
|
||||
For a `StreamingResponse` / `EventSourceResponse`, the commit fires at the **start** of the stream. A stream that **writes** must open a short-lived session per write with [`db.session()`](#session-context-manager); the start-time commit will not flush writes made later during the stream.
|
||||
|
||||
## Lifespan
|
||||
|
||||
`db.install(app)` disposes the engine on shutdown, composing around your own lifespan:
|
||||
|
||||
```python
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
await warm_cache() # your startup
|
||||
yield
|
||||
await flush_metrics() # your shutdown
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
db.install(app) # your shutdown runs first, then the engine is disposed
|
||||
```
|
||||
|
||||
If you have no lifespan of your own, [`db.lifespan`](../reference/db.md#fastapi_toolsets.db.Database) works standalone as `FastAPI(lifespan=db.lifespan)`. Engine disposal is idempotent and is a no-op when you passed your own `engine=`.
|
||||
|
||||
## Session context manager
|
||||
|
||||
Use [`create_db_context`](../reference/db.md#fastapi_toolsets.db.create_db_context) for sessions outside request handlers (e.g. background tasks, CLI commands):
|
||||
Use [`db.session()`](../reference/db.md#fastapi_toolsets.db.Database) for sessions outside request handlers (e.g. background tasks, CLI commands). It commits on clean exit and rolls back on exception:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import create_db_context
|
||||
|
||||
db_context = create_db_context(session_maker=session_maker)
|
||||
|
||||
async def seed():
|
||||
async with db_context() as session:
|
||||
async with db.session() as session:
|
||||
...
|
||||
```
|
||||
|
||||
## Nested transactions
|
||||
## Transactions
|
||||
|
||||
[`get_transaction`](../reference/db.md#fastapi_toolsets.db.get_transaction) handles savepoints automatically, allowing safe nesting:
|
||||
[`transaction`](../reference/db.md#fastapi_toolsets.db.transaction) opens a transaction on a session, using a savepoint when one is already open so it nests safely:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import get_transaction
|
||||
from fastapi_toolsets.db import transaction
|
||||
|
||||
async def create_user_with_role(session=session):
|
||||
async with get_transaction(session=session):
|
||||
|
||||
async def create_user_with_role(session):
|
||||
async with transaction(session):
|
||||
...
|
||||
async with get_transaction(session=session): # uses savepoint
|
||||
async with transaction(session): # uses a savepoint
|
||||
...
|
||||
```
|
||||
|
||||
When you have a `Database`, [`db.begin()`](../reference/db.md#fastapi_toolsets.db.Database) opens a session already inside a transaction:
|
||||
|
||||
```python
|
||||
async with db.begin() as session:
|
||||
session.add(User(name="ada")) # commits on exit, rolls back on exception
|
||||
```
|
||||
|
||||
## Table locking
|
||||
|
||||
[`lock_tables`](../reference/db.md#fastapi_toolsets.db.lock_tables) acquires PostgreSQL table-level locks before executing critical sections:
|
||||
[`db.lock_tables`](../reference/db.md#fastapi_toolsets.db.Database) acquires PostgreSQL table-level locks for a critical section. It opens a dedicated session internally and releases the lock when the context exits:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import lock_tables
|
||||
from fastapi_toolsets.db import LockMode
|
||||
|
||||
async with lock_tables(session=session, tables=[User], mode="EXCLUSIVE"):
|
||||
async with db.lock_tables([User], mode=LockMode.EXCLUSIVE) as session:
|
||||
# No other transaction can modify User until this block exits
|
||||
...
|
||||
```
|
||||
|
||||
Available lock modes are defined in [`LockMode`](../reference/db.md#fastapi_toolsets.db.LockMode): `ACCESS_SHARE`, `ROW_SHARE`, `ROW_EXCLUSIVE`, `SHARE_UPDATE_EXCLUSIVE`, `SHARE`, `SHARE_ROW_EXCLUSIVE`, `EXCLUSIVE`, `ACCESS_EXCLUSIVE`.
|
||||
|
||||
Pass `timeout` to limit how long the lock waits. On timeout, a [`LockTimeoutError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.LockTimeoutError) is raised instead of a raw database error:
|
||||
|
||||
```python
|
||||
async with db.lock_tables([Order], timeout="2s") as session:
|
||||
...
|
||||
```
|
||||
|
||||
## Advisory locking
|
||||
|
||||
[`advisory_lock`](../reference/db.md#fastapi_toolsets.db.advisory_lock) acquires a PostgreSQL session-level advisory lock on a session you provide. The lock is released when the context exits:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import advisory_lock
|
||||
|
||||
# Blocking exclusive lock: waits until the lock is free
|
||||
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 LockTimeoutError if not acquired in time
|
||||
async with advisory_lock(session=session, key=42, timeout="5s"):
|
||||
...
|
||||
|
||||
# Shared lock: 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, so it is released when the context exits even if the surrounding transaction is still open.
|
||||
|
||||
## Row-change polling
|
||||
|
||||
[`wait_for_row_change`](../reference/db.md#fastapi_toolsets.db.wait_for_row_change) polls a row until a specific column changes value, useful for waiting on async side effects:
|
||||
[`wait_for_row_change`](../reference/db.md#fastapi_toolsets.db.wait_for_row_change) polls a row until a specific column changes value:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import wait_for_row_change
|
||||
@@ -81,7 +185,7 @@ await wait_for_row_change(
|
||||
session=session,
|
||||
model=Order,
|
||||
pk_value=order_id,
|
||||
columns=[Order.status],
|
||||
columns=["status"],
|
||||
interval=1.0,
|
||||
timeout=30.0,
|
||||
)
|
||||
@@ -89,28 +193,25 @@ await wait_for_row_change(
|
||||
|
||||
## Creating a database
|
||||
|
||||
!!! info "Added in `v2.1`"
|
||||
|
||||
[`create_database`](../reference/db.md#fastapi_toolsets.db.create_database) creates a database at a given URL. It connects to *server_url* and issues a `CREATE DATABASE` statement:
|
||||
[`create_database`](../reference/db.md#fastapi_toolsets.db.testing.create_database) (in `fastapi_toolsets.db.testing`) connects to *server_url* and issues a `CREATE DATABASE` statement:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import create_database
|
||||
from fastapi_toolsets.db.testing import create_database
|
||||
|
||||
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
|
||||
|
||||
await create_database(db_name="myapp_test", server_url=SERVER_URL)
|
||||
```
|
||||
|
||||
For test isolation with automatic cleanup, use [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_worker_database) from the `pytest` module instead — it handles drop-before, create, and drop-after automatically.
|
||||
For test isolation with automatic cleanup, use [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_worker_database) from the `pytest` module, which handles drop-before, create, and drop-after.
|
||||
|
||||
## Cleaning up tables
|
||||
|
||||
!!! info "Added in `v2.1`"
|
||||
|
||||
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables) truncates all tables:
|
||||
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.testing.cleanup_tables) (in `fastapi_toolsets.db.testing`) truncates all tables:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import cleanup_tables
|
||||
from fastapi_toolsets.db.testing import cleanup_tables
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def clean(db_session):
|
||||
@@ -118,6 +219,57 @@ async def clean(db_session):
|
||||
await cleanup_tables(session=db_session, base=Base)
|
||||
```
|
||||
|
||||
## Many-to-Many helpers
|
||||
|
||||
The three `m2m_*` helpers modify a many-to-many association table with direct SQL, without loading the ORM collection.
|
||||
|
||||
### `m2m_add`: insert associations
|
||||
|
||||
[`m2m_add`](../reference/db.md#fastapi_toolsets.db.m2m_add) inserts one or more rows into a secondary table:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import m2m_add
|
||||
|
||||
async with db.lock_tables([Tag]) as session:
|
||||
tag = await TagCrud.create(session, TagCreate(name="python"))
|
||||
await m2m_add(session, post, Post.tags, tag)
|
||||
```
|
||||
|
||||
Pass `ignore_conflicts=True` to 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 m2m_remove, transaction
|
||||
|
||||
async with 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) replaces all associations: it deletes every existing row for the owner instance then inserts the new set. Passing no related instances clears the association:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import m2m_set, transaction
|
||||
|
||||
# Replace all tags
|
||||
async with transaction(session):
|
||||
await m2m_set(session, post, Post.tags, tag_a, tag_b)
|
||||
|
||||
# Clear all tags
|
||||
async with 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)
|
||||
|
||||
@@ -20,6 +20,7 @@ UserDep = PathDependency(model=User, field=User.id, session_dep=get_db)
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
UserDep = PathDependency(model=User, field=User.id, session_dep=SessionDep)
|
||||
|
||||
|
||||
@router.get("/users/{user_id}")
|
||||
async def get_user(user: User = UserDep):
|
||||
return user
|
||||
@@ -30,6 +31,7 @@ By default the parameter name is inferred from the field (`user_id` for `User.id
|
||||
```python
|
||||
UserDep = PathDependency(model=User, field=User.id, session_dep=get_db, param_name="id")
|
||||
|
||||
|
||||
@router.get("/users/{id}")
|
||||
async def get_user(user: User = UserDep):
|
||||
return user
|
||||
@@ -43,11 +45,15 @@ async def get_user(user: User = UserDep):
|
||||
from fastapi_toolsets.dependencies import BodyDependency
|
||||
|
||||
# Plain callable
|
||||
RoleDep = BodyDependency(model=Role, field=Role.id, session_dep=get_db, body_field="role_id")
|
||||
RoleDep = BodyDependency(
|
||||
model=Role, field=Role.id, session_dep=get_db, body_field="role_id"
|
||||
)
|
||||
|
||||
# Annotated
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
RoleDep = BodyDependency(model=Role, field=Role.id, session_dep=SessionDep, body_field="role_id")
|
||||
RoleDep = BodyDependency(
|
||||
model=Role, field=Role.id, session_dep=SessionDep, body_field="role_id"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
|
||||
@@ -39,6 +39,8 @@ It also patches `app.openapi()` to replace the default Pydantic 422 schema with
|
||||
| [`NoSearchableFieldsError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError) | 400 | No Searchable Fields |
|
||||
| [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError) | 400 | Invalid Facet Filter |
|
||||
| [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) | 422 | Invalid Order Field |
|
||||
| [`PoolExhaustedError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.PoolExhaustedError) | 503 | Service Unavailable |
|
||||
| [`LockTimeoutError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.LockTimeoutError) | 503 | Service Unavailable |
|
||||
|
||||
### Per-instance overrides
|
||||
|
||||
@@ -51,7 +53,9 @@ All built-in exceptions accept optional keyword arguments to customise the respo
|
||||
| `data` | Overrides the `data` field |
|
||||
|
||||
```python
|
||||
raise NotFoundError(detail="User 42 not found", desc="No user with that ID exists in the database.")
|
||||
raise NotFoundError(
|
||||
detail="User 42 not found", desc="No user with that ID exists in the database."
|
||||
)
|
||||
```
|
||||
|
||||
## Custom exceptions
|
||||
@@ -62,6 +66,7 @@ Subclass [`ApiException`](../reference/exceptions.md#fastapi_toolsets.exceptions
|
||||
from fastapi_toolsets.exceptions import ApiException
|
||||
from fastapi_toolsets.schemas import ApiError
|
||||
|
||||
|
||||
class PaymentRequiredError(ApiException):
|
||||
api_error = ApiError(
|
||||
code=402,
|
||||
@@ -103,11 +108,17 @@ Use `abstract=True` when creating a shared base that is not meant to be raised d
|
||||
class BillingError(ApiException, abstract=True):
|
||||
"""Base for all billing-related errors."""
|
||||
|
||||
|
||||
class PaymentRequiredError(BillingError):
|
||||
api_error = ApiError(code=402, msg="Payment Required", desc="...", err_code="BILLING-402")
|
||||
api_error = ApiError(
|
||||
code=402, msg="Payment Required", desc="...", err_code="BILLING-402"
|
||||
)
|
||||
|
||||
|
||||
class SubscriptionExpiredError(BillingError):
|
||||
api_error = ApiError(code=402, msg="Subscription Expired", desc="...", err_code="BILLING-402-EXP")
|
||||
api_error = ApiError(
|
||||
code=402, msg="Subscription Expired", desc="...", err_code="BILLING-402-EXP"
|
||||
)
|
||||
```
|
||||
|
||||
## OpenAPI response documentation
|
||||
|
||||
+39
-10
@@ -13,6 +13,7 @@ from fastapi_toolsets.fixtures import FixtureRegistry, Context
|
||||
|
||||
fixtures = FixtureRegistry()
|
||||
|
||||
|
||||
@fixtures.register
|
||||
def roles():
|
||||
return [
|
||||
@@ -20,6 +21,7 @@ def roles():
|
||||
Role(id=2, name="user"),
|
||||
]
|
||||
|
||||
|
||||
@fixtures.register(depends_on=["roles"], contexts=[Context.TESTING])
|
||||
def test_users():
|
||||
return [
|
||||
@@ -65,6 +67,13 @@ Both functions return a `dict[str, list[...]]` mapping each fixture name to the
|
||||
|
||||
A fixture with no `contexts` defined takes `Context.BASE` by default.
|
||||
|
||||
`Context.BASE` fixtures are always included alongside whatever context you load or list — there's no way to load a non-base context in isolation:
|
||||
|
||||
```python
|
||||
# also loads any Context.BASE fixtures, even though only TESTING is requested
|
||||
await load_fixtures_by_context(session, fixtures, Context.TESTING)
|
||||
```
|
||||
|
||||
### Custom contexts
|
||||
|
||||
Plain strings and any `Enum` subclass are accepted wherever a `Context` enum is expected.
|
||||
@@ -72,14 +81,18 @@ Plain strings and any `Enum` subclass are accepted wherever a `Context` enum is
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class AppContext(str, Enum):
|
||||
STAGING = "staging"
|
||||
DEMO = "demo"
|
||||
|
||||
|
||||
@fixtures.register(contexts=[AppContext.STAGING])
|
||||
def staging_data():
|
||||
return [Config(key="feature_x", enabled=True)]
|
||||
|
||||
|
||||
# loads staging_data plus any Context.BASE fixtures
|
||||
await load_fixtures_by_context(session, fixtures, AppContext.STAGING)
|
||||
```
|
||||
|
||||
@@ -90,6 +103,7 @@ Pass `contexts` to `FixtureRegistry` to set a default for all fixtures registere
|
||||
```python
|
||||
testing_registry = FixtureRegistry(contexts=[Context.TESTING])
|
||||
|
||||
|
||||
@testing_registry.register # implicitly contexts=[Context.TESTING]
|
||||
def test_orders():
|
||||
return [Order(id=1, total=99)]
|
||||
@@ -104,12 +118,14 @@ The same fixture name may be registered under different (non-overlapping) contex
|
||||
def users():
|
||||
return [User(id=1, username="admin")]
|
||||
|
||||
|
||||
@fixtures.register(contexts=[Context.TESTING])
|
||||
def users():
|
||||
return [User(id=2, username="tester")]
|
||||
|
||||
# loads both admin and tester
|
||||
await load_fixtures_by_context(session, fixtures, Context.BASE, Context.TESTING)
|
||||
|
||||
# loads both admin and tester (Context.BASE is included automatically)
|
||||
await load_fixtures_by_context(session, fixtures, Context.TESTING)
|
||||
```
|
||||
|
||||
Registering two variants with overlapping context sets raises `ValueError`.
|
||||
@@ -147,18 +163,28 @@ Fixtures with the same name are allowed as long as their context sets do not ove
|
||||
|
||||
## Looking up fixture instances
|
||||
|
||||
[`get_obj_by_attr`](../reference/fixtures.md#fastapi_toolsets.fixtures.utils.get_obj_by_attr) retrieves a specific instance from a fixture function by attribute value — useful when building cross-fixture `depends_on` relationships:
|
||||
[`FixtureRegistry.obj`](../reference/fixtures.md#fastapi_toolsets.fixtures.registry.FixtureRegistry.obj) retrieves a specific instance from a registered fixture by attribute value, looked up by name on the registry — useful when building cross-fixture `depends_on` relationships:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.fixtures import get_obj_by_attr
|
||||
|
||||
@fixtures.register(depends_on=["roles"])
|
||||
def users():
|
||||
admin_role = get_obj_by_attr(roles, "name", "admin")
|
||||
admin_role = fixtures.obj("roles", "name", "admin")
|
||||
return [User(id=1, username="alice", role_id=admin_role.id)]
|
||||
```
|
||||
|
||||
Raises `StopIteration` if no matching instance is found.
|
||||
Looking the fixture up by name (instead of importing the `roles` function directly) means fixture modules never need to import each other, which avoids circular imports in larger projects split across multiple files — the same reason `depends_on` takes fixture names rather than the functions themselves. The registry passed in must be the one that actually contains the fixture by load time; with a single shared registry this is automatic, but if you merge registries with `include_registry`, call `obj`/`field` on the merged registry.
|
||||
|
||||
[`FixtureRegistry.field`](../reference/fixtures.md#fastapi_toolsets.fixtures.registry.FixtureRegistry.field) is shorthand for pulling a single attribute (`id` by default):
|
||||
|
||||
```python
|
||||
@fixtures.register(depends_on=["roles"])
|
||||
def users():
|
||||
return [
|
||||
User(id=1, username="alice", role_id=fixtures.field("roles", "name", "admin"))
|
||||
]
|
||||
```
|
||||
|
||||
Both raise `StopIteration` if no matching instance is found, and `KeyError` if the fixture name isn't registered.
|
||||
|
||||
## Pytest integration
|
||||
|
||||
@@ -173,18 +199,21 @@ from app.models import Base
|
||||
|
||||
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/test_db"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session():
|
||||
async with create_db_session(database_url=DATABASE_URL, base=Base, cleanup=True) as session:
|
||||
async with create_db_session(
|
||||
database_url=DATABASE_URL, base=Base, cleanup=True
|
||||
) as session:
|
||||
yield session
|
||||
|
||||
|
||||
register_fixtures(registry=registry, namespace=globals())
|
||||
```
|
||||
|
||||
```python
|
||||
# test_users.py
|
||||
async def test_user_can_login(fixture_users: list[User], fixture_roles: list[Role]):
|
||||
...
|
||||
async def test_user_can_login(fixture_users: list[User], fixture_roles: list[Role]): ...
|
||||
```
|
||||
|
||||
The load order is resolved automatically from the `depends_on` declarations in your registry. Each generated fixture receives `db_session` as a dependency and returns the list of loaded model instances.
|
||||
|
||||
@@ -47,10 +47,12 @@ If neither of these applies to you, declaring metrics at module level (e.g. `HTT
|
||||
```python
|
||||
from prometheus_client import Counter, Histogram
|
||||
|
||||
|
||||
@metrics.register
|
||||
def http_requests():
|
||||
return Counter("http_requests_total", "Total HTTP requests", ["method", "status"])
|
||||
|
||||
|
||||
@metrics.register
|
||||
def request_duration():
|
||||
return Histogram("request_duration_seconds", "Request duration")
|
||||
@@ -79,6 +81,7 @@ from prometheus_client import Gauge
|
||||
|
||||
_queue_depth = Gauge("queue_depth", "Current queue depth")
|
||||
|
||||
|
||||
@metrics.register(collect=True)
|
||||
def collect_queue_depth():
|
||||
_queue_depth.set(get_current_queue_depth())
|
||||
|
||||
+20
-2
@@ -11,6 +11,7 @@ The `models` module provides mixins that each add a single, well-defined column
|
||||
```python
|
||||
from fastapi_toolsets.models import UUIDMixin, TimestampMixin
|
||||
|
||||
|
||||
class Article(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "articles"
|
||||
|
||||
@@ -31,11 +32,13 @@ Adds a `id: UUID` primary key generated server-side by PostgreSQL using `gen_ran
|
||||
```python
|
||||
from fastapi_toolsets.models import UUIDMixin
|
||||
|
||||
|
||||
class User(Base, UUIDMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
username: Mapped[str]
|
||||
|
||||
|
||||
# id is None before flush
|
||||
user = User(username="alice")
|
||||
session.add(user)
|
||||
@@ -54,11 +57,13 @@ Adds a `id: UUID` primary key generated server-side by PostgreSQL using `uuidv7(
|
||||
```python
|
||||
from fastapi_toolsets.models import UUIDv7Mixin
|
||||
|
||||
|
||||
class Event(Base, UUIDv7Mixin):
|
||||
__tablename__ = "events"
|
||||
|
||||
name: Mapped[str]
|
||||
|
||||
|
||||
# id is None before flush
|
||||
event = Event(name="user.signup")
|
||||
session.add(event)
|
||||
@@ -73,6 +78,7 @@ Adds a `created_at: datetime` column set to `clock_timestamp()` on insert. The c
|
||||
```python
|
||||
from fastapi_toolsets.models import UUIDMixin, CreatedAtMixin
|
||||
|
||||
|
||||
class Order(Base, UUIDMixin, CreatedAtMixin):
|
||||
__tablename__ = "orders"
|
||||
|
||||
@@ -86,11 +92,13 @@ Adds an `updated_at: datetime` column set to `clock_timestamp()` on insert and a
|
||||
```python
|
||||
from fastapi_toolsets.models import UUIDMixin, UpdatedAtMixin
|
||||
|
||||
|
||||
class Post(Base, UUIDMixin, UpdatedAtMixin):
|
||||
__tablename__ = "posts"
|
||||
|
||||
title: Mapped[str]
|
||||
|
||||
|
||||
post = Post(title="Hello")
|
||||
await session.flush()
|
||||
await session.refresh(post)
|
||||
@@ -111,6 +119,7 @@ Convenience mixin that combines [`CreatedAtMixin`](../reference/models.md#fastap
|
||||
```python
|
||||
from fastapi_toolsets.models import UUIDMixin, TimestampMixin
|
||||
|
||||
|
||||
class Article(Base, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "articles"
|
||||
|
||||
@@ -134,7 +143,7 @@ SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=EventSe
|
||||
```
|
||||
|
||||
!!! info "Callbacks fire on `session.commit()` only — not on savepoints."
|
||||
Savepoints created by [`get_transaction`](db.md) or `begin_nested()` do **not**
|
||||
Savepoints created by [`transaction`](db.md) or `begin_nested()` do **not**
|
||||
trigger callbacks. All events accumulated across flushes are dispatched once
|
||||
when the outermost `commit()` is called.
|
||||
|
||||
@@ -166,10 +175,12 @@ class Order(Base, UUIDMixin):
|
||||
__watched_fields__ = ("status",)
|
||||
...
|
||||
|
||||
|
||||
class UrgentOrder(Order):
|
||||
# inherits __watched_fields__ = ("status",)
|
||||
...
|
||||
|
||||
|
||||
class PriorityOrder(Order):
|
||||
__watched_fields__ = ("priority",)
|
||||
# overrides parent — UPDATE fires only for priority changes
|
||||
@@ -183,20 +194,24 @@ Register handlers with the [`listens_for`](../reference/models.md#fastapi_toolse
|
||||
```python
|
||||
from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for
|
||||
|
||||
|
||||
class Order(Base, UUIDMixin):
|
||||
__tablename__ = "orders"
|
||||
__watched_fields__ = ("status",)
|
||||
|
||||
status: Mapped[str]
|
||||
|
||||
|
||||
@listens_for(Order, [ModelEvent.CREATE])
|
||||
async def on_order_created(order: Order, event_type: ModelEvent, changes: None):
|
||||
await notify_new_order(order.id)
|
||||
|
||||
|
||||
@listens_for(Order, [ModelEvent.DELETE])
|
||||
async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None):
|
||||
await notify_order_cancelled(order.id)
|
||||
|
||||
|
||||
@listens_for(Order, [ModelEvent.UPDATE])
|
||||
async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict):
|
||||
if "status" in changes:
|
||||
@@ -212,8 +227,11 @@ A single handler can listen for multiple events at once. When `event_types` is o
|
||||
async def on_order_changed(order: Order, event_type: ModelEvent, changes: dict | None):
|
||||
await invalidate_cache(order.id)
|
||||
|
||||
|
||||
@listens_for(Order) # all events
|
||||
async def on_any_order_event(order: Order, event_type: ModelEvent, changes: dict | None):
|
||||
async def on_any_order_event(
|
||||
order: Order, event_type: ModelEvent, changes: dict | None
|
||||
):
|
||||
await audit_log(order.id, event_type)
|
||||
```
|
||||
|
||||
|
||||
+56
-19
@@ -1,6 +1,6 @@
|
||||
# Pytest
|
||||
|
||||
Testing helpers for FastAPI applications with async client, database sessions, and parallel worker support.
|
||||
Testing helpers for FastAPI applications: async HTTP client, database sessions, and parallel worker support.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -14,17 +14,14 @@ Testing helpers for FastAPI applications with async client, database sessions, a
|
||||
pip install "fastapi-toolsets[pytest]"
|
||||
```
|
||||
|
||||
## Overview
|
||||
## Async client
|
||||
|
||||
The `pytest` module provides utilities for setting up async test clients, managing test database sessions, and supporting parallel test execution with `pytest-xdist`.
|
||||
|
||||
## Creating an async client
|
||||
|
||||
Use [`create_async_client`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_async_client) to get an `httpx.AsyncClient` configured for your FastAPI app:
|
||||
Use [`create_async_client`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_async_client) to get an `httpx.AsyncClient` bound to your FastAPI app:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.pytest import create_async_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def http_client(db_session):
|
||||
async def _override_get_db():
|
||||
@@ -38,13 +35,25 @@ async def http_client(db_session):
|
||||
yield c
|
||||
```
|
||||
|
||||
## Database sessions in tests
|
||||
Any extra keyword arguments are forwarded to `httpx.AsyncClient`, so you can set default headers, authentication, timeouts, and more:
|
||||
|
||||
Use [`create_db_session`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_db_session) to create an isolated `AsyncSession` for a test, combined with [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_worker_database) to set up a per-worker database:
|
||||
```python
|
||||
async with create_async_client(
|
||||
app=app,
|
||||
headers={"X-Api-Key": "secret"},
|
||||
timeout=10,
|
||||
) as c:
|
||||
...
|
||||
```
|
||||
|
||||
## Database sessions
|
||||
|
||||
Use [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_worker_database) + [`create_db_session`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_db_session) to get a fully isolated `AsyncSession` for each test:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.pytest import create_worker_database, create_db_session
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def worker_db_url():
|
||||
async with create_worker_database(
|
||||
@@ -61,28 +70,56 @@ async def db_session(worker_db_url):
|
||||
yield session
|
||||
```
|
||||
|
||||
`create_worker_database` connects without specifying a database (asyncpg falls back to the username), so the target test database does not need to exist beforehand.
|
||||
|
||||
!!! info
|
||||
In this example, the database is reset between each test using the argument `cleanup=True`.
|
||||
`cleanup=True` truncates all tables between tests via `TRUNCATE … RESTART IDENTITY CASCADE`, which is faster than dropping and recreating tables.
|
||||
|
||||
### Engine and session options
|
||||
|
||||
Pass `engine_kwargs` or `session_kwargs` to forward options to the underlying SQLAlchemy primitives:
|
||||
|
||||
```python
|
||||
async with create_db_session(
|
||||
database_url=worker_db_url,
|
||||
base=Base,
|
||||
engine_kwargs={"pool_size": 5, "connect_args": {"timeout": 10}},
|
||||
session_kwargs={"autoflush": False},
|
||||
) as session:
|
||||
...
|
||||
```
|
||||
|
||||
## Parallel testing with pytest-xdist
|
||||
|
||||
The fixtures above work with `pytest-xdist` out of the box. Each worker gets its own database named after the worker (e.g. `gw0`, `gw1`). Pass `prefix` to namespace the database (e.g. `prefix="myapp"` → `myapp_gw0`).
|
||||
|
||||
Use [`worker_database_url`](../reference/pytest.md#fastapi_toolsets.pytest.utils.worker_database_url) to derive the per-worker URL manually if needed:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.pytest import worker_database_url
|
||||
|
||||
url = worker_database_url("postgresql+asyncpg://user:pass@localhost/test_db", default_test_db="test")
|
||||
# e.g. "postgresql+asyncpg://user:pass@localhost/test_db_gw0" under xdist
|
||||
url = worker_database_url(
|
||||
"postgresql+asyncpg://user:pass@localhost/myapp", default_test_db="test"
|
||||
)
|
||||
# → "postgresql+asyncpg://user:pass@localhost/gw0" under xdist
|
||||
# → "postgresql+asyncpg://user:pass@localhost/test" otherwise
|
||||
|
||||
url = worker_database_url(
|
||||
"postgresql+asyncpg://user:pass@localhost/myapp",
|
||||
default_test_db="test",
|
||||
prefix="myapp",
|
||||
)
|
||||
# → "postgresql+asyncpg://user:pass@localhost/myapp_gw0" under xdist
|
||||
# → "postgresql+asyncpg://user:pass@localhost/myapp_test" otherwise
|
||||
```
|
||||
|
||||
## Parallel testing with pytest-xdist
|
||||
## Manual table cleanup
|
||||
|
||||
The examples above are already compatible with parallel test execution with `pytest-xdist`.
|
||||
|
||||
## Cleaning up tables
|
||||
|
||||
If you want to manually clean up a database you can use [`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables), this will truncate all tables between tests for fast isolation:
|
||||
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.testing.cleanup_tables) truncates all tables in a single statement and can be called directly when you need more control:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import cleanup_tables
|
||||
from fastapi_toolsets.pytest import cleanup_tables
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def clean(db_session):
|
||||
|
||||
@@ -15,6 +15,7 @@ The most common wrapper for a single resource response.
|
||||
```python
|
||||
from fastapi_toolsets.schemas import Response
|
||||
|
||||
|
||||
@router.get("/users/{id}")
|
||||
async def get_user(user: User = UserDep) -> Response[UserSchema]:
|
||||
return Response(data=user, message="User retrieved")
|
||||
@@ -39,6 +40,7 @@ Use as the return type when the endpoint always uses [`offset_paginate`](crud.md
|
||||
```python
|
||||
from fastapi_toolsets.schemas import OffsetPaginatedResponse
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(
|
||||
page: int = 1,
|
||||
@@ -74,6 +76,7 @@ Use as the return type when the endpoint always uses [`cursor_paginate`](crud.md
|
||||
```python
|
||||
from fastapi_toolsets.schemas import CursorPaginatedResponse
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
async def list_events(
|
||||
cursor: str | None = None,
|
||||
@@ -102,7 +105,7 @@ async def list_events(
|
||||
|
||||
#### [`PaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse)
|
||||
|
||||
Return type for endpoints that support **both** pagination strategies via a `pagination_type` query parameter (using [`paginate()`](crud.md#unified-paginate--both-strategies-on-one-endpoint)).
|
||||
Return type for endpoints that support **both** pagination strategies via a `pagination_type` query parameter (using [`paginate()`](crud.md#unified-endpoint-both-strategies)).
|
||||
|
||||
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:
|
||||
|
||||
@@ -110,6 +113,7 @@ When used as a return annotation, `PaginatedResponse[T]` automatically expands t
|
||||
from fastapi_toolsets.crud import PaginationType
|
||||
from fastapi_toolsets.schemas import PaginatedResponse
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(
|
||||
pagination_type: PaginationType = PaginationType.OFFSET,
|
||||
@@ -129,7 +133,7 @@ async def list_users(
|
||||
|
||||
#### Pagination metadata models
|
||||
|
||||
The optional `filter_attributes` field is populated when `facet_fields` are configured on the CRUD class (see [Filter attributes](crud.md#filter-attributes-facets)). It is `None` by default and can be hidden from API responses with `response_model_exclude_none=True`.
|
||||
The optional `filter_attributes` field is populated when `facet_fields` are configured on the CRUD class (see [Filter attributes](crud.md#faceted-search)). 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)
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ You can import the main symbols from `fastapi_toolsets.crud`:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.crud import CrudFactory, AsyncCrud
|
||||
from fastapi_toolsets.crud.search import SearchConfig, get_searchable_fields, build_search_filters
|
||||
from fastapi_toolsets.crud.search import (
|
||||
SearchConfig,
|
||||
get_searchable_fields,
|
||||
build_search_filters,
|
||||
)
|
||||
```
|
||||
|
||||
## ::: fastapi_toolsets.crud.factory.AsyncCrud
|
||||
|
||||
+28
-14
@@ -1,34 +1,48 @@
|
||||
# `db`
|
||||
|
||||
Here's the reference for all database session utilities, transaction helpers, and locking functions.
|
||||
Here's the reference for the `Database` facade, the transaction helper, locking
|
||||
functions, many-to-many helpers, and row-watching utilities.
|
||||
|
||||
You can import them directly from `fastapi_toolsets.db`:
|
||||
|
||||
```python
|
||||
from fastapi_toolsets.db import (
|
||||
Database,
|
||||
LockMode,
|
||||
cleanup_tables,
|
||||
create_database,
|
||||
create_db_dependency,
|
||||
create_db_context,
|
||||
get_transaction,
|
||||
advisory_lock,
|
||||
lock_tables,
|
||||
m2m_add,
|
||||
m2m_remove,
|
||||
m2m_set,
|
||||
transaction,
|
||||
wait_for_row_change,
|
||||
)
|
||||
```
|
||||
|
||||
## ::: fastapi_toolsets.db.Database
|
||||
|
||||
## ::: fastapi_toolsets.db.transaction
|
||||
|
||||
## ::: fastapi_toolsets.db.LockMode
|
||||
|
||||
## ::: fastapi_toolsets.db.create_db_dependency
|
||||
|
||||
## ::: fastapi_toolsets.db.create_db_context
|
||||
|
||||
## ::: fastapi_toolsets.db.get_transaction
|
||||
|
||||
## ::: fastapi_toolsets.db.lock_tables
|
||||
|
||||
## ::: fastapi_toolsets.db.advisory_lock
|
||||
|
||||
## ::: fastapi_toolsets.db.m2m_add
|
||||
|
||||
## ::: fastapi_toolsets.db.m2m_remove
|
||||
|
||||
## ::: fastapi_toolsets.db.m2m_set
|
||||
|
||||
## ::: fastapi_toolsets.db.wait_for_row_change
|
||||
|
||||
## ::: fastapi_toolsets.db.create_database
|
||||
Admin and test helpers live in `fastapi_toolsets.db.testing`:
|
||||
|
||||
## ::: fastapi_toolsets.db.cleanup_tables
|
||||
```python
|
||||
from fastapi_toolsets.db.testing import cleanup_tables, create_database
|
||||
```
|
||||
|
||||
## ::: fastapi_toolsets.db.testing.create_database
|
||||
|
||||
## ::: fastapi_toolsets.db.testing.cleanup_tables
|
||||
|
||||
@@ -12,8 +12,11 @@ from fastapi_toolsets.exceptions import (
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
NoSearchableFieldsError,
|
||||
InvalidSearchColumnError,
|
||||
InvalidFacetFilterError,
|
||||
InvalidOrderFieldError,
|
||||
PoolExhaustedError,
|
||||
LockTimeoutError,
|
||||
generate_error_responses,
|
||||
init_exceptions_handlers,
|
||||
)
|
||||
@@ -31,10 +34,16 @@ from fastapi_toolsets.exceptions import (
|
||||
|
||||
## ::: fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError
|
||||
|
||||
## ::: fastapi_toolsets.exceptions.exceptions.InvalidSearchColumnError
|
||||
|
||||
## ::: fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError
|
||||
|
||||
## ::: fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError
|
||||
|
||||
## ::: fastapi_toolsets.exceptions.exceptions.PoolExhaustedError
|
||||
|
||||
## ::: fastapi_toolsets.exceptions.exceptions.LockTimeoutError
|
||||
|
||||
## ::: fastapi_toolsets.exceptions.exceptions.generate_error_responses
|
||||
|
||||
## ::: fastapi_toolsets.exceptions.handler.init_exceptions_handlers
|
||||
|
||||
@@ -12,7 +12,6 @@ from fastapi_toolsets.fixtures import (
|
||||
FixtureRegistry,
|
||||
load_fixtures,
|
||||
load_fixtures_by_context,
|
||||
get_obj_by_attr,
|
||||
)
|
||||
```
|
||||
|
||||
@@ -27,5 +26,3 @@ from fastapi_toolsets.fixtures import (
|
||||
## ::: fastapi_toolsets.fixtures.utils.load_fixtures
|
||||
|
||||
## ::: fastapi_toolsets.fixtures.utils.load_fixtures_by_context
|
||||
|
||||
## ::: fastapi_toolsets.fixtures.utils.get_obj_by_attr
|
||||
|
||||
@@ -2,8 +2,10 @@ from fastapi import FastAPI
|
||||
|
||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||
|
||||
from .db import db
|
||||
from .routes import router
|
||||
|
||||
app = FastAPI()
|
||||
db.install(app=app)
|
||||
init_exceptions_handlers(app=app)
|
||||
app.include_router(router=router)
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fastapi_toolsets.db import create_db_context, create_db_dependency
|
||||
from fastapi_toolsets.db import Database
|
||||
|
||||
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres"
|
||||
|
||||
engine = create_async_engine(url=DATABASE_URL, future=True)
|
||||
async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||
db = Database(url=DATABASE_URL)
|
||||
|
||||
get_db = create_db_dependency(session_maker=async_session_maker)
|
||||
get_db_context = create_db_context(session_maker=async_session_maker)
|
||||
get_db = db
|
||||
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(db)]
|
||||
|
||||
+18
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "fastapi-toolsets"
|
||||
version = "3.0.1"
|
||||
version = "5.1.0"
|
||||
description = "Production-ready utilities for FastAPI applications"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@@ -66,12 +66,14 @@ manager = "fastapi_toolsets.cli.app:cli"
|
||||
dev = [
|
||||
{include-group = "tests"},
|
||||
{include-group = "docs"},
|
||||
{include-group = "docs-src"},
|
||||
"fastapi-toolsets[all]",
|
||||
"prek>=0.3.8",
|
||||
"ruff>=0.1.0",
|
||||
"ty>=0.0.1a0",
|
||||
]
|
||||
tests = [
|
||||
"async-lru>=1.0",
|
||||
"coverage>=7.0.0",
|
||||
"httpx>=0.25.0",
|
||||
"pytest-anyio>=0.0.0",
|
||||
@@ -84,11 +86,26 @@ docs = [
|
||||
"mkdocstrings-python>=2.0.2",
|
||||
"zensical>=0.0.30",
|
||||
]
|
||||
docs-src = [
|
||||
"bcrypt>=4.0.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.10,<0.12.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.ruff.format]
|
||||
exclude = ["*.md"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
extend-select = ["E712"]
|
||||
|
||||
[tool.ruff.lint.flake8-bugbear]
|
||||
extend-immutable-calls = ["fastapi.Depends"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**" = ["RUF012", "RUF059", "SIM117", "DTZ001", "S110", "BLE001"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
filterwarnings = [
|
||||
|
||||
@@ -7,18 +7,21 @@ Example usage:
|
||||
from fastapi import FastAPI, Depends
|
||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||
from fastapi_toolsets.crud import CrudFactory
|
||||
from fastapi_toolsets.db import create_db_dependency
|
||||
from fastapi_toolsets.db import Database
|
||||
from fastapi_toolsets.schemas import Response
|
||||
|
||||
db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
|
||||
|
||||
app = FastAPI()
|
||||
db.install(app)
|
||||
init_exceptions_handlers(app)
|
||||
|
||||
UserCrud = CrudFactory(User)
|
||||
|
||||
@app.get("/users/{user_id}", response_model=Response[dict])
|
||||
async def get_user(user_id: int, session = Depends(get_db)):
|
||||
async def get_user(user_id: int, session = Depends(db)):
|
||||
user = await UserCrud.get(session, [User.id == user_id])
|
||||
return Response(data={"user": user.username}, message="Success")
|
||||
"""
|
||||
|
||||
__version__ = "3.0.1"
|
||||
__version__ = "5.1.0"
|
||||
|
||||
@@ -6,7 +6,8 @@ import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from ...fixtures import Context, LoadStrategy, load_fixtures_by_context
|
||||
from ...fixtures import Context, LoadStrategy
|
||||
from ...logger import get_logger
|
||||
from ..config import get_db_context, get_fixtures_registry
|
||||
from ..utils import async_command
|
||||
|
||||
@@ -16,13 +17,14 @@ fixture_cli = typer.Typer(
|
||||
no_args_is_help=True,
|
||||
)
|
||||
console = Console()
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
@fixture_cli.command("list")
|
||||
def list_fixtures(
|
||||
ctx: typer.Context,
|
||||
context: Annotated[
|
||||
Context | None,
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--context",
|
||||
"-c",
|
||||
@@ -32,10 +34,10 @@ def list_fixtures(
|
||||
) -> None:
|
||||
"""List all registered fixtures."""
|
||||
registry = get_fixtures_registry()
|
||||
fixtures = registry.get_by_context(context.value) if context else registry.get_all()
|
||||
fixtures = registry.get_by_context(context) if context else registry.get_all()
|
||||
|
||||
if not fixtures:
|
||||
print("No fixtures found.")
|
||||
logger.info("No fixtures found.")
|
||||
return
|
||||
|
||||
table = Table("Name", "Contexts", "Dependencies")
|
||||
@@ -46,7 +48,7 @@ def list_fixtures(
|
||||
table.add_row(fixture.name, contexts, deps)
|
||||
|
||||
console.print(table)
|
||||
print(f"\nTotal: {len(fixtures)} fixture(s)")
|
||||
logger.info("Total: %d fixture(s)", len(fixtures))
|
||||
|
||||
|
||||
@fixture_cli.command("load")
|
||||
@@ -54,8 +56,8 @@ def list_fixtures(
|
||||
async def load(
|
||||
ctx: typer.Context,
|
||||
contexts: Annotated[
|
||||
list[Context] | None,
|
||||
typer.Argument(help="Contexts to load."),
|
||||
list[str] | None,
|
||||
typer.Argument(help="Contexts to load.", envvar="FIXTURES_CONTEXT"),
|
||||
] = None,
|
||||
strategy: Annotated[
|
||||
LoadStrategy,
|
||||
@@ -69,26 +71,27 @@ async def load(
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Load fixtures into the database."""
|
||||
from ...fixtures import load_fixtures_by_context
|
||||
|
||||
registry = get_fixtures_registry()
|
||||
db_context = get_db_context()
|
||||
|
||||
context_list = list(contexts) if contexts else [Context.BASE]
|
||||
context_list = contexts or [Context.BASE.value]
|
||||
|
||||
ordered = registry.resolve_context_dependencies(*context_list)
|
||||
|
||||
if not ordered:
|
||||
print("No fixtures to load for the specified context(s).")
|
||||
logger.info("No fixtures to load for the specified context(s).")
|
||||
return
|
||||
|
||||
print(f"\nFixtures to load ({strategy.value} strategy):")
|
||||
for name in ordered:
|
||||
fixture = registry.get(name)
|
||||
instances = list(fixture.func())
|
||||
model_name = type(instances[0]).__name__ if instances else "?"
|
||||
print(f" - {name}: {len(instances)} {model_name}(s)")
|
||||
|
||||
if dry_run:
|
||||
print("\n[Dry run - no changes made]")
|
||||
logger.info("Fixtures to load (%s strategy):", strategy.value)
|
||||
for name in ordered:
|
||||
variants = registry.get_load_variants(name, *context_list)
|
||||
instances = [inst for v in variants for inst in v.func()]
|
||||
model_name = type(instances[0]).__name__ if instances else "?"
|
||||
logger.info(" - %s: %d %s(s)", name, len(instances), model_name)
|
||||
logger.info("[Dry run - no changes made]")
|
||||
return
|
||||
|
||||
async with db_context() as session:
|
||||
@@ -97,4 +100,4 @@ async def load(
|
||||
)
|
||||
|
||||
total = sum(len(items) for items in result.values())
|
||||
print(f"\nLoaded {total} record(s) successfully.")
|
||||
logger.info("Loaded %d record(s) successfully.", total)
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any, Literal, overload
|
||||
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
|
||||
|
||||
import typer
|
||||
|
||||
@@ -13,6 +13,8 @@ from .pyproject import find_pyproject, load_pyproject
|
||||
if TYPE_CHECKING:
|
||||
from ..fixtures import FixtureRegistry
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def _ensure_project_in_path():
|
||||
"""Add project root to sys.path if not installed in editable mode."""
|
||||
@@ -88,19 +90,39 @@ def get_config_value(key: str, required: bool = False) -> Any | None:
|
||||
return value
|
||||
|
||||
|
||||
@overload
|
||||
def _import_typed(
|
||||
key: str, expected_type: type[T], *, required: Literal[True]
|
||||
) -> T: ... # pragma: no cover
|
||||
@overload
|
||||
def _import_typed(
|
||||
key: str, expected_type: type[T], *, required: bool
|
||||
) -> T | None: ... # pragma: no cover
|
||||
def _import_typed(key: str, expected_type: type[T], *, required: bool) -> T | None:
|
||||
"""Import a config value by key and validate its type.
|
||||
|
||||
Raises:
|
||||
typer.BadParameter: If required and missing, or if the imported
|
||||
value isn't an instance of *expected_type*.
|
||||
"""
|
||||
import_path = get_config_value(key, required=required)
|
||||
if not import_path:
|
||||
return None
|
||||
|
||||
obj = import_from_string(import_path)
|
||||
if not isinstance(obj, expected_type):
|
||||
raise typer.BadParameter(
|
||||
f"'{key}' must be a {expected_type.__name__} instance, got {type(obj).__name__}"
|
||||
)
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
def get_fixtures_registry() -> FixtureRegistry:
|
||||
"""Import and return the fixtures registry from config."""
|
||||
from ..fixtures import FixtureRegistry
|
||||
|
||||
import_path = get_config_value("fixtures", required=True)
|
||||
registry = import_from_string(import_path)
|
||||
|
||||
if not isinstance(registry, FixtureRegistry):
|
||||
raise typer.BadParameter(
|
||||
f"'fixtures' must be a FixtureRegistry instance, got {type(registry).__name__}"
|
||||
)
|
||||
|
||||
return registry
|
||||
return _import_typed("fixtures", FixtureRegistry, required=True)
|
||||
|
||||
|
||||
def get_db_context() -> Any:
|
||||
@@ -111,15 +133,4 @@ def get_db_context() -> Any:
|
||||
|
||||
def get_custom_cli() -> typer.Typer | None:
|
||||
"""Import and return the custom CLI Typer instance from config."""
|
||||
import_path = get_config_value("custom_cli")
|
||||
if not import_path:
|
||||
return None
|
||||
|
||||
custom = import_from_string(import_path)
|
||||
|
||||
if not isinstance(custom, typer.Typer):
|
||||
raise typer.BadParameter(
|
||||
f"'custom_cli' must be a Typer instance, got {type(custom).__name__}"
|
||||
)
|
||||
|
||||
return custom
|
||||
return _import_typed("custom_cli", typer.Typer, required=False)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""CLI utility functions."""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any, ParamSpec, TypeVar
|
||||
@@ -24,6 +23,8 @@ def async_command(func: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]:
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||
import asyncio
|
||||
|
||||
return asyncio.run(func(*args, **kwargs))
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -12,6 +12,7 @@ from ..types import (
|
||||
JoinType,
|
||||
M2MFieldType,
|
||||
OrderByClause,
|
||||
OrderFieldType,
|
||||
SearchFieldType,
|
||||
)
|
||||
from .factory import AsyncCrud, CrudFactory
|
||||
@@ -21,15 +22,16 @@ __all__ = [
|
||||
"AsyncCrud",
|
||||
"CrudFactory",
|
||||
"FacetFieldType",
|
||||
"get_searchable_fields",
|
||||
"InvalidFacetFilterError",
|
||||
"InvalidSearchColumnError",
|
||||
"JoinType",
|
||||
"M2MFieldType",
|
||||
"NoSearchableFieldsError",
|
||||
"OrderByClause",
|
||||
"OrderFieldType",
|
||||
"PaginationType",
|
||||
"SearchConfig",
|
||||
"SearchFieldType",
|
||||
"UnsupportedFacetTypeError",
|
||||
"get_searchable_fields",
|
||||
]
|
||||
|
||||
@@ -10,7 +10,7 @@ from collections.abc import Awaitable, Callable, Sequence
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Any, ClassVar, Generic, Literal, Self, cast, overload
|
||||
from typing import Any, ClassVar, Generic, Literal, Self, TypeAlias, cast, overload
|
||||
|
||||
from fastapi import Query
|
||||
from pydantic import BaseModel
|
||||
@@ -22,7 +22,7 @@ from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload
|
||||
from sqlalchemy.sql.base import ExecutableOption
|
||||
from sqlalchemy.sql.roles import WhereHavingRole
|
||||
|
||||
from ..db import get_transaction
|
||||
from ..db import transaction
|
||||
from ..exceptions import InvalidOrderFieldError, NotFoundError
|
||||
from ..schemas import (
|
||||
CursorPaginatedResponse,
|
||||
@@ -38,11 +38,13 @@ from ..types import (
|
||||
M2MFieldType,
|
||||
ModelType,
|
||||
OrderByClause,
|
||||
OrderFieldType,
|
||||
SchemaType,
|
||||
SearchFieldType,
|
||||
)
|
||||
from .search import (
|
||||
SearchConfig,
|
||||
apply_search_joins,
|
||||
build_facets,
|
||||
build_filter_by,
|
||||
build_search_filters,
|
||||
@@ -50,6 +52,18 @@ from .search import (
|
||||
search_field_keys,
|
||||
)
|
||||
|
||||
_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):
|
||||
NEXT = "next"
|
||||
@@ -114,17 +128,6 @@ def _apply_joins(q: Any, joins: JoinType | None, outer_join: bool) -> Any:
|
||||
return q
|
||||
|
||||
|
||||
def _apply_search_joins(q: Any, search_joins: list[Any]) -> Any:
|
||||
"""Apply relationship-based outer joins (from search/filter_by) to a query."""
|
||||
seen: set[str] = set()
|
||||
for join_rel in search_joins:
|
||||
key = str(join_rel)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
q = q.outerjoin(join_rel)
|
||||
return q
|
||||
|
||||
|
||||
class AsyncCrud(Generic[ModelType]):
|
||||
"""Generic async CRUD operations for SQLAlchemy models.
|
||||
|
||||
@@ -134,7 +137,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
model: ClassVar[type[DeclarativeBase]]
|
||||
searchable_fields: ClassVar[Sequence[SearchFieldType] | None] = None
|
||||
facet_fields: ClassVar[Sequence[FacetFieldType] | None] = None
|
||||
order_fields: ClassVar[Sequence[QueryableAttribute[Any]] | None] = None
|
||||
order_fields: ClassVar[Sequence[OrderFieldType] | None] = None
|
||||
m2m_fields: ClassVar[M2MFieldType | None] = None
|
||||
default_load_options: ClassVar[Sequence[ExecutableOption] | None] = None
|
||||
cursor_column: ClassVar[Any | None] = None
|
||||
@@ -169,6 +172,32 @@ class AsyncCrud(Generic[ModelType]):
|
||||
return load_options
|
||||
return cls.default_load_options
|
||||
|
||||
@classmethod
|
||||
def _capture_pk_values(cls: type[Self], instance: ModelType) -> dict[str, Any]:
|
||||
"""Capture PK values off instance — call before commit expires attributes."""
|
||||
return {
|
||||
cast(str, col.key): getattr(instance, cast(str, col.key))
|
||||
for col in cls.model.__mapper__.primary_key
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def _reload_with_options_by_pk(
|
||||
cls: type[Self], session: AsyncSession, pk_values: dict[str, Any]
|
||||
) -> ModelType:
|
||||
"""Re-query by previously captured PK values, with default_load_options applied."""
|
||||
# Only called when cls.default_load_options is set (see call sites).
|
||||
pk_filters = [
|
||||
getattr(cls.model, key) == value for key, value in pk_values.items()
|
||||
]
|
||||
q = select(cls.model).where(and_(*pk_filters))
|
||||
q = q.execution_options(populate_existing=True)
|
||||
q = q.options(*cast(Sequence[ExecutableOption], cls.default_load_options))
|
||||
result = await session.execute(q)
|
||||
item = result.unique().scalar_one_or_none()
|
||||
if item is None: # pragma: no cover — row was just flushed in this transaction
|
||||
raise NotFoundError()
|
||||
return cast(ModelType, item)
|
||||
|
||||
@classmethod
|
||||
async def _resolve_m2m(
|
||||
cls: type[Self],
|
||||
@@ -238,12 +267,12 @@ class AsyncCrud(Generic[ModelType]):
|
||||
cls: type[Self],
|
||||
filter_by: dict[str, Any] | BaseModel | None,
|
||||
facet_fields: Sequence[FacetFieldType] | None,
|
||||
) -> tuple[list[Any], list[Any]]:
|
||||
"""Normalize filter_by and return (filters, joins) to apply to the query."""
|
||||
) -> tuple[dict[str, Any], list[Any]]:
|
||||
"""Normalize filter_by and return ({facet_key: filter}, joins) to apply to the query."""
|
||||
if isinstance(filter_by, BaseModel):
|
||||
filter_by = filter_by.model_dump(exclude_none=True)
|
||||
if not filter_by:
|
||||
return [], []
|
||||
return {}, []
|
||||
resolved = cls._resolve_facet_fields(facet_fields)
|
||||
return build_filter_by(filter_by, resolved or [])
|
||||
|
||||
@@ -254,8 +283,13 @@ class AsyncCrud(Generic[ModelType]):
|
||||
facet_fields: Sequence[FacetFieldType] | None,
|
||||
filters: list[Any],
|
||||
search_joins: list[Any],
|
||||
*,
|
||||
include_facets: bool = True,
|
||||
own_filters: dict[str, Any] | None = None,
|
||||
) -> dict[str, list[Any]] | None:
|
||||
"""Build facet filter_attributes, or return None if no facet fields configured."""
|
||||
"""Build facet filter_attributes, or None if disabled/no facet fields configured."""
|
||||
if not include_facets:
|
||||
return None
|
||||
resolved = cls._resolve_facet_fields(facet_fields)
|
||||
if not resolved:
|
||||
return None
|
||||
@@ -265,6 +299,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
resolved,
|
||||
base_filters=filters,
|
||||
base_joins=search_joins,
|
||||
own_filters=own_filters,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -278,6 +313,17 @@ class AsyncCrud(Generic[ModelType]):
|
||||
return None
|
||||
return search_field_keys(fields)
|
||||
|
||||
@classmethod
|
||||
def _resolve_order_columns(
|
||||
cls: type[Self],
|
||||
order_fields: Sequence[OrderFieldType] | None,
|
||||
) -> list[str] | None:
|
||||
"""Return sort column keys, or None if no order fields configured."""
|
||||
fields = order_fields if order_fields is not None else cls.order_fields
|
||||
if not fields:
|
||||
return None
|
||||
return sorted(facet_keys(fields))
|
||||
|
||||
@classmethod
|
||||
def _build_paginate_params(
|
||||
cls: type[Self],
|
||||
@@ -290,7 +336,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
order: bool,
|
||||
search_fields: Sequence[SearchFieldType] | None,
|
||||
facet_fields: Sequence[FacetFieldType] | None,
|
||||
order_fields: Sequence[QueryableAttribute[Any]] | None,
|
||||
order_fields: Sequence[OrderFieldType] | None,
|
||||
default_order_field: QueryableAttribute[Any] | None,
|
||||
default_order: Literal["asc", "desc"],
|
||||
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
||||
@@ -349,14 +395,15 @@ class AsyncCrud(Generic[ModelType]):
|
||||
)
|
||||
reserved_names.update(filter_keys)
|
||||
|
||||
order_field_map: dict[str, QueryableAttribute[Any]] | None = None
|
||||
order_field_map: dict[str, OrderFieldType] | None = None
|
||||
order_valid_keys: list[str] | None = None
|
||||
if order:
|
||||
resolved_order = (
|
||||
order_fields if order_fields is not None else cls.order_fields
|
||||
)
|
||||
if resolved_order:
|
||||
order_field_map = {f.key: f for f in resolved_order}
|
||||
keys = facet_keys(resolved_order)
|
||||
order_field_map = dict(zip(keys, resolved_order))
|
||||
order_valid_keys = sorted(order_field_map.keys())
|
||||
all_params.extend(
|
||||
[
|
||||
@@ -408,6 +455,13 @@ class AsyncCrud(Generic[ModelType]):
|
||||
else:
|
||||
field = order_field_map[order_by_val]
|
||||
if field is not None:
|
||||
if isinstance(field, tuple):
|
||||
col = field[-1]
|
||||
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()
|
||||
)
|
||||
@@ -429,12 +483,13 @@ class AsyncCrud(Generic[ModelType]):
|
||||
default_page_size: int = 20,
|
||||
max_page_size: int = 100,
|
||||
include_total: bool = True,
|
||||
include_facets: bool = True,
|
||||
search: bool = True,
|
||||
filter: bool = True,
|
||||
order: bool = True,
|
||||
search_fields: Sequence[SearchFieldType] | None = None,
|
||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||
order_fields: Sequence[OrderFieldType] | None = None,
|
||||
default_order_field: QueryableAttribute[Any] | None = None,
|
||||
default_order: Literal["asc", "desc"] = "asc",
|
||||
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
||||
@@ -444,6 +499,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
default_page_size: Default ``items_per_page`` value.
|
||||
max_page_size: Maximum ``items_per_page`` value.
|
||||
include_total: Whether to include total count (not a query param).
|
||||
include_facets: Whether to run facet queries (not a query param).
|
||||
search: Enable search query parameters.
|
||||
filter: Enable facet filter query parameters.
|
||||
order: Enable order query parameters.
|
||||
@@ -473,7 +529,10 @@ class AsyncCrud(Generic[ModelType]):
|
||||
]
|
||||
return cls._build_paginate_params(
|
||||
pagination_params=pagination_params,
|
||||
pagination_fixed={"include_total": include_total},
|
||||
pagination_fixed={
|
||||
"include_total": include_total,
|
||||
"include_facets": include_facets,
|
||||
},
|
||||
dep_name=f"{cls.model.__name__}OffsetPaginateParams",
|
||||
search=search,
|
||||
filter=filter,
|
||||
@@ -491,12 +550,13 @@ class AsyncCrud(Generic[ModelType]):
|
||||
*,
|
||||
default_page_size: int = 20,
|
||||
max_page_size: int = 100,
|
||||
include_facets: bool = True,
|
||||
search: bool = True,
|
||||
filter: bool = True,
|
||||
order: bool = True,
|
||||
search_fields: Sequence[SearchFieldType] | None = None,
|
||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||
order_fields: Sequence[OrderFieldType] | None = None,
|
||||
default_order_field: QueryableAttribute[Any] | None = None,
|
||||
default_order: Literal["asc", "desc"] = "asc",
|
||||
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
||||
@@ -505,6 +565,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
Args:
|
||||
default_page_size: Default ``items_per_page`` value.
|
||||
max_page_size: Maximum ``items_per_page`` value.
|
||||
include_facets: Whether to run facet queries (not a query param).
|
||||
search: Enable search query parameters.
|
||||
filter: Enable facet filter query parameters.
|
||||
order: Enable order query parameters.
|
||||
@@ -536,7 +597,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
]
|
||||
return cls._build_paginate_params(
|
||||
pagination_params=pagination_params,
|
||||
pagination_fixed={},
|
||||
pagination_fixed={"include_facets": include_facets},
|
||||
dep_name=f"{cls.model.__name__}CursorPaginateParams",
|
||||
search=search,
|
||||
filter=filter,
|
||||
@@ -556,12 +617,13 @@ class AsyncCrud(Generic[ModelType]):
|
||||
max_page_size: int = 100,
|
||||
default_pagination_type: PaginationType = PaginationType.OFFSET,
|
||||
include_total: bool = True,
|
||||
include_facets: bool = True,
|
||||
search: bool = True,
|
||||
filter: bool = True,
|
||||
order: bool = True,
|
||||
search_fields: Sequence[SearchFieldType] | None = None,
|
||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||
order_fields: Sequence[OrderFieldType] | None = None,
|
||||
default_order_field: QueryableAttribute[Any] | None = None,
|
||||
default_order: Literal["asc", "desc"] = "asc",
|
||||
) -> Callable[..., Awaitable[dict[str, Any]]]:
|
||||
@@ -572,6 +634,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
max_page_size: Maximum ``items_per_page`` value.
|
||||
default_pagination_type: Default pagination strategy.
|
||||
include_total: Whether to include total count (not a query param).
|
||||
include_facets: Whether to run facet queries (not a query param).
|
||||
search: Enable search query parameters.
|
||||
filter: Enable facet filter query parameters.
|
||||
order: Enable order query parameters.
|
||||
@@ -620,7 +683,10 @@ class AsyncCrud(Generic[ModelType]):
|
||||
]
|
||||
return cls._build_paginate_params(
|
||||
pagination_params=pagination_params,
|
||||
pagination_fixed={"include_total": include_total},
|
||||
pagination_fixed={
|
||||
"include_total": include_total,
|
||||
"include_facets": include_facets,
|
||||
},
|
||||
dep_name=f"{cls.model.__name__}PaginateParams",
|
||||
search=search,
|
||||
filter=filter,
|
||||
@@ -671,7 +737,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
Returns:
|
||||
Created model instance, or ``Response[schema]`` when ``schema`` is given.
|
||||
"""
|
||||
async with get_transaction(session):
|
||||
async with transaction(session):
|
||||
m2m_exclude = cls._m2m_schema_fields()
|
||||
data = (
|
||||
obj.model_dump(exclude=m2m_exclude) if m2m_exclude else obj.model_dump()
|
||||
@@ -684,6 +750,13 @@ class AsyncCrud(Generic[ModelType]):
|
||||
setattr(db_model, rel_attr, related_instances)
|
||||
|
||||
session.add(db_model)
|
||||
pk_values: dict[str, Any] | None = None
|
||||
if cls.default_load_options:
|
||||
await session.flush()
|
||||
pk_values = cls._capture_pk_values(db_model)
|
||||
if pk_values is not None:
|
||||
db_model = await cls._reload_with_options_by_pk(session, pk_values)
|
||||
else:
|
||||
await session.refresh(db_model)
|
||||
result = cast(ModelType, db_model)
|
||||
if schema:
|
||||
@@ -699,7 +772,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
*,
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
with_for_update: _ForUpdateMode = False,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
schema: type[SchemaType],
|
||||
) -> Response[SchemaType]: ...
|
||||
@@ -713,7 +786,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
*,
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
with_for_update: _ForUpdateMode = False,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
schema: None = ...,
|
||||
) -> ModelType: ...
|
||||
@@ -726,7 +799,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
*,
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
with_for_update: _ForUpdateMode = False,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
schema: type[BaseModel] | None = None,
|
||||
) -> ModelType | Response[Any]:
|
||||
@@ -771,7 +844,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
*,
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
with_for_update: _ForUpdateMode = False,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
schema: type[SchemaType],
|
||||
) -> Response[SchemaType] | None: ...
|
||||
@@ -785,7 +858,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
*,
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
with_for_update: _ForUpdateMode = False,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
schema: None = ...,
|
||||
) -> ModelType | None: ...
|
||||
@@ -798,7 +871,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
*,
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
with_for_update: _ForUpdateMode = False,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
schema: type[BaseModel] | None = None,
|
||||
) -> ModelType | Response[Any] | None:
|
||||
@@ -830,8 +903,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
q = q.where(and_(*filters))
|
||||
if resolved := cls._resolve_load_options(load_options):
|
||||
q = q.options(*resolved)
|
||||
if with_for_update:
|
||||
q = q.with_for_update()
|
||||
q = _apply_for_update(q, with_for_update)
|
||||
result = await session.execute(q)
|
||||
item = result.unique().scalar_one_or_none()
|
||||
if item is None:
|
||||
@@ -850,7 +922,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
*,
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
with_for_update: _ForUpdateMode = False,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
schema: type[SchemaType],
|
||||
) -> Response[SchemaType] | None: ...
|
||||
@@ -864,7 +936,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
*,
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
with_for_update: _ForUpdateMode = False,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
schema: None = ...,
|
||||
) -> ModelType | None: ...
|
||||
@@ -877,7 +949,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
*,
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: bool = False,
|
||||
with_for_update: _ForUpdateMode = False,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
schema: type[BaseModel] | None = None,
|
||||
) -> ModelType | Response[Any] | None:
|
||||
@@ -903,8 +975,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
q = q.where(and_(*filters))
|
||||
if resolved := cls._resolve_load_options(load_options):
|
||||
q = q.options(*resolved)
|
||||
if with_for_update:
|
||||
q = q.with_for_update()
|
||||
q = _apply_for_update(q, with_for_update)
|
||||
result = await session.execute(q)
|
||||
item = result.unique().scalars().first()
|
||||
if item is None:
|
||||
@@ -922,6 +993,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
filters: list[Any] | None = None,
|
||||
joins: JoinType | None = None,
|
||||
outer_join: bool = False,
|
||||
with_for_update: _ForUpdateMode = False,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
order_by: OrderByClause | None = None,
|
||||
limit: int | None = None,
|
||||
@@ -934,6 +1006,9 @@ class AsyncCrud(Generic[ModelType]):
|
||||
filters: List of SQLAlchemy filter conditions
|
||||
joins: List of (model, condition) tuples for joining related tables
|
||||
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
|
||||
order_by: Column or list of columns to order by
|
||||
limit: Max number of rows to return
|
||||
@@ -948,6 +1023,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
q = q.where(and_(*filters))
|
||||
if resolved := cls._resolve_load_options(load_options):
|
||||
q = q.options(*resolved)
|
||||
q = _apply_for_update(q, with_for_update)
|
||||
if order_by is not None:
|
||||
q = q.order_by(order_by)
|
||||
if offset is not None:
|
||||
@@ -967,6 +1043,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
*,
|
||||
exclude_unset: bool = True,
|
||||
exclude_none: bool = False,
|
||||
with_for_update: _ForUpdateMode = False,
|
||||
schema: type[SchemaType],
|
||||
) -> Response[SchemaType]: ...
|
||||
|
||||
@@ -980,6 +1057,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
*,
|
||||
exclude_unset: bool = True,
|
||||
exclude_none: bool = False,
|
||||
with_for_update: _ForUpdateMode = False,
|
||||
schema: None = ...,
|
||||
) -> ModelType: ...
|
||||
|
||||
@@ -992,6 +1070,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
*,
|
||||
exclude_unset: bool = True,
|
||||
exclude_none: bool = False,
|
||||
with_for_update: _ForUpdateMode = False,
|
||||
schema: type[BaseModel] | None = None,
|
||||
) -> ModelType | Response[Any]:
|
||||
"""Update a record in the database.
|
||||
@@ -1002,6 +1081,9 @@ class AsyncCrud(Generic[ModelType]):
|
||||
filters: List of SQLAlchemy filter conditions
|
||||
exclude_unset: Exclude fields not explicitly set in the schema
|
||||
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,
|
||||
the result is automatically wrapped in a ``Response[schema]``.
|
||||
|
||||
@@ -1011,7 +1093,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
Raises:
|
||||
NotFoundError: If no record found
|
||||
"""
|
||||
async with get_transaction(session):
|
||||
async with transaction(session):
|
||||
m2m_exclude = cls._m2m_schema_fields()
|
||||
|
||||
# Eagerly load M2M relationships that will be updated so that
|
||||
@@ -1025,6 +1107,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
db_model = await cls.get(
|
||||
session=session,
|
||||
filters=filters,
|
||||
with_for_update=with_for_update,
|
||||
load_options=m2m_load_options or None,
|
||||
)
|
||||
values = obj.model_dump(
|
||||
@@ -1039,6 +1122,14 @@ class AsyncCrud(Generic[ModelType]):
|
||||
m2m_resolved = await cls._resolve_m2m(session, obj, only_set=True)
|
||||
for rel_attr, related_instances in m2m_resolved.items():
|
||||
setattr(db_model, rel_attr, related_instances)
|
||||
|
||||
pk_values: dict[str, Any] | None = None
|
||||
if cls.default_load_options:
|
||||
await session.flush()
|
||||
pk_values = cls._capture_pk_values(db_model)
|
||||
if pk_values is not None:
|
||||
db_model = await cls._reload_with_options_by_pk(session, pk_values)
|
||||
else:
|
||||
await session.refresh(db_model)
|
||||
if schema:
|
||||
return Response(data=schema.model_validate(db_model))
|
||||
@@ -1068,7 +1159,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
Returns:
|
||||
Model instance
|
||||
"""
|
||||
async with get_transaction(session):
|
||||
async with transaction(session):
|
||||
values = obj.model_dump(exclude_unset=True)
|
||||
q = insert(cls.model).values(**values)
|
||||
if set_:
|
||||
@@ -1130,7 +1221,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
Returns:
|
||||
``None``, or ``Response[None]`` when ``return_response=True``.
|
||||
"""
|
||||
async with get_transaction(session):
|
||||
async with transaction(session):
|
||||
result = await session.execute(select(cls.model).where(and_(*filters)))
|
||||
objects = result.scalars().all()
|
||||
for obj in objects:
|
||||
@@ -1202,13 +1293,16 @@ class AsyncCrud(Generic[ModelType]):
|
||||
outer_join: bool = False,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
order_by: OrderByClause | None = None,
|
||||
order_joins: list[Any] | None = None,
|
||||
page: int = 1,
|
||||
items_per_page: int = 20,
|
||||
include_total: bool = True,
|
||||
search: str | SearchConfig | None = None,
|
||||
search_fields: Sequence[SearchFieldType] | None = None,
|
||||
search_column: str | None = None,
|
||||
order_fields: Sequence[OrderFieldType] | None = None,
|
||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||
include_facets: bool = True,
|
||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||
schema: type[BaseModel],
|
||||
) -> OffsetPaginatedResponse[Any]:
|
||||
@@ -1228,7 +1322,11 @@ class AsyncCrud(Generic[ModelType]):
|
||||
search: Search query string or SearchConfig object
|
||||
search_fields: Fields to search in (overrides class default)
|
||||
search_column: Restrict search to a single column key.
|
||||
order_fields: Fields allowed for sorting (overrides class default).
|
||||
facet_fields: Columns to compute distinct values for (overrides class default)
|
||||
include_facets: When ``False``, skip facet queries entirely;
|
||||
``filter_attributes`` will be ``None``. Useful on pages 2..N
|
||||
where the facet counts were already fetched on page 1.
|
||||
filter_by: Dict of {column_key: value} to filter by declared facet fields.
|
||||
Keys must match the column.key of a facet field. Scalar → equality,
|
||||
list → IN clause. Raises InvalidFacetFilterError for unknown keys.
|
||||
@@ -1241,7 +1339,6 @@ class AsyncCrud(Generic[ModelType]):
|
||||
offset = (page - 1) * items_per_page
|
||||
|
||||
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
|
||||
filters.extend(fb_filters)
|
||||
|
||||
# Build search filters
|
||||
if search:
|
||||
@@ -1255,6 +1352,11 @@ class AsyncCrud(Generic[ModelType]):
|
||||
filters.extend(search_filters)
|
||||
search_joins.extend(new_search_joins)
|
||||
|
||||
# Facets combine these with each facet's own filter individually, so
|
||||
# fb_filters is applied to the query below but excluded here.
|
||||
facet_base_filters = list(filters)
|
||||
filters.extend(fb_filters.values())
|
||||
|
||||
# Build query with joins
|
||||
q = select(cls.model)
|
||||
|
||||
@@ -1262,7 +1364,11 @@ class AsyncCrud(Generic[ModelType]):
|
||||
q = _apply_joins(q, joins, outer_join)
|
||||
|
||||
# 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:
|
||||
q = q.where(and_(*filters))
|
||||
@@ -1285,13 +1391,13 @@ class AsyncCrud(Generic[ModelType]):
|
||||
count_q = _apply_joins(count_q, joins, outer_join)
|
||||
|
||||
# Apply search joins to count query
|
||||
count_q = _apply_search_joins(count_q, search_joins)
|
||||
count_q = apply_search_joins(count_q, search_joins)
|
||||
|
||||
if filters:
|
||||
count_q = count_q.where(and_(*filters))
|
||||
|
||||
count_result = await session.execute(count_q)
|
||||
total_count: int | None = count_result.scalar_one()
|
||||
total_count: int = count_result.scalar_one()
|
||||
has_more = page * items_per_page < total_count
|
||||
else:
|
||||
# Fetch one extra row to detect if a next page exists without COUNT
|
||||
@@ -1305,9 +1411,15 @@ class AsyncCrud(Generic[ModelType]):
|
||||
items: list[Any] = [schema.model_validate(item) for item in raw_items]
|
||||
|
||||
filter_attributes = await cls._build_filter_attributes(
|
||||
session, facet_fields, filters, search_joins
|
||||
session,
|
||||
facet_fields,
|
||||
facet_base_filters,
|
||||
search_joins,
|
||||
include_facets=include_facets,
|
||||
own_filters=fb_filters,
|
||||
)
|
||||
search_columns = cls._resolve_search_columns(search_fields)
|
||||
order_columns = cls._resolve_order_columns(order_fields)
|
||||
|
||||
return OffsetPaginatedResponse(
|
||||
data=items,
|
||||
@@ -1319,6 +1431,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
),
|
||||
filter_attributes=filter_attributes,
|
||||
search_columns=search_columns,
|
||||
order_columns=order_columns,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -1332,11 +1445,14 @@ class AsyncCrud(Generic[ModelType]):
|
||||
outer_join: bool = False,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
order_by: OrderByClause | None = None,
|
||||
order_joins: list[Any] | None = None,
|
||||
items_per_page: int = 20,
|
||||
search: str | SearchConfig | None = None,
|
||||
search_fields: Sequence[SearchFieldType] | None = None,
|
||||
search_column: str | None = None,
|
||||
order_fields: Sequence[OrderFieldType] | None = None,
|
||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||
include_facets: bool = True,
|
||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||
schema: type[BaseModel],
|
||||
) -> CursorPaginatedResponse[Any]:
|
||||
@@ -1357,7 +1473,10 @@ class AsyncCrud(Generic[ModelType]):
|
||||
search: Search query string or SearchConfig object.
|
||||
search_fields: Fields to search in (overrides class default).
|
||||
search_column: Restrict search to a single column key.
|
||||
order_fields: Fields allowed for sorting (overrides class default).
|
||||
facet_fields: Columns to compute distinct values for (overrides class default).
|
||||
include_facets: When ``False``, skip facet queries entirely;
|
||||
``filter_attributes`` will be ``None``.
|
||||
filter_by: Dict of {column_key: value} to filter by declared facet fields.
|
||||
Keys must match the column.key of a facet field. Scalar → equality,
|
||||
list → IN clause. Raises InvalidFacetFilterError for unknown keys.
|
||||
@@ -1369,7 +1488,6 @@ class AsyncCrud(Generic[ModelType]):
|
||||
filters = list(filters) if filters else []
|
||||
|
||||
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
|
||||
filters.extend(fb_filters)
|
||||
|
||||
if cls.cursor_column is None:
|
||||
raise ValueError(
|
||||
@@ -1401,6 +1519,11 @@ class AsyncCrud(Generic[ModelType]):
|
||||
filters.extend(search_filters)
|
||||
search_joins.extend(new_search_joins)
|
||||
|
||||
# Facets combine these with each facet's own filter individually, so
|
||||
# fb_filters is applied to the query below but excluded here.
|
||||
facet_base_filters = list(filters)
|
||||
filters.extend(fb_filters.values())
|
||||
|
||||
# Build query
|
||||
q = select(cls.model)
|
||||
|
||||
@@ -1408,7 +1531,11 @@ class AsyncCrud(Generic[ModelType]):
|
||||
q = _apply_joins(q, joins, outer_join)
|
||||
|
||||
# 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:
|
||||
q = q.where(and_(*filters))
|
||||
@@ -1453,11 +1580,10 @@ class AsyncCrud(Generic[ModelType]):
|
||||
|
||||
# prev_cursor: points before the first item in ascending order
|
||||
prev_cursor: str | None = None
|
||||
if direction is _CursorDirection.NEXT and cursor is not None and items_page:
|
||||
prev_cursor = _encode_cursor(
|
||||
getattr(items_page[0], cursor_col_name), direction=_CursorDirection.PREV
|
||||
)
|
||||
elif direction is _CursorDirection.PREV and has_more and items_page:
|
||||
if items_page and (
|
||||
(direction is _CursorDirection.NEXT and cursor is not None)
|
||||
or (direction is _CursorDirection.PREV and has_more)
|
||||
):
|
||||
prev_cursor = _encode_cursor(
|
||||
getattr(items_page[0], cursor_col_name), direction=_CursorDirection.PREV
|
||||
)
|
||||
@@ -1465,9 +1591,15 @@ class AsyncCrud(Generic[ModelType]):
|
||||
items: list[Any] = [schema.model_validate(item) for item in items_page]
|
||||
|
||||
filter_attributes = await cls._build_filter_attributes(
|
||||
session, facet_fields, filters, search_joins
|
||||
session,
|
||||
facet_fields,
|
||||
facet_base_filters,
|
||||
search_joins,
|
||||
include_facets=include_facets,
|
||||
own_filters=fb_filters,
|
||||
)
|
||||
search_columns = cls._resolve_search_columns(search_fields)
|
||||
order_columns = cls._resolve_order_columns(order_fields)
|
||||
|
||||
return CursorPaginatedResponse(
|
||||
data=items,
|
||||
@@ -1479,6 +1611,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
),
|
||||
filter_attributes=filter_attributes,
|
||||
search_columns=search_columns,
|
||||
order_columns=order_columns,
|
||||
)
|
||||
|
||||
@overload
|
||||
@@ -1493,6 +1626,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
outer_join: bool = ...,
|
||||
load_options: Sequence[ExecutableOption] | None = ...,
|
||||
order_by: OrderByClause | None = ...,
|
||||
order_joins: list[Any] | None = ...,
|
||||
page: int = ...,
|
||||
cursor: str | None = ...,
|
||||
items_per_page: int = ...,
|
||||
@@ -1500,7 +1634,9 @@ class AsyncCrud(Generic[ModelType]):
|
||||
search: str | SearchConfig | None = ...,
|
||||
search_fields: Sequence[SearchFieldType] | None = ...,
|
||||
search_column: str | None = ...,
|
||||
order_fields: Sequence[OrderFieldType] | None = ...,
|
||||
facet_fields: Sequence[FacetFieldType] | None = ...,
|
||||
include_facets: bool = ...,
|
||||
filter_by: dict[str, Any] | BaseModel | None = ...,
|
||||
schema: type[BaseModel],
|
||||
) -> OffsetPaginatedResponse[Any]: ...
|
||||
@@ -1517,6 +1653,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
outer_join: bool = ...,
|
||||
load_options: Sequence[ExecutableOption] | None = ...,
|
||||
order_by: OrderByClause | None = ...,
|
||||
order_joins: list[Any] | None = ...,
|
||||
page: int = ...,
|
||||
cursor: str | None = ...,
|
||||
items_per_page: int = ...,
|
||||
@@ -1524,7 +1661,9 @@ class AsyncCrud(Generic[ModelType]):
|
||||
search: str | SearchConfig | None = ...,
|
||||
search_fields: Sequence[SearchFieldType] | None = ...,
|
||||
search_column: str | None = ...,
|
||||
order_fields: Sequence[OrderFieldType] | None = ...,
|
||||
facet_fields: Sequence[FacetFieldType] | None = ...,
|
||||
include_facets: bool = ...,
|
||||
filter_by: dict[str, Any] | BaseModel | None = ...,
|
||||
schema: type[BaseModel],
|
||||
) -> CursorPaginatedResponse[Any]: ...
|
||||
@@ -1540,6 +1679,7 @@ class AsyncCrud(Generic[ModelType]):
|
||||
outer_join: bool = False,
|
||||
load_options: Sequence[ExecutableOption] | None = None,
|
||||
order_by: OrderByClause | None = None,
|
||||
order_joins: list[Any] | None = None,
|
||||
page: int = 1,
|
||||
cursor: str | None = None,
|
||||
items_per_page: int = 20,
|
||||
@@ -1547,7 +1687,9 @@ class AsyncCrud(Generic[ModelType]):
|
||||
search: str | SearchConfig | None = None,
|
||||
search_fields: Sequence[SearchFieldType] | None = None,
|
||||
search_column: str | None = None,
|
||||
order_fields: Sequence[OrderFieldType] | None = None,
|
||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||
include_facets: bool = True,
|
||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||
schema: type[BaseModel],
|
||||
) -> OffsetPaginatedResponse[Any] | CursorPaginatedResponse[Any]:
|
||||
@@ -1575,8 +1717,11 @@ class AsyncCrud(Generic[ModelType]):
|
||||
search: Search query string or :class:`.SearchConfig` object.
|
||||
search_fields: Fields to search in (overrides class default).
|
||||
search_column: Restrict search to a single column key.
|
||||
order_fields: Fields allowed for sorting (overrides class default).
|
||||
facet_fields: Columns to compute distinct values for (overrides
|
||||
class default).
|
||||
include_facets: When ``False``, skip facet queries entirely;
|
||||
``filter_attributes`` will be ``None``.
|
||||
filter_by: Dict of ``{column_key: value}`` to filter by declared
|
||||
facet fields. Keys must match the ``column.key`` of a facet
|
||||
field. Scalar → equality, list → IN clause. Raises
|
||||
@@ -1600,11 +1745,14 @@ class AsyncCrud(Generic[ModelType]):
|
||||
outer_join=outer_join,
|
||||
load_options=load_options,
|
||||
order_by=order_by,
|
||||
order_joins=order_joins,
|
||||
items_per_page=items_per_page,
|
||||
search=search,
|
||||
search_fields=search_fields,
|
||||
search_column=search_column,
|
||||
order_fields=order_fields,
|
||||
facet_fields=facet_fields,
|
||||
include_facets=include_facets,
|
||||
filter_by=filter_by,
|
||||
schema=schema,
|
||||
)
|
||||
@@ -1618,13 +1766,16 @@ class AsyncCrud(Generic[ModelType]):
|
||||
outer_join=outer_join,
|
||||
load_options=load_options,
|
||||
order_by=order_by,
|
||||
order_joins=order_joins,
|
||||
page=page,
|
||||
items_per_page=items_per_page,
|
||||
include_total=include_total,
|
||||
search=search,
|
||||
search_fields=search_fields,
|
||||
search_column=search_column,
|
||||
order_fields=order_fields,
|
||||
facet_fields=facet_fields,
|
||||
include_facets=include_facets,
|
||||
filter_by=filter_by,
|
||||
schema=schema,
|
||||
)
|
||||
@@ -1638,7 +1789,7 @@ def CrudFactory(
|
||||
base_class: type[AsyncCrud[Any]] = AsyncCrud,
|
||||
searchable_fields: Sequence[SearchFieldType] | None = None,
|
||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||
order_fields: Sequence[QueryableAttribute[Any]] | None = None,
|
||||
order_fields: Sequence[OrderFieldType] | None = None,
|
||||
m2m_fields: M2MFieldType | None = None,
|
||||
default_load_options: Sequence[ExecutableOption] | None = None,
|
||||
cursor_column: Any | None = None,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Search utilities for AsyncCrud."""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from sqlalchemy import String, and_, func, or_, select
|
||||
from sqlalchemy import String, and_, distinct, func, or_, select
|
||||
from sqlalchemy.dialects.postgresql import aggregate_order_by
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
@@ -159,8 +159,11 @@ def build_search_filters(
|
||||
else:
|
||||
column = field
|
||||
|
||||
# Build the filter (cast to String for non-text columns)
|
||||
column_as_string = column.cast(String)
|
||||
# Build the filter (cast to String only when needed, to preserve
|
||||
# pg_trgm GIN index usability on already-String columns)
|
||||
column_as_string = (
|
||||
column if isinstance(column.type, String) else column.cast(String)
|
||||
)
|
||||
if config.case_sensitive:
|
||||
filters.append(column_as_string.like(f"%{query}%"))
|
||||
else:
|
||||
@@ -181,6 +184,21 @@ def search_field_keys(fields: Sequence[SearchFieldType]) -> list[str]:
|
||||
return facet_keys(fields)
|
||||
|
||||
|
||||
def apply_search_joins(q: Any, joins: Sequence[Any]) -> Any:
|
||||
"""Apply relationship-based outer joins (from search/filter_by/facets) to a query.
|
||||
|
||||
Deduplicates by relationship identity so a join used by several fields
|
||||
(e.g. search + a facet on the same relation) is only applied once.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
for rel in joins:
|
||||
rel_key = str(rel)
|
||||
if rel_key not in seen:
|
||||
seen.add(rel_key)
|
||||
q = q.outerjoin(rel)
|
||||
return q
|
||||
|
||||
|
||||
def facet_keys(facet_fields: Sequence[FacetFieldType]) -> list[str]:
|
||||
"""Return a key for each facet field.
|
||||
|
||||
@@ -207,6 +225,7 @@ async def build_facets(
|
||||
*,
|
||||
base_filters: "list[ColumnElement[bool]] | None" = None,
|
||||
base_joins: list[InstrumentedAttribute[Any]] | None = None,
|
||||
own_filters: "dict[str, ColumnElement[bool]] | None" = None,
|
||||
) -> dict[str, list[Any]]:
|
||||
"""Return distinct values for each facet field, respecting current filters.
|
||||
|
||||
@@ -216,15 +235,24 @@ async def build_facets(
|
||||
facet_fields: Columns or relationship tuples to facet on
|
||||
base_filters: Filter conditions already applied to the main query (search + caller filters)
|
||||
base_joins: Relationship joins already applied to the main query
|
||||
own_filters: Map of facet key -> the ``filter_by`` condition for that
|
||||
same key (if any). Excluded from that facet's own subquery so
|
||||
filtering on a facet doesn't collapse its own value list down to
|
||||
just the filtered value.
|
||||
|
||||
Returns:
|
||||
Dict mapping column key to sorted list of distinct non-None values
|
||||
"""
|
||||
existing_join_keys: set[str] = {str(j) for j in (base_joins or [])}
|
||||
if not facet_fields:
|
||||
return {}
|
||||
|
||||
keys = facet_keys(facet_fields)
|
||||
own_filters = own_filters or {}
|
||||
|
||||
async def _query_facet(field: FacetFieldType, key: str) -> tuple[str, list[Any]]:
|
||||
scalars: list[Any] = []
|
||||
enum_classes: dict[str, Any] = {}
|
||||
|
||||
for field, key in zip(facet_fields, keys):
|
||||
if isinstance(field, tuple):
|
||||
# Relationship chain: (User.role, Role.name) — last element is the column
|
||||
rels = field[:-1]
|
||||
@@ -235,43 +263,48 @@ async def build_facets(
|
||||
|
||||
col_type = column.property.columns[0].type
|
||||
is_array = isinstance(col_type, ARRAY)
|
||||
enum_classes[key] = getattr(col_type, "enum_class", None)
|
||||
|
||||
filters = [
|
||||
*(base_filters or []),
|
||||
*(f for k, f in own_filters.items() if k != key),
|
||||
]
|
||||
joins = [*(base_joins or []), *rels]
|
||||
|
||||
if is_array:
|
||||
unnested = func.unnest(column).label(column.key)
|
||||
q = select(unnested).select_from(model).distinct()
|
||||
else:
|
||||
q = select(column).select_from(model).distinct()
|
||||
|
||||
# Apply base joins (deduplicated) — needed here independently
|
||||
seen_joins: set[str] = set()
|
||||
for rel in base_joins or []:
|
||||
rel_key = str(rel)
|
||||
if rel_key not in seen_joins:
|
||||
seen_joins.add(rel_key)
|
||||
q = q.outerjoin(rel)
|
||||
|
||||
# Add any extra joins required by this facet field that aren't already applied
|
||||
for rel in rels:
|
||||
rel_key = str(rel)
|
||||
if rel_key not in existing_join_keys and rel_key not in seen_joins:
|
||||
seen_joins.add(rel_key)
|
||||
q = q.outerjoin(rel)
|
||||
|
||||
if base_filters:
|
||||
q = q.where(and_(*base_filters))
|
||||
|
||||
if is_array:
|
||||
q = q.order_by(unnested)
|
||||
else:
|
||||
q = q.order_by(column)
|
||||
result = await session.execute(q)
|
||||
values = [row[0] for row in result.all() if row[0] is not None]
|
||||
return key, values
|
||||
|
||||
pairs = await asyncio.gather(
|
||||
*[_query_facet(f, k) for f, k in zip(facet_fields, keys)]
|
||||
unnested = apply_search_joins(
|
||||
select(func.unnest(column).label("v")).select_from(model), joins
|
||||
)
|
||||
return dict(pairs)
|
||||
if filters:
|
||||
unnested = unnested.where(and_(*filters))
|
||||
unnested_sq = unnested.subquery()
|
||||
v = unnested_sq.c.v
|
||||
agg = (
|
||||
select(func.array_agg(aggregate_order_by(distinct(v), v)))
|
||||
.select_from(unnested_sq)
|
||||
.where(v.isnot(None))
|
||||
)
|
||||
else:
|
||||
agg = apply_search_joins(
|
||||
select(
|
||||
func.array_agg(aggregate_order_by(distinct(column), column))
|
||||
).select_from(model),
|
||||
joins,
|
||||
)
|
||||
agg = agg.where(and_(*filters, column.isnot(None)))
|
||||
|
||||
scalars.append(agg.scalar_subquery().label(key))
|
||||
|
||||
row = (await session.execute(select(*scalars))).one()
|
||||
|
||||
facets: dict[str, list[Any]] = {}
|
||||
for key, values in zip(keys, row):
|
||||
enum_class = enum_classes[key]
|
||||
facets[key] = [
|
||||
v.name if (enum_class is not None and isinstance(v, enum_class)) else v
|
||||
for v in (values or [])
|
||||
]
|
||||
return facets
|
||||
|
||||
|
||||
_EQUALITY_TYPES = (String, Integer, Numeric, Date, DateTime, Time, Enum, Uuid)
|
||||
@@ -293,7 +326,7 @@ def _coerce_bool(value: Any) -> bool:
|
||||
def build_filter_by(
|
||||
filter_by: dict[str, Any],
|
||||
facet_fields: Sequence[FacetFieldType],
|
||||
) -> tuple["list[ColumnElement[bool]]", list[InstrumentedAttribute[Any]]]:
|
||||
) -> tuple["dict[str, ColumnElement[bool]]", list[InstrumentedAttribute[Any]]]:
|
||||
"""Translate a {column_key: value} dict into SQLAlchemy filter conditions.
|
||||
|
||||
Args:
|
||||
@@ -301,7 +334,9 @@ def build_filter_by(
|
||||
facet_fields: Declared facet fields to validate keys against
|
||||
|
||||
Returns:
|
||||
Tuple of (filter_conditions, joins_needed)
|
||||
Tuple of ({facet_key: filter_condition}, joins_needed). One filter
|
||||
condition per key, so callers can identify (and exclude) a facet's
|
||||
own filter when computing that facet's distinct values.
|
||||
|
||||
Raises:
|
||||
InvalidFacetFilterError: If a key in filter_by is not a declared facet field
|
||||
@@ -319,7 +354,7 @@ def build_filter_by(
|
||||
index[key] = (column, rels)
|
||||
|
||||
valid_keys = set(index)
|
||||
filters: list[ColumnElement[bool]] = []
|
||||
filters: dict[str, ColumnElement[bool]] = {}
|
||||
joins: list[InstrumentedAttribute[Any]] = []
|
||||
added_join_keys: set[str] = set()
|
||||
|
||||
@@ -339,19 +374,37 @@ def build_filter_by(
|
||||
if isinstance(col_type, Boolean):
|
||||
coerce = _coerce_bool
|
||||
if isinstance(value, list):
|
||||
filters.append(column.in_([coerce(v) for v in value]))
|
||||
filters[key] = column.in_([coerce(v) for v in value])
|
||||
else:
|
||||
filters.append(column == coerce(value))
|
||||
filters[key] = column == coerce(value)
|
||||
elif isinstance(col_type, ARRAY):
|
||||
if isinstance(value, list):
|
||||
filters.append(column.overlap(value))
|
||||
filters[key] = column.overlap(value)
|
||||
else:
|
||||
filters.append(column.any(value))
|
||||
filters[key] = 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, enum_class: Any = enum_class) -> Any:
|
||||
if isinstance(v, enum_class):
|
||||
return v
|
||||
return enum_class[v] # lookup by name: "PENDING", "RED"
|
||||
|
||||
if isinstance(value, list):
|
||||
filters[key] = column.in_([_coerce_enum(v) for v in value])
|
||||
else:
|
||||
filters[key] = column == _coerce_enum(value)
|
||||
else: # pragma: no cover
|
||||
if isinstance(value, list):
|
||||
filters[key] = column.in_(value)
|
||||
else:
|
||||
filters[key] = column == value
|
||||
elif isinstance(col_type, _EQUALITY_TYPES):
|
||||
if isinstance(value, list):
|
||||
filters.append(column.in_(value))
|
||||
filters[key] = column.in_(value)
|
||||
else:
|
||||
filters.append(column == value)
|
||||
filters[key] = column == value
|
||||
else:
|
||||
raise UnsupportedFacetTypeError(key, type(col_type).__name__)
|
||||
|
||||
|
||||
@@ -1,341 +0,0 @@
|
||||
"""Database utilities: sessions, transactions, and locks."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from enum import Enum
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from .exceptions import NotFoundError
|
||||
|
||||
__all__ = [
|
||||
"LockMode",
|
||||
"cleanup_tables",
|
||||
"create_database",
|
||||
"create_db_context",
|
||||
"create_db_dependency",
|
||||
"get_transaction",
|
||||
"lock_tables",
|
||||
"wait_for_row_change",
|
||||
]
|
||||
|
||||
|
||||
_SessionT = TypeVar("_SessionT", bound=AsyncSession)
|
||||
|
||||
|
||||
def create_db_dependency(
|
||||
session_maker: async_sessionmaker[_SessionT],
|
||||
) -> Callable[[], AsyncGenerator[_SessionT, None]]:
|
||||
"""Create a FastAPI dependency for database sessions.
|
||||
|
||||
Creates a dependency function that yields a session and auto-commits
|
||||
if a transaction is active when the request completes.
|
||||
|
||||
Args:
|
||||
session_maker: Async session factory from create_session_factory()
|
||||
|
||||
Returns:
|
||||
An async generator function usable with FastAPI's Depends()
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from fastapi_toolsets.db import create_db_dependency
|
||||
|
||||
engine = create_async_engine("postgresql+asyncpg://...")
|
||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
get_db = create_db_dependency(SessionLocal)
|
||||
|
||||
@app.get("/users")
|
||||
async def list_users(session: AsyncSession = Depends(get_db)):
|
||||
...
|
||||
```
|
||||
"""
|
||||
|
||||
async def get_db() -> AsyncGenerator[_SessionT, None]:
|
||||
async with session_maker() as session:
|
||||
await session.connection()
|
||||
yield session
|
||||
if session.in_transaction():
|
||||
await session.commit()
|
||||
|
||||
return get_db
|
||||
|
||||
|
||||
def create_db_context(
|
||||
session_maker: async_sessionmaker[_SessionT],
|
||||
) -> Callable[[], AbstractAsyncContextManager[_SessionT]]:
|
||||
"""Create a context manager for database sessions.
|
||||
|
||||
Creates a context manager for use outside of FastAPI request handlers,
|
||||
such as in background tasks, CLI commands, or tests.
|
||||
|
||||
Args:
|
||||
session_maker: Async session factory from create_session_factory()
|
||||
|
||||
Returns:
|
||||
An async context manager function
|
||||
|
||||
Example:
|
||||
```python
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
from fastapi_toolsets.db import create_db_context
|
||||
|
||||
engine = create_async_engine("postgresql+asyncpg://...")
|
||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
get_db_context = create_db_context(SessionLocal)
|
||||
|
||||
async def background_task():
|
||||
async with get_db_context() as session:
|
||||
user = await UserCrud.get(session, [User.id == 1])
|
||||
...
|
||||
```
|
||||
"""
|
||||
get_db = create_db_dependency(session_maker)
|
||||
return asynccontextmanager(get_db)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_transaction(
|
||||
session: AsyncSession,
|
||||
) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Get a transaction context, handling nested transactions.
|
||||
|
||||
If already in a transaction, creates a savepoint (nested transaction).
|
||||
Otherwise, starts a new transaction.
|
||||
|
||||
Args:
|
||||
session: AsyncSession instance
|
||||
|
||||
Yields:
|
||||
The session within the transaction context
|
||||
|
||||
Example:
|
||||
```python
|
||||
async with get_transaction(session):
|
||||
session.add(model)
|
||||
# Auto-commits on exit, rolls back on exception
|
||||
```
|
||||
"""
|
||||
if session.in_transaction():
|
||||
async with session.begin_nested():
|
||||
yield session
|
||||
else:
|
||||
async with session.begin():
|
||||
yield session
|
||||
|
||||
|
||||
class LockMode(str, Enum):
|
||||
"""PostgreSQL table lock modes.
|
||||
|
||||
See: https://www.postgresql.org/docs/current/explicit-locking.html
|
||||
"""
|
||||
|
||||
ACCESS_SHARE = "ACCESS SHARE"
|
||||
ROW_SHARE = "ROW SHARE"
|
||||
ROW_EXCLUSIVE = "ROW EXCLUSIVE"
|
||||
SHARE_UPDATE_EXCLUSIVE = "SHARE UPDATE EXCLUSIVE"
|
||||
SHARE = "SHARE"
|
||||
SHARE_ROW_EXCLUSIVE = "SHARE ROW EXCLUSIVE"
|
||||
EXCLUSIVE = "EXCLUSIVE"
|
||||
ACCESS_EXCLUSIVE = "ACCESS EXCLUSIVE"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lock_tables(
|
||||
session: AsyncSession,
|
||||
tables: list[type[DeclarativeBase]],
|
||||
*,
|
||||
mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE,
|
||||
timeout: str = "5s",
|
||||
) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""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:
|
||||
session: AsyncSession instance
|
||||
tables: List of SQLAlchemy model classes to lock
|
||||
mode: Lock mode (default: SHARE UPDATE EXCLUSIVE)
|
||||
timeout: Lock timeout (default: "5s")
|
||||
|
||||
Yields:
|
||||
The session with locked tables
|
||||
|
||||
Raises:
|
||||
SQLAlchemyError: If lock cannot be acquired within timeout
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastapi_toolsets.db import lock_tables, LockMode
|
||||
|
||||
async with lock_tables(session, [User, Account]):
|
||||
# Tables are locked with SHARE UPDATE EXCLUSIVE mode
|
||||
user = await UserCrud.get(session, [User.id == 1])
|
||||
user.balance += 100
|
||||
|
||||
# With custom lock mode
|
||||
async with lock_tables(session, [Order], mode=LockMode.EXCLUSIVE):
|
||||
# Exclusive lock - no other transactions can access
|
||||
await process_order(session, order_id)
|
||||
```
|
||||
"""
|
||||
table_names = ",".join(table.__tablename__ for table in tables)
|
||||
|
||||
async with get_transaction(session):
|
||||
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
|
||||
await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
|
||||
yield session
|
||||
|
||||
|
||||
async def create_database(
|
||||
db_name: str,
|
||||
*,
|
||||
server_url: str,
|
||||
) -> None:
|
||||
"""Create a database.
|
||||
|
||||
Connects to *server_url* using ``AUTOCOMMIT`` isolation and issues a
|
||||
``CREATE DATABASE`` statement for *db_name*.
|
||||
|
||||
Args:
|
||||
db_name: Name of the database to create.
|
||||
server_url: URL used for server-level DDL (must point to an existing
|
||||
database on the same server).
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastapi_toolsets.db import create_database
|
||||
|
||||
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
|
||||
await create_database("myapp_test", server_url=SERVER_URL)
|
||||
```
|
||||
"""
|
||||
engine = create_async_engine(server_url, isolation_level="AUTOCOMMIT")
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text(f"CREATE DATABASE {db_name}"))
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def cleanup_tables(
|
||||
session: AsyncSession,
|
||||
base: type[DeclarativeBase],
|
||||
) -> None:
|
||||
"""Truncate all tables for fast between-test cleanup.
|
||||
|
||||
Executes a single ``TRUNCATE … RESTART IDENTITY CASCADE`` statement
|
||||
across every table in *base*'s metadata, which is significantly faster
|
||||
than dropping and re-creating tables between tests.
|
||||
|
||||
This is a no-op when the metadata contains no tables.
|
||||
|
||||
Args:
|
||||
session: An active async database session.
|
||||
base: SQLAlchemy DeclarativeBase class containing model metadata.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@pytest.fixture
|
||||
async def db_session(worker_db_url):
|
||||
async with create_db_session(worker_db_url, Base) as session:
|
||||
yield session
|
||||
await cleanup_tables(session, Base)
|
||||
```
|
||||
"""
|
||||
tables = base.metadata.sorted_tables
|
||||
if not tables:
|
||||
return
|
||||
|
||||
table_names = ", ".join(f'"{t.name}"' for t in tables)
|
||||
await session.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
_M = TypeVar("_M", bound=DeclarativeBase)
|
||||
|
||||
|
||||
async def wait_for_row_change(
|
||||
session: AsyncSession,
|
||||
model: type[_M],
|
||||
pk_value: Any,
|
||||
*,
|
||||
columns: list[str] | None = None,
|
||||
interval: float = 0.5,
|
||||
timeout: float | None = None,
|
||||
) -> _M:
|
||||
"""Poll a database row until a change is detected.
|
||||
|
||||
Queries the row every ``interval`` seconds and returns the model instance
|
||||
once a change is detected in any column (or only the specified ``columns``).
|
||||
|
||||
Args:
|
||||
session: AsyncSession instance
|
||||
model: SQLAlchemy model class
|
||||
pk_value: Primary key value of the row to watch
|
||||
columns: Optional list of column names to watch. If None, all columns
|
||||
are watched.
|
||||
interval: Polling interval in seconds (default: 0.5)
|
||||
timeout: Maximum time to wait in seconds. None means wait forever.
|
||||
|
||||
Returns:
|
||||
The refreshed model instance with updated values
|
||||
|
||||
Raises:
|
||||
NotFoundError: If the row does not exist or is deleted during polling
|
||||
TimeoutError: If timeout expires before a change is detected
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastapi_toolsets.db import wait_for_row_change
|
||||
|
||||
# Wait for any column to change
|
||||
updated = await wait_for_row_change(session, User, user_id)
|
||||
|
||||
# Watch specific columns with a timeout
|
||||
updated = await wait_for_row_change(
|
||||
session, User, user_id,
|
||||
columns=["status", "email"],
|
||||
interval=1.0,
|
||||
timeout=30.0,
|
||||
)
|
||||
```
|
||||
"""
|
||||
instance = await session.get(model, pk_value)
|
||||
if instance is None:
|
||||
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} not found")
|
||||
|
||||
if columns is not None:
|
||||
watch_cols = columns
|
||||
else:
|
||||
watch_cols = [attr.key for attr in model.__mapper__.column_attrs]
|
||||
|
||||
initial = {col: getattr(instance, col) for col in watch_cols}
|
||||
|
||||
elapsed = 0.0
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
elapsed += interval
|
||||
|
||||
if timeout is not None and elapsed >= timeout:
|
||||
raise TimeoutError(
|
||||
f"No change detected on {model.__name__} "
|
||||
f"with pk={pk_value!r} within {timeout}s"
|
||||
)
|
||||
|
||||
session.expunge(instance)
|
||||
instance = await session.get(model, pk_value)
|
||||
|
||||
if instance is None:
|
||||
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} was deleted")
|
||||
|
||||
current = {col: getattr(instance, col) for col in watch_cols}
|
||||
if current != initial:
|
||||
return instance
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Database package: the ``Database`` facade plus PostgreSQL power-tools."""
|
||||
|
||||
from .core import Database, transaction
|
||||
from .locks import LockMode, advisory_lock, lock_tables
|
||||
from .m2m import m2m_add, m2m_remove, m2m_set
|
||||
from .watch import wait_for_row_change
|
||||
|
||||
__all__ = [
|
||||
"Database",
|
||||
"LockMode",
|
||||
"advisory_lock",
|
||||
"lock_tables",
|
||||
"m2m_add",
|
||||
"m2m_remove",
|
||||
"m2m_set",
|
||||
"transaction",
|
||||
"wait_for_row_change",
|
||||
]
|
||||
@@ -0,0 +1,322 @@
|
||||
"""The ``Database`` facade: session lifecycle, dependency, middleware, transactions."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from pydantic import PostgresDsn
|
||||
from sqlalchemy import exc as sa_exc
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from starlette.requests import Request
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from ..exceptions import PoolExhaustedError
|
||||
from .locks import LockMode, lock_tables
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(
|
||||
session: AsyncSession,
|
||||
) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Run a block inside a savepoint-aware transaction.
|
||||
|
||||
If *session* is already in a transaction, a nested transaction (savepoint)
|
||||
is opened so the block can roll back independently. Otherwise a top-level
|
||||
transaction is started. Commits on clean exit, rolls back on exception.
|
||||
|
||||
Args:
|
||||
session: AsyncSession instance.
|
||||
|
||||
Yields:
|
||||
The session within the transaction context.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastapi_toolsets.db import transaction
|
||||
|
||||
async with transaction(session):
|
||||
session.add(model)
|
||||
```
|
||||
"""
|
||||
if session.in_transaction():
|
||||
async with session.begin_nested():
|
||||
yield session
|
||||
else:
|
||||
async with session.begin():
|
||||
yield session
|
||||
|
||||
|
||||
class _CommitOnResponseMiddleware:
|
||||
"""Commit the request's DB session before the response is sent."""
|
||||
|
||||
def __init__(self, app: ASGIApp, *, state_attr: str) -> None:
|
||||
self.app = app
|
||||
self.state_attr = state_attr
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
async def send_wrapper(message: Message) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
# ``scope["state"]`` is the same dict ``request.state`` writes
|
||||
# to, so this is the session stashed by the dependency.
|
||||
state = scope.get("state")
|
||||
session = state.get(self.state_attr) if state else None
|
||||
if session is not None and session.in_transaction():
|
||||
await session.commit()
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_wrapper)
|
||||
|
||||
|
||||
class Database:
|
||||
"""One object that owns the engine, sessions, dependency, and middleware.
|
||||
|
||||
Provide exactly one of *url* (the facade builds and disposes the engine) or
|
||||
*engine* (an engine you own, e.g. for Alembic or ``event.listen``, left
|
||||
untouched).
|
||||
|
||||
Args:
|
||||
url: Database connection URL. Accepts a plain string or a Pydantic
|
||||
:class:`~pydantic.PostgresDsn`.
|
||||
engine: An existing :class:`AsyncEngine` to reuse instead of *url*.
|
||||
session_class: Session class for the sessionmaker (e.g. ``EventSession``).
|
||||
expire_on_commit: Expire attributes after commit. Defaults to ``False``.
|
||||
autoflush: Autoflush the session before queries. Defaults to ``True``.
|
||||
connect_args: DBAPI-level connection arguments forwarded to
|
||||
:func:`create_async_engine` (URL mode only).
|
||||
**engine_options: Extra keyword arguments forwarded to
|
||||
:func:`create_async_engine` (URL mode only).
|
||||
|
||||
Raises:
|
||||
TypeError: If neither or both of *url* and *engine* are given, or if
|
||||
*connect_args*/*engine_options* are passed together with *engine*.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi_toolsets.db import Database
|
||||
|
||||
db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
|
||||
|
||||
app = FastAPI()
|
||||
db.install(app)
|
||||
|
||||
@app.get("/users/{user_id}")
|
||||
async def get_user(user_id: int, session=Depends(db)):
|
||||
return await UserCrud.get(session, [User.id == user_id])
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str | PostgresDsn | None = None,
|
||||
*,
|
||||
engine: AsyncEngine | None = None,
|
||||
session_class: type[AsyncSession] = AsyncSession,
|
||||
expire_on_commit: bool = False,
|
||||
autoflush: bool = True,
|
||||
connect_args: dict[str, Any] | None = None,
|
||||
**engine_options: Any,
|
||||
) -> None:
|
||||
if (url is None) == (engine is None):
|
||||
raise TypeError(
|
||||
"Database requires exactly one of 'url' or 'engine' "
|
||||
"(got both or neither)."
|
||||
)
|
||||
if engine is not None and (engine_options or connect_args is not None):
|
||||
raise TypeError(
|
||||
"connect_args/engine_options are only valid in URL mode; "
|
||||
"configure the engine you pass via 'engine=' yourself."
|
||||
)
|
||||
|
||||
if engine is not None:
|
||||
self._owns_engine = False
|
||||
self.engine: AsyncEngine = engine
|
||||
else:
|
||||
assert url is not None # guaranteed by the XOR check above
|
||||
self._owns_engine = True
|
||||
if connect_args is not None:
|
||||
engine_options["connect_args"] = connect_args
|
||||
# ``PostgresDsn`` (and other URL objects) are not str subclasses, so
|
||||
# coerce to the string form SQLAlchemy expects.
|
||||
self.engine = create_async_engine(str(url), **engine_options)
|
||||
self._sessionmaker: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||
self.engine,
|
||||
class_=session_class,
|
||||
expire_on_commit=expire_on_commit,
|
||||
autoflush=autoflush,
|
||||
)
|
||||
# Private, per-instance state attribute; cannot collide with another
|
||||
# Database or be mismatched against the middleware.
|
||||
self._state_attr = f"_ft_db_session_{id(self):x}"
|
||||
self._middleware_installed = False
|
||||
self._disposed = False
|
||||
|
||||
async def _dispose(self) -> None:
|
||||
"""Dispose the engine once, only if we own it (idempotent)."""
|
||||
if self._owns_engine and not self._disposed:
|
||||
self._disposed = True
|
||||
await self.engine.dispose()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(self, app: Any) -> AsyncGenerator[None, None]:
|
||||
"""Dispose the engine on shutdown; use as ``FastAPI(lifespan=db.lifespan)``.
|
||||
|
||||
Args:
|
||||
app: The ASGI application (unused; required by the lifespan protocol).
|
||||
|
||||
Yields:
|
||||
Control to the application for its lifetime.
|
||||
|
||||
Example:
|
||||
```python
|
||||
app = FastAPI(lifespan=db.lifespan)
|
||||
```
|
||||
"""
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await self._dispose()
|
||||
|
||||
def install(self, app: Any) -> None:
|
||||
"""Wire the commit middleware and engine disposal onto *app*.
|
||||
|
||||
Args:
|
||||
app: The FastAPI/Starlette application to wire.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
... # your startup
|
||||
yield
|
||||
... # your shutdown
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
db.install(app)
|
||||
```
|
||||
"""
|
||||
app.add_middleware(_CommitOnResponseMiddleware, state_attr=self._state_attr)
|
||||
self._middleware_installed = True
|
||||
|
||||
inner_lifespan = app.router.lifespan_context
|
||||
|
||||
@asynccontextmanager
|
||||
async def _composed(app_: Any) -> AsyncGenerator[None, None]:
|
||||
async with self.lifespan(app_), inner_lifespan(app_):
|
||||
yield
|
||||
|
||||
app.router.lifespan_context = _composed
|
||||
|
||||
@asynccontextmanager
|
||||
async def _open(self) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Open a session and eagerly acquire a connection (fail-fast on pool)."""
|
||||
async with self._sessionmaker() as session:
|
||||
try:
|
||||
await session.connection()
|
||||
except sa_exc.TimeoutError as e:
|
||||
raise PoolExhaustedError() from e
|
||||
yield session
|
||||
|
||||
async def __call__(self, request: Request) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI dependency: yield a session and commit once at the right time.
|
||||
|
||||
Args:
|
||||
request: The incoming request (injected by FastAPI).
|
||||
|
||||
Yields:
|
||||
An AsyncSession for the duration of the request.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@app.get("/users/{user_id}")
|
||||
async def get_user(user_id: int, session=Depends(db)):
|
||||
return await UserCrud.get(session, [User.id == user_id])
|
||||
```
|
||||
"""
|
||||
async with self._open() as session:
|
||||
setattr(request.state, self._state_attr, session)
|
||||
yield session
|
||||
if not self._middleware_installed and session.in_transaction():
|
||||
await session.commit()
|
||||
|
||||
@asynccontextmanager
|
||||
async def session(self) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Open a session outside request handlers (background tasks, CLI, tests).
|
||||
|
||||
Commits on clean exit, rolls back on exception.
|
||||
|
||||
Yields:
|
||||
An AsyncSession ready for database operations.
|
||||
|
||||
Example:
|
||||
```python
|
||||
async with db.session() as session:
|
||||
user = await UserCrud.get(session, [User.id == 1])
|
||||
```
|
||||
"""
|
||||
async with self._open() as session:
|
||||
yield session
|
||||
if session.in_transaction():
|
||||
await session.commit()
|
||||
|
||||
@asynccontextmanager
|
||||
async def begin(self) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Open a session already inside a transaction (sugar for the common case).
|
||||
|
||||
Equivalent to ``session()`` + :func:`transaction`. Commits on clean exit,
|
||||
rolls back on exception.
|
||||
|
||||
Yields:
|
||||
An AsyncSession open within a transaction.
|
||||
|
||||
Example:
|
||||
```python
|
||||
async with db.begin() as session:
|
||||
session.add(User(name="ada"))
|
||||
```
|
||||
"""
|
||||
async with self.session() as session, transaction(session):
|
||||
yield session
|
||||
|
||||
def lock_tables(
|
||||
self,
|
||||
tables: list[type[DeclarativeBase]],
|
||||
*,
|
||||
mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE,
|
||||
timeout: str = "5s",
|
||||
) -> AbstractAsyncContextManager[AsyncSession]:
|
||||
"""Lock PostgreSQL tables for the duration of a dedicated transaction.
|
||||
|
||||
Opens its own session from the facade's sessionmaker, changes are
|
||||
committed when the context exits.
|
||||
|
||||
Args:
|
||||
tables: List of SQLAlchemy model classes to lock.
|
||||
mode: Lock mode (default: ``SHARE UPDATE EXCLUSIVE``).
|
||||
timeout: Lock timeout (default: ``"5s"``).
|
||||
|
||||
Yields:
|
||||
The dedicated session, open within the locked transaction.
|
||||
|
||||
Raises:
|
||||
LockTimeoutError: If the lock cannot be acquired within *timeout*.
|
||||
PoolExhaustedError: If the connection pool is exhausted.
|
||||
|
||||
Example:
|
||||
```python
|
||||
async with db.lock_tables([User, Account]) as session:
|
||||
user = await UserCrud.get(session, [User.id == 1])
|
||||
user.balance += 100
|
||||
```
|
||||
"""
|
||||
return lock_tables(self._sessionmaker, tables, mode=mode, timeout=timeout)
|
||||
@@ -0,0 +1,185 @@
|
||||
"""PostgreSQL locking helpers: table locks and advisory locks."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
from enum import Enum
|
||||
from typing import TypeVar
|
||||
|
||||
import asyncpg
|
||||
from sqlalchemy import exc as sa_exc
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from ..exceptions import LockTimeoutError, PoolExhaustedError
|
||||
|
||||
_SessionT = TypeVar("_SessionT", bound=AsyncSession)
|
||||
|
||||
|
||||
def _is_lock_not_available(e: sa_exc.DBAPIError) -> bool:
|
||||
return e.orig is not None and isinstance(
|
||||
e.orig.__cause__, asyncpg.exceptions.LockNotAvailableError
|
||||
)
|
||||
|
||||
|
||||
class LockMode(str, Enum):
|
||||
"""PostgreSQL table lock modes.
|
||||
|
||||
See: https://www.postgresql.org/docs/current/explicit-locking.html
|
||||
"""
|
||||
|
||||
ACCESS_SHARE = "ACCESS SHARE"
|
||||
ROW_SHARE = "ROW SHARE"
|
||||
ROW_EXCLUSIVE = "ROW EXCLUSIVE"
|
||||
SHARE_UPDATE_EXCLUSIVE = "SHARE UPDATE EXCLUSIVE"
|
||||
SHARE = "SHARE"
|
||||
SHARE_ROW_EXCLUSIVE = "SHARE ROW EXCLUSIVE"
|
||||
EXCLUSIVE = "EXCLUSIVE"
|
||||
ACCESS_EXCLUSIVE = "ACCESS EXCLUSIVE"
|
||||
|
||||
|
||||
def lock_tables(
|
||||
session_maker: async_sessionmaker[_SessionT],
|
||||
tables: list[type[DeclarativeBase]],
|
||||
*,
|
||||
mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE,
|
||||
timeout: str = "5s",
|
||||
) -> AbstractAsyncContextManager[_SessionT]:
|
||||
"""Lock PostgreSQL tables for the duration of a transaction.
|
||||
|
||||
Prefer the method on a :class:`Database` instance; use this
|
||||
directly only when you manage your own session factory.
|
||||
|
||||
Args:
|
||||
session_maker: Async session factory used to create the dedicated
|
||||
session.
|
||||
tables: List of SQLAlchemy model classes to lock.
|
||||
mode: Lock mode (default: SHARE UPDATE EXCLUSIVE).
|
||||
timeout: Lock timeout (default: "5s").
|
||||
|
||||
Yields:
|
||||
The dedicated session, open within the locked transaction.
|
||||
|
||||
Raises:
|
||||
LockTimeoutError: If the lock cannot be acquired within *timeout*.
|
||||
PoolExhaustedError: If the connection pool is exhausted.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastapi_toolsets.db import lock_tables
|
||||
|
||||
async with lock_tables(session_maker, [User, Account]) as session:
|
||||
user = await UserCrud.get(session, [User.id == 1])
|
||||
user.balance += 100
|
||||
```
|
||||
"""
|
||||
table_names = ",".join(table.__tablename__ for table in tables)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lock() -> AsyncGenerator[_SessionT, None]:
|
||||
async with session_maker() as session:
|
||||
try:
|
||||
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
|
||||
await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
|
||||
yield session
|
||||
await session.commit()
|
||||
except sa_exc.TimeoutError as e:
|
||||
await session.rollback()
|
||||
raise PoolExhaustedError(
|
||||
f"Connection pool exhausted while locking '{table_names}'. "
|
||||
) from e
|
||||
except sa_exc.DBAPIError as e:
|
||||
await session.rollback()
|
||||
if _is_lock_not_available(e):
|
||||
raise LockTimeoutError(
|
||||
f"Lock on '{table_names}' could not be acquired within {timeout}."
|
||||
) from e
|
||||
raise # pragma: no cover
|
||||
except BaseException:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
return _lock()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def advisory_lock(
|
||||
session: AsyncSession,
|
||||
key: int | tuple[int, int],
|
||||
*,
|
||||
shared: bool = False,
|
||||
nowait: bool = False,
|
||||
timeout: str | None = None,
|
||||
) -> AsyncGenerator[bool, None]:
|
||||
"""Acquire a PostgreSQL session-level advisory lock.
|
||||
|
||||
Args:
|
||||
session: AsyncSession instance.
|
||||
key: Lock key, either a single ``int`` (bigint) or a ``(int, int)`` pair for namespacing.
|
||||
shared: Acquire a shared lock (multiple holders allowed). Default is exclusive.
|
||||
nowait: Return ``False`` immediately if the lock is unavailable instead of waiting.
|
||||
timeout: Maximum wait time (e.g. ``"5s"``, ``"500ms"``). Raises ``DBAPIError``
|
||||
if exceeded. Ignored when *nowait* is ``True``.
|
||||
|
||||
Yields:
|
||||
``True`` if the lock was acquired, ``False`` if *nowait* is ``True`` and the lock
|
||||
is already held.
|
||||
|
||||
Raises:
|
||||
LockTimeoutError: If *timeout* is set and the lock cannot be acquired in time.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastapi_toolsets.db import advisory_lock
|
||||
|
||||
async with advisory_lock(session, 42):
|
||||
...
|
||||
|
||||
async with advisory_lock(session, 42, nowait=True) as acquired:
|
||||
if not acquired:
|
||||
raise HTTPException(409, "Resource is locked")
|
||||
|
||||
async with advisory_lock(session, 42, timeout="5s"):
|
||||
...
|
||||
|
||||
async with advisory_lock(session, (1, user_id), shared=True):
|
||||
...
|
||||
```
|
||||
"""
|
||||
suffix = "_shared" if shared else ""
|
||||
acquire_fn = f"{'pg_try_advisory_lock' if nowait else 'pg_advisory_lock'}{suffix}"
|
||||
release_fn = f"pg_advisory_unlock{suffix}"
|
||||
|
||||
if isinstance(key, tuple):
|
||||
k1, k2 = key
|
||||
args = "CAST(:k1 AS integer), CAST(:k2 AS integer)"
|
||||
params: dict[str, int] = {"k1": k1, "k2": k2}
|
||||
else:
|
||||
args = ":k"
|
||||
params = {"k": key}
|
||||
|
||||
acquire_sql = text(f"SELECT {acquire_fn}({args})")
|
||||
release_sql = text(f"SELECT {release_fn}({args})")
|
||||
|
||||
# Lock management runs raw SQL on the caller's session. Guard it with
|
||||
# ``no_autoflush`` so acquiring or releasing the lock never flushes the
|
||||
# caller's pending ORM changes; SQLAlchemy 2.1 autoflushes on raw
|
||||
# ``text()`` too, where 2.0 did not.
|
||||
try:
|
||||
with session.no_autoflush:
|
||||
if timeout is not None and not nowait:
|
||||
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
|
||||
result = await session.execute(acquire_sql, params)
|
||||
except sa_exc.DBAPIError as e:
|
||||
if _is_lock_not_available(e):
|
||||
raise LockTimeoutError(
|
||||
f"Advisory lock {key!r} could not be acquired within {timeout}."
|
||||
) from e
|
||||
raise # pragma: no cover
|
||||
acquired = result.scalar() if nowait else True
|
||||
try:
|
||||
yield acquired
|
||||
finally:
|
||||
if acquired:
|
||||
with session.no_autoflush:
|
||||
await session.execute(release_sql, params)
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Many-to-Many association-table helpers (direct, without loading collections)."""
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
from sqlalchemy import ColumnElement, Table, delete, tuple_
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
|
||||
from sqlalchemy.orm.relationships import RelationshipProperty
|
||||
|
||||
|
||||
def _m2m_prop(rel_attr: QueryableAttribute) -> tuple[RelationshipProperty, Table]: # type: ignore[type-arg]
|
||||
"""Return the validated M2M RelationshipProperty and its secondary table.
|
||||
|
||||
Raises TypeError if *rel_attr* is not a Many-to-Many relationship.
|
||||
"""
|
||||
prop = rel_attr.property
|
||||
if not isinstance(prop, RelationshipProperty) or prop.secondary is None:
|
||||
raise TypeError(
|
||||
f"m2m helpers require a Many-to-Many relationship attribute, "
|
||||
f"got {rel_attr!r}. Use a relationship with a secondary table."
|
||||
)
|
||||
return prop, cast(Table, prop.secondary)
|
||||
|
||||
|
||||
def _parent_where(
|
||||
prop: RelationshipProperty, # type: ignore[type-arg]
|
||||
instance: DeclarativeBase,
|
||||
) -> list[ColumnElement[bool]]:
|
||||
"""Build the WHERE clauses matching the owner side of *instance*."""
|
||||
return [
|
||||
assoc_col == getattr(instance, cast(str, parent_col.key))
|
||||
for parent_col, assoc_col in prop.synchronize_pairs
|
||||
]
|
||||
|
||||
|
||||
async def m2m_add(
|
||||
session: AsyncSession,
|
||||
instance: DeclarativeBase,
|
||||
rel_attr: QueryableAttribute,
|
||||
*related: DeclarativeBase,
|
||||
ignore_conflicts: bool = False,
|
||||
) -> None:
|
||||
"""Insert rows into a Many-to-Many association table without loading the ORM collection.
|
||||
|
||||
Args:
|
||||
session: DB async session.
|
||||
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
||||
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
||||
*related: One or more related instances to associate with ``instance``.
|
||||
ignore_conflicts: When ``True``, silently skip rows that already exist
|
||||
in the association table (``ON CONFLICT DO NOTHING``).
|
||||
|
||||
Raises:
|
||||
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastapi_toolsets.db import m2m_add, transaction
|
||||
|
||||
async with transaction(session):
|
||||
await m2m_add(session, post, Post.tags, tag1, tag2)
|
||||
```
|
||||
"""
|
||||
prop, secondary = _m2m_prop(rel_attr)
|
||||
if not related:
|
||||
return
|
||||
|
||||
sync_pairs = prop.secondary_synchronize_pairs
|
||||
assert sync_pairs is not None # set whenever secondary is set
|
||||
|
||||
# synchronize_pairs: [(parent_col, assoc_col), ...]
|
||||
# secondary_synchronize_pairs: [(related_col, assoc_col), ...]
|
||||
rows: list[dict[str, Any]] = []
|
||||
for rel_instance in related:
|
||||
row: dict[str, Any] = {}
|
||||
for parent_col, assoc_col in prop.synchronize_pairs:
|
||||
row[assoc_col.name] = getattr(instance, cast(str, parent_col.key))
|
||||
for related_col, assoc_col in sync_pairs:
|
||||
row[assoc_col.name] = getattr(rel_instance, cast(str, related_col.key))
|
||||
rows.append(row)
|
||||
|
||||
stmt = pg_insert(secondary).values(rows)
|
||||
if ignore_conflicts:
|
||||
stmt = stmt.on_conflict_do_nothing()
|
||||
await session.execute(stmt)
|
||||
|
||||
|
||||
async def m2m_remove(
|
||||
session: AsyncSession,
|
||||
instance: DeclarativeBase,
|
||||
rel_attr: QueryableAttribute,
|
||||
*related: DeclarativeBase,
|
||||
) -> None:
|
||||
"""Remove rows from a Many-to-Many association table without loading the ORM collection.
|
||||
|
||||
Args:
|
||||
session: DB async session.
|
||||
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
||||
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
||||
*related: One or more related instances to disassociate from ``instance``.
|
||||
|
||||
Raises:
|
||||
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastapi_toolsets.db import m2m_remove, transaction
|
||||
|
||||
async with transaction(session):
|
||||
await m2m_remove(session, post, Post.tags, tag1)
|
||||
```
|
||||
"""
|
||||
prop, secondary = _m2m_prop(rel_attr)
|
||||
if not related:
|
||||
return
|
||||
|
||||
related_pairs = prop.secondary_synchronize_pairs
|
||||
assert related_pairs is not None # set whenever secondary is set
|
||||
|
||||
parent_where = _parent_where(prop, instance)
|
||||
|
||||
if len(related_pairs) == 1:
|
||||
related_col, assoc_col = related_pairs[0]
|
||||
related_values = [getattr(r, cast(str, related_col.key)) for r in related]
|
||||
related_where = assoc_col.in_(related_values)
|
||||
else:
|
||||
assoc_cols = [ac for _, ac in related_pairs]
|
||||
rel_cols = [rc for rc, _ in related_pairs]
|
||||
related_values_t = [
|
||||
tuple(getattr(r, cast(str, rc.key)) for rc in rel_cols) for r in related
|
||||
]
|
||||
related_where = tuple_(*assoc_cols).in_(related_values_t)
|
||||
|
||||
await session.execute(delete(secondary).where(*parent_where, related_where))
|
||||
|
||||
|
||||
async def m2m_set(
|
||||
session: AsyncSession,
|
||||
instance: DeclarativeBase,
|
||||
rel_attr: QueryableAttribute,
|
||||
*related: DeclarativeBase,
|
||||
) -> None:
|
||||
"""Replace the entire Many-to-Many association set atomically.
|
||||
|
||||
Args:
|
||||
session: DB async session.
|
||||
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
||||
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
||||
*related: The new complete set of related instances.
|
||||
|
||||
Raises:
|
||||
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastapi_toolsets.db import m2m_set, transaction
|
||||
|
||||
async with transaction(session):
|
||||
await m2m_set(session, post, Post.tags, tag1, tag2) # replaces all
|
||||
```
|
||||
"""
|
||||
prop, secondary = _m2m_prop(rel_attr)
|
||||
|
||||
await session.execute(delete(secondary).where(*_parent_where(prop, instance)))
|
||||
|
||||
if related:
|
||||
await m2m_add(session, instance, rel_attr, *related)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Database admin and test helpers: DDL and truncation."""
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
async def create_database(
|
||||
db_name: str,
|
||||
*,
|
||||
server_url: str,
|
||||
) -> None:
|
||||
"""Create a database.
|
||||
|
||||
Connects to *server_url* using ``AUTOCOMMIT`` isolation and issues a
|
||||
``CREATE DATABASE`` statement for *db_name*.
|
||||
|
||||
Args:
|
||||
db_name: Name of the database to create.
|
||||
server_url: URL used for server-level DDL (must point to an existing
|
||||
database on the same server).
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastapi_toolsets.db.testing import create_database
|
||||
|
||||
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
|
||||
await create_database("myapp_test", server_url=SERVER_URL)
|
||||
```
|
||||
"""
|
||||
engine = create_async_engine(server_url, isolation_level="AUTOCOMMIT")
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text(f"CREATE DATABASE {db_name}"))
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def cleanup_tables(
|
||||
session: AsyncSession,
|
||||
base: type[DeclarativeBase],
|
||||
) -> None:
|
||||
"""Truncate all tables for fast between-test cleanup.
|
||||
|
||||
Executes a single ``TRUNCATE … RESTART IDENTITY CASCADE`` statement
|
||||
across every table in *base*'s metadata.
|
||||
|
||||
This is a no-op when the metadata contains no tables.
|
||||
|
||||
Args:
|
||||
session: An active async database session.
|
||||
base: SQLAlchemy DeclarativeBase class containing model metadata.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@pytest.fixture
|
||||
async def db_session(worker_db_url):
|
||||
async with create_db_session(worker_db_url, Base) as session:
|
||||
yield session
|
||||
await cleanup_tables(session, Base)
|
||||
```
|
||||
"""
|
||||
tables = base.metadata.sorted_tables
|
||||
if not tables:
|
||||
return
|
||||
|
||||
table_names = ", ".join(f'"{t.name}"' for t in tables)
|
||||
await session.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE"))
|
||||
await session.commit()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Row-watching helpers: poll a database row until it changes."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from ..exceptions import NotFoundError
|
||||
|
||||
_M = TypeVar("_M", bound=DeclarativeBase)
|
||||
|
||||
|
||||
async def wait_for_row_change(
|
||||
session: AsyncSession,
|
||||
model: type[_M],
|
||||
pk_value: Any,
|
||||
*,
|
||||
columns: list[str] | None = None,
|
||||
interval: float = 0.5,
|
||||
timeout: float | None = None,
|
||||
) -> _M:
|
||||
"""Poll a database row until a change is detected.
|
||||
|
||||
Queries the row every ``interval`` seconds and returns the model instance
|
||||
once a change is detected in any column (or only the specified ``columns``).
|
||||
|
||||
Args:
|
||||
session: AsyncSession instance.
|
||||
model: SQLAlchemy model class.
|
||||
pk_value: Primary key value of the row to watch.
|
||||
columns: Optional list of column names to watch. If None, all columns
|
||||
are watched.
|
||||
interval: Polling interval in seconds (default: 0.5).
|
||||
timeout: Maximum time to wait in seconds. None means wait forever.
|
||||
|
||||
Returns:
|
||||
The refreshed model instance with updated values.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If the row does not exist or is deleted during polling.
|
||||
TimeoutError: If timeout expires before a change is detected.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastapi_toolsets.db import wait_for_row_change
|
||||
|
||||
# Wait for any column to change
|
||||
updated = await wait_for_row_change(session, User, user_id)
|
||||
|
||||
# Watch specific columns with a timeout
|
||||
updated = await wait_for_row_change(
|
||||
session, User, user_id,
|
||||
columns=["status", "email"],
|
||||
interval=1.0,
|
||||
timeout=30.0,
|
||||
)
|
||||
```
|
||||
"""
|
||||
bind = getattr(session, "bind", None)
|
||||
if bind is None:
|
||||
raise TypeError(
|
||||
"wait_for_row_change requires a session bound to an engine "
|
||||
"(session.bind is None)"
|
||||
)
|
||||
watcher = AsyncSession(bind=bind)
|
||||
try:
|
||||
|
||||
async def _reload() -> _M | None:
|
||||
await watcher.rollback()
|
||||
return await watcher.get(model, pk_value, populate_existing=True)
|
||||
|
||||
instance = await _reload()
|
||||
if instance is None:
|
||||
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} not found")
|
||||
|
||||
if columns is not None:
|
||||
watch_cols = columns
|
||||
else:
|
||||
watch_cols = [attr.key for attr in model.__mapper__.column_attrs]
|
||||
|
||||
initial = {col: getattr(instance, col) for col in watch_cols}
|
||||
|
||||
elapsed = 0.0
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
elapsed += interval
|
||||
|
||||
if timeout is not None and elapsed >= timeout:
|
||||
raise TimeoutError(
|
||||
f"No change detected on {model.__name__} "
|
||||
f"with pk={pk_value!r} within {timeout}s"
|
||||
)
|
||||
|
||||
instance = await _reload()
|
||||
|
||||
if instance is None:
|
||||
raise NotFoundError(
|
||||
f"{model.__name__} with pk={pk_value!r} was deleted"
|
||||
)
|
||||
|
||||
current = {col: getattr(instance, col) for col in watch_cols}
|
||||
if current != initial:
|
||||
return instance
|
||||
finally:
|
||||
await watcher.close()
|
||||
@@ -60,7 +60,7 @@ def PathDependency(
|
||||
name = (
|
||||
param_name
|
||||
if param_name is not None
|
||||
else "{}_{}".format(model.__name__.lower(), field.key)
|
||||
else f"{model.__name__.lower()}_{field.key}"
|
||||
)
|
||||
python_type = field.type.python_type
|
||||
|
||||
@@ -70,10 +70,7 @@ def PathDependency(
|
||||
value = kwargs[name]
|
||||
return await crud.get(session, filters=[field == value])
|
||||
|
||||
setattr(
|
||||
dependency,
|
||||
"__signature__",
|
||||
inspect.Signature(
|
||||
dependency.__signature__ = inspect.Signature( # ty:ignore[unresolved-attribute]
|
||||
parameters=[
|
||||
inspect.Parameter(
|
||||
name, inspect.Parameter.KEYWORD_ONLY, annotation=python_type
|
||||
@@ -85,7 +82,6 @@ def PathDependency(
|
||||
default=Depends(session_callable),
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
return cast(ModelType, Depends(cast(Callable[..., ModelType], dependency)))
|
||||
@@ -134,10 +130,7 @@ def BodyDependency(
|
||||
value = kwargs[body_field]
|
||||
return await crud.get(session, filters=[field == value])
|
||||
|
||||
setattr(
|
||||
dependency,
|
||||
"__signature__",
|
||||
inspect.Signature(
|
||||
dependency.__signature__ = inspect.Signature( # ty:ignore[unresolved-attribute]
|
||||
parameters=[
|
||||
inspect.Parameter(
|
||||
body_field, inspect.Parameter.KEYWORD_ONLY, annotation=python_type
|
||||
@@ -149,7 +142,6 @@ def BodyDependency(
|
||||
default=Depends(session_callable),
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
return cast(ModelType, Depends(cast(Callable[..., ModelType], dependency)))
|
||||
|
||||
@@ -8,8 +8,10 @@ from .exceptions import (
|
||||
InvalidFacetFilterError,
|
||||
InvalidOrderFieldError,
|
||||
InvalidSearchColumnError,
|
||||
LockTimeoutError,
|
||||
NoSearchableFieldsError,
|
||||
NotFoundError,
|
||||
PoolExhaustedError,
|
||||
UnauthorizedError,
|
||||
UnsupportedFacetTypeError,
|
||||
generate_error_responses,
|
||||
@@ -21,13 +23,15 @@ __all__ = [
|
||||
"ApiException",
|
||||
"ConflictError",
|
||||
"ForbiddenError",
|
||||
"generate_error_responses",
|
||||
"init_exceptions_handlers",
|
||||
"InvalidFacetFilterError",
|
||||
"InvalidOrderFieldError",
|
||||
"InvalidSearchColumnError",
|
||||
"LockTimeoutError",
|
||||
"NoSearchableFieldsError",
|
||||
"NotFoundError",
|
||||
"PoolExhaustedError",
|
||||
"UnauthorizedError",
|
||||
"UnsupportedFacetTypeError",
|
||||
"generate_error_responses",
|
||||
"init_exceptions_handlers",
|
||||
]
|
||||
|
||||
@@ -223,6 +223,35 @@ class InvalidOrderFieldError(ApiException):
|
||||
)
|
||||
|
||||
|
||||
class PoolExhaustedError(ApiException):
|
||||
"""HTTP 503 - Database connection pool is exhausted."""
|
||||
|
||||
api_error = ApiError(
|
||||
code=503,
|
||||
msg="Service Unavailable",
|
||||
desc=(
|
||||
"The database connection pool is exhausted. "
|
||||
"Too many concurrent requests are holding connections. "
|
||||
"Retry shortly or contact support if the issue persists."
|
||||
),
|
||||
err_code="DB-503-POOL",
|
||||
)
|
||||
|
||||
|
||||
class LockTimeoutError(ApiException):
|
||||
"""HTTP 503 - A database lock could not be acquired within the timeout."""
|
||||
|
||||
api_error = ApiError(
|
||||
code=503,
|
||||
msg="Service Unavailable",
|
||||
desc=(
|
||||
"A database lock could not be acquired within the allowed timeout. "
|
||||
"The resource is under heavy contention. Retry shortly."
|
||||
),
|
||||
err_code="DB-503-LOCK",
|
||||
)
|
||||
|
||||
|
||||
def generate_error_responses(
|
||||
*errors: type[ApiException],
|
||||
) -> dict[int | str, dict[str, Any]]:
|
||||
|
||||
@@ -146,8 +146,7 @@ def _patched_openapi(
|
||||
|
||||
for path_data in openapi_schema.get("paths", {}).values():
|
||||
for operation in path_data.values():
|
||||
if isinstance(operation, dict) and "responses" in operation:
|
||||
if "422" in operation["responses"]:
|
||||
if isinstance(operation, dict) and "422" in operation.get("responses", {}):
|
||||
operation["responses"]["422"] = {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
"""Fixture system for seeding databases with dependency resolution."""
|
||||
|
||||
from .enum import LoadStrategy
|
||||
from .registry import Context, FixtureRegistry
|
||||
from .utils import get_obj_by_attr, load_fixtures, load_fixtures_by_context
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .enum import Context, LoadStrategy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .registry import FixtureRegistry
|
||||
from .utils import load_fixtures, load_fixtures_by_context
|
||||
|
||||
__all__ = [
|
||||
"Context",
|
||||
"FixtureRegistry",
|
||||
"LoadStrategy",
|
||||
"get_obj_by_attr",
|
||||
"load_fixtures",
|
||||
"load_fixtures_by_context",
|
||||
"register_fixtures",
|
||||
]
|
||||
|
||||
_LAZY = {
|
||||
"FixtureRegistry": ".registry",
|
||||
"load_fixtures": ".utils",
|
||||
"load_fixtures_by_context": ".utils",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
module_name = _LAZY.get(name)
|
||||
if module_name is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
import importlib
|
||||
|
||||
module = importlib.import_module(module_name, __name__)
|
||||
return getattr(module, name)
|
||||
|
||||
@@ -7,11 +7,8 @@ from typing import Any, cast
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from ..logger import get_logger
|
||||
from .enum import Context
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
def _normalize_contexts(
|
||||
contexts: list[str | Enum] | tuple[str | Enum, ...],
|
||||
@@ -20,6 +17,11 @@ def _normalize_contexts(
|
||||
return [c.value if isinstance(c, Enum) else c for c in contexts]
|
||||
|
||||
|
||||
def _context_filter_values(contexts: tuple[str | Enum, ...]) -> set[str]:
|
||||
"""Normalize *contexts* for filtering, always including Context.BASE."""
|
||||
return set(_normalize_contexts(contexts)) | {Context.BASE.value}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fixture:
|
||||
"""A fixture definition with metadata."""
|
||||
@@ -70,8 +72,6 @@ class FixtureRegistry:
|
||||
@fixtures.register(contexts=[Context.TESTING])
|
||||
def users():
|
||||
return [User(id=2, username="tester")]
|
||||
# load_fixtures_by_context(..., Context.BASE, Context.TESTING)
|
||||
# → loads both User(admin) and User(tester) under the "users" name
|
||||
```
|
||||
"""
|
||||
|
||||
@@ -189,9 +189,7 @@ class FixtureRegistry:
|
||||
ValueError: If the fixture has multiple context variants — use
|
||||
:meth:`get_variants` in that case.
|
||||
"""
|
||||
if name not in self._fixtures:
|
||||
raise KeyError(f"Fixture '{name}' not found")
|
||||
variants = self._fixtures[name]
|
||||
variants = self.get_variants(name)
|
||||
if len(variants) > 1:
|
||||
raise ValueError(
|
||||
f"Fixture '{name}' has {len(variants)} context variants. "
|
||||
@@ -205,8 +203,9 @@ class FixtureRegistry:
|
||||
Args:
|
||||
name: Fixture name.
|
||||
*contexts: If given, only return variants whose context set
|
||||
intersects with these values. Both :class:`Context` enum
|
||||
values and plain strings are accepted.
|
||||
intersects with these values (:class:`Context.BASE` variants
|
||||
are always included). Both :class:`Context` enum values and
|
||||
plain strings are accepted.
|
||||
|
||||
Returns:
|
||||
List of matching :class:`Fixture` objects (may be empty when a
|
||||
@@ -220,16 +219,89 @@ class FixtureRegistry:
|
||||
variants = self._fixtures[name]
|
||||
if not contexts:
|
||||
return list(variants)
|
||||
context_values = set(_normalize_contexts(contexts))
|
||||
context_values = _context_filter_values(contexts)
|
||||
return [v for v in variants if set(v.contexts) & context_values]
|
||||
|
||||
def get_load_variants(self, name: str, *contexts: str | Enum) -> list[Fixture]:
|
||||
"""Return variants for *name* filtered by *contexts*.
|
||||
|
||||
Raises:
|
||||
KeyError: If no fixture with *name* is registered.
|
||||
"""
|
||||
variants = self.get_variants(name, *contexts)
|
||||
if contexts and not variants:
|
||||
return self.get_variants(name)
|
||||
return variants
|
||||
|
||||
def get_all(self) -> list[Fixture]:
|
||||
"""Get all registered fixtures (all variants of all names)."""
|
||||
return [f for variants in self._fixtures.values() for f in variants]
|
||||
|
||||
def get_dependencies(self, name: str) -> list[str]:
|
||||
"""Get the union of ``depends_on`` across all variants of *name*.
|
||||
|
||||
Raises:
|
||||
KeyError: If no fixture named *name* is registered.
|
||||
"""
|
||||
variants = self._fixtures.get(name)
|
||||
if variants is None:
|
||||
raise KeyError(f"Fixture '{name}' not found")
|
||||
|
||||
seen: set[str] = set()
|
||||
deps: list[str] = []
|
||||
for variant in variants:
|
||||
for dep in variant.depends_on:
|
||||
if dep not in seen:
|
||||
deps.append(dep)
|
||||
seen.add(dep)
|
||||
return deps
|
||||
|
||||
def obj(self, name: str, attr_name: str, value: Any) -> DeclarativeBase:
|
||||
"""Get a model instance from a registered fixture by attribute value.
|
||||
|
||||
Args:
|
||||
name: Fixture name to look up.
|
||||
attr_name: Name of the attribute to match against.
|
||||
value: Value to match.
|
||||
|
||||
Returns:
|
||||
The first model instance where the attribute matches the given value.
|
||||
|
||||
Raises:
|
||||
KeyError: If no fixture named *name* is registered.
|
||||
StopIteration: If no matching object is found.
|
||||
"""
|
||||
instances = (
|
||||
obj for variant in self.get_variants(name) for obj in variant.func()
|
||||
)
|
||||
try:
|
||||
return next(obj for obj in instances if getattr(obj, attr_name) == value)
|
||||
except StopIteration:
|
||||
raise StopIteration(
|
||||
f"No object with {attr_name}={value} found in fixture '{name}'"
|
||||
) from None
|
||||
|
||||
def field(self, name: str, attr_name: str, value: Any, *, field: str = "id") -> Any:
|
||||
"""Get a single field value from a fixture object matched by an attribute.
|
||||
|
||||
Args:
|
||||
name: Fixture name to look up.
|
||||
attr_name: Name of the attribute to match against.
|
||||
value: Value to match.
|
||||
field: Attribute name to return from the matched object (default: ``"id"``).
|
||||
|
||||
Returns:
|
||||
The value of ``field`` on the first matching model instance.
|
||||
|
||||
Raises:
|
||||
KeyError: If no fixture named *name* is registered.
|
||||
StopIteration: If no matching object is found.
|
||||
"""
|
||||
return getattr(self.obj(name, attr_name, value), field)
|
||||
|
||||
def get_by_context(self, *contexts: str | Enum) -> list[Fixture]:
|
||||
"""Get fixtures for specific contexts."""
|
||||
context_values = set(_normalize_contexts(contexts))
|
||||
context_values = _context_filter_values(contexts)
|
||||
return [
|
||||
f
|
||||
for variants in self._fixtures.values()
|
||||
@@ -254,7 +326,6 @@ class FixtureRegistry:
|
||||
ValueError: If circular dependency detected
|
||||
"""
|
||||
resolved: list[str] = []
|
||||
seen: set[str] = set()
|
||||
visiting: set[str] = set()
|
||||
|
||||
def visit(name: str) -> None:
|
||||
@@ -264,25 +335,11 @@ class FixtureRegistry:
|
||||
raise ValueError(f"Circular dependency detected: {name}")
|
||||
|
||||
visiting.add(name)
|
||||
variants = self._fixtures.get(name)
|
||||
if variants is None:
|
||||
raise KeyError(f"Fixture '{name}' not found")
|
||||
|
||||
# Union of depends_on across all variants, preserving first-seen order.
|
||||
seen_deps: set[str] = set()
|
||||
all_deps: list[str] = []
|
||||
for variant in variants:
|
||||
for dep in variant.depends_on:
|
||||
if dep not in seen_deps:
|
||||
all_deps.append(dep)
|
||||
seen_deps.add(dep)
|
||||
|
||||
for dep in all_deps:
|
||||
for dep in self.get_dependencies(name):
|
||||
visit(dep)
|
||||
|
||||
visiting.remove(name)
|
||||
resolved.append(name)
|
||||
seen.add(name)
|
||||
|
||||
for name in names:
|
||||
visit(name)
|
||||
@@ -303,9 +360,4 @@ class FixtureRegistry:
|
||||
# appear multiple times if it has variants in different contexts).
|
||||
names = list(dict.fromkeys(f.name for f in context_fixtures))
|
||||
|
||||
all_deps: set[str] = set()
|
||||
for name in names:
|
||||
deps = self.resolve_dependencies(name)
|
||||
all_deps.update(deps)
|
||||
|
||||
return self.resolve_dependencies(*all_deps)
|
||||
return self.resolve_dependencies(*names)
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"""Fixture loading utilities for database seeding."""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from collections.abc import Iterator
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from sqlalchemy import Table, select
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.orm import DeclarativeBase, selectinload
|
||||
from sqlalchemy.orm.interfaces import ExecutableOption, ORMOption
|
||||
|
||||
from ..db import get_transaction
|
||||
from ..db import transaction
|
||||
from ..logger import get_logger
|
||||
from ..types import ModelType
|
||||
from .enum import LoadStrategy
|
||||
from .registry import FixtureRegistry, _normalize_contexts
|
||||
|
||||
@@ -40,6 +41,32 @@ def _instance_to_dict(instance: DeclarativeBase) -> dict[str, Any]:
|
||||
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(
|
||||
instances: list[DeclarativeBase],
|
||||
) -> list[tuple[type[DeclarativeBase], list[DeclarativeBase]]]:
|
||||
@@ -67,15 +94,42 @@ def _group_by_column_set(
|
||||
return list(groups.values())
|
||||
|
||||
|
||||
def _grouped_table_dicts(
|
||||
model_cls: type[DeclarativeBase], instances: list[DeclarativeBase]
|
||||
) -> Iterator[
|
||||
tuple[type[DeclarativeBase], list[dict[str, Any]], list[DeclarativeBase]]
|
||||
]:
|
||||
"""Yield (cls, group_dicts, group_instances) per table in the inheritance
|
||||
chain and per column-set group, skipping empty groups.
|
||||
"""
|
||||
for cls in _get_table_chain(model_cls):
|
||||
dicts = [_instance_to_dict_for_cls(i, cls) for i in instances]
|
||||
for group_dicts, group_instances in _group_by_column_set(dicts, instances):
|
||||
if group_dicts and group_dicts[0]: # pragma: no branch
|
||||
yield cls, group_dicts, group_instances
|
||||
|
||||
|
||||
async def _batch_insert(
|
||||
session: AsyncSession,
|
||||
model_cls: type[DeclarativeBase],
|
||||
instances: list[DeclarativeBase],
|
||||
) -> None:
|
||||
"""INSERT all instances — raises on conflict (no duplicate handling)."""
|
||||
dicts = [_instance_to_dict(i) for i in instances]
|
||||
for group_dicts, _ in _group_by_column_set(dicts, instances):
|
||||
await session.execute(pg_insert(model_cls).values(group_dicts))
|
||||
"""INSERT all instances, raises on conflict."""
|
||||
for cls, group_dicts, group_instances in _grouped_table_dicts(model_cls, instances):
|
||||
table = cast(Table, cls.__table__)
|
||||
missing_pk_cols = [
|
||||
col for col in table.primary_key.columns if col.key not in group_dicts[0]
|
||||
]
|
||||
if not missing_pk_cols:
|
||||
await session.execute(pg_insert(table), group_dicts)
|
||||
continue
|
||||
stmt = pg_insert(table).returning(
|
||||
*missing_pk_cols, sort_by_parameter_order=True
|
||||
)
|
||||
result = await session.execute(stmt, group_dicts)
|
||||
for inst, row in zip(group_instances, result):
|
||||
for col, val in zip(missing_pk_cols, row):
|
||||
setattr(inst, col.key, val)
|
||||
|
||||
|
||||
async def _batch_merge(
|
||||
@@ -84,18 +138,14 @@ async def _batch_merge(
|
||||
instances: list[DeclarativeBase],
|
||||
) -> None:
|
||||
"""UPSERT: insert new rows, update existing ones with the provided values."""
|
||||
mapper = model_cls.__mapper__
|
||||
pk_names = [col.name for col in mapper.primary_key]
|
||||
for cls, group_dicts, _ in _grouped_table_dicts(model_cls, instances):
|
||||
table = cast(Table, cls.__table__)
|
||||
pk_names = [col.name for col in table.primary_key]
|
||||
pk_names_set = set(pk_names)
|
||||
non_pk_cols = [
|
||||
prop.key
|
||||
for prop in mapper.column_attrs
|
||||
if not any(col.name in pk_names_set for col in prop.columns)
|
||||
]
|
||||
own_col_keys = {col.key for col in table.columns}
|
||||
non_pk_cols = [k for k in own_col_keys if k not in pk_names_set]
|
||||
|
||||
dicts = [_instance_to_dict(i) for i in instances]
|
||||
for group_dicts, _ in _group_by_column_set(dicts, instances):
|
||||
stmt = pg_insert(model_cls).values(group_dicts)
|
||||
stmt = pg_insert(table).values(group_dicts)
|
||||
|
||||
inserted_keys = set(group_dicts[0])
|
||||
update_cols = [col for col in non_pk_cols if col in inserted_keys]
|
||||
@@ -117,6 +167,16 @@ async def _batch_skip_existing(
|
||||
instances: list[DeclarativeBase],
|
||||
) -> list[DeclarativeBase]:
|
||||
"""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__
|
||||
pk_names = [col.name for col in mapper.primary_key]
|
||||
|
||||
@@ -129,11 +189,17 @@ async def _batch_skip_existing(
|
||||
else:
|
||||
with_pk_pairs.append((inst, pk))
|
||||
|
||||
loaded: list[DeclarativeBase] = list(no_pk)
|
||||
loaded = list(no_pk)
|
||||
if no_pk:
|
||||
no_pk_dicts = [_instance_to_dict(i) for i in no_pk]
|
||||
for group_dicts, _ in _group_by_column_set(no_pk_dicts, no_pk):
|
||||
await session.execute(pg_insert(model_cls).values(group_dicts))
|
||||
for group_dicts, group_instances in _group_by_column_set(no_pk_dicts, no_pk):
|
||||
stmt = pg_insert(cast(Table, model_cls.__table__)).returning(
|
||||
*mapper.primary_key, sort_by_parameter_order=True
|
||||
)
|
||||
result = await session.execute(stmt, group_dicts)
|
||||
for inst, row in zip(group_instances, result):
|
||||
for col, val in zip(mapper.primary_key, row):
|
||||
setattr(inst, cast(str, col.key), val)
|
||||
|
||||
if with_pk_pairs:
|
||||
with_pk = [i for i, _ in with_pk_pairs]
|
||||
@@ -159,6 +225,65 @@ async def _batch_skip_existing(
|
||||
return loaded
|
||||
|
||||
|
||||
def _relationship_load_options(model: type[DeclarativeBase]) -> list[ExecutableOption]:
|
||||
"""Build selectinload options for all direct relationships on a model."""
|
||||
return [
|
||||
selectinload(getattr(model, rel.key)) for rel in model.__mapper__.relationships
|
||||
]
|
||||
|
||||
|
||||
async def _reload_with_relationships(
|
||||
session: AsyncSession,
|
||||
instances: list[DeclarativeBase],
|
||||
load_options: list[ExecutableOption],
|
||||
) -> list[DeclarativeBase]:
|
||||
"""Reload instances in a single bulk query with relationship eager-loading."""
|
||||
model = type(instances[0])
|
||||
mapper = model.__mapper__
|
||||
pk_cols = mapper.primary_key
|
||||
|
||||
if len(pk_cols) == 1:
|
||||
pk_key = cast(str, pk_cols[0].key)
|
||||
pk_attr = getattr(model, pk_key)
|
||||
pks = [getattr(inst, pk_key) for inst in instances]
|
||||
result = await session.execute(
|
||||
select(model).where(pk_attr.in_(pks)).options(*load_options)
|
||||
)
|
||||
by_pk = {getattr(row, pk_key): row for row in result.unique().scalars()}
|
||||
return [by_pk[pk] for pk in pks]
|
||||
|
||||
# Composite PK: fall back to per-instance reload
|
||||
reloaded: list[DeclarativeBase] = []
|
||||
for instance in instances:
|
||||
pk = _get_primary_key(instance)
|
||||
refreshed = await session.get(
|
||||
model,
|
||||
pk,
|
||||
options=cast(list[ORMOption], load_options),
|
||||
populate_existing=True,
|
||||
)
|
||||
if refreshed is not None: # pragma: no branch
|
||||
reloaded.append(refreshed)
|
||||
return reloaded
|
||||
|
||||
|
||||
async def _refresh_loaded(
|
||||
session: AsyncSession, instances: list[DeclarativeBase]
|
||||
) -> list[DeclarativeBase]:
|
||||
"""Re-select freshly written rows, eager-loading relationships."""
|
||||
if not instances:
|
||||
return []
|
||||
refreshed: list[DeclarativeBase | None] = [None] * len(instances)
|
||||
for model_cls, group in _group_by_type(instances):
|
||||
positions = [i for i, inst in enumerate(instances) if type(inst) is model_cls]
|
||||
load_options = _relationship_load_options(model_cls)
|
||||
for pos, new in zip(
|
||||
positions, await _reload_with_relationships(session, group, load_options)
|
||||
):
|
||||
refreshed[pos] = new
|
||||
return cast(list[DeclarativeBase], refreshed)
|
||||
|
||||
|
||||
async def _load_ordered(
|
||||
session: AsyncSession,
|
||||
registry: FixtureRegistry,
|
||||
@@ -171,15 +296,12 @@ async def _load_ordered(
|
||||
|
||||
for name in ordered_names:
|
||||
variants = (
|
||||
registry.get_variants(name, *contexts)
|
||||
registry.get_load_variants(name, *contexts)
|
||||
if contexts is not None
|
||||
else registry.get_variants(name)
|
||||
)
|
||||
|
||||
if contexts is not None and not variants:
|
||||
variants = registry.get_variants(name)
|
||||
|
||||
if not variants:
|
||||
if not variants: # pragma: no cover
|
||||
results[name] = []
|
||||
continue
|
||||
|
||||
@@ -192,7 +314,7 @@ async def _load_ordered(
|
||||
model_name = type(instances[0]).__name__
|
||||
loaded: list[DeclarativeBase] = []
|
||||
|
||||
async with get_transaction(session):
|
||||
async with transaction(session):
|
||||
for model_cls, group in _group_by_type(instances):
|
||||
match strategy:
|
||||
case LoadStrategy.INSERT:
|
||||
@@ -204,9 +326,13 @@ async def _load_ordered(
|
||||
case LoadStrategy.SKIP_EXISTING:
|
||||
inserted = await _batch_skip_existing(session, model_cls, group)
|
||||
loaded.extend(inserted)
|
||||
case _: # pragma: no cover
|
||||
pass
|
||||
|
||||
loaded = await _refresh_loaded(session, loaded)
|
||||
|
||||
results[name] = loaded
|
||||
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
|
||||
logger.info("Loaded fixture '%s': %d %s(s)", name, len(loaded), model_name)
|
||||
|
||||
return results
|
||||
|
||||
@@ -225,31 +351,6 @@ def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_obj_by_attr(
|
||||
fixtures: Callable[[], Sequence[ModelType]], attr_name: str, value: Any
|
||||
) -> ModelType:
|
||||
"""Get a SQLAlchemy model instance by matching an attribute value.
|
||||
|
||||
Args:
|
||||
fixtures: A fixture function registered via ``@registry.register``
|
||||
that returns a sequence of SQLAlchemy model instances.
|
||||
attr_name: Name of the attribute to match against.
|
||||
value: Value to match.
|
||||
|
||||
Returns:
|
||||
The first model instance where the attribute matches the given value.
|
||||
|
||||
Raises:
|
||||
StopIteration: If no matching object is found in the fixture group.
|
||||
"""
|
||||
try:
|
||||
return next(obj for obj in fixtures() if getattr(obj, attr_name) == value)
|
||||
except StopIteration:
|
||||
raise StopIteration(
|
||||
f"No object with {attr_name}={value} found in fixture '{getattr(fixtures, '__name__', repr(fixtures))}'"
|
||||
) from None
|
||||
|
||||
|
||||
async def load_fixtures(
|
||||
session: AsyncSession,
|
||||
registry: FixtureRegistry,
|
||||
@@ -284,8 +385,8 @@ async def load_fixtures_by_context(
|
||||
Args:
|
||||
session: Database session
|
||||
registry: Fixture registry
|
||||
*contexts: Contexts to load (e.g., ``Context.BASE``, ``Context.TESTING``,
|
||||
or plain strings for custom contexts)
|
||||
*contexts: Contexts to load (e.g., ``Context.TESTING``, or plain
|
||||
strings for custom contexts)
|
||||
strategy: How to handle existing records
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
from .columns import (
|
||||
CreatedAtMixin,
|
||||
TimestampMixin,
|
||||
UpdatedAtMixin,
|
||||
UUIDMixin,
|
||||
UUIDv7Mixin,
|
||||
UpdatedAtMixin,
|
||||
)
|
||||
from .watched import EventSession, ModelEvent, listens_for
|
||||
|
||||
__all__ = [
|
||||
"CreatedAtMixin",
|
||||
"EventSession",
|
||||
"ModelEvent",
|
||||
"TimestampMixin",
|
||||
"UUIDMixin",
|
||||
"UUIDv7Mixin",
|
||||
"CreatedAtMixin",
|
||||
"UpdatedAtMixin",
|
||||
"TimestampMixin",
|
||||
"listens_for",
|
||||
]
|
||||
|
||||
@@ -5,7 +5,7 @@ from collections.abc import Callable
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy import event, select, tuple_
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import set_committed_value as _sa_set_committed_value
|
||||
@@ -29,14 +29,11 @@ _SESSION_DELETES = "_ft_deletes"
|
||||
_SESSION_UPDATES = "_ft_updates"
|
||||
_DEFERRED_STRATEGY_KEY = (("deferred", True), ("instrument", True))
|
||||
_EVENT_HANDLERS: dict[tuple[type, ModelEvent], list[Callable[..., Any]]] = {}
|
||||
_WATCHED_MODELS: set[type] = set()
|
||||
_WATCHED_CACHE: dict[type, bool] = {}
|
||||
_HANDLER_CACHE: dict[tuple[type, ModelEvent], list[Callable[..., Any]]] = {}
|
||||
|
||||
|
||||
def _invalidate_caches() -> None:
|
||||
"""Clear lookup caches after handler registration."""
|
||||
_WATCHED_CACHE.clear()
|
||||
_HANDLER_CACHE.clear()
|
||||
|
||||
|
||||
@@ -56,24 +53,12 @@ def listens_for(
|
||||
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||
for ev in evs:
|
||||
_EVENT_HANDLERS.setdefault((model_class, ev), []).append(fn)
|
||||
_WATCHED_MODELS.add(model_class)
|
||||
_invalidate_caches()
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _is_watched(obj: Any) -> bool:
|
||||
"""Return True if *obj*'s type (or any ancestor) has registered handlers."""
|
||||
cls = type(obj)
|
||||
try:
|
||||
return _WATCHED_CACHE[cls]
|
||||
except KeyError:
|
||||
result = any(klass in _WATCHED_MODELS for klass in cls.__mro__)
|
||||
_WATCHED_CACHE[cls] = result
|
||||
return result
|
||||
|
||||
|
||||
def _get_handlers(cls: type, ev: ModelEvent) -> list[Callable[..., Any]]:
|
||||
"""Return registered handlers for *cls* and *ev*, walking the MRO."""
|
||||
key = (cls, ev)
|
||||
@@ -144,18 +129,18 @@ def _upsert_changes(
|
||||
def _after_flush(session: Any, flush_context: Any) -> None:
|
||||
# New objects: capture reference. Attributes will be refreshed after commit.
|
||||
for obj in session.new:
|
||||
if _is_watched(obj):
|
||||
if _get_handlers(type(obj), ModelEvent.CREATE):
|
||||
session.info.setdefault(_SESSION_CREATES, []).append(obj)
|
||||
|
||||
# Deleted objects: snapshot now while attributes are still loaded.
|
||||
for obj in session.deleted:
|
||||
if _is_watched(obj):
|
||||
if _get_handlers(type(obj), ModelEvent.DELETE):
|
||||
snapshot = _snapshot_column_attrs(obj)
|
||||
session.info.setdefault(_SESSION_DELETES, []).append((obj, snapshot))
|
||||
|
||||
# Dirty objects: read old/new from SQLAlchemy attribute history.
|
||||
for obj in session.dirty:
|
||||
if not _is_watched(obj):
|
||||
if not _get_handlers(type(obj), ModelEvent.UPDATE):
|
||||
continue
|
||||
|
||||
watched = _get_watched_fields(type(obj))
|
||||
@@ -204,10 +189,24 @@ async def _invoke_callback(
|
||||
await result
|
||||
|
||||
|
||||
async def _batch_reload(
|
||||
session: AsyncSession, model: type, pk_tuples: list[tuple[Any, ...]]
|
||||
) -> None:
|
||||
"""Re-populate all rows of *model* identified by *pk_tuples* in one round trip."""
|
||||
pk_cols = sa_inspect(model, raiseerr=True).primary_key
|
||||
where = (
|
||||
pk_cols[0].in_([pk[0] for pk in pk_tuples])
|
||||
if len(pk_cols) == 1
|
||||
else tuple_(*pk_cols).in_(pk_tuples)
|
||||
)
|
||||
q = select(model).where(where).execution_options(populate_existing=True)
|
||||
await session.execute(q)
|
||||
|
||||
|
||||
class EventSession(AsyncSession):
|
||||
"""AsyncSession subclass that dispatches lifecycle callbacks after commit."""
|
||||
|
||||
async def commit(self) -> None: # noqa: C901
|
||||
async def commit(self) -> None:
|
||||
await super().commit()
|
||||
|
||||
creates: list[Any] = self.info.pop(_SESSION_CREATES, [])
|
||||
@@ -245,15 +244,36 @@ class EventSession(AsyncSession):
|
||||
k: v for k, v in field_changes.items() if k not in create_ids
|
||||
}
|
||||
|
||||
# Dispatch CREATE callbacks.
|
||||
# Resolve reloadable state up front and group PKs by model type so
|
||||
# the post-commit reload is one query per type instead of one
|
||||
# session.get() per object.
|
||||
create_items: list[Any] = []
|
||||
update_items: list[tuple[Any, dict[str, dict[str, Any]]]] = []
|
||||
pk_by_type: dict[type, list[tuple[Any, ...]]] = {}
|
||||
|
||||
for obj in creates:
|
||||
try:
|
||||
state = sa_inspect(obj, raiseerr=False)
|
||||
if (
|
||||
state is None or state.detached or state.transient
|
||||
): # pragma: no cover
|
||||
if state is None or state.detached or state.transient: # pragma: no cover
|
||||
continue
|
||||
await self.refresh(obj)
|
||||
create_items.append(obj)
|
||||
pk_by_type.setdefault(type(obj), []).append(state.key[1])
|
||||
|
||||
for obj, changes in field_changes.values():
|
||||
state = sa_inspect(obj, raiseerr=False)
|
||||
if state is None or state.detached or state.transient: # pragma: no cover
|
||||
continue
|
||||
update_items.append((obj, changes))
|
||||
pk_by_type.setdefault(type(obj), []).append(state.key[1])
|
||||
|
||||
for model, pk_tuples in pk_by_type.items():
|
||||
try:
|
||||
await _batch_reload(self, model, pk_tuples)
|
||||
except Exception as exc:
|
||||
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
||||
|
||||
# Dispatch CREATE callbacks.
|
||||
for obj in create_items:
|
||||
try:
|
||||
for handler in _get_handlers(type(obj), ModelEvent.CREATE):
|
||||
await _invoke_callback(handler, obj, ModelEvent.CREATE, None)
|
||||
except Exception as exc:
|
||||
@@ -270,14 +290,8 @@ class EventSession(AsyncSession):
|
||||
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
||||
|
||||
# Dispatch UPDATE callbacks.
|
||||
for obj, changes in field_changes.values():
|
||||
for obj, changes in update_items:
|
||||
try:
|
||||
state = sa_inspect(obj, raiseerr=False)
|
||||
if (
|
||||
state is None or state.detached or state.transient
|
||||
): # pragma: no cover
|
||||
continue
|
||||
await self.refresh(obj)
|
||||
for handler in _get_handlers(type(obj), ModelEvent.UPDATE):
|
||||
await _invoke_callback(handler, obj, ModelEvent.UPDATE, changes)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Pytest plugin for using FixtureRegistry fixtures in tests."""
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from ..db import get_transaction
|
||||
from ..fixtures import FixtureRegistry, LoadStrategy
|
||||
from ..fixtures.utils import _get_primary_key, _load_ordered, _refresh_loaded
|
||||
|
||||
|
||||
def register_fixtures(
|
||||
@@ -55,7 +55,7 @@ def register_fixtures(
|
||||
|
||||
# Build list of pytest fixture dependencies
|
||||
pytest_deps = [session_fixture]
|
||||
for dep in fixture.depends_on:
|
||||
for dep in registry.get_dependencies(fixture.name):
|
||||
pytest_deps.append(f"{prefix}{dep}")
|
||||
|
||||
# Create the fixture function
|
||||
@@ -81,51 +81,38 @@ def _create_fixture_function(
|
||||
fixture_name: str,
|
||||
dependencies: list[str],
|
||||
strategy: LoadStrategy,
|
||||
) -> Callable[..., Any]:
|
||||
) -> Any:
|
||||
"""Create a fixture function with the correct signature.
|
||||
|
||||
The function signature must include all dependencies as parameters
|
||||
for pytest to resolve them correctly.
|
||||
for pytest (and pytest-anyio's fixture chaining) to resolve them
|
||||
correctly — dynamic resolution via ``request.getfixturevalue`` deadlocks
|
||||
when called from inside an already-running async fixture.
|
||||
"""
|
||||
# Get the fixture definition
|
||||
fixture_def = registry.get(fixture_name)
|
||||
|
||||
# Build the function dynamically with correct parameters
|
||||
# We need the session as first param, then all dependencies
|
||||
async def fixture_func(**kwargs: Any) -> Sequence[DeclarativeBase]:
|
||||
# Get session from kwargs (first dependency)
|
||||
session: AsyncSession = kwargs[dependencies[0]]
|
||||
result = (await _load_ordered(session, registry, [fixture_name], strategy))[
|
||||
fixture_name
|
||||
]
|
||||
|
||||
# Load the fixture data
|
||||
instances = list(fixture_def.func())
|
||||
if strategy is LoadStrategy.SKIP_EXISTING:
|
||||
# _load_ordered only returns newly-inserted rows for this
|
||||
# strategy (the CLI seeding contract). A test fixture should
|
||||
# still hand back the full, usable set including rows that
|
||||
# were already present, so top up with those.
|
||||
declared = list(fixture_def.func())
|
||||
result_pks = {_get_primary_key(r) for r in result}
|
||||
missing = [
|
||||
d
|
||||
for d in declared
|
||||
if (pk := _get_primary_key(d)) is not None and pk not in result_pks
|
||||
]
|
||||
if missing:
|
||||
result = result + await _refresh_loaded(session, missing)
|
||||
|
||||
if not instances:
|
||||
return []
|
||||
|
||||
loaded: list[DeclarativeBase] = []
|
||||
|
||||
async with get_transaction(session):
|
||||
for instance in instances:
|
||||
if strategy == LoadStrategy.INSERT:
|
||||
session.add(instance)
|
||||
loaded.append(instance)
|
||||
elif strategy == LoadStrategy.MERGE:
|
||||
merged = await session.merge(instance)
|
||||
loaded.append(merged)
|
||||
elif strategy == LoadStrategy.SKIP_EXISTING:
|
||||
pk = _get_primary_key(instance)
|
||||
if pk is not None:
|
||||
existing = await session.get(type(instance), pk)
|
||||
if existing is None:
|
||||
session.add(instance)
|
||||
loaded.append(instance)
|
||||
else:
|
||||
loaded.append(existing)
|
||||
else:
|
||||
session.add(instance)
|
||||
loaded.append(instance)
|
||||
|
||||
return loaded
|
||||
return result
|
||||
|
||||
# Update function signature to include dependencies
|
||||
# This is needed for pytest to inject the right fixtures
|
||||
@@ -139,17 +126,3 @@ def _create_fixture_function(
|
||||
created_func.__doc__ = f"Load {fixture_name} fixture data."
|
||||
|
||||
return created_func
|
||||
|
||||
|
||||
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
||||
"""Get the primary key value of a model instance."""
|
||||
mapper = instance.__class__.__mapper__
|
||||
pk_cols = mapper.primary_key
|
||||
|
||||
if len(pk_cols) == 1:
|
||||
return getattr(instance, pk_cols[0].name, None)
|
||||
|
||||
pk_values = tuple(getattr(instance, col.name, None) for col in pk_cols)
|
||||
if all(v is not None for v in pk_values):
|
||||
return pk_values
|
||||
return None
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.engine import URL, make_url
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
@@ -15,7 +15,7 @@ from sqlalchemy.ext.asyncio import (
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from ..db import cleanup_tables, create_database
|
||||
from ..db.testing import cleanup_tables, create_database
|
||||
from ..models.watched import EventSession
|
||||
|
||||
|
||||
@@ -34,12 +34,18 @@ def _get_xdist_worker(default_test_db: str) -> str:
|
||||
return os.environ.get("PYTEST_XDIST_WORKER", default_test_db)
|
||||
|
||||
|
||||
def worker_database_url(database_url: str, default_test_db: str) -> str:
|
||||
def worker_database_url(
|
||||
database_url: str,
|
||||
default_test_db: str,
|
||||
*,
|
||||
prefix: str | None = None,
|
||||
) -> str:
|
||||
"""Derive a per-worker database URL for pytest-xdist parallel runs.
|
||||
|
||||
Appends ``_{worker_name}`` to the database name so each xdist worker
|
||||
operates on its own database. When not running under xdist,
|
||||
``_{default_test_db}`` is appended instead.
|
||||
Sets the database name to the worker name so each xdist worker operates
|
||||
on its own database. When not running under xdist, *default_test_db* is
|
||||
used instead. When *prefix* is provided, the name becomes
|
||||
``{prefix}_{worker}``.
|
||||
|
||||
The worker name is read from the ``PYTEST_XDIST_WORKER`` environment
|
||||
variable (set automatically by xdist in each worker process).
|
||||
@@ -48,6 +54,9 @@ def worker_database_url(database_url: str, default_test_db: str) -> str:
|
||||
database_url: Original database connection URL.
|
||||
default_test_db: Suffix appended to the database name when
|
||||
``PYTEST_XDIST_WORKER`` is not set.
|
||||
prefix: Optional prefix prepended to the worker name
|
||||
(e.g. ``"test"`` → ``"test_gw0"``). Without it, the database
|
||||
name is just the worker name (e.g. ``"gw0"``).
|
||||
|
||||
Returns:
|
||||
A database URL with a worker- or default-specific database name.
|
||||
@@ -55,7 +64,8 @@ def worker_database_url(database_url: str, default_test_db: str) -> str:
|
||||
worker = _get_xdist_worker(default_test_db=default_test_db)
|
||||
|
||||
url = make_url(database_url)
|
||||
url = url.set(database=f"{url.database}_{worker}")
|
||||
db_name = f"{prefix}_{worker}" if prefix else worker
|
||||
url = url.set(database=db_name)
|
||||
return url.render_as_string(hide_password=False)
|
||||
|
||||
|
||||
@@ -63,6 +73,9 @@ def worker_database_url(database_url: str, default_test_db: str) -> str:
|
||||
async def create_worker_database(
|
||||
database_url: str,
|
||||
default_test_db: str = "test_db",
|
||||
*,
|
||||
prefix: str | None = None,
|
||||
server_url: str | None = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Create and drop a per-worker database for pytest-xdist isolation.
|
||||
|
||||
@@ -74,10 +87,16 @@ async def create_worker_database(
|
||||
name (e.g. ``_gw0``). Otherwise it is suffixed with *default_test_db*.
|
||||
|
||||
Args:
|
||||
database_url: Original database connection URL (used as the server
|
||||
connection and as the base for the worker database name).
|
||||
database_url: Original database connection URL (used as the base for
|
||||
the worker database name).
|
||||
default_test_db: Suffix appended to the database name when
|
||||
``PYTEST_XDIST_WORKER`` is not set. Defaults to ``"test_db"``.
|
||||
prefix: Optional prefix prepended to the worker name
|
||||
(e.g. ``prefix="test"`` → ``"test_gw0"``). Without it, the
|
||||
database name is just the worker name (e.g. ``"gw0"``).
|
||||
server_url: URL used for server-level DDL (must point to an existing
|
||||
database on the same server). Defaults to *database_url* with the
|
||||
database omitted, letting asyncpg fall back to the username.
|
||||
|
||||
Yields:
|
||||
The worker-specific database URL.
|
||||
@@ -86,7 +105,7 @@ async def create_worker_database(
|
||||
```python
|
||||
from fastapi_toolsets.pytest import create_worker_database, create_db_session
|
||||
|
||||
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost/test_db"
|
||||
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost/myapp"
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def worker_db_url():
|
||||
@@ -102,21 +121,35 @@ async def create_worker_database(
|
||||
```
|
||||
"""
|
||||
worker_url = worker_database_url(
|
||||
database_url=database_url, default_test_db=default_test_db
|
||||
database_url=database_url, default_test_db=default_test_db, prefix=prefix
|
||||
)
|
||||
worker_db_name = make_url(worker_url).database
|
||||
assert worker_db_name is not None
|
||||
|
||||
engine = create_async_engine(database_url, isolation_level="AUTOCOMMIT")
|
||||
_parsed = make_url(database_url)
|
||||
_server_url = server_url or URL.create(
|
||||
drivername=_parsed.drivername,
|
||||
username=_parsed.username,
|
||||
password=_parsed.password,
|
||||
host=_parsed.host,
|
||||
port=_parsed.port,
|
||||
query=_parsed.query,
|
||||
).render_as_string(hide_password=False)
|
||||
|
||||
engine = create_async_engine(_server_url, isolation_level="AUTOCOMMIT")
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text(f"DROP DATABASE IF EXISTS {worker_db_name}"))
|
||||
await create_database(db_name=worker_db_name, server_url=database_url)
|
||||
await conn.execute(
|
||||
text(f"DROP DATABASE IF EXISTS {worker_db_name} WITH (FORCE)")
|
||||
)
|
||||
await create_database(db_name=worker_db_name, server_url=_server_url)
|
||||
|
||||
yield worker_url
|
||||
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text(f"DROP DATABASE IF EXISTS {worker_db_name}"))
|
||||
await conn.execute(
|
||||
text(f"DROP DATABASE IF EXISTS {worker_db_name} WITH (FORCE)")
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
@@ -126,6 +159,7 @@ async def create_async_client(
|
||||
app: Any,
|
||||
base_url: str = "http://test",
|
||||
dependency_overrides: dict[Callable[..., Any], Callable[..., Any]] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create an async httpx client for testing FastAPI applications.
|
||||
|
||||
@@ -135,6 +169,9 @@ async def create_async_client(
|
||||
dependency_overrides: Optional mapping of original dependencies to
|
||||
their test replacements. Applied via ``app.dependency_overrides``
|
||||
before yielding and cleaned up after.
|
||||
**kwargs: Additional keyword arguments forwarded to
|
||||
:class:`httpx.AsyncClient` (e.g. ``headers``, ``cookies``,
|
||||
``auth``, ``timeout``).
|
||||
|
||||
Yields:
|
||||
An AsyncClient configured for the app.
|
||||
@@ -182,7 +219,9 @@ async def create_async_client(
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url=base_url) as client:
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url=base_url, **kwargs
|
||||
) as client:
|
||||
yield client
|
||||
finally:
|
||||
if dependency_overrides:
|
||||
@@ -199,6 +238,8 @@ async def create_db_session(
|
||||
expire_on_commit: bool = False,
|
||||
drop_tables: bool = True,
|
||||
cleanup: bool = False,
|
||||
engine_kwargs: dict[str, Any] | None = None,
|
||||
session_kwargs: dict[str, Any] | None = None,
|
||||
) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create a database session for testing.
|
||||
|
||||
@@ -213,6 +254,12 @@ async def create_db_session(
|
||||
drop_tables: Drop tables after test. Defaults to True.
|
||||
cleanup: Truncate all tables after test using
|
||||
:func:`cleanup_tables`. Defaults to False.
|
||||
engine_kwargs: Additional keyword arguments forwarded to
|
||||
:func:`sqlalchemy.ext.asyncio.create_async_engine`
|
||||
(e.g. ``pool_size``, ``connect_args``).
|
||||
session_kwargs: Additional keyword arguments forwarded to
|
||||
:class:`sqlalchemy.ext.asyncio.async_sessionmaker`
|
||||
(e.g. ``autoflush``, ``class_``).
|
||||
|
||||
Yields:
|
||||
An AsyncSession ready for database operations.
|
||||
@@ -237,15 +284,17 @@ async def create_db_session(
|
||||
await db_session.commit()
|
||||
```
|
||||
"""
|
||||
engine = create_async_engine(database_url, echo=echo)
|
||||
engine = create_async_engine(database_url, echo=echo, **(engine_kwargs or {}))
|
||||
|
||||
try:
|
||||
# Create tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(base.metadata.create_all)
|
||||
|
||||
session_maker = async_sessionmaker(
|
||||
engine, expire_on_commit=expire_on_commit, class_=EventSession
|
||||
engine,
|
||||
expire_on_commit=expire_on_commit,
|
||||
class_=EventSession,
|
||||
**(session_kwargs or {}),
|
||||
)
|
||||
async with session_maker() as session:
|
||||
yield session
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import math
|
||||
from enum import Enum
|
||||
from typing import Annotated, Any, ClassVar, Generic, Literal, TypeVar, Union
|
||||
from typing import Annotated, Any, ClassVar, Generic, Literal, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
||||
|
||||
@@ -10,11 +10,11 @@ from .types import DataT
|
||||
|
||||
__all__ = [
|
||||
"ApiError",
|
||||
"CursorPagination",
|
||||
"CursorPaginatedResponse",
|
||||
"CursorPagination",
|
||||
"ErrorResponse",
|
||||
"OffsetPagination",
|
||||
"OffsetPaginatedResponse",
|
||||
"OffsetPagination",
|
||||
"PaginatedResponse",
|
||||
"PaginationType",
|
||||
"PydanticBase",
|
||||
@@ -163,6 +163,7 @@ class PaginatedResponse(BaseResponse, Generic[DataT]):
|
||||
pagination_type: PaginationType | None = None
|
||||
filter_attributes: dict[str, list[Any]] | None = None
|
||||
search_columns: list[str] | None = None
|
||||
order_columns: list[str] | None = None
|
||||
|
||||
_discriminated_union_cache: ClassVar[dict[Any, Any]] = {}
|
||||
|
||||
@@ -173,12 +174,12 @@ class PaginatedResponse(BaseResponse, Generic[DataT]):
|
||||
cached = cls._discriminated_union_cache.get(item)
|
||||
if cached is None:
|
||||
cached = Annotated[
|
||||
Union[CursorPaginatedResponse[item], OffsetPaginatedResponse[item]], # ty:ignore[invalid-type-form]
|
||||
CursorPaginatedResponse[item] | OffsetPaginatedResponse[item], # ty:ignore[invalid-type-form]
|
||||
Field(discriminator="pagination_type"),
|
||||
]
|
||||
cls._discriminated_union_cache[item] = cached
|
||||
return cached # ty:ignore[invalid-return-type]
|
||||
return super().__class_getitem__(item)
|
||||
return super().__class_getitem__(item) # ty:ignore[invalid-return-type]
|
||||
|
||||
|
||||
class OffsetPaginatedResponse(PaginatedResponse[DataT]):
|
||||
|
||||
@@ -19,9 +19,10 @@ JoinType = list[tuple[type[DeclarativeBase] | Any, Any]]
|
||||
M2MFieldType = Mapping[str, QueryableAttribute[Any]]
|
||||
OrderByClause = ColumnElement[Any] | QueryableAttribute[Any]
|
||||
|
||||
# Search / facet type aliases
|
||||
# Search / facet / order type aliases
|
||||
SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any], ...]
|
||||
FacetFieldType = SearchFieldType
|
||||
OrderFieldType = SearchFieldType
|
||||
|
||||
# Dependency type aliases
|
||||
SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]] | Any
|
||||
|
||||
+124
-6
@@ -1,25 +1,28 @@
|
||||
"""Shared pytest fixtures for fastapi-utils tests."""
|
||||
|
||||
import datetime
|
||||
import decimal
|
||||
import os
|
||||
import uuid
|
||||
from enum import Enum
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
import datetime
|
||||
import decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Column,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
JSON,
|
||||
Numeric,
|
||||
String,
|
||||
Table,
|
||||
Uuid,
|
||||
)
|
||||
from sqlalchemy import (
|
||||
Enum as SAEnum,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
@@ -36,8 +39,6 @@ DATABASE_URL = os.getenv(
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for test models."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class Role(Base):
|
||||
"""Test role model."""
|
||||
@@ -139,6 +140,35 @@ class Post(Base):
|
||||
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):
|
||||
"""Test model with two FKs to the same table (users)."""
|
||||
|
||||
@@ -161,6 +191,35 @@ class Article(Base):
|
||||
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):
|
||||
"""Schema for creating a role."""
|
||||
|
||||
@@ -311,6 +370,26 @@ class ArticleRead(PydanticBase):
|
||||
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):
|
||||
"""Schema for creating a transfer."""
|
||||
|
||||
@@ -327,6 +406,7 @@ class TransferRead(PydanticBase):
|
||||
amount: str
|
||||
|
||||
|
||||
OrderCrud = CrudFactory(Order)
|
||||
TransferCrud = CrudFactory(Transfer)
|
||||
ArticleCrud = CrudFactory(Article)
|
||||
RoleCrud = CrudFactory(Role)
|
||||
@@ -358,6 +438,21 @@ async def engine():
|
||||
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")
|
||||
async def db_session(engine):
|
||||
"""Create a test database session with tables.
|
||||
@@ -380,3 +475,26 @@ async def db_session(engine):
|
||||
# Drop tables after test
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def db_session_expire_on_commit(engine):
|
||||
"""Session with expire_on_commit=True (the SQLAlchemy default).
|
||||
|
||||
Attributes read off an instance after commit are expired and trigger an
|
||||
implicit (sync) refresh under this setting — which fails under asyncio
|
||||
with MissingGreenlet. The other ``db_session`` fixture uses
|
||||
``expire_on_commit=False`` and would not catch that class of bug.
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=True)
|
||||
session = session_factory()
|
||||
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
+26
-2
@@ -277,6 +277,10 @@ class TestFixturesCli:
|
||||
'@registry.register(depends_on=["roles"], contexts=[Context.TESTING])\n'
|
||||
"def users():\n"
|
||||
' return [{"id": 1, "name": "alice", "role_id": 1}]\n'
|
||||
"\n"
|
||||
'@registry.register(contexts=["staging"])\n'
|
||||
"def staging_only():\n"
|
||||
' return [{"id": 3, "name": "staging-user"}]\n'
|
||||
)
|
||||
|
||||
# Create db module
|
||||
@@ -316,7 +320,7 @@ class TestFixturesCli:
|
||||
assert result.exit_code == 0
|
||||
assert "roles" in result.output
|
||||
assert "users" in result.output
|
||||
assert "Total: 2 fixture(s)" in result.output
|
||||
assert "Total: 3 fixture(s)" in result.output
|
||||
|
||||
def test_fixtures_list_with_context(self, cli_env):
|
||||
"""fixtures list --context filters by context."""
|
||||
@@ -338,6 +342,27 @@ class TestFixturesCli:
|
||||
assert "roles" in result.output
|
||||
assert "[Dry run - no changes made]" in result.output
|
||||
|
||||
def test_fixtures_list_with_custom_context(self, cli_env):
|
||||
"""fixtures list --context accepts contexts outside the Context enum, and
|
||||
always includes base fixtures alongside the requested context."""
|
||||
tmp_path, cli = cli_env
|
||||
result = runner.invoke(cli, ["fixtures", "list", "--context", "staging"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "staging_only" in result.output
|
||||
assert "roles" in result.output
|
||||
assert "Total: 2 fixture(s)" in result.output
|
||||
|
||||
def test_fixtures_load_custom_context_dry_run(self, cli_env):
|
||||
"""fixtures load accepts a custom context argument outside the Context enum,
|
||||
and always loads base fixtures alongside it."""
|
||||
tmp_path, cli = cli_env
|
||||
result = runner.invoke(cli, ["fixtures", "load", "staging", "--dry-run"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "staging_only" in result.output
|
||||
assert "roles" in result.output
|
||||
|
||||
def test_fixtures_load_invalid_strategy(self, cli_env):
|
||||
"""fixtures load with invalid strategy shows error."""
|
||||
tmp_path, cli = cli_env
|
||||
@@ -529,7 +554,6 @@ class TestAsyncCommand:
|
||||
@async_command
|
||||
async def async_func() -> None:
|
||||
"""This is a docstring."""
|
||||
pass
|
||||
|
||||
assert async_func.__doc__ == """This is a docstring."""
|
||||
|
||||
|
||||
+343
-1
@@ -247,6 +247,60 @@ class TestResolveSearchColumns:
|
||||
assert "username" not in result
|
||||
|
||||
|
||||
class TestResolveOrderColumns:
|
||||
"""Tests for _resolve_order_columns logic."""
|
||||
|
||||
def test_returns_none_when_no_order_fields(self):
|
||||
"""Returns None when cls.order_fields is None and no order_fields passed."""
|
||||
|
||||
class AbstractCrud(AsyncCrud[User]):
|
||||
pass
|
||||
|
||||
assert AbstractCrud._resolve_order_columns(None) is None
|
||||
|
||||
def test_returns_none_when_empty_order_fields_passed(self):
|
||||
"""Returns None when an empty list is passed explicitly."""
|
||||
crud = CrudFactory(User)
|
||||
assert crud._resolve_order_columns([]) is None
|
||||
|
||||
def test_returns_keys_from_class_order_fields(self):
|
||||
"""Returns sorted column keys from cls.order_fields when no override passed."""
|
||||
crud = CrudFactory(User, order_fields=[User.username])
|
||||
result = crud._resolve_order_columns(None)
|
||||
assert result is not None
|
||||
assert "username" in result
|
||||
|
||||
def test_order_fields_override_takes_priority(self):
|
||||
"""Explicit order_fields override cls.order_fields."""
|
||||
crud = CrudFactory(User, order_fields=[User.username])
|
||||
result = crud._resolve_order_columns([User.email])
|
||||
assert result is not None
|
||||
assert "email" in result
|
||||
assert "username" not in result
|
||||
|
||||
def test_returns_sorted_keys(self):
|
||||
"""Keys are returned in sorted order."""
|
||||
crud = CrudFactory(User, order_fields=[User.email, User.username])
|
||||
result = crud._resolve_order_columns(None)
|
||||
assert result is not None
|
||||
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:
|
||||
"""Integration tests for default_load_options with real DB queries."""
|
||||
|
||||
@@ -326,6 +380,92 @@ class TestDefaultLoadOptionsIntegration:
|
||||
assert result.data[0].role is not None
|
||||
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
|
||||
async def test_default_load_options_applied_to_create_expire_on_commit(
|
||||
self, db_session_expire_on_commit: AsyncSession
|
||||
):
|
||||
"""create()'s reload uses captured PK values, not an expired instance attribute.
|
||||
|
||||
Regression test for MissingGreenlet: reading a PK off `db_model` after
|
||||
commit under expire_on_commit=True (the SQLAlchemy default) would
|
||||
trigger an implicit sync refresh, which fails under asyncio.
|
||||
"""
|
||||
UserWithDefaultLoad = CrudFactory(
|
||||
User, default_load_options=[selectinload(User.role)]
|
||||
)
|
||||
role = await RoleCrud.create(
|
||||
db_session_expire_on_commit, RoleCreate(name="admin")
|
||||
)
|
||||
user = await UserWithDefaultLoad.create(
|
||||
db_session_expire_on_commit,
|
||||
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_expire_on_commit(
|
||||
self, db_session_expire_on_commit: AsyncSession
|
||||
):
|
||||
"""update()'s reload uses captured PK values, not an expired instance attribute.
|
||||
|
||||
Regression test for MissingGreenlet under expire_on_commit=True.
|
||||
"""
|
||||
UserWithDefaultLoad = CrudFactory(
|
||||
User, default_load_options=[selectinload(User.role)]
|
||||
)
|
||||
role = await RoleCrud.create(
|
||||
db_session_expire_on_commit, RoleCreate(name="admin")
|
||||
)
|
||||
user = await UserCrud.create(
|
||||
db_session_expire_on_commit,
|
||||
UserCreate(username="alice", email="alice@test.com"),
|
||||
)
|
||||
updated = await UserWithDefaultLoad.update(
|
||||
db_session_expire_on_commit,
|
||||
UserUpdate(role_id=role.id),
|
||||
filters=[User.id == user.id],
|
||||
)
|
||||
assert updated.role is not None
|
||||
assert updated.role.name == "admin"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_load_options_overrides_default_load_options(
|
||||
self, db_session: AsyncSession
|
||||
@@ -579,6 +719,28 @@ class TestCrudFirst:
|
||||
assert role is not None
|
||||
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:
|
||||
"""Tests for CRUD get_multi operations."""
|
||||
@@ -644,6 +806,45 @@ class TestCrudGetMulti:
|
||||
names = [r.name for r in roles]
|
||||
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:
|
||||
"""Tests for CRUD update operations."""
|
||||
@@ -690,6 +891,48 @@ class TestCrudUpdate:
|
||||
assert updated.email == "john@test.com"
|
||||
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:
|
||||
"""Tests for CRUD delete operations."""
|
||||
@@ -2519,7 +2762,7 @@ class TestCursorPaginateSearchJoins:
|
||||
|
||||
|
||||
class TestGetWithForUpdate:
|
||||
"""Tests for get() with with_for_update=True."""
|
||||
"""Tests for get/get_or_none with_for_update variants."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_with_for_update(self, db_session: AsyncSession):
|
||||
@@ -2535,6 +2778,105 @@ class TestGetWithForUpdate:
|
||||
assert result.id == role.id
|
||||
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:
|
||||
"""Tests for cursor_paginate() covering DateTime, Date and Numeric column types."""
|
||||
|
||||
+598
-10
@@ -23,6 +23,12 @@ from .conftest import (
|
||||
ArticleCreate,
|
||||
ArticleCrud,
|
||||
ArticleRead,
|
||||
Color,
|
||||
Order,
|
||||
OrderCreate,
|
||||
OrderCrud,
|
||||
OrderRead,
|
||||
OrderStatus,
|
||||
Role,
|
||||
RoleCreate,
|
||||
RoleCrud,
|
||||
@@ -366,6 +372,22 @@ class TestBuildSearchFilters:
|
||||
|
||||
assert len(joins) == 1
|
||||
|
||||
def test_skips_cast_on_string_column(self):
|
||||
"""String columns are filtered directly, without a CAST (keeps pg_trgm indexable)."""
|
||||
from fastapi_toolsets.crud.search import build_search_filters
|
||||
|
||||
filters, _ = build_search_filters(User, "john", search_fields=[User.username])
|
||||
|
||||
assert "CAST" not in str(filters[0])
|
||||
|
||||
def test_casts_non_string_column(self):
|
||||
"""Non-string columns (e.g. UUID) still get cast to String so ilike works."""
|
||||
from fastapi_toolsets.crud.search import build_search_filters
|
||||
|
||||
filters, _ = build_search_filters(User, "123", search_fields=[User.id])
|
||||
|
||||
assert "CAST" in str(filters[0])
|
||||
|
||||
|
||||
class TestSearchConfig:
|
||||
"""Tests for SearchConfig options."""
|
||||
@@ -525,6 +547,15 @@ class TestFacetsNotSet:
|
||||
|
||||
assert result.filter_attributes is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_build_facets_empty_field_list(self, db_session: AsyncSession):
|
||||
"""build_facets([]) is a no-op that returns {} without querying — the escape hatch."""
|
||||
from fastapi_toolsets.crud.search import build_facets
|
||||
|
||||
result = await build_facets(db_session, User, [])
|
||||
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestFacetsDirectColumn:
|
||||
"""Facets on direct model columns."""
|
||||
@@ -600,6 +631,91 @@ class TestFacetsDirectColumn:
|
||||
assert "username" not in result.filter_attributes
|
||||
|
||||
|
||||
class TestFacetsMixedTypes:
|
||||
"""Facet values keep their native Python type through the batched query."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_enum_and_integer_facets_preserve_types(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""Enum facets return member names (not raw DB values); Integer facets return ints."""
|
||||
OrderMixedCrud = CrudFactory(
|
||||
Order, facet_fields=[Order.status, Order.priority, Order.color]
|
||||
)
|
||||
await OrderCrud.create(
|
||||
db_session,
|
||||
OrderCreate(
|
||||
name="order-1", status=OrderStatus.PENDING, priority=1, color=Color.RED
|
||||
),
|
||||
)
|
||||
await OrderCrud.create(
|
||||
db_session,
|
||||
OrderCreate(
|
||||
name="order-2", status=OrderStatus.SHIPPED, priority=3, color=Color.BLUE
|
||||
),
|
||||
)
|
||||
|
||||
result = await OrderMixedCrud.offset_paginate(db_session, schema=OrderRead)
|
||||
|
||||
assert result.filter_attributes is not None
|
||||
assert set(result.filter_attributes["status"]) == {"PENDING", "SHIPPED"}
|
||||
assert all(isinstance(v, str) for v in result.filter_attributes["status"])
|
||||
assert set(result.filter_attributes["priority"]) == {1, 3}
|
||||
assert all(isinstance(v, int) for v in result.filter_attributes["priority"])
|
||||
assert set(result.filter_attributes["color"]) == {"RED", "BLUE"}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bool_facet_keeps_python_bool(self, db_session: AsyncSession):
|
||||
"""A Boolean facet returns Python bool values, not stringified 'true'/'false'."""
|
||||
UserBoolFacetCrud = CrudFactory(User, facet_fields=[User.is_active])
|
||||
await UserCrud.create(
|
||||
db_session, UserCreate(username="alice", email="a@test.com", is_active=True)
|
||||
)
|
||||
await UserCrud.create(
|
||||
db_session, UserCreate(username="bob", email="b@test.com", is_active=False)
|
||||
)
|
||||
|
||||
result = await UserBoolFacetCrud.offset_paginate(db_session, schema=UserRead)
|
||||
|
||||
assert result.filter_attributes is not None
|
||||
assert set(result.filter_attributes["is_active"]) == {True, False}
|
||||
assert all(isinstance(v, bool) for v in result.filter_attributes["is_active"])
|
||||
|
||||
|
||||
class TestIncludeFacets:
|
||||
"""include_facets=False skips facet queries entirely."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_offset_paginate_include_facets_false(self, db_session: AsyncSession):
|
||||
"""filter_attributes is None when include_facets=False, even with facet_fields set."""
|
||||
UserFacetCrud = CrudFactory(User, facet_fields=[User.username])
|
||||
await UserCrud.create(
|
||||
db_session, UserCreate(username="alice", email="a@test.com")
|
||||
)
|
||||
|
||||
result = await UserFacetCrud.offset_paginate(
|
||||
db_session, include_facets=False, schema=UserRead
|
||||
)
|
||||
|
||||
assert result.filter_attributes is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cursor_paginate_include_facets_false(self, db_session: AsyncSession):
|
||||
"""filter_attributes is None when include_facets=False for cursor_paginate."""
|
||||
UserFacetCursorCrud = CrudFactory(
|
||||
User, cursor_column=User.id, facet_fields=[User.username]
|
||||
)
|
||||
await UserCrud.create(
|
||||
db_session, UserCreate(username="alice", email="a@test.com")
|
||||
)
|
||||
|
||||
result = await UserFacetCursorCrud.cursor_paginate(
|
||||
db_session, include_facets=False, schema=UserRead
|
||||
)
|
||||
|
||||
assert result.filter_attributes is None
|
||||
|
||||
|
||||
class TestFacetsRespectFilters:
|
||||
"""Facets reflect the active filter conditions."""
|
||||
|
||||
@@ -624,6 +740,28 @@ class TestFacetsRespectFilters:
|
||||
assert result.filter_attributes is not None
|
||||
assert result.filter_attributes["username"] == ["alice"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_array_facet_respects_unrelated_filter(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""An ARRAY facet is scoped by a filter on a different column (not self-collapse)."""
|
||||
ArticleFacetCrud = CrudFactory(Article, facet_fields=[Article.labels])
|
||||
await ArticleCrud.create(
|
||||
db_session, ArticleCreate(title="Post 1", labels=["python", "fastapi"])
|
||||
)
|
||||
await ArticleCrud.create(
|
||||
db_session, ArticleCreate(title="Post 2", labels=["rust", "axum"])
|
||||
)
|
||||
|
||||
result = await ArticleFacetCrud.offset_paginate(
|
||||
db_session,
|
||||
filters=[Article.title == "Post 1"],
|
||||
schema=ArticleRead,
|
||||
)
|
||||
|
||||
assert result.filter_attributes is not None
|
||||
assert result.filter_attributes["labels"] == ["fastapi", "python"]
|
||||
|
||||
|
||||
class TestFacetsRelationship:
|
||||
"""Facets on relationship columns via tuple syntax."""
|
||||
@@ -779,8 +917,8 @@ class TestFilterBy:
|
||||
|
||||
assert len(result.data) == 1
|
||||
assert result.data[0].username == "alice"
|
||||
# facet also scoped to the filter
|
||||
assert result.filter_attributes == {"username": ["alice"]}
|
||||
# facet excludes its own filter_by condition, so it isn't collapsed
|
||||
assert result.filter_attributes == {"username": ["alice", "bob"]}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_filter_produces_in_clause(self, db_session: AsyncSession):
|
||||
@@ -918,7 +1056,7 @@ class TestFilterBy:
|
||||
|
||||
assert len(result.data) == 1
|
||||
assert result.data[0].username == "alice"
|
||||
assert result.filter_attributes == {"username": ["alice"]}
|
||||
assert result.filter_attributes == {"username": ["alice", "bob"]}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_basemodel_filter_by_offset_paginate(self, db_session: AsyncSession):
|
||||
@@ -1079,8 +1217,10 @@ class TestFilterBy:
|
||||
assert result.pagination.total_count == 2
|
||||
titles = {a.title for a in result.data}
|
||||
assert titles == {"Post 1", "Post 3"}
|
||||
# facet returns individual unnested values, not whole arrays
|
||||
assert result.filter_attributes == {"labels": ["django", "fastapi", "python"]}
|
||||
# facet excludes its own filter_by condition (not collapsed to matching rows)
|
||||
assert result.filter_attributes == {
|
||||
"labels": ["axum", "django", "fastapi", "python", "rust"]
|
||||
}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_array_overlap_list_value(self, db_session: AsyncSession):
|
||||
@@ -1121,6 +1261,253 @@ class TestFilterBy:
|
||||
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:
|
||||
"""Tests for filter params via consolidated offset_paginate_params()."""
|
||||
|
||||
@@ -1516,6 +1903,111 @@ class TestSearchColumns:
|
||||
assert result.data[0].username == "bob"
|
||||
|
||||
|
||||
class TestOrderColumns:
|
||||
"""Tests for order_columns in paginated responses."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_order_columns_returned_in_offset_paginate(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""offset_paginate response includes order_columns."""
|
||||
UserSortCrud = CrudFactory(User, order_fields=[User.username, User.email])
|
||||
await UserCrud.create(
|
||||
db_session, UserCreate(username="alice", email="a@test.com")
|
||||
)
|
||||
|
||||
result = await UserSortCrud.offset_paginate(db_session, schema=UserRead)
|
||||
|
||||
assert result.order_columns is not None
|
||||
assert "username" in result.order_columns
|
||||
assert "email" in result.order_columns
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_order_columns_returned_in_cursor_paginate(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""cursor_paginate response includes order_columns."""
|
||||
UserSortCursorCrud = CrudFactory(
|
||||
User,
|
||||
cursor_column=User.id,
|
||||
order_fields=[User.username, User.email],
|
||||
)
|
||||
await UserCrud.create(
|
||||
db_session, UserCreate(username="alice", email="a@test.com")
|
||||
)
|
||||
|
||||
result = await UserSortCursorCrud.cursor_paginate(db_session, schema=UserRead)
|
||||
|
||||
assert result.order_columns is not None
|
||||
assert "username" in result.order_columns
|
||||
assert "email" in result.order_columns
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_order_columns_none_when_no_order_fields(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""order_columns is None when no order_fields are configured."""
|
||||
result = await UserCrud.offset_paginate(db_session, schema=UserRead)
|
||||
|
||||
assert result.order_columns is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_order_columns_override_in_offset_paginate(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""order_fields override in offset_paginate is reflected in order_columns."""
|
||||
await UserCrud.create(
|
||||
db_session, UserCreate(username="alice", email="a@test.com")
|
||||
)
|
||||
|
||||
result = await UserCrud.offset_paginate(
|
||||
db_session, order_fields=[User.email], schema=UserRead
|
||||
)
|
||||
|
||||
assert result.order_columns == ["email"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_order_columns_override_in_cursor_paginate(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""order_fields override in cursor_paginate is reflected in order_columns."""
|
||||
UserCursorCrud = CrudFactory(User, cursor_column=User.id)
|
||||
await UserCrud.create(
|
||||
db_session, UserCreate(username="alice", email="a@test.com")
|
||||
)
|
||||
|
||||
result = await UserCursorCrud.cursor_paginate(
|
||||
db_session, order_fields=[User.username], schema=UserRead
|
||||
)
|
||||
|
||||
assert result.order_columns == ["username"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_order_columns_are_sorted_alphabetically(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""order_columns keys are returned in alphabetical order."""
|
||||
UserSortCrud = CrudFactory(User, order_fields=[User.email, User.username])
|
||||
await UserCrud.create(
|
||||
db_session, UserCreate(username="alice", email="a@test.com")
|
||||
)
|
||||
|
||||
result = await UserSortCrud.offset_paginate(db_session, schema=UserRead)
|
||||
|
||||
assert result.order_columns is not None
|
||||
assert result.order_columns == sorted(result.order_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:
|
||||
"""Tests for order params via consolidated offset_paginate_params()."""
|
||||
|
||||
@@ -1670,6 +2162,92 @@ class TestOrderParamsViaConsolidated:
|
||||
assert result.data[0].username == "alice"
|
||||
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:
|
||||
"""Tests for AsyncCrud.offset_paginate_params()."""
|
||||
@@ -1683,7 +2261,7 @@ class TestOffsetPaginateParamsSchema:
|
||||
def test_dependency_name_includes_model_name(self):
|
||||
"""Dependency function is named after the model."""
|
||||
dep = RoleCrud.offset_paginate_params(search=False, filter=False, order=False)
|
||||
assert getattr(dep, "__name__") == "RoleOffsetPaginateParams"
|
||||
assert dep.__name__ == "RoleOffsetPaginateParams" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
def test_default_page_size_reflected_in_items_per_page_default(self):
|
||||
"""default_page_size is used as the default for items_per_page."""
|
||||
@@ -1735,7 +2313,12 @@ class TestOffsetPaginateParamsSchema:
|
||||
include_total=False, search=False, filter=False, order=False
|
||||
)
|
||||
result = await dep(page=2, items_per_page=10)
|
||||
assert result == {"page": 2, "items_per_page": 10, "include_total": False}
|
||||
assert result == {
|
||||
"page": 2,
|
||||
"items_per_page": 10,
|
||||
"include_total": False,
|
||||
"include_facets": True,
|
||||
}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_integrates_with_offset_paginate(self, db_session: AsyncSession):
|
||||
@@ -1808,7 +2391,7 @@ class TestCursorPaginateParamsSchema:
|
||||
dep = RoleCursorCrud.cursor_paginate_params(
|
||||
search=False, filter=False, order=False
|
||||
)
|
||||
assert getattr(dep, "__name__") == "RoleCursorPaginateParams"
|
||||
assert dep.__name__ == "RoleCursorPaginateParams" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
def test_default_page_size_reflected_in_items_per_page_default(self):
|
||||
"""default_page_size is used as the default for items_per_page."""
|
||||
@@ -1846,7 +2429,11 @@ class TestCursorPaginateParamsSchema:
|
||||
search=False, filter=False, order=False
|
||||
)
|
||||
result = await dep(cursor=None, items_per_page=5)
|
||||
assert result == {"cursor": None, "items_per_page": 5}
|
||||
assert result == {
|
||||
"cursor": None,
|
||||
"items_per_page": 5,
|
||||
"include_facets": True,
|
||||
}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_integrates_with_cursor_paginate(self, db_session: AsyncSession):
|
||||
@@ -1874,7 +2461,7 @@ class TestPaginateParamsSchema:
|
||||
def test_dependency_name_includes_model_name(self):
|
||||
"""Dependency function is named after the model."""
|
||||
dep = RoleCursorCrud.paginate_params(search=False, filter=False, order=False)
|
||||
assert getattr(dep, "__name__") == "RolePaginateParams"
|
||||
assert dep.__name__ == "RolePaginateParams" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
||||
|
||||
def test_default_pagination_type(self):
|
||||
"""default_pagination_type is reflected in pagination_type default."""
|
||||
@@ -1955,6 +2542,7 @@ class TestPaginateParamsSchema:
|
||||
"cursor": None,
|
||||
"items_per_page": 10,
|
||||
"include_total": True,
|
||||
"include_facets": True,
|
||||
}
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
+1287
-138
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,7 @@ async def seed(session: AsyncSession):
|
||||
session.add_all([python, backend])
|
||||
await session.flush()
|
||||
|
||||
now = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)
|
||||
now = datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC)
|
||||
session.add_all(
|
||||
[
|
||||
Article(
|
||||
@@ -91,13 +91,19 @@ async def seed(session: AsyncSession):
|
||||
class TestAppSessionDep:
|
||||
@pytest.mark.anyio
|
||||
async def test_get_db_yields_async_session(self):
|
||||
"""get_db yields a real AsyncSession when called directly."""
|
||||
from docs_src.examples.pagination_search.db import get_db
|
||||
"""The Database dependency yields a real AsyncSession when called directly."""
|
||||
from starlette.requests import Request
|
||||
|
||||
gen = get_db()
|
||||
from fastapi_toolsets.db import Database
|
||||
|
||||
db = Database(DATABASE_URL)
|
||||
try:
|
||||
gen = db(Request({"type": "http", "headers": []}))
|
||||
session = await gen.__anext__()
|
||||
assert isinstance(session, AsyncSession)
|
||||
await gen.aclose()
|
||||
finally:
|
||||
await db.engine.dispose()
|
||||
|
||||
|
||||
class TestOffsetPagination:
|
||||
@@ -193,8 +199,9 @@ class TestOffsetPagination:
|
||||
resp = await client.get("/articles/offset?status=published")
|
||||
|
||||
body = resp.json()
|
||||
# draft is filtered out → should not appear in filter_attributes
|
||||
assert "draft" not in body["filter_attributes"]["status"]
|
||||
# a facet excludes its own filter_by condition, so filtering by
|
||||
# status=published still shows every status the facet offers
|
||||
assert "draft" in body["filter_attributes"]["status"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_and_filter_combined(self, client: AsyncClient, ex_db_session):
|
||||
|
||||
@@ -10,7 +10,9 @@ from fastapi_toolsets.exceptions import (
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
InvalidOrderFieldError,
|
||||
LockTimeoutError,
|
||||
NotFoundError,
|
||||
PoolExhaustedError,
|
||||
UnauthorizedError,
|
||||
generate_error_responses,
|
||||
init_exceptions_handlers,
|
||||
@@ -216,6 +218,72 @@ class TestApiExceptionGuard:
|
||||
assert err.api_error.code == 404
|
||||
|
||||
|
||||
class TestDbExceptions:
|
||||
"""Tests for database-related exception classes."""
|
||||
|
||||
def test_pool_exhausted_error_attributes(self):
|
||||
"""PoolExhaustedError has 503 status and DB-503-POOL error code."""
|
||||
error = PoolExhaustedError()
|
||||
assert error.api_error.code == 503
|
||||
assert error.api_error.err_code == "DB-503-POOL"
|
||||
assert error.api_error.msg == "Service Unavailable"
|
||||
|
||||
def test_pool_exhausted_error_with_detail(self):
|
||||
"""PoolExhaustedError accepts a detail string that overrides msg."""
|
||||
error = PoolExhaustedError("pool full")
|
||||
assert error.api_error.msg == "pool full"
|
||||
assert PoolExhaustedError.api_error.msg == "Service Unavailable"
|
||||
|
||||
def test_lock_timeout_error_attributes(self):
|
||||
"""LockTimeoutError has 503 status and DB-503-LOCK error code."""
|
||||
error = LockTimeoutError()
|
||||
assert error.api_error.code == 503
|
||||
assert error.api_error.err_code == "DB-503-LOCK"
|
||||
assert error.api_error.msg == "Service Unavailable"
|
||||
|
||||
def test_lock_timeout_error_with_detail(self):
|
||||
"""LockTimeoutError accepts a detail string that overrides msg."""
|
||||
error = LockTimeoutError("contended")
|
||||
assert error.api_error.msg == "contended"
|
||||
assert LockTimeoutError.api_error.msg == "Service Unavailable"
|
||||
|
||||
def test_pool_exhausted_handled_as_503(self):
|
||||
"""init_exceptions_handlers turns PoolExhaustedError into a 503 response."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||
|
||||
app = FastAPI()
|
||||
init_exceptions_handlers(app)
|
||||
|
||||
@app.get("/db")
|
||||
async def endpoint():
|
||||
raise PoolExhaustedError()
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/db")
|
||||
assert response.status_code == 503
|
||||
assert response.json()["error_code"] == "DB-503-POOL"
|
||||
|
||||
def test_lock_timeout_handled_as_503(self):
|
||||
"""init_exceptions_handlers turns LockTimeoutError into a 503 response."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||
|
||||
app = FastAPI()
|
||||
init_exceptions_handlers(app)
|
||||
|
||||
@app.get("/lock")
|
||||
async def endpoint():
|
||||
raise LockTimeoutError()
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/lock")
|
||||
assert response.status_code == 503
|
||||
assert response.json()["error_code"] == "DB-503-LOCK"
|
||||
|
||||
|
||||
class TestBuiltInExceptions:
|
||||
"""Tests for built-in exception classes."""
|
||||
|
||||
|
||||
+381
-16
@@ -2,6 +2,7 @@
|
||||
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -10,13 +11,27 @@ from fastapi_toolsets.fixtures import (
|
||||
Context,
|
||||
FixtureRegistry,
|
||||
LoadStrategy,
|
||||
get_obj_by_attr,
|
||||
load_fixtures,
|
||||
load_fixtures_by_context,
|
||||
)
|
||||
from fastapi_toolsets.fixtures.utils import _get_primary_key, _instance_to_dict
|
||||
from fastapi_toolsets.fixtures.utils import (
|
||||
_get_primary_key,
|
||||
_get_table_chain,
|
||||
_instance_to_dict,
|
||||
_instance_to_dict_for_cls,
|
||||
)
|
||||
|
||||
from .conftest import IntRole, Permission, Role, RoleCreate, RoleCrud, User, UserCrud
|
||||
from .conftest import (
|
||||
Challenge,
|
||||
ChallengeStandard,
|
||||
IntRole,
|
||||
Permission,
|
||||
Role,
|
||||
RoleCreate,
|
||||
RoleCrud,
|
||||
User,
|
||||
UserCrud,
|
||||
)
|
||||
|
||||
|
||||
class AppContext(str, Enum):
|
||||
@@ -59,7 +74,7 @@ class TestCustomEnumContext:
|
||||
"""Python prohibits extending an Enum that already has members."""
|
||||
with pytest.raises(TypeError):
|
||||
|
||||
class MyContext(Context): # noqa: F841 # ty: ignore[subclass-of-final-class]
|
||||
class MyContext(Context): # ty: ignore[subclass-of-final-class]
|
||||
STAGING = "staging"
|
||||
|
||||
def test_custom_enum_values_interchangeable_with_context(self):
|
||||
@@ -251,7 +266,34 @@ class TestFixtureRegistry:
|
||||
|
||||
testing_fixtures = registry.get_by_context(Context.TESTING)
|
||||
names = {f.name for f in testing_fixtures}
|
||||
assert names == {"test_data"}
|
||||
assert names == {"test_data", "base_data"}
|
||||
|
||||
def test_get_by_context_always_includes_base(self):
|
||||
"""Context.BASE fixtures load even for a fully custom context."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(contexts=[Context.BASE])
|
||||
def base_data():
|
||||
return []
|
||||
|
||||
@registry.register(contexts=["staging"])
|
||||
def staging_data():
|
||||
return []
|
||||
|
||||
names = {f.name for f in registry.get_by_context("staging")}
|
||||
assert names == {"staging_data", "base_data"}
|
||||
|
||||
def test_get_load_variants_falls_back_to_all_when_context_has_no_match(self):
|
||||
"""get_load_variants returns every variant if none match the requested
|
||||
context (and none are Context.BASE either)."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(contexts=["staging"])
|
||||
def env_data():
|
||||
return []
|
||||
|
||||
variants = registry.get_load_variants("env_data", "production")
|
||||
assert [v.contexts for v in variants] == [["staging"]]
|
||||
|
||||
|
||||
class TestIncludeRegistry:
|
||||
@@ -796,6 +838,45 @@ class TestLoadFixtures:
|
||||
db_session, registry, "int_roles", strategy=LoadStrategy.SKIP_EXISTING
|
||||
)
|
||||
assert len(result["int_roles"]) == 1
|
||||
# The generated autoincrement PK must be written back onto the
|
||||
# returned instance, not just visible via a fresh DB query.
|
||||
assert cast(IntRole, result["int_roles"][0]).id is not None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_insert_refreshes_autoincrement_pk_on_returned_instance(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""INSERT strategy writes the generated PK back onto the returned instance."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register
|
||||
def int_roles():
|
||||
return [IntRole(name="auto")]
|
||||
|
||||
result = await load_fixtures(
|
||||
db_session, registry, "int_roles", strategy=LoadStrategy.INSERT
|
||||
)
|
||||
assert cast(IntRole, result["int_roles"][0]).id is not None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_merge_refreshes_server_default_on_returned_instance(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
"""MERGE strategy refreshes the returned instance with server-generated values."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register
|
||||
def challenges():
|
||||
return [
|
||||
Challenge(id=uuid.uuid4(), title="Solo", challenge_type="challenge")
|
||||
]
|
||||
|
||||
result = await load_fixtures(
|
||||
db_session, registry, "challenges", strategy=LoadStrategy.MERGE
|
||||
)
|
||||
# `points` has a column default of 0 applied by the DB, never set on
|
||||
# the in-memory instance — the returned object must reflect it.
|
||||
assert cast(Challenge, result["challenges"][0]).points == 0
|
||||
|
||||
|
||||
class TestLoadFixturesByContext:
|
||||
@@ -875,8 +956,8 @@ class TestLoadFixturesByContext:
|
||||
assert await UserCrud.count(db_session) == 1
|
||||
|
||||
|
||||
class TestGetObjByAttr:
|
||||
"""Tests for get_obj_by_attr helper function."""
|
||||
class TestRegistryObj:
|
||||
"""Tests for FixtureRegistry.obj."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures for each test."""
|
||||
@@ -918,23 +999,20 @@ class TestGetObjByAttr:
|
||||
),
|
||||
]
|
||||
|
||||
self.roles = roles
|
||||
self.users = users
|
||||
|
||||
def test_get_by_id(self):
|
||||
"""Get an object by its id attribute."""
|
||||
role = get_obj_by_attr(self.roles, "id", self.role_id_1)
|
||||
assert role.name == "admin"
|
||||
role = self.registry.obj("roles", "id", self.role_id_1)
|
||||
assert cast(Role, role).name == "admin"
|
||||
|
||||
def test_get_user_by_username(self):
|
||||
"""Get a user by username."""
|
||||
user = get_obj_by_attr(self.users, "username", "bob")
|
||||
user = cast(User, self.registry.obj("users", "username", "bob"))
|
||||
assert user.id == self.user_id_2
|
||||
assert user.email == "bob@example.com"
|
||||
|
||||
def test_returns_first_match(self):
|
||||
"""Returns the first matching object when multiple could match."""
|
||||
user = get_obj_by_attr(self.users, "role_id", self.role_id_1)
|
||||
user = cast(User, self.registry.obj("users", "role_id", self.role_id_1))
|
||||
assert user.username == "alice"
|
||||
|
||||
def test_no_match_raises_stop_iteration(self):
|
||||
@@ -943,12 +1021,66 @@ class TestGetObjByAttr:
|
||||
StopIteration,
|
||||
match="No object with name=nonexistent found in fixture 'roles'",
|
||||
):
|
||||
get_obj_by_attr(self.roles, "name", "nonexistent")
|
||||
self.registry.obj("roles", "name", "nonexistent")
|
||||
|
||||
def test_no_match_on_wrong_value_type(self):
|
||||
"""Raises StopIteration when value type doesn't match."""
|
||||
with pytest.raises(StopIteration):
|
||||
get_obj_by_attr(self.roles, "id", "not-a-uuid")
|
||||
self.registry.obj("roles", "id", "not-a-uuid")
|
||||
|
||||
def test_unknown_fixture_raises_key_error(self):
|
||||
"""Raises KeyError when the fixture name isn't registered."""
|
||||
with pytest.raises(KeyError):
|
||||
self.registry.obj("unknown", "id", self.role_id_1)
|
||||
|
||||
def test_searches_across_context_variants(self):
|
||||
"""obj() finds matches across all context variants of a fixture name, not just one."""
|
||||
registry = FixtureRegistry()
|
||||
tester_id = uuid.uuid4()
|
||||
|
||||
@registry.register(contexts=[Context.BASE])
|
||||
def variant_users() -> list[User]:
|
||||
return [User(id=uuid.uuid4(), username="admin", email="admin@x.com")]
|
||||
|
||||
@registry.register(contexts=[Context.TESTING])
|
||||
def variant_users() -> list[User]: # noqa: F811
|
||||
return [User(id=tester_id, username="tester", email="tester@x.com")]
|
||||
|
||||
user = cast(User, registry.obj("variant_users", "username", "tester"))
|
||||
assert user.id == tester_id
|
||||
|
||||
|
||||
class TestRegistryField:
|
||||
"""Tests for FixtureRegistry.field."""
|
||||
|
||||
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"),
|
||||
]
|
||||
|
||||
def test_returns_id_by_default(self):
|
||||
"""Returns the id field when no field is specified."""
|
||||
result = self.registry.field("roles", "name", "admin")
|
||||
assert result == self.role_id_1
|
||||
|
||||
def test_returns_specified_field(self):
|
||||
"""Returns the requested field instead of id."""
|
||||
result = self.registry.field("roles", "id", self.role_id_2, field="name")
|
||||
assert result == "user"
|
||||
|
||||
def test_no_match_raises_stop_iteration(self):
|
||||
"""Propagates StopIteration from obj() when no match found."""
|
||||
with pytest.raises(StopIteration, match="No object with name=missing"):
|
||||
self.registry.field("roles", "name", "missing")
|
||||
|
||||
|
||||
class TestGetPrimaryKey:
|
||||
@@ -1473,3 +1605,236 @@ class TestBatchNullableColumnEdgeCases:
|
||||
assert rows["only_role"].notes is None
|
||||
assert rows["only_notes"].role_id is None
|
||||
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"
|
||||
|
||||
@@ -197,8 +197,8 @@ class TestCliImportGuard:
|
||||
importlib.import_module("fastapi_toolsets.cli.app")
|
||||
finally:
|
||||
for key in list(sys.modules):
|
||||
if key.startswith("fastapi_toolsets.cli.app") or key.startswith(
|
||||
"fastapi_toolsets.cli.config"
|
||||
if key.startswith(
|
||||
("fastapi_toolsets.cli.app", "fastapi_toolsets.cli.config")
|
||||
):
|
||||
sys.modules.pop(key, None)
|
||||
sys.modules.update(saved)
|
||||
|
||||
+122
-69
@@ -7,6 +7,7 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
import fastapi_toolsets.models.watched as _watched_module
|
||||
@@ -24,12 +25,11 @@ from fastapi_toolsets.models.watched import (
|
||||
_SESSION_CREATES,
|
||||
_SESSION_DELETES,
|
||||
_SESSION_UPDATES,
|
||||
_WATCHED_MODELS,
|
||||
EventSession,
|
||||
_after_flush,
|
||||
_after_rollback,
|
||||
_get_watched_fields,
|
||||
_invalidate_caches,
|
||||
_is_watched,
|
||||
_snapshot_column_attrs,
|
||||
_upsert_changes,
|
||||
)
|
||||
@@ -338,6 +338,23 @@ async def mixin_session_expire():
|
||||
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:
|
||||
@pytest.mark.anyio
|
||||
async def test_uuid_generated_by_db(self, mixin_session):
|
||||
@@ -639,22 +656,6 @@ class TestWatchInheritance:
|
||||
assert "other" in _watch_inherit_events[0]["changes"]
|
||||
|
||||
|
||||
class TestIsWatched:
|
||||
def test_watched_model_is_watched(self):
|
||||
"""_is_watched returns True for models with registered handlers."""
|
||||
obj = WatchedModel(status="x", other="y")
|
||||
assert _is_watched(obj) is True
|
||||
|
||||
def test_non_watched_model_is_not_watched(self):
|
||||
"""_is_watched returns False for models without registered handlers."""
|
||||
assert _is_watched(object()) is False
|
||||
|
||||
def test_subclass_of_watched_model_is_watched(self):
|
||||
"""_is_watched returns True for subclasses of watched models (via MRO)."""
|
||||
dog = PolyDog(name="Rex")
|
||||
assert _is_watched(dog) is True
|
||||
|
||||
|
||||
class TestUpsertChanges:
|
||||
def test_inserts_new_entry(self):
|
||||
"""New key is inserted with the full changes dict."""
|
||||
@@ -696,7 +697,10 @@ class TestAfterFlush:
|
||||
"""New watched objects are added to _SESSION_CREATES."""
|
||||
obj = object()
|
||||
session = SimpleNamespace(new=[obj], deleted=[], dirty=[], info={})
|
||||
with patch("fastapi_toolsets.models.watched._is_watched", return_value=True):
|
||||
with patch(
|
||||
"fastapi_toolsets.models.watched._get_handlers",
|
||||
return_value=[lambda *a: None],
|
||||
):
|
||||
_after_flush(session, None)
|
||||
assert session.info[_SESSION_CREATES] == [obj]
|
||||
|
||||
@@ -712,7 +716,10 @@ class TestAfterFlush:
|
||||
obj = object()
|
||||
session = SimpleNamespace(new=[], deleted=[obj], dirty=[], info={})
|
||||
with (
|
||||
patch("fastapi_toolsets.models.watched._is_watched", return_value=True),
|
||||
patch(
|
||||
"fastapi_toolsets.models.watched._get_handlers",
|
||||
return_value=[lambda *a: None],
|
||||
),
|
||||
patch(
|
||||
"fastapi_toolsets.models.watched._snapshot_column_attrs",
|
||||
return_value={"id": 1},
|
||||
@@ -982,6 +989,69 @@ class TestEventCallbacks:
|
||||
|
||||
assert _test_events == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_survives_row_deleted_before_reload(self, mixin_session):
|
||||
"""A row deleted by another transaction right after commit still fires CREATE."""
|
||||
keep = WatchedModel(status="active", other="x")
|
||||
doomed = WatchedModel(status="active", other="x")
|
||||
mixin_session.add_all([keep, doomed])
|
||||
await mixin_session.flush()
|
||||
doomed_id = doomed.id
|
||||
|
||||
raced = {"done": False}
|
||||
|
||||
async def kill_doomed_row_once():
|
||||
if raced["done"]:
|
||||
return
|
||||
raced["done"] = True
|
||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||
async with async_sessionmaker(engine)() as other:
|
||||
row = await other.get(WatchedModel, doomed_id)
|
||||
await other.delete(row)
|
||||
await other.commit()
|
||||
await engine.dispose()
|
||||
|
||||
real_batch_reload = _watched_module._batch_reload
|
||||
|
||||
async def racing_batch_reload(session, model, pk_tuples):
|
||||
if any(pk[0] == doomed_id for pk in pk_tuples):
|
||||
await kill_doomed_row_once()
|
||||
return await real_batch_reload(session, model, pk_tuples)
|
||||
|
||||
# Patch the batched reload EventSession.commit() uses to pick up
|
||||
# server defaults, so this test still exercises the race.
|
||||
with (
|
||||
patch.object(_watched_module, "_batch_reload", racing_batch_reload),
|
||||
patch.object(_watched_module._logger, "error") as mock_error,
|
||||
):
|
||||
await mixin_session.commit()
|
||||
mock_error.assert_not_called()
|
||||
|
||||
assert raced["done"]
|
||||
created_ids = {e["obj_id"] for e in _test_events if e["event"] == "create"}
|
||||
assert created_ids == {keep.id, doomed_id}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_batch_reload_exception_is_logged_and_dispatch_continues(
|
||||
self, mixin_session
|
||||
):
|
||||
"""A batched-reload failure is logged; CREATE handlers still fire."""
|
||||
obj = WatchedModel(status="active", other="x")
|
||||
mixin_session.add(obj)
|
||||
|
||||
async def failing_batch_reload(session, model, pk_tuples):
|
||||
raise RuntimeError("reload failed")
|
||||
|
||||
with (
|
||||
patch.object(_watched_module, "_batch_reload", failing_batch_reload),
|
||||
patch.object(_watched_module._logger, "error") as mock_error,
|
||||
):
|
||||
await mixin_session.commit()
|
||||
|
||||
mock_error.assert_called_once()
|
||||
creates = [e for e in _test_events if e["event"] == "create"]
|
||||
assert len(creates) == 1
|
||||
|
||||
|
||||
class TestTransientObject:
|
||||
"""Create + delete within the same transaction should fire no events."""
|
||||
@@ -1351,7 +1421,6 @@ class TestListensFor:
|
||||
for key in list(_EVENT_HANDLERS):
|
||||
if key[0] is ListenerModel:
|
||||
del _EVENT_HANDLERS[key]
|
||||
_WATCHED_MODELS.discard(ListenerModel)
|
||||
_invalidate_caches()
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -1487,8 +1556,8 @@ class TestListensFor:
|
||||
assert all(e["event"] == "change" for e in _listener_events)
|
||||
|
||||
|
||||
class TestEventSessionWithGetTransaction:
|
||||
"""Verify callbacks fire correctly when using get_transaction / lock_tables."""
|
||||
class TestEventSessionWithTransaction:
|
||||
"""Verify callbacks fire correctly when using transaction / lock_tables."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_events(self):
|
||||
@@ -1498,10 +1567,10 @@ class TestEventSessionWithGetTransaction:
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_callbacks_fire_after_outer_commit_not_savepoint(self, mixin_session):
|
||||
"""get_transaction creates a savepoint; callbacks fire only on outer commit."""
|
||||
from fastapi_toolsets.db import get_transaction
|
||||
"""transaction creates a savepoint; callbacks fire only on outer commit."""
|
||||
from fastapi_toolsets.db import transaction
|
||||
|
||||
async with get_transaction(mixin_session):
|
||||
async with transaction(mixin_session):
|
||||
obj = WatchedModel(status="active", other="x")
|
||||
mixin_session.add(obj)
|
||||
|
||||
@@ -1516,14 +1585,14 @@ class TestEventSessionWithGetTransaction:
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nested_transactions_accumulate_events(self, mixin_session):
|
||||
"""Multiple get_transaction blocks accumulate events for a single commit."""
|
||||
from fastapi_toolsets.db import get_transaction
|
||||
"""Multiple transaction blocks accumulate events for a single commit."""
|
||||
from fastapi_toolsets.db import transaction
|
||||
|
||||
async with get_transaction(mixin_session):
|
||||
async with transaction(mixin_session):
|
||||
obj1 = WatchedModel(status="first", other="x")
|
||||
mixin_session.add(obj1)
|
||||
|
||||
async with get_transaction(mixin_session):
|
||||
async with transaction(mixin_session):
|
||||
obj2 = WatchedModel(status="second", other="y")
|
||||
mixin_session.add(obj2)
|
||||
|
||||
@@ -1537,14 +1606,14 @@ class TestEventSessionWithGetTransaction:
|
||||
@pytest.mark.anyio
|
||||
async def test_savepoint_rollback_suppresses_events(self, mixin_session):
|
||||
"""Objects from a rolled-back savepoint don't fire callbacks."""
|
||||
from fastapi_toolsets.db import get_transaction
|
||||
from fastapi_toolsets.db import transaction
|
||||
|
||||
survivor = WatchedModel(status="kept", other="x")
|
||||
mixin_session.add(survivor)
|
||||
await mixin_session.flush()
|
||||
|
||||
try:
|
||||
async with get_transaction(mixin_session):
|
||||
async with transaction(mixin_session):
|
||||
doomed = WatchedModel(status="doomed", other="y")
|
||||
mixin_session.add(doomed)
|
||||
await mixin_session.flush()
|
||||
@@ -1559,23 +1628,21 @@ class TestEventSessionWithGetTransaction:
|
||||
assert creates[0]["obj_id"] == survivor.id
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_lock_tables_with_events(self, mixin_session):
|
||||
"""Events fire correctly after lock_tables context."""
|
||||
async def test_lock_tables_with_events(self, mixin_session_maker):
|
||||
"""Events fire correctly when lock_tables commits on context exit."""
|
||||
from fastapi_toolsets.db import lock_tables
|
||||
|
||||
async with lock_tables(mixin_session, [WatchedModel]):
|
||||
async with lock_tables(mixin_session_maker, [WatchedModel]) as session:
|
||||
obj = WatchedModel(status="locked", other="x")
|
||||
mixin_session.add(obj)
|
||||
|
||||
await mixin_session.commit()
|
||||
session.add(obj)
|
||||
|
||||
creates = [e for e in _test_events if e["event"] == "create"]
|
||||
assert len(creates) == 1
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_inside_get_transaction(self, mixin_session):
|
||||
"""UPDATE events fire with correct changes after get_transaction commit."""
|
||||
from fastapi_toolsets.db import get_transaction
|
||||
async def test_update_inside_transaction(self, mixin_session):
|
||||
"""UPDATE events fire with correct changes after transaction commit."""
|
||||
from fastapi_toolsets.db import transaction
|
||||
|
||||
obj = WatchedModel(status="initial", other="x")
|
||||
mixin_session.add(obj)
|
||||
@@ -1583,7 +1650,7 @@ class TestEventSessionWithGetTransaction:
|
||||
|
||||
_test_events.clear()
|
||||
|
||||
async with get_transaction(mixin_session):
|
||||
async with transaction(mixin_session):
|
||||
obj.status = "updated"
|
||||
|
||||
await mixin_session.commit()
|
||||
@@ -1679,7 +1746,7 @@ class TestEventSessionWithNullableFields:
|
||||
|
||||
|
||||
class TestEventSessionWithFastAPIDependency:
|
||||
"""Verify EventSession works when session comes from create_db_dependency."""
|
||||
"""Verify EventSession works when session comes from the Database dependency."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_events(self):
|
||||
@@ -1689,31 +1756,24 @@ class TestEventSessionWithFastAPIDependency:
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_event_fires_via_dependency(self):
|
||||
"""CREATE callback fires when session is provided by create_db_dependency."""
|
||||
"""CREATE callback fires when session is provided by the Database dependency."""
|
||||
from fastapi import Depends, FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
|
||||
from fastapi_toolsets.db import create_db_dependency
|
||||
from fastapi_toolsets.db import Database
|
||||
from fastapi_toolsets.models import EventSession
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||
session_factory = async_sessionmaker(
|
||||
engine, expire_on_commit=False, class_=EventSession
|
||||
)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(MixinBase.metadata.create_all)
|
||||
|
||||
get_db = create_db_dependency(session_factory)
|
||||
db = Database(engine=engine, session_class=EventSession)
|
||||
app = FastAPI()
|
||||
|
||||
@app.post("/watched")
|
||||
async def create_watched(session: AsyncSession = Depends(get_db)):
|
||||
async def create_watched(session: AsyncSession = Depends(db)):
|
||||
obj = WatchedModel(status="from-api", other="x")
|
||||
session.add(obj)
|
||||
return {"id": str(obj.id)}
|
||||
@@ -1736,40 +1796,33 @@ class TestEventSessionWithFastAPIDependency:
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_event_fires_via_dependency(self):
|
||||
"""UPDATE callback fires when session is provided by create_db_dependency."""
|
||||
"""UPDATE callback fires when session is provided by the Database dependency."""
|
||||
from fastapi import Depends, FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
|
||||
from fastapi_toolsets.db import create_db_dependency
|
||||
from fastapi_toolsets.db import Database
|
||||
from fastapi_toolsets.models import EventSession
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||
session_factory = async_sessionmaker(
|
||||
engine, expire_on_commit=False, class_=EventSession
|
||||
)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(MixinBase.metadata.create_all)
|
||||
|
||||
get_db = create_db_dependency(session_factory)
|
||||
db = Database(engine=engine, session_class=EventSession)
|
||||
app = FastAPI()
|
||||
|
||||
# Pre-seed an object.
|
||||
async with session_factory() as seed_session:
|
||||
async with db.session() as seed_session:
|
||||
obj = WatchedModel(status="initial", other="x")
|
||||
seed_session.add(obj)
|
||||
await seed_session.commit()
|
||||
await seed_session.flush()
|
||||
obj_id = obj.id
|
||||
|
||||
_test_events.clear()
|
||||
|
||||
@app.put("/watched/{item_id}")
|
||||
async def update_watched(item_id: str, session: AsyncSession = Depends(get_db)):
|
||||
async def update_watched(item_id: str, session: AsyncSession = Depends(db)):
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(WatchedModel).where(WatchedModel.id == item_id)
|
||||
|
||||
+398
-29
@@ -1,17 +1,23 @@
|
||||
"""Tests for fastapi_toolsets.pytest module."""
|
||||
|
||||
import uuid
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends, FastAPI
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy import ForeignKey, String, select, text
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
from fastapi_toolsets.db import get_transaction
|
||||
from fastapi_toolsets.fixtures import Context, FixtureRegistry
|
||||
from fastapi_toolsets.db import transaction
|
||||
from fastapi_toolsets.fixtures import Context, FixtureRegistry, LoadStrategy
|
||||
from fastapi_toolsets.fixtures.utils import (
|
||||
_get_primary_key,
|
||||
_relationship_load_options,
|
||||
_reload_with_relationships,
|
||||
)
|
||||
from fastapi_toolsets.pytest import (
|
||||
create_async_client,
|
||||
create_db_session,
|
||||
@@ -21,7 +27,16 @@ from fastapi_toolsets.pytest import (
|
||||
)
|
||||
from fastapi_toolsets.pytest.utils import _get_xdist_worker
|
||||
|
||||
from .conftest import DATABASE_URL, Base, Role, RoleCrud, User, UserCrud
|
||||
from .conftest import (
|
||||
DATABASE_URL,
|
||||
Base,
|
||||
IntRole,
|
||||
Permission,
|
||||
Role,
|
||||
RoleCrud,
|
||||
User,
|
||||
UserCrud,
|
||||
)
|
||||
|
||||
test_registry = FixtureRegistry()
|
||||
|
||||
@@ -136,14 +151,8 @@ class TestGeneratedFixtures:
|
||||
async def test_fixture_relationships_work(
|
||||
self, db_session: AsyncSession, fixture_users: list[User]
|
||||
):
|
||||
"""Loaded fixtures have working relationships."""
|
||||
# Load user with role relationship
|
||||
user = await UserCrud.get(
|
||||
db_session,
|
||||
[User.id == USER_ADMIN_ID],
|
||||
load_options=[selectinload(User.role)],
|
||||
)
|
||||
|
||||
"""Loaded fixtures have working relationships directly accessible."""
|
||||
user = next(u for u in fixture_users if u.id == USER_ADMIN_ID)
|
||||
assert user.role is not None
|
||||
assert user.role.name == "plugin_admin"
|
||||
|
||||
@@ -177,6 +186,15 @@ class TestGeneratedFixtures:
|
||||
assert users[0].username == "plugin_admin"
|
||||
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
|
||||
async def test_multiple_fixtures_in_same_test(
|
||||
self,
|
||||
@@ -260,6 +278,21 @@ class TestCreateAsyncClient:
|
||||
# Overrides should be cleaned up
|
||||
assert original_dep not in app.dependency_overrides
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_kwargs_forwarded_to_async_client(self):
|
||||
"""Extra kwargs are forwarded to AsyncClient (e.g. default headers)."""
|
||||
from fastapi import Request
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/headers")
|
||||
async def headers_endpoint(request: Request):
|
||||
return {"x-custom": request.headers.get("x-custom")}
|
||||
|
||||
async with create_async_client(app, headers={"X-Custom": "sentinel"}) as client:
|
||||
response = await client.get("/headers")
|
||||
assert response.json() == {"x-custom": "sentinel"}
|
||||
|
||||
|
||||
class TestCreateDbSession:
|
||||
"""Tests for create_db_session helper."""
|
||||
@@ -338,14 +371,30 @@ class TestCreateDbSession:
|
||||
assert result.all() == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_transaction_commits_visible_to_separate_session(self):
|
||||
"""Data written via get_transaction() is committed and visible to other sessions."""
|
||||
async def test_engine_kwargs_forwarded(self):
|
||||
"""engine_kwargs are forwarded to create_async_engine."""
|
||||
async with create_db_session(
|
||||
DATABASE_URL, Base, engine_kwargs={"pool_pre_ping": True}
|
||||
) as session:
|
||||
assert isinstance(session, AsyncSession)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_session_kwargs_forwarded(self):
|
||||
"""session_kwargs are forwarded to async_sessionmaker."""
|
||||
async with create_db_session(
|
||||
DATABASE_URL, Base, session_kwargs={"autoflush": False}
|
||||
) as session:
|
||||
assert session.autoflush is False
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_transaction_commits_visible_to_separate_session(self):
|
||||
"""Data written via transaction() is committed and visible to other sessions."""
|
||||
role_id = uuid.uuid4()
|
||||
|
||||
async with create_db_session(DATABASE_URL, Base, drop_tables=False) as session:
|
||||
# Simulate what _create_fixture_function does: insert via get_transaction
|
||||
# Simulate what _create_fixture_function does: insert via transaction()
|
||||
# with no explicit commit afterward.
|
||||
async with get_transaction(session):
|
||||
async with transaction(session):
|
||||
role = Role(id=role_id, name="visible_to_other_session")
|
||||
session.add(role)
|
||||
|
||||
@@ -360,9 +409,9 @@ class TestCreateDbSession:
|
||||
result = await other.execute(select(Role).where(Role.id == role_id))
|
||||
fetched = result.scalar_one_or_none()
|
||||
assert fetched is not None, (
|
||||
"Fixture data inserted via get_transaction() must be committed "
|
||||
"Fixture data inserted via transaction() must be committed "
|
||||
"and visible to a separate session. If create_db_session uses "
|
||||
"create_db_context, auto-begin forces get_transaction() into "
|
||||
"db.session(), auto-begin forces transaction() into "
|
||||
"savepoints instead of real commits."
|
||||
)
|
||||
assert fetched.name == "visible_to_other_session"
|
||||
@@ -393,21 +442,19 @@ class TestGetXdistWorker:
|
||||
class TestWorkerDatabaseUrl:
|
||||
"""Tests for worker_database_url helper."""
|
||||
|
||||
def test_appends_default_test_db_without_xdist(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""default_test_db is appended when not running under xdist."""
|
||||
def test_uses_default_test_db_without_xdist(self, monkeypatch: pytest.MonkeyPatch):
|
||||
"""default_test_db is used as the database name when not running under xdist."""
|
||||
monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False)
|
||||
url = "postgresql+asyncpg://user:pass@localhost:5432/mydb"
|
||||
result = worker_database_url(url, default_test_db="fallback")
|
||||
assert make_url(result).database == "mydb_fallback"
|
||||
assert make_url(result).database == "fallback"
|
||||
|
||||
def test_appends_worker_id_to_database_name(self, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Worker name is appended to the database name."""
|
||||
def test_uses_worker_id_as_database_name(self, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Worker name is used as the database name."""
|
||||
monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw0")
|
||||
url = "postgresql+asyncpg://user:pass@localhost:5432/db"
|
||||
result = worker_database_url(url, default_test_db="unused")
|
||||
assert make_url(result).database == "db_gw0"
|
||||
assert make_url(result).database == "gw0"
|
||||
|
||||
def test_preserves_url_components(self, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Host, port, username, password, and driver are preserved."""
|
||||
@@ -420,7 +467,21 @@ class TestWorkerDatabaseUrl:
|
||||
assert result.password == "secret"
|
||||
assert result.host == "dbhost"
|
||||
assert result.port == 6543
|
||||
assert result.database == "testdb_gw2"
|
||||
assert result.database == "gw2"
|
||||
|
||||
def test_prefix_with_xdist(self, monkeypatch: pytest.MonkeyPatch):
|
||||
"""prefix is prepended to the worker name when running under xdist."""
|
||||
monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw0")
|
||||
url = "postgresql+asyncpg://user:pass@localhost:5432/mydb"
|
||||
result = worker_database_url(url, default_test_db="unused", prefix="myapp")
|
||||
assert make_url(result).database == "myapp_gw0"
|
||||
|
||||
def test_prefix_without_xdist(self, monkeypatch: pytest.MonkeyPatch):
|
||||
"""prefix is prepended to default_test_db when not running under xdist."""
|
||||
monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False)
|
||||
url = "postgresql+asyncpg://user:pass@localhost:5432/mydb"
|
||||
result = worker_database_url(url, default_test_db="test", prefix="myapp")
|
||||
assert make_url(result).database == "myapp_test"
|
||||
|
||||
|
||||
class TestCreateWorkerDatabase:
|
||||
@@ -430,7 +491,7 @@ class TestCreateWorkerDatabase:
|
||||
async def test_creates_default_db_without_xdist(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Without xdist, creates a database suffixed with default_test_db."""
|
||||
"""Without xdist, creates a database named after default_test_db."""
|
||||
monkeypatch.delenv("PYTEST_XDIST_WORKER", raising=False)
|
||||
default_test_db = "no_xdist_default"
|
||||
expected_db = make_url(
|
||||
@@ -516,3 +577,311 @@ class TestCreateWorkerDatabase:
|
||||
)
|
||||
assert result.scalar() is None
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_works_when_database_url_db_does_not_exist(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Succeeds even when the database named in database_url does not exist.
|
||||
|
||||
Regression test: the old code connected the DDL engine to database_url
|
||||
itself, which failed when that database had not been created yet.
|
||||
"""
|
||||
monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw_noexist")
|
||||
nonexistent_url = (
|
||||
make_url(DATABASE_URL)
|
||||
.set(database="no_such_db")
|
||||
.render_as_string(hide_password=False)
|
||||
)
|
||||
expected_db = make_url(
|
||||
worker_database_url(nonexistent_url, default_test_db="unused")
|
||||
).database
|
||||
|
||||
async with create_worker_database(nonexistent_url) as url:
|
||||
assert make_url(url).database == expected_db
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(
|
||||
text("SELECT 1 FROM pg_database WHERE datname = :name"),
|
||||
{"name": expected_db},
|
||||
)
|
||||
assert result.scalar() == 1
|
||||
await engine.dispose()
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(
|
||||
text("SELECT 1 FROM pg_database WHERE datname = :name"),
|
||||
{"name": expected_db},
|
||||
)
|
||||
assert result.scalar() is None
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_explicit_server_url(self, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Explicit server_url is used instead of the auto-derived one."""
|
||||
monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw_explicit_srv")
|
||||
expected_db = make_url(
|
||||
worker_database_url(DATABASE_URL, default_test_db="unused")
|
||||
).database
|
||||
|
||||
async with create_worker_database(DATABASE_URL, server_url=DATABASE_URL) as url:
|
||||
assert make_url(url).database == expected_db
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(
|
||||
text("SELECT 1 FROM pg_database WHERE datname = :name"),
|
||||
{"name": expected_db},
|
||||
)
|
||||
assert result.scalar() == 1
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_drops_database_with_active_connections(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""DROP DATABASE succeeds even when a connection is still open to it."""
|
||||
monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw_active_conn")
|
||||
expected_db = make_url(
|
||||
worker_database_url(DATABASE_URL, default_test_db="unused")
|
||||
).database
|
||||
|
||||
lingering_engine = None
|
||||
async with create_worker_database(DATABASE_URL) as url:
|
||||
# Open a connection to the worker DB and intentionally leave it open.
|
||||
lingering_engine = create_async_engine(url)
|
||||
async with lingering_engine.connect():
|
||||
pass # connection returned to pool but engine not disposed
|
||||
|
||||
# If WITH (FORCE) is absent the DROP above would raise; reaching here means it worked.
|
||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(
|
||||
text("SELECT 1 FROM pg_database WHERE datname = :name"),
|
||||
{"name": expected_db},
|
||||
)
|
||||
assert result.scalar() is None
|
||||
await engine.dispose()
|
||||
if lingering_engine:
|
||||
await lingering_engine.dispose()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_prefix_names_database(self, monkeypatch: pytest.MonkeyPatch):
|
||||
"""prefix is prepended to the worker name in the created database."""
|
||||
monkeypatch.setenv("PYTEST_XDIST_WORKER", "gw_prefix")
|
||||
expected_db = make_url(
|
||||
worker_database_url(DATABASE_URL, default_test_db="unused", prefix="pfx")
|
||||
).database
|
||||
assert expected_db == "pfx_gw_prefix"
|
||||
|
||||
async with create_worker_database(DATABASE_URL, prefix="pfx") as url:
|
||||
assert make_url(url).database == expected_db
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(
|
||||
text("SELECT 1 FROM pg_database WHERE datname = :name"),
|
||||
{"name": expected_db},
|
||||
)
|
||||
assert result.scalar() == 1
|
||||
await engine.dispose()
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, isolation_level="AUTOCOMMIT")
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(
|
||||
text("SELECT 1 FROM pg_database WHERE datname = :name"),
|
||||
{"name": expected_db},
|
||||
)
|
||||
assert result.scalar() is None
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
class _LocalBase(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -5,11 +5,11 @@ from pydantic import ValidationError
|
||||
|
||||
from fastapi_toolsets.schemas import (
|
||||
ApiError,
|
||||
CursorPagination,
|
||||
CursorPaginatedResponse,
|
||||
CursorPagination,
|
||||
ErrorResponse,
|
||||
OffsetPagination,
|
||||
OffsetPaginatedResponse,
|
||||
OffsetPagination,
|
||||
PaginatedResponse,
|
||||
PaginationType,
|
||||
Response,
|
||||
|
||||
@@ -33,6 +33,15 @@ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "asyncpg"
|
||||
version = "0.31.0"
|
||||
@@ -81,6 +90,76 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt"
|
||||
version = "5.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.1.4"
|
||||
@@ -113,101 +192,86 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.5"
|
||||
version = "7.15.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" }
|
||||
wheels = [
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -235,7 +299,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.135.3"
|
||||
version = "0.139.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
@@ -244,14 +308,14 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi-toolsets"
|
||||
version = "3.0.1"
|
||||
version = "5.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "asyncpg" },
|
||||
@@ -282,6 +346,8 @@ pytest = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "async-lru" },
|
||||
{ name = "bcrypt" },
|
||||
{ name = "coverage" },
|
||||
{ name = "fastapi-toolsets", extra = ["all"] },
|
||||
{ name = "httpx" },
|
||||
@@ -301,7 +367,11 @@ docs = [
|
||||
{ name = "mkdocstrings-python" },
|
||||
{ name = "zensical" },
|
||||
]
|
||||
docs-src = [
|
||||
{ name = "bcrypt" },
|
||||
]
|
||||
tests = [
|
||||
{ name = "async-lru" },
|
||||
{ name = "coverage" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
@@ -327,6 +397,8 @@ provides-extras = ["cli", "metrics", "pytest", "all"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "async-lru", specifier = ">=1.0" },
|
||||
{ name = "bcrypt", specifier = ">=4.0.0" },
|
||||
{ name = "coverage", specifier = ">=7.0.0" },
|
||||
{ name = "fastapi-toolsets", extras = ["all"] },
|
||||
{ name = "httpx", specifier = ">=0.25.0" },
|
||||
@@ -346,7 +418,9 @@ docs = [
|
||||
{ name = "mkdocstrings-python", specifier = ">=2.0.2" },
|
||||
{ name = "zensical", specifier = ">=0.0.30" },
|
||||
]
|
||||
docs-src = [{ name = "bcrypt", specifier = ">=4.0.0" }]
|
||||
tests = [
|
||||
{ name = "async-lru", specifier = ">=1.0" },
|
||||
{ name = "coverage", specifier = ">=7.0.0" },
|
||||
{ name = "httpx", specifier = ">=0.25.0" },
|
||||
{ name = "pytest", specifier = ">=8.0.0" },
|
||||
@@ -467,11 +541,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
version = "3.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -690,16 +764,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mkdocstrings-python"
|
||||
version = "2.0.3"
|
||||
version = "2.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "griffelib" },
|
||||
{ name = "mkdocs-autorefs" },
|
||||
{ name = "mkdocstrings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/b6/e858701499d57eee8b3fd8e78168083956c6683ddbe727b46758b19e1119/mkdocstrings_python-2.0.5.tar.gz", hash = "sha256:3a4d92556ad39637e88af94a5374213af9a8e3040c3824ceaed04b486c017594", size = 199578, upload-time = "2026-06-19T10:41:08.868Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl", hash = "sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d", size = 104951, upload-time = "2026-06-19T10:41:07.378Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -740,40 +814,40 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prek"
|
||||
version = "0.3.8"
|
||||
version = "0.4.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/62/ee/03e8180e3fda9de25b6480bd15cc2bde40d573868d50648b0e527b35562f/prek-0.3.8.tar.gz", hash = "sha256:434a214256516f187a3ab15f869d950243be66b94ad47987ee4281b69643a2d9", size = 400224, upload-time = "2026-03-23T08:23:35.981Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ea/df/94ed29398576e03494c5aacda8bfed9536edf348bed29cd09f382f2b9b23/prek-0.4.9.tar.gz", hash = "sha256:f8b86441484a5756f3fdb6f3b201d3d448f8845902a84653d78cbf6f875c424f", size = 492711, upload-time = "2026-07-11T11:04:04.332Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/84/40d2ddf362d12c4cd4a25a8c89a862edf87cdfbf1422aa41aac8e315d409/prek-0.3.8-py3-none-linux_armv6l.whl", hash = "sha256:6fb646ada60658fa6dd7771b2e0fb097f005151be222f869dada3eb26d79ed33", size = 5226646, upload-time = "2026-03-23T08:23:18.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/52/7308a033fa43b7e8e188797bd2b3b017c0f0adda70fa7af575b1f43ea888/prek-0.3.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f3d7fdadb15efc19c09953c7a33cf2061a70f367d1e1957358d3ad5cc49d0616", size = 5620104, upload-time = "2026-03-23T08:23:40.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b1/f106ac000a91511a9cd80169868daf2f5b693480ef5232cec5517a38a512/prek-0.3.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:72728c3295e79ca443f8c1ec037d2a5b914ec73a358f69cf1bc1964511876bf8", size = 5199867, upload-time = "2026-03-23T08:23:38.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/e9/970713f4b019f69de9844e1bab37b8ddb67558e410916f4eb5869a696165/prek-0.3.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:48efc28f2f53b5b8087efca9daaed91572d62df97d5f24a1c7a087fecb5017de", size = 5441801, upload-time = "2026-03-23T08:23:32.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/a4/7ef44032b181753e19452ec3b09abb3a32607cf6b0a0508f0604becaaf2b/prek-0.3.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f6ca9d63bacbc448a5c18e955c78d3ac5176c3a17c3baacdd949b1a623e08a36", size = 5155107, upload-time = "2026-03-23T08:23:31.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/77/4d9c8985dbba84149760785dfe07093ea1e29d710257dfb7c89615e2234c/prek-0.3.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1000f7029696b4fe712fb1fefd4c55b9c4de72b65509c8e50296370a06f9dc3f", size = 5566541, upload-time = "2026-03-23T08:23:45.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/1a/81e6769ac1f7f8346d09ce2ab0b47cf06466acd9ff72e87e5d1f0d98cd32/prek-0.3.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ff0bed0e2c1286522987d982168a86cbbd0d069d840506a46c9fda983515517", size = 6552991, upload-time = "2026-03-23T08:23:21.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/fa/ce2df0dd2dc75a9437a52463239d0782998943d7b04e191fb89b83016c34/prek-0.3.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fb087ac0ffda3ac65bbbae9a38326a7fd27ee007bb4a94323ce1eb539d8bbec", size = 5832972, upload-time = "2026-03-23T08:23:20.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/6b/9d4269df9073216d296244595a21c253b6475dfc9076c0bd2906be7a436c/prek-0.3.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2e1e5e206ff7b31bd079cce525daddc96cd6bc544d20dc128921ad92f7a4c85d", size = 5448371, upload-time = "2026-03-23T08:23:41.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/1d/1e4d8a78abefa5b9d086e5a9f1638a74b5e540eec8a648d9946707701f29/prek-0.3.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:dcea3fe23832a4481bccb7c45f55650cb233be7c805602e788bb7dba60f2d861", size = 5270546, upload-time = "2026-03-23T08:23:24.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/07/34f36551a6319ae36e272bea63a42f59d41d2d47ab0d5fb00eb7b4e88e87/prek-0.3.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:4d25e647e9682f6818ab5c31e7a4b842993c14782a6ffcd128d22b784e0d677f", size = 5124032, upload-time = "2026-03-23T08:23:26.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/01/6d544009bb655e709993411796af77339f439526db4f3b3509c583ad8eb9/prek-0.3.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:de528b82935e33074815acff3c7c86026754d1212136295bc88fe9c43b4231d5", size = 5432245, upload-time = "2026-03-23T08:23:47.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/96/1237ee269e9bfa283ffadbcba1f401f48a47aed2b2563eb1002740d6079d/prek-0.3.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6d660f1c25a126e6d9f682fe61449441226514f412a4469f5d71f8f8cad56db2", size = 5950550, upload-time = "2026-03-23T08:23:43.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/6b/a574411459049bc691047c9912f375deda10c44a707b6ce98df2b658f0b3/prek-0.3.8-py3-none-win32.whl", hash = "sha256:b0c291c577615d9f8450421dff0b32bfd77a6b0d223ee4115a1f820cb636fdf1", size = 4949501, upload-time = "2026-03-23T08:23:16.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/b4/46b59fe49f635acd9f6530778ce577f9d8b49452835726a5311ffc902c67/prek-0.3.8-py3-none-win_amd64.whl", hash = "sha256:bc147fdbdd4ec33fc7a987b893ecb69b1413ac100d95c9889a70f3fd58c73d06", size = 5346551, upload-time = "2026-03-23T08:23:34.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/05/9cca1708bb8c65264124eb4b04251e0f65ce5bfc707080bb6b492d5a0df7/prek-0.3.8-py3-none-win_arm64.whl", hash = "sha256:a2614647aeafa817a5802ccb9561e92eedc20dcf840639a1b00826e2c2442515", size = 5190872, upload-time = "2026-03-23T08:23:29.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/02/632446b72103daf92526203bf83cb76043ebce70588632aa2aea3741da07/prek-0.4.9-py3-none-linux_armv6l.whl", hash = "sha256:7b240ad6f679104309a944c4dd427ccc46d9aaf4f53ee07379c02bf7578c2750", size = 5637604, upload-time = "2026-07-11T11:03:38.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/bf/89daefc85a1db9b8c525731c77121de0a8f57731e81c99d823e919e1f6a5/prek-0.4.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5cbb220d6d77cb047747dcabf421375fdfdd958e01a998a854758698d38fb239", size = 5960396, upload-time = "2026-07-11T11:03:40.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/39/0e448da00671e77740be32cca96530ef64bcdbed178acce7f155b87f6057/prek-0.4.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fd85df4c186becdc47b2e6155de8cace99133c7517387e30f08ca15b93ead11f", size = 5524982, upload-time = "2026-07-11T11:03:42.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e5/1beceff9cfcf02817c06f38e726c9875cd003f62cbfa516f282fb3c3109b/prek-0.4.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:ba40511145e948d461d6641b556b6ff671b186b0c90743600b9dcb788dd5eb5c", size = 5793691, upload-time = "2026-07-11T11:03:44.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/17/99e4884c45c46be90e81e220d2bdd5020716963707ef6702361cc398dd91/prek-0.4.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0cc5c06ae448568d076bb5e7ce4d630879d6467b2a89fd79061c349a47826efa", size = 5544549, upload-time = "2026-07-11T11:03:45.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/4b/63f5fe22d867a6e07b12e8a7887a0f3b193c2ef0fa9ed80649f2d257f4ac/prek-0.4.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31af616c216ec7e47913802b082ca816d707d24738fa58eae0463ae296cbe71e", size = 5948798, upload-time = "2026-07-11T11:03:47.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/89/90d5005436afb6ab99d3ba6599820fe0f0fcb776f5e9e362b5a19e10cbea/prek-0.4.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a1ef842d7f19879fac2135c42ba15508f2677346ba800bc8bb82e5f43fe6a6ec", size = 6696938, upload-time = "2026-07-11T11:03:49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/62/068dd25e1106e262b0ce69ffcdc594cf52d85aa13f64628f851e458980d4/prek-0.4.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:442ecf6a454c692bc8a2d5a16936a0fe8e3419727ff208e15818932acca9923a", size = 6170870, upload-time = "2026-07-11T11:03:50.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/42/bb1f3f3d84af4d12bb675ff071305fa776d3525c10626465d9960ad93c21/prek-0.4.9-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:5b3c590252e3d5724ebed3774695712ff7cb554743b25184808aa5f42b06bd4c", size = 5800355, upload-time = "2026-07-11T11:03:51.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ca/dfc8312b4a7ce8fa100ce37a843279d108eec7e7fe2ddbe069448acd1642/prek-0.4.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dd283b15dec4da29caa3910bd72c8c9d7df93209770503465ad109d6370cb8e", size = 5655126, upload-time = "2026-07-11T11:03:53.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8a/b19413cb64b81c18502ea7bbef32897f9126b8e53b58cd656996573dc53c/prek-0.4.9-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:cc25e30e1700a5c7dd9bad665c321e5589b51502bb1bbc6ada45e326d08b428b", size = 5517839, upload-time = "2026-07-11T11:03:54.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/af/900df3f7535e87045df331646c6a01bef6e77dba7f2bdb53483f30cbb988/prek-0.4.9-py3-none-musllinux_1_1_i686.whl", hash = "sha256:4ff5947deeb9a92e6508dfba8b27962dfb927bf60fd36472c9ae862df96fb38c", size = 5802556, upload-time = "2026-07-11T11:03:56.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/7b/80e560cbe396d0f8687012769cb2d7d7f3428dd019549225ebf205dea806/prek-0.4.9-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:8320ca167d41855d9c4fed66df599f31f96307cbb0da1311a9fe465152e20bd5", size = 6285747, upload-time = "2026-07-11T11:03:58.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/d4/01ae3b99d09559a69befd128859d0036c604608dd9c6c99986592dde3c21/prek-0.4.9-py3-none-win32.whl", hash = "sha256:b1e8d3bc88ddce6414853468ed8126f45d4ae20f2f4677801ade20ad67a826fa", size = 5320862, upload-time = "2026-07-11T11:03:59.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/68/d038b14f0220fed197be8bc93229e6ea7ad460803ff8a26e4b14a8c81f66/prek-0.4.9-py3-none-win_amd64.whl", hash = "sha256:ed1b4f87a13d1565e8731c60db7fa058966049cbb4d8872d160add510a286558", size = 5706850, upload-time = "2026-07-11T11:04:01.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/4a/57d04de49f591088901794cc22f36563a102681e3512ae17ee6085cd2f30/prek-0.4.9-py3-none-win_arm64.whl", hash = "sha256:7eab3900d9ea614c8ea0d0d55a8b708f0c88e43c966dc8b13a4e36c1e398dd16", size = 5540477, upload-time = "2026-07-11T11:04:02.815Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prometheus-client"
|
||||
version = "0.24.1"
|
||||
version = "0.25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
version = "2.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
@@ -781,106 +855,111 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.41.5"
|
||||
version = "2.46.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
|
||||
@@ -894,15 +973,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pymdown-extensions"
|
||||
version = "10.21.2"
|
||||
version = "11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown" },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/67/f1e79672a5f91985577c7984c9709ca110e4fd37fe7fd167b60422e6ccc2/pymdown_extensions-11.0.tar.gz", hash = "sha256:8269cef0247f9e2d0a62fcea10860aba05c1cbab5470fd4b63230b96434dc589", size = 857049, upload-time = "2026-06-23T02:27:45.146Z" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-py3-none-any.whl", hash = "sha256:fbc4acb641814fa9d17521bbd21a5240ef739a662f11c06330c4b78c93e954d6", size = 269415, upload-time = "2026-06-23T02:27:43.826Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -916,7 +995,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -925,9 +1004,9 @@ dependencies = [
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1064,27 +1143,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.8"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" }
|
||||
wheels = [
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1161,15 +1240,15 @@ asyncio = [
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.50.0"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1228,41 +1307,42 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.27"
|
||||
version = "0.0.65"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cf561927e8e9ab5c1892a833b664aa9cd6f051a75f6280c66d8047246bda/ty-0.0.65.tar.gz", hash = "sha256:b7134bffcc00b715fa8291e84d845782ced810a998dc1f7f11d71c85c4046325", size = 6460098, upload-time = "2026-07-29T18:31:03.27Z" }
|
||||
wheels = [
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/7b/4e/71e2d325d2b53a1afad81624ad076b2ede413213fc4a18cb05b78c568571/ty-0.0.65-py3-none-linux_armv6l.whl", hash = "sha256:dc556c9f05408bef4c4ef02b2cc382e4e5f797b4b20d64410289848f0d76705f", size = 12298466, upload-time = "2026-07-29T18:30:12.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/77/fec8f29647c55794efa430a7f365e44f5ce7ffb6459d9445a87fac569bec/ty-0.0.65-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:29d2e0d34cc0a28a17ef0cf81135c5ebabc3562131f9079138ba5e7bae0f56bd", size = 11942421, upload-time = "2026-07-29T18:30:16.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/09/7f3766aef9dc627e2698cf4e3e59cf53389dcae3812040d33c1aa931230f/ty-0.0.65-py3-none-macosx_11_0_arm64.whl", hash = "sha256:685f49a9312bbf69d5b65bbb66384fed1f927403ea030c217b9289092d7e46c4", size = 11451922, upload-time = "2026-07-29T18:30:19.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/7b/1a77cd50e0befb50f55b8bf9bd3ed3eddf184bf28c61b56727039e0774fc/ty-0.0.65-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f564b5ebe78e2f3a8e7b8eacb1292eb88b7c0f3c8630671cfca31abc0709cd9", size = 11994999, upload-time = "2026-07-29T18:30:22.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/7b/feda16f3a4a0a99be27431e0c9598eeeec0db1eb2fec9a15976698209418/ty-0.0.65-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c983e156fe9e113fb56389e13d327b6b8549fe866de9b269684723a88e9b732d", size = 12090662, upload-time = "2026-07-29T18:30:24.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/3e/3f69bf9c9307dbdc0771719f65ce5b556e7bdeeaccbdd599d4f57866d801/ty-0.0.65-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3663b7396e8b1a9954e20e732de7ccb0192bf4118473069b4945920d6923921", size = 12822094, upload-time = "2026-07-29T18:30:28.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/38/8fa791b3bb503ee2b46ad81690cd1bdd54519582df6d805cee57fe143e85/ty-0.0.65-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:306ed01f29d6e108e98feb233dbbf5878a027603b71bd3743b343977933a9f16", size = 13357833, upload-time = "2026-07-29T18:30:31.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/73/4dda396a201e1dd0ed3594a9b48e559cb41c4bc048c6cd4c4d1b39eb4313/ty-0.0.65-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28bcfc8898c94f079a9100e684bcf312b6a64ad3a7d4ebb35a4591546030a2cd", size = 12977303, upload-time = "2026-07-29T18:30:33.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/26/c250c2c569adc53a8591716641388397bcb2a442e4a30b952ae81b50c0e0/ty-0.0.65-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a75bd0c245c38802a8f488378e74f92feb7dd33db7d63fbdd6fdf82791ba730", size = 12579338, upload-time = "2026-07-29T18:30:37.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/94/4a5647d44753ca218fc930d7e4d9bf468d0ed4a0ad4b3d57588bc1bbacf7/ty-0.0.65-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9e5e1bdea9662d2b5312b4e99f319f4e6e2ea427511b5fbc546141b79ec53f76", size = 12957731, upload-time = "2026-07-29T18:30:39.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/b6/1e22fa11a1e0dfb20b1c7f3cbfd8170273aada2a82f9ecd3055275370c44/ty-0.0.65-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:03a88493d4842889f65280ae241e06b399d57eb3c63571054cad21a4c33b3b69", size = 11938625, upload-time = "2026-07-29T18:30:42.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/0a/fe5f22ef62b193201bc5566762e22049762cd485bfafb5095a7050760054/ty-0.0.65-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:600b8bf6f4940cf7ffb2f43d3716faaf38dcb97cd8617c55771451bc0276408f", size = 12105592, upload-time = "2026-07-29T18:30:45.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/fd/922b3a6e9d697452cdbb4b7e3f636868add5ec652154518a736e4364f3b7/ty-0.0.65-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0c28007bc79d648c1ddaf1e65885d07baec48eb87240da442f608e4107c1b7d8", size = 12387335, upload-time = "2026-07-29T18:30:48.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/22/a1a08ebc84c083db2fb55e3b5cd186db0c067692f4921146f601360231e2/ty-0.0.65-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c852da96091ad22361e6586b7c7ba98e1334dcd4d8ffb67e47f4fb673de33f77", size = 12682710, upload-time = "2026-07-29T18:30:51.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/14/eaaa410a25bbdea19722109b5422380a0e211b3afcf3071d15953ddbd5db/ty-0.0.65-py3-none-win32.whl", hash = "sha256:cf529d538f1403b14b0511e6ec3cdb95d3d974adabf24cc76cedc533368c3edc", size = 11692341, upload-time = "2026-07-29T18:30:54.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/0f/6d48f206dce9d7e53fe3b5ea0f0ab5800dd9d2365b2b48f736783436c43f/ty-0.0.65-py3-none-win_amd64.whl", hash = "sha256:234a321e33c7cbbfbd67bfa0b01b685dd9c21f1841781a21e5ca1fa0b25f1d5d", size = 12729355, upload-time = "2026-07-29T18:30:57.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/aa/7446f7725e303cf78e058c893af1f0552b9451895454908706f4c6c3494b/ty-0.0.65-py3-none-win_arm64.whl", hash = "sha256:b9424be1ec56d93ff18609fb1c0a0a2283fe1282cd6d1c7604f97d73b94d61f2", size = 12051375, upload-time = "2026-07-29T18:31:00.579Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.24.1"
|
||||
version = "0.27.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "click" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
]
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1324,28 +1404,30 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "zensical"
|
||||
version = "0.0.31"
|
||||
version = "0.0.51"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "deepmerge" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "markdown" },
|
||||
{ name = "pygments" },
|
||||
{ name = "pymdown-extensions" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "tomli" },
|
||||
]
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/f7/d07ffb268ca86afb26b7f32dbabe25dec03d3aa63ba4d876720c84681d33/zensical-0.0.51.tar.gz", hash = "sha256:de25de067bedfa18f916d7f366fd64a7fbf09bfcc615b44d1ddbe3b5fe02ab49", size = 3979640, upload-time = "2026-07-17T18:08:03.445Z" }
|
||||
wheels = [
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/21/02db3e1fb3904016bfac310037c95b9f1eaaf0ffe7b4a84f14263a7d95df/zensical-0.0.51-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:134d776afa526098e05e34713e2f577c075e57a232e01b97842bb0206716afce", size = 12791154, upload-time = "2026-07-17T18:07:20.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/35/b0d96f58253514cb3d08f5779020ab01ee5472334fb984b92e3fc9e9c9ac/zensical-0.0.51-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e97ab39668ae3b452c550634e921a0336443743aae5e1fe031c7bb57d049e535", size = 12692190, upload-time = "2026-07-17T18:07:24.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/90/7a60e126a10c37c6b789938ff17e73fe76bba707fa029cb40ac659aeaa82/zensical-0.0.51-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c9579809f88608e7aa2cff516fff9d267d74a843cf6088a5f4227de2f092bb5", size = 13139337, upload-time = "2026-07-17T18:07:27.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/c3/9101c97b90d4713ef2816db03366a45ae4762efebffd296737a2dd2df325/zensical-0.0.51-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296dc7a14aa28b81a58eb57df2d5c9c9a4b0de7e90c11d99c943354287952925", size = 13069851, upload-time = "2026-07-17T18:07:31.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/79/0474df9e15a2c18f6281a786e10177c1b6e16feac1c568e7f36ad39b339c/zensical-0.0.51-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f779d2d87b4bf228cf2e279bc0ae6bcf3b36a9335ff283a317d01f7c15ae46b2", size = 13451083, upload-time = "2026-07-17T18:07:35.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/6f/91bbf78f704d5fd4c0c9be27d6bce3b6e4c2c339e4dcd6e7cf19ecda643c/zensical-0.0.51-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f813a1514a90890ca86248a8d54b81b2164bcbff11a6bcf11b01e1c01a1454", size = 13110446, upload-time = "2026-07-17T18:07:38.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/89/aa9a95f81771614c37bdc52b8ab21fcdef4c8de7c9cedf34e9bf62674281/zensical-0.0.51-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:186ef37e0eee0e969e2cfae47b1b97775e3164e2cba95c71faa4dd6ef47ed009", size = 13315871, upload-time = "2026-07-17T18:07:42.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/11/1bf6e9ded29d376f8c12644cc4de04676b010fee8caa17f682606b1f16d5/zensical-0.0.51-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5d91ce246ed930224603083cef02ae8947132fc7c52901d72015ea03526fa58", size = 13344382, upload-time = "2026-07-17T18:07:46.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/ec/663f16ff82d08b212e7c3236a88bd332f73331f94ddac1c91aaf882bbd1e/zensical-0.0.51-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:b1108eae82c6e8ffc33026f60b485c1512647a5333be4f547166b7c8877b98af", size = 13499628, upload-time = "2026-07-17T18:07:49.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/b4/7f1b6c3cf06d9f6ff5216523168a5d6ccc693444d5ceeb911eca97b30d98/zensical-0.0.51-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6fa0ecaf14f56841bfc595fa141396350c72aafbec73a016ebe3c824ed21ac72", size = 13451420, upload-time = "2026-07-17T18:07:52.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/44/be4bc09ec8f69e7be1b07b875887961c4e0e478b03a10d2cc624ef28fbe6/zensical-0.0.51-cp310-abi3-win32.whl", hash = "sha256:fb7ff4946b72168759c6af0a29cf5de4c38aebe633a83292e8cd4145b5213cc2", size = 12375639, upload-time = "2026-07-17T18:07:56.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/85/aa827c244ed4f404e99a91c3ecf5e5adb62eca806a9e9c8e3333bbad8660/zensical-0.0.51-cp310-abi3-win_amd64.whl", hash = "sha256:12529d3d3991b63820952111dc1d1edc29b2c9b3a3abb16c243bcb649631ebf2", size = 12628965, upload-time = "2026-07-17T18:07:59.741Z" },
|
||||
]
|
||||
|
||||
@@ -145,6 +145,8 @@ Examples = [
|
||||
|
||||
[[project.nav]]
|
||||
Migration = [
|
||||
{"v5.0" = "migration/v5.md"},
|
||||
{"v4.0" = "migration/v4.md"},
|
||||
{"v3.0" = "migration/v3.md"},
|
||||
{"v2.0" = "migration/v2.md"},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user